diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index c9f90c832b..0000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,176 +0,0 @@ -version: 2 -jobs: - run_tests: - docker: - - image: python:3.7 - - steps: - - checkout - - - restore_cache: - key: v1-py3-{{ .Branch }}-{{ checksum "setup.py" }} - - - run: - name: Create a virtualenv - command: | - mkdir -p /tmp/venv/openfisca_core - python -m venv /tmp/venv/openfisca_core - echo "source /tmp/venv/openfisca_core/bin/activate" >> $BASH_ENV - - - run: - name: Install dependencies - command: | - make install - # pip install --editable git+https://github.com/openfisca/country-template.git@BRANCH_NAME#egg=OpenFisca-Country-Template # use a specific branch of OpenFisca-Country-Template - # pip install --editable git+https://github.com/openfisca/extension-template.git@BRANCH_NAME#egg=OpenFisca-Extension-Template # use a specific branch of OpenFisca-Extension-Template - - - save_cache: - key: v1-py3-{{ .Branch }}-{{ checksum "setup.py" }} - paths: - - /tmp/venv/openfisca_core - - - run: - name: Run Core tests - command: env PYTEST_ADDOPTS="--exitfirst" make test - - - run: - name: Check NumPy typing against latest 3 minor versions - command: for i in {1..3}; do VERSION=$(.circleci/get-numpy-version.py prev) && pip install numpy==$VERSION && make check-types; done - - - persist_to_workspace: - root: . - paths: - - .coverage - - - run: - name: Run Country Template tests - command: | - COUNTRY_TEMPLATE_PATH=`python -c "import openfisca_country_template; print(openfisca_country_template.CountryTaxBenefitSystem().get_package_metadata()['location'])"` - openfisca test $COUNTRY_TEMPLATE_PATH/openfisca_country_template/tests/ - - test_docs: - docker: - - image: python:3.7 - - steps: - - checkout - - - run: - name: Checkout docs - command: make test-doc-checkout branch=$CIRCLE_BRANCH - - - restore_cache: - key: v1-py3-{{ .Branch }}-{{ checksum "setup.py" }} - - - restore_cache: - key: v1-py3-docs-{{ .Branch }}-{{ checksum "doc/requirements.txt" }} - - - run: - name: Create a virtualenv - command: | - mkdir -p /tmp/venv/openfisca_doc - python -m venv /tmp/venv/openfisca_doc - echo "source /tmp/venv/openfisca_doc/bin/activate" >> $BASH_ENV - - - run: - name: Install dependencies - command: make test-doc-install - - - save_cache: - key: v1-py3-docs-{{ .Branch }}-{{ checksum "doc/requirements.txt" }} - paths: - - /tmp/venv/openfisca_doc - - - run: - name: Run doc tests - command: make test-doc-build - - - check_version: - docker: - - image: python:3.7 - - steps: - - checkout - - - run: - name: Check version number has been properly updated - command: | - git fetch - .circleci/is-version-number-acceptable.sh - - submit_coverage: - docker: - - image: python:3.7 - - steps: - - checkout - - - attach_workspace: - at: . - - - restore_cache: - key: v1-py3-{{ .Branch }}-{{ checksum "setup.py" }} - - - run: - name: Submit coverage to Coveralls - command: | - source /tmp/venv/openfisca_core/bin/activate - pip install coveralls - coveralls - - - save_cache: - key: v1-py3-{{ .Branch }}-{{ checksum "setup.py" }} - paths: - - /tmp/venv/openfisca_core - - deploy: - docker: - - image: python:3.7 - environment: - PYPI_USERNAME: openfisca-bot - # PYPI_PASSWORD: this value is set in CircleCI's web interface; do not set it here, it is a secret! - - steps: - - checkout - - - restore_cache: - key: v1-py3-{{ .Branch }}-{{ checksum "setup.py" }} - - - run: - name: Check for functional changes - command: if ! .circleci/has-functional-changes.sh ; then circleci step halt ; fi - - - run: - name: Upload a Python package to Pypi - command: | - source /tmp/venv/openfisca_core/bin/activate - .circleci/publish-python-package.sh - - - run: - name: Publish a git tag - command: .circleci/publish-git-tag.sh - - - run: - name: Update doc - command: | - curl -X POST --header "Content-Type: application/json" -d '{"branch":"master"}' https://circleci.com/api/v1.1/project/github/openfisca/openfisca-doc/build?circle-token=$CIRCLE_TOKEN - -workflows: - version: 2 - build_and_deploy: - jobs: - - run_tests - - test_docs - - check_version - - submit_coverage: - requires: - - run_tests - - deploy: - requires: - - run_tests - - test_docs - - check_version - filters: - branches: - only: master diff --git a/.circleci/publish-python-package.sh b/.circleci/publish-python-package.sh deleted file mode 100755 index 8d331bd946..0000000000 --- a/.circleci/publish-python-package.sh +++ /dev/null @@ -1,4 +0,0 @@ -#! /usr/bin/env bash - -python setup.py bdist_wheel # build this package in the dist directory -twine upload dist/* --username $PYPI_USERNAME --password $PYPI_PASSWORD # publish diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..fcb2acc162 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: monthly + labels: + - kind:dependencies diff --git a/.circleci/get-numpy-version.py b/.github/get-numpy-version.py similarity index 100% rename from .circleci/get-numpy-version.py rename to .github/get-numpy-version.py diff --git a/.circleci/has-functional-changes.sh b/.github/has-functional-changes.sh similarity index 91% rename from .circleci/has-functional-changes.sh rename to .github/has-functional-changes.sh index 049a94d6cd..bf1270989a 100755 --- a/.circleci/has-functional-changes.sh +++ b/.github/has-functional-changes.sh @@ -1,6 +1,6 @@ #! /usr/bin/env bash -IGNORE_DIFF_ON="README.md CONTRIBUTING.md Makefile .gitignore LICENSE* .circleci/* .github/* tests/*" +IGNORE_DIFF_ON="README.md CONTRIBUTING.md Makefile .gitignore LICENSE* .github/* tests/* openfisca_tasks/*.mk tasks/*.mk" last_tagged_commit=`git describe --tags --abbrev=0 --first-parent` # --first-parent ensures we don't follow tags not published in master through an unlikely intermediary merge commit diff --git a/.circleci/is-version-number-acceptable.sh b/.github/is-version-number-acceptable.sh similarity index 95% rename from .circleci/is-version-number-acceptable.sh rename to .github/is-version-number-acceptable.sh index ae370e2a17..0f704a93fe 100755 --- a/.circleci/is-version-number-acceptable.sh +++ b/.github/is-version-number-acceptable.sh @@ -1,6 +1,6 @@ #! /usr/bin/env bash -if [[ $CIRCLE_BRANCH == master ]] +if [[ ${GITHUB_REF#refs/heads/} == master ]] then echo "No need for a version check on master." exit 0 diff --git a/.circleci/publish-git-tag.sh b/.github/publish-git-tag.sh similarity index 100% rename from .circleci/publish-git-tag.sh rename to .github/publish-git-tag.sh diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml new file mode 100644 index 0000000000..0a5a1771ff --- /dev/null +++ b/.github/workflows/workflow.yml @@ -0,0 +1,240 @@ +name: OpenFisca Core + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + env: + TERM: xterm-256color # To colorize output of make tasks. + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.7.12 # Patch version must be specified to avoid any cache confusion, since the cache key depends on the full Python version. If left unspecified, different patch versions could be allocated between jobs, and any such difference would lead to a cache not found error. + - name: Cache build + id: restore-build + uses: actions/cache@v2 + with: + path: ${{ env.pythonLocation }} + key: build-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}-${{ github.sha }} + restore-keys: | # in case of a cache miss (systematically unless the same commit is built repeatedly), the keys below will be used to restore dependencies from previous builds, and the cache will be stored at the end of the job, making up-to-date dependencies available for all jobs of the workflow; see more at https://docs.github.com/en/actions/advanced-guides/caching-dependencies-to-speed-up-workflows#example-using-the-cache-action + build-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }} + build-${{ env.pythonLocation }}- + - name: Build package + run: make build + - name: Cache release + id: restore-release + uses: actions/cache@v2 + with: + path: dist + key: release-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}-${{ github.sha }} + + test-core: + runs-on: ubuntu-latest + needs: [ build ] + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TERM: xterm-256color # To colorize output of make tasks. + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.7.12 + - name: Cache build + id: restore-build + uses: actions/cache@v2 + with: + path: ${{ env.pythonLocation }} + key: build-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}-${{ github.sha }} + - name: Run openfisca-core tests + run: make test-core + - name: Submit coverage to Coveralls + run: | + pip install coveralls + coveralls --service=github + + test-country-template: + runs-on: ubuntu-latest + needs: [ build ] + env: + TERM: xterm-256color + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.7.12 + - name: Cache build + id: restore-build + uses: actions/cache@v2 + with: + path: ${{ env.pythonLocation }} + key: build-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}-${{ github.sha }} + - name: Run Country Template tests + run: make test-country + + test-extension-template: + runs-on: ubuntu-latest + needs: [ build ] + env: + TERM: xterm-256color + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.7.12 + - name: Cache build + id: restore-build + uses: actions/cache@v2 + with: + path: ${{ env.pythonLocation }} + key: build-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}-${{ github.sha }} + - name: Run Extension Template tests + run: make test-extension + + check-numpy: + runs-on: ubuntu-latest + needs: [ build ] + env: + TERM: xterm-256color + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.7.12 + - name: Cache build + id: restore-build + uses: actions/cache@v2 + with: + path: ${{ env.pythonLocation }} + key: build-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}-${{ github.sha }} + - name: Check NumPy typing against latest 3 minor versions + run: for i in {1..3}; do VERSION=$(${GITHUB_WORKSPACE}/.github/get-numpy-version.py prev) && pip install numpy==$VERSION && make check-types; done + + test-docs: + runs-on: ubuntu-latest + needs: [ build ] + env: + TERM: xterm-256color + steps: + - uses: actions/checkout@v2 + - name: Checkout docs + run: make test-doc-checkout branch=${GITHUB_REF#refs/heads/} + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.7.12 + - name: Cache build + id: restore-build + uses: actions/cache@v2 + with: + path: ${{ env.pythonLocation }} + key: build-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}-${{ github.sha }} + - name: Cache docs + id: restore-docs + uses: actions/cache@v2 + with: + path: ${{ env.pythonLocation }} + key: docs-${{ env.pythonLocation }}-${{ hashFiles('doc/requirements.txt') }}--${{ github.sha }} + - name: Install dependencies + run: make test-doc-install + - name: Run doc tests + run: make test-doc-build + + lint-files: + runs-on: ubuntu-latest + needs: [ build ] + env: + TERM: xterm-256color + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # Fetch all the tags + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.7.12 + - name: Cache build + id: restore-build + uses: actions/cache@v2 + with: + path: ${{ env.pythonLocation }} + key: build-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}-${{ github.sha }} + - name: Run linters + run: make lint + + check-version: + runs-on: ubuntu-latest + needs: [ test-core, test-country-template, test-extension-template, check-numpy, test-docs, lint-files ] # Last job to run + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # Fetch all the tags + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.7.12 + - name: Check version number has been properly updated + run: "${GITHUB_WORKSPACE}/.github/is-version-number-acceptable.sh" + + # GitHub Actions does not have a halt job option, to stop from deploying if no functional changes were found. + # We build a separate job to substitute the halt option. + # The `deploy` job is dependent on the output of the `check-for-functional-changes`job. + check-for-functional-changes: + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' # Only triggered for the `master` branch + needs: [ check-version ] + outputs: + status: ${{ steps.stop-early.outputs.status }} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # Fetch all the tags + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.7.12 + - id: stop-early + run: if "${GITHUB_WORKSPACE}/.github/has-functional-changes.sh" ; then echo "::set-output name=status::success" ; fi # The `check-for-functional-changes` job should always succeed regardless of the `has-functional-changes` script's exit code. Consequently, we do not use that exit code to trigger deploy, but rather a dedicated output variable `status`, to avoid a job failure if the exit code is different from 0. Conversely, if the job fails the entire workflow would be marked as `failed` which is disturbing for contributors. + + deploy: + runs-on: ubuntu-latest + needs: [ check-for-functional-changes ] + if: needs.check-for-functional-changes.outputs.status == 'success' + env: + PYPI_USERNAME: openfisca-bot + PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + CIRCLE_TOKEN: ${{ secrets.CIRCLECI_V1_OPENFISCADOC_TOKEN }} # Personal API token created in CircleCI to grant full read and write permissions + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # Fetch all the tags + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.7.12 + - name: Cache build + id: restore-build + uses: actions/cache@v2 + with: + path: ${{ env.pythonLocation }} + key: build-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}-${{ github.sha }} + - name: Cache release + id: restore-release + uses: actions/cache@v2 + with: + path: dist + key: release-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}-${{ github.sha }} + - name: Upload a Python package to PyPi + run: twine upload dist/* --username $PYPI_USERNAME --password $PYPI_PASSWORD + - name: Publish a git tag + run: "${GITHUB_WORKSPACE}/.github/publish-git-tag.sh" + - name: Update doc + run: | + curl -X POST --header "Content-Type: application/json" -d '{"branch":"master"}' https://circleci.com/api/v1.1/project/github/openfisca/openfisca-doc/build?circle-token=${{ secrets.CIRCLECI_V1_OPENFISCADOC_TOKEN }} diff --git a/.gitignore b/.gitignore index 4b56efc6da..c66d2bd194 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,22 @@ -.venv -.project -.spyderproject -.pydevproject -.vscode -.settings/ -.vscode/ -build/ -dist/ -doc/ *.egg-info *.mo *.pyc *~ -/cover -/.coverage -/tags -.tags* +.coverage +.mypy_cache .noseids +.project +.pydevproject .pytest_cache -.mypy_cache +.settings +.spyderproject +.tags* +.venv +.vscode +.vscode +build +cover +dist +doc performance.json +tags diff --git a/CHANGELOG.md b/CHANGELOG.md index 385cc78b89..bb304c5453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,98 @@ # Changelog -## 35.6.0 [#1021](https://github.com/openfisca/openfisca-core/pull/1021) +## 35.8.0 [#1021](https://github.com/openfisca/openfisca-core/pull/1021) #### New features - Introduce `neutralize_variables` option in YAML test files - A neutralized variable in a YAML test will return its default value when computed. +### 35.7.4 [#1083](https://github.com/openfisca/openfisca-core/pull/1083) + +#### Technical changes + +- Add GitHub `pull-request` event as a trigger to GitHub Actions workflow + +### 35.7.3 [#1081](https://github.com/openfisca/openfisca-core/pull/1081) + +- Correct error message in case of mis-sized population + +### 35.7.2 [#1057](https://github.com/openfisca/openfisca-core/pull/1057) + +#### Technical changes + +- Switch CI provider from CircleCI to GitHub Actions + +### 35.7.1 [#1075](https://github.com/openfisca/openfisca-core/pull/1075) + +#### Bug fix + +- Fix the collection of OpenFisca-Core tests coverage data + - Tests within `openfisca_core/*` were not run + +## 35.7.0 [#1070](https://github.com/openfisca/openfisca-core/pulls/1070) + +#### New Features + +- Add group population shortcut to containing groups entities + +## 35.6.0 [#1054](https://github.com/openfisca/openfisca-core/pull/1054) + +#### New Features + +- Introduce `openfisca_core.types` + +#### Documentation + +- Complete typing of the commons module + +#### Dependencies + +- `nptyping` + - To add backport-support for numpy typing + - Can be removed once lower-bound numpy version is 1.21+ + +- `typing_extensions` + - To add backport-support for `typing.Protocol` and `typing.Literal` + - Can be removed once lower-bound python version is 3.8+ + +### 35.5.5 [#1055](https://github.com/openfisca/openfisca-core/pull/1055) + +#### Documentation + +- Complete the documentation of the commons module + +### 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 + +- Run openfisca-core & country/extension template tests systematically + +### 35.5.2 [#1048](https://github.com/openfisca/openfisca-core/pull/1048) + +#### Bug fix + +- In _test_yaml.py_: + - Fix yaml tests loading —required for testing against the built version. + +### 35.5.1 [#1046](https://github.com/openfisca/openfisca-core/pull/1046) + +#### Non-technical changes + +- Reorganise `Makefile` into context files (install, test, publish…) +- Colorise `make` tasks and improve messages printed to the user + ## 35.5.0 [#1038](https://github.com/openfisca/openfisca-core/pull/1038) #### New Features diff --git a/Makefile b/Makefile index c139b6aac0..b5c73a5ff8 100644 --- a/Makefile +++ b/Makefile @@ -1,120 +1,35 @@ -help = sed -n "/^$1/ { x ; p ; } ; s/\#\#/[⚙]/ ; s/\./.../ ; x" ${MAKEFILE_LIST} -repo = https://github.com/openfisca/openfisca-doc -branch = $(shell git branch --show-current) +include openfisca_tasks/install.mk +include openfisca_tasks/lint.mk +include openfisca_tasks/publish.mk +include openfisca_tasks/serve.mk +include openfisca_tasks/test_code.mk +include openfisca_tasks/test_doc.mk -## Same as `make test`. -all: test - -## Install project dependencies. -install: - @$(call help,$@:) - @pip install --upgrade pip twine wheel - @pip install --editable .[dev] --upgrade --use-deprecated=legacy-resolver - -## Install openfisca-core for deployment and publishing. -build: setup.py - @## This allows us to be sure tests are run against the packaged version - @## of openfisca-core, the same we put in the hands of users and reusers. - @$(call help,$@:) - @python $? bdist_wheel - @find dist -name "*.whl" -exec pip install --force-reinstall {}[dev] \; - -## Uninstall project dependencies. -uninstall: - @$(call help,$@:) - @pip freeze | grep -v "^-e" | sed "s/@.*//" | xargs pip uninstall -y - -## Delete builds and compiled python files. -clean: \ - $(shell ls -d * | grep "build\|dist") \ - $(shell find . -name "*.pyc") - @$(call help,$@:) - @rm -rf $? +## To share info with the user, but no action is needed. +print_info = $$(tput setaf 6)[i]$$(tput sgr0) -## Compile python files to check for syntax errors. -check-syntax-errors: . - @$(call help,$@:) - @python -m compileall -q $? +## To warn the user of something, but no action is needed. +print_warn = $$(tput setaf 3)[!]$$(tput sgr0) -## Run linters to check for syntax and style errors. -check-style: $(shell git ls-files "*.py") - @$(call help,$@:) - @flake8 $? +## To let the user know where we are in the task pipeline. +print_work = $$(tput setaf 5)[⚙]$$(tput sgr0) -## Run code formatters to correct style errors. -format-style: $(shell git ls-files "*.py") - @$(call help,$@:) - @autopep8 $? +## To let the user know the task in progress succeded. +## The `$1` is a function argument, passed from a task (usually the task name). +print_pass = echo $$(tput setaf 2)[✓]$$(tput sgr0) $$(tput setaf 8)$1$$(tput sgr0)$$(tput setaf 2)passed$$(tput sgr0) $$(tput setaf 1)❤$$(tput sgr0) -## Run static type checkers for type errors. -check-types: openfisca_core openfisca_web_api - @$(call help,$@:) - @mypy $? +## Similar to `print_work`, but this will read the comments above a task, and +## print them to the user at the start of each task. The `$1` is a function +## argument. +print_help = sed -n "/^$1/ { x ; p ; } ; s/\#\#/\r$(print_work)/ ; s/\./…/ ; x" ${MAKEFILE_LIST} -## Run openfisca-core tests. -test: clean check-syntax-errors check-style check-types - @$(call help,$@:) - @env PYTEST_ADDOPTS="${PYTEST_ADDOPTS} --cov=openfisca_core" pytest +## Same as `make`. +.DEFAULT_GOAL := all -## Check that the current changes do not break the doc. -test-doc: - @## Usage: - @## - @## make test-doc [branch=BRANCH] - @## - @## Examples: - @## - @## # Will check the current branch in openfisca-doc. - @## make test-doc - @## - @## # Will check "test-doc" in openfisca-doc. - @## make test-doc branch=test-doc - @## - @## # Will check "master" if "asdf1234" does not exist. - @## make test-doc branch=asdf1234 - @## - @$(call help,$@:) - @${MAKE} test-doc-checkout - @${MAKE} test-doc-install - @${MAKE} test-doc-build - -## Update the local copy of the doc. -test-doc-checkout: - @$(call help,$@:) - @[ ! -d doc ] && git clone ${repo} doc || : - @cd doc && { \ - git reset --hard ; \ - git fetch --all ; \ - [ $$(git branch --show-current) != master ] && git checkout master || : ; \ - [ ${branch} != "master" ] \ - && { \ - { \ - git branch -D ${branch} 2> /dev/null ; \ - git checkout ${branch} ; \ - } \ - && git pull --ff-only origin ${branch} \ - || { \ - >&2 echo "[!] The branch '${branch}' doesn't exist, checking out 'master' instead..." ; \ - git pull --ff-only origin master ; \ - } \ - } \ - || git pull --ff-only origin master ; \ - } 1> /dev/null - -## Install doc dependencies. -test-doc-install: - @$(call help,$@:) - @pip install --requirement doc/requirements.txt 1> /dev/null - @pip install --editable .[dev] --upgrade 1> /dev/null - -## Dry-build the doc. -test-doc-build: - @$(call help,$@:) - @sphinx-build -M dummy doc/source doc/build -n -q -W +## Same as `make test`. +all: test + @$(call print_pass,$@:) -## Serve the openfisca Web API. -api: - @$(call help,$@:) - @openfisca serve \ - --country-package openfisca_country_template \ - --extensions openfisca_extension_template +## Run all lints and tests. +test: clean lint test-code + @$(call print_pass,$@:) diff --git a/README.md b/README.md index 7f253c9114..7f8b79c150 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ [![Twitter](https://img.shields.io/badge/twitter-follow%20us!-9cf.svg?style=flat)](https://twitter.com/intent/follow?screen_name=openfisca) [![Slack](https://img.shields.io/badge/slack-join%20us!-blueviolet.svg?style=flat)](mailto:contact%40openfisca.org?subject=Join%20you%20on%20Slack%20%7C%20Nous%20rejoindre%20sur%20Slack&body=%5BEnglish%20version%20below%5D%0A%0ABonjour%2C%0A%0AVotre%C2%A0pr%C3%A9sence%C2%A0ici%C2%A0nous%C2%A0ravit%C2%A0!%20%F0%9F%98%83%0A%0ARacontez-nous%20un%20peu%20de%20vous%2C%20et%20du%20pourquoi%20de%20votre%20int%C3%A9r%C3%AAt%20de%20rejoindre%20la%20communaut%C3%A9%20OpenFisca%20sur%20Slack.%0A%0AAh%C2%A0!%20Et%20si%20vous%20pouviez%20remplir%20ce%20petit%20questionnaire%2C%20%C3%A7a%20serait%20encore%20mieux%C2%A0!%0Ahttps%3A%2F%2Fgoo.gl%2Fforms%2F45M0VR1TYKD1RGzX2%0A%0AN%E2%80%99oubliez%20pas%20de%20nous%20envoyer%20cet%20email%C2%A0!%20Sinon%2C%20on%20ne%20pourra%20pas%20vous%20contacter%20ni%20vous%20inviter%20sur%20Slack.%0A%0AAmiti%C3%A9%2C%0AL%E2%80%99%C3%A9quipe%20OpenFisca%0A%0A%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%20ENGLISH%20VERSION%20%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%3D%0A%0AHi%2C%20%0A%0AWe're%20glad%20to%20see%20you%20here!%20%F0%9F%98%83%0A%0APlease%20tell%20us%20a%20bit%20about%20you%20and%20why%20you%20want%20to%20join%20the%20OpenFisca%20community%20on%20Slack.%0A%0AAlso%2C%20if%20you%20can%20fill%20out%20this%20short%20survey%2C%20even%20better!%0Ahttps%3A%2F%2Fgoo.gl%2Fforms%2FsOg8K1abhhm441LG2.%0A%0ADon't%20forget%20to%20send%20us%20this%20email!%20Otherwise%20we%20won't%20be%20able%20to%20contact%20you%20back%2C%20nor%20invite%20you%20on%20Slack.%0A%0ACheers%2C%0AThe%20OpenFisca%20Team) -[![CircleCI](https://img.shields.io/circleci/project/github/openfisca/openfisca-core/master.svg?style=flat)](https://circleci.com/gh/openfisca/openfisca-core) [![Coveralls](https://img.shields.io/coveralls/github/openfisca/openfisca-core/master.svg?style=flat)](https://coveralls.io/github/openfisca/openfisca-core?branch=master) [![Python](https://img.shields.io/pypi/pyversions/openfisca-core.svg)](https://pypi.python.org/pypi/openfisca-core) [![PyPi](https://img.shields.io/pypi/v/openfisca-core.svg?style=flat)](https://pypi.python.org/pypi/openfisca-core) @@ -158,7 +157,7 @@ rm -rf doc 7. Finally, open a pull request both in [core](https://github.com/openfisca/openfisca-core/compare/master...fix-doc) and in the [doc](https://github.com/openfisca/openfisca-doc/compare/master...fix-doc). -[CircleCI](.circleci/config.yml) will automatically try to build the documentation from the same branch in both core and the doc (in our example "fix-doc") so we can integrate first our changes to core, and then our changes to the doc. +Continuous integration will automatically try to build the documentation from the same branch in both core and the doc (in our example "fix-doc") so we can integrate first our changes to Core, and then our changes to the doc. If no changes were needed to the doc, then your changes to core will be verified against the production version of the doc. diff --git a/openfisca_core/commons/__init__.py b/openfisca_core/commons/__init__.py index db41ed1874..b3b5d8cbb2 100644 --- a/openfisca_core/commons/__init__.py +++ b/openfisca_core/commons/__init__.py @@ -1,28 +1,67 @@ -# 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.module import Symbol -# >>> Symbol() -# -# See: https://www.python.org/dev/peps/pep-0008/#imports +"""Common tools for contributors and users. -from .dummy import Dummy # noqa: F401 +The tools in this sub-package are intended, to help both contributors +to OpenFisca Core and to country packages. + +Official Public API: + * :func:`.apply_thresholds` + * :func:`.average_rate` + * :func:`.concat` + * :func:`.empty_clone` + * :func:`.marginal_rate` + * :func:`.stringify_array` + * :func:`.switch` + +Deprecated: + * :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 + major release. + +Note: + How imports are being used today:: + + from openfisca_core.commons import * # Bad + from openfisca_core.commons.formulas import switch # Bad + from openfisca_core.commons.decorators import deprecated # 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 commons + from openfisca_core.commons import deprecated + + deprecated() # Good: import classes as publicly exposed + commons.switch() # 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 .formulas import apply_thresholds, concat, switch # noqa: F401 from .misc import empty_clone, stringify_array # noqa: F401 from .rates import average_rate, marginal_rate # noqa: F401 + +__all__ = ["apply_thresholds", "concat", "switch"] +__all__ = ["empty_clone", "stringify_array", *__all__] +__all__ = ["average_rate", "marginal_rate", *__all__] + +# Deprecated + +from .dummy import Dummy # noqa: F401 + +__all__ = ["Dummy", *__all__] diff --git a/openfisca_core/commons/dummy.py b/openfisca_core/commons/dummy.py index 4136a0d429..5f1b0be330 100644 --- a/openfisca_core/commons/dummy.py +++ b/openfisca_core/commons/dummy.py @@ -2,7 +2,17 @@ class Dummy: - """A class that does nothing.""" + """A class that did nothing. + + Examples: + >>> Dummy() + None: message = [ diff --git a/openfisca_core/commons/formulas.py b/openfisca_core/commons/formulas.py index 4fadc1b518..6a90622147 100644 --- a/openfisca_core/commons/formulas.py +++ b/openfisca_core/commons/formulas.py @@ -1,52 +1,128 @@ +from typing import Any, Dict, Sequence, TypeVar + import numpy +from openfisca_core.types import ArrayLike, ArrayType + +T = TypeVar("T") + + +def apply_thresholds( + input: ArrayType[float], + thresholds: ArrayLike[float], + choices: ArrayLike[float], + ) -> ArrayType[float]: + """Makes a choice based on an input and thresholds. + + From a list of ``choices``, this function selects one of these values + based on a list of inputs, depending on the value of each ``input`` within + a list of ``thresholds``. + + Args: + input: A list of inputs to make a choice from. + thresholds: A list of thresholds to choose. + choices: A list of the possible values to choose from. + + Returns: + :obj:`numpy.ndarray` of :obj:`float`: + A list of the values chosen. + + Raises: + :exc:`AssertionError`: When the number of ``thresholds`` (t) and the + number of choices (c) are not either t == c or t == c - 1. + + 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]) -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]) """ + + condlist: Sequence[ArrayType[bool]] 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. + # If a choice is provided for input > highest threshold, last condition + # must be true to return it. condlist += [True] + assert len(condlist) == len(choices), \ - "apply_thresholds must be called with the same number of thresholds than choices, or one more choice" + " ".join([ + "'apply_thresholds' must be called with the same number of", + "thresholds than choices, or one more choice.", + ]) + return numpy.select(condlist, choices) -def concat(this, that): - if isinstance(this, numpy.ndarray) and not numpy.issubdtype(this.dtype, numpy.str): +def concat(this: ArrayLike[str], that: ArrayLike[str]) -> ArrayType[str]: + """Concatenates the values of two arrays. + + Args: + this: An array to concatenate. + that: Another array to concatenate. + + Returns: + :obj:`numpy.ndarray` of :obj:`float`: + An array with the concatenated values. + + 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): + + if isinstance(that, numpy.ndarray) and \ + not numpy.issubdtype(that.dtype, numpy.str_): + that = that.astype('str') - return numpy.core.defchararray.add(this, that) + return numpy.char.add(this, that) + +def switch( + conditions: ArrayType[Any], + value_by_condition: Dict[float, T], + ) -> ArrayType[T]: + """Mimicks a switch statement. -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. + Given an array of conditions, returns an array of the same size, + replacing each condition item with the matching given value. - Example: - >>> switch(np.array([1, 1, 1, 2]), {1: 80, 2: 90}) + Args: + conditions: An array of conditions. + value_by_condition: Values to replace for each condition. + + Returns: + :obj:`numpy.ndarray`: + An array with the replaced values. + + Raises: + :exc:`AssertionError`: When ``value_by_condition`` is empty. + + 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" + "'switch' must be called with at least one value." + condlist = [ conditions == condition for condition in value_by_condition.keys() ] + return numpy.select(condlist, value_by_condition.values()) diff --git a/openfisca_core/commons/misc.py b/openfisca_core/commons/misc.py index eb2bc7372c..dd05cea11b 100644 --- a/openfisca_core/commons/misc.py +++ b/openfisca_core/commons/misc.py @@ -1,20 +1,75 @@ -import numpy +from typing import TypeVar +from openfisca_core.types import ArrayType -def empty_clone(original): - """Create a new empty instance of the same class of the original object.""" - class Dummy(original.__class__): - def __init__(self) -> None: - pass +T = TypeVar("T") + + +def empty_clone(original: T) -> T: + """Creates an empty instance of the same class of the original object. + + Args: + original: An object to clone. + + Returns: + The cloned, empty, object. + + Examples: + >>> Foo = type("Foo", (list,), {}) + >>> foo = Foo([1, 2, 3]) + >>> foo + [1, 2, 3] + + >>> bar = empty_clone(foo) + >>> bar + [] + + >>> isinstance(bar, Foo) + True + + """ + + Dummy: object + new: T + + Dummy = type( + "Dummy", + (original.__class__,), + {"__init__": lambda self: None}, + ) new = Dummy() new.__class__ = original.__class__ return new -def stringify_array(array: numpy.ndarray) -> str: - """ - Generate a clean string representation of a NumPY array. +def stringify_array(array: ArrayType) -> str: + """Generates a clean string representation of a numpy array. + + Args: + array: An array. + + Returns: + :obj:`str`: + "None" if the ``array`` is None, the stringified ``array`` otherwise. + + 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) + "[, {}, ArrayType[float]: + """Computes the average rate of a target net income. + + Given a ``target`` net income, and according to the ``varying`` gross + income. Optionally, a ``trim`` can be applied consisting of the lower and + upper bounds of the average rate to be computed. + + Note: + Usually, ``target`` and ``varying`` are the same size. + + Args: + target: The targeted net income. + varying: The varying gross income. + trim: The lower and upper bounds of the average rate. + + Returns: + :obj:`numpy.ndarray` of :obj:`float`: -def average_rate(target = None, varying = None, trim = None): - ''' - Computes the average rate of a targeted net income, according to the varying gross income. + The average rate for each target. + + When ``trim`` is provided, values that are out of the provided bounds + are replaced by :obj:`numpy.nan`. + + Examples: + >>> 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: ArrayType[float] - :param target: Targeted net income, numerator - :param varying: Varying gross income, denominator - :param trim: Lower and upper bound of average rate to return - ''' average_rate = 1 - target / varying + if trim is not None: - average_rate = numpy.where(average_rate <= max(trim), average_rate, numpy.nan) - average_rate = numpy.where(average_rate >= min(trim), average_rate, numpy.nan) + + average_rate = numpy.where( + average_rate <= max(trim), + average_rate, + numpy.nan, + ) + + average_rate = numpy.where( + average_rate >= min(trim), + average_rate, + numpy.nan, + ) return average_rate -def marginal_rate(target = None, varying = None, trim = None): - # target: numerator, varying: denominator - marginal_rate = 1 - (target[:-1] - target[1:]) / (varying[:-1] - varying[1:]) +def marginal_rate( + target: ArrayType[float], + varying: ArrayType[float], + trim: Optional[ArrayLike[float]] = None, + ) -> ArrayType[float]: + """Computes the marginal rate of a target net income. + + Given a ``target`` net income, and according to the ``varying`` gross + income. Optionally, a ``trim`` can be applied consisting of the lower and + upper bounds of the marginal rate to be computed. + + Note: + Usually, ``target`` and ``varying`` are the same size. + + Args: + target: The targeted net income. + varying: The varying gross income. + trim: The lower and upper bounds of the marginal rate. + + Returns: + :obj:`numpy.ndarray` of :obj:`float`: + + The marginal rate for each target. + + When ``trim`` is provided, values that are out of the provided bounds + are replaced by :obj:`numpy.nan`. + + 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]) + + """ + + marginal_rate: ArrayType[float] + + marginal_rate = ( + + 1 + - (target[:-1] - target[1:]) + / (varying[:-1] - varying[1:]) + ) + if trim is not None: - marginal_rate = numpy.where(marginal_rate <= max(trim), marginal_rate, numpy.nan) - marginal_rate = numpy.where(marginal_rate >= min(trim), marginal_rate, numpy.nan) + + marginal_rate = numpy.where( + marginal_rate <= max(trim), + marginal_rate, + numpy.nan, + ) + + marginal_rate = numpy.where( + marginal_rate >= min(trim), + marginal_rate, + numpy.nan, + ) return marginal_rate 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_core/entities/group_entity.py b/openfisca_core/entities/group_entity.py index d0a4113b35..0d58acc6ba 100644 --- a/openfisca_core/entities/group_entity.py +++ b/openfisca_core/entities/group_entity.py @@ -2,11 +2,28 @@ class GroupEntity(Entity): - """ - Represents an entity composed of several persons with different roles, on which calculations are run. + """Represents an entity containing several others with different roles. + + A :class:`.GroupEntity` represents an :class:`.Entity` containing + several other :class:`.Entity` with different :class:`.Role`, and on + which calculations can be run. + + 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. + + .. versionchanged:: 35.7.0 + Added ``containing_entities``, that allows the defining of group + entities which entirely contain other group entities. + """ - def __init__(self, key, plural, label, doc, roles): + def __init__(self, key, plural, label, doc, roles, containing_entities = ()): super().__init__(key, plural, label, doc) self.roles_description = roles self.roles = [] @@ -23,3 +40,4 @@ def __init__(self, key, plural, label, doc, roles): 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 diff --git a/openfisca_core/entities/helpers.py b/openfisca_core/entities/helpers.py index d1c66a66ba..86d7bb6a6b 100644 --- a/openfisca_core/entities/helpers.py +++ b/openfisca_core/entities/helpers.py @@ -1,8 +1,8 @@ from openfisca_core import entities -def build_entity(key, plural, label, doc = "", roles = None, is_person = False, class_override = None): +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) + return entities.GroupEntity(key, plural, label, doc, roles, containing_entities = containing_entities) diff --git a/openfisca_core/populations/population.py b/openfisca_core/populations/population.py index 41cdbcd8c4..a0a717ac97 100644 --- a/openfisca_core/populations/population.py +++ b/openfisca_core/populations/population.py @@ -44,7 +44,7 @@ def get_index(self, id): def check_array_compatible_with_entity(self, array): if not self.count == array.size: raise ValueError("Input {} is not a valid value for the entity {} (size = {} != {} = count)".format( - array, self.key, array.size, self.count)) + array, self.entity.key, array.size, self.count)) def check_period_validity(self, variable_name, period): if period is None: diff --git a/openfisca_core/projectors/helpers.py b/openfisca_core/projectors/helpers.py index 502eee1dfb..7bc55e0fd9 100644 --- a/openfisca_core/projectors/helpers.py +++ b/openfisca_core/projectors/helpers.py @@ -21,3 +21,5 @@ def get_projector_from_shortcut(population, shortcut, parent = None): role = next((role for role in population.entity.flattened_roles if (role.max == 1) and (role.key == shortcut)), None) if role: return projectors.UniqueRoleToEntityProjector(population, role, parent) + if shortcut in population.entity.containing_entities: + return getattr(projectors.FirstPersonToEntityProjector(population, parent), shortcut) diff --git a/openfisca_core/tools/test_runner.py b/openfisca_core/tools/test_runner.py index 7960a9bf26..07028e1ce1 100644 --- a/openfisca_core/tools/test_runner.py +++ b/openfisca_core/tools/test_runner.py @@ -87,17 +87,17 @@ def run_tests(tax_benefit_system, paths, options = None): """ - argv = ["--capture", "no"] + argv = [] if options.get('pdb'): argv.append('--pdb') + if options.get('verbose'): + argv.append('--verbose') + if isinstance(paths, str): paths = [paths] - if options is None: - options = {} - return pytest.main([*argv, *paths] if True else paths, plugins = [OpenFiscaPlugin(tax_benefit_system, options)]) diff --git a/openfisca_core/types/__init__.py b/openfisca_core/types/__init__.py new file mode 100644 index 0000000000..e14cfea65d --- /dev/null +++ b/openfisca_core/types/__init__.py @@ -0,0 +1,45 @@ +"""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 new file mode 100644 index 0000000000..6dd38194e3 --- /dev/null +++ b/openfisca_core/types/data_types/__init__.py @@ -0,0 +1 @@ +from .arrays import ArrayLike, ArrayType # noqa: F401 diff --git a/openfisca_core/types/data_types/arrays.py b/openfisca_core/types/data_types/arrays.py new file mode 100644 index 0000000000..5cfef639c5 --- /dev/null +++ b/openfisca_core/types/data_types/arrays.py @@ -0,0 +1,51 @@ +from typing import Sequence, TypeVar, Union + +from nptyping import types, NDArray as ArrayType + +import numpy + +T = TypeVar("T", bool, bytes, float, int, object, str) + +types._ndarray_meta._Type = Union[type, numpy.dtype, TypeVar] + +ArrayLike = Union[ArrayType[T], Sequence[T]] +""":obj:`typing.Generic`: Type of any castable to :class:`numpy.ndarray`. + +These include any :obj:`numpy.ndarray` and sequences (like +:obj:`list`, :obj:`tuple`, and so on). + +Examples: + >>> ArrayLike[float] + typing.Union[numpy.ndarray, typing.Sequence[float]] + + >>> ArrayLike[str] + typing.Union[numpy.ndarray, typing.Sequence[str]] + +Note: + It is possible since numpy version 1.21 to specify the type of an + array, thanks to `numpy.typing.NDArray`_:: + + from numpy.typing import NDArray + NDArray[numpy.float64] + + `mypy`_ provides `duck type compatibility`_, so an :obj:`int` is + considered to be valid whenever a :obj:`float` is expected. + +Todo: + * Refactor once numpy version >= 1.21 is used. + +.. versionadded:: 35.5.0 + +.. versionchanged:: 35.6.0 + Moved to :mod:`.types` + +.. _mypy: + https://mypy.readthedocs.io/en/stable/ + +.. _duck type compatibility: + https://mypy.readthedocs.io/en/stable/duck_type_compatibility.html + +.. _numpy.typing.NDArray: + https://numpy.org/doc/stable/reference/typing.html#numpy.typing.NDArray + +""" diff --git a/openfisca_tasks/install.mk b/openfisca_tasks/install.mk new file mode 100644 index 0000000000..f37d17f26f --- /dev/null +++ b/openfisca_tasks/install.mk @@ -0,0 +1,17 @@ +## Install project dependencies. +install: + @$(call print_help,$@:) + @pip install --upgrade pip twine wheel + @pip install --editable .[dev] --upgrade --use-deprecated=legacy-resolver + +## Uninstall project dependencies. +uninstall: + @$(call print_help,$@:) + @pip freeze | grep -v "^-e" | sed "s/@.*//" | xargs pip uninstall -y + +## Delete builds and compiled python files. +clean: \ + $(shell ls -d * | grep "build\|dist") \ + $(shell find . -name "*.pyc") + @$(call print_help,$@:) + @rm -rf $? diff --git a/openfisca_tasks/lint.mk b/openfisca_tasks/lint.mk new file mode 100644 index 0000000000..115c6267bb --- /dev/null +++ b/openfisca_tasks/lint.mk @@ -0,0 +1,62 @@ +## Lint the codebase. +lint: check-syntax-errors check-style lint-doc check-types lint-typing-strict + @$(call print_pass,$@:) + +## Compile python files to check for syntax errors. +check-syntax-errors: . + @$(call print_help,$@:) + @python -m compileall -q $? + @$(call print_pass,$@:) + +## Run linters to check for syntax and style errors. +check-style: $(shell git ls-files "*.py") + @$(call print_help,$@:) + @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 progresively. + @## + @$(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: + @$(call print_help,$@:) + @mypy --package openfisca_core --package openfisca_web_api + @$(call print_pass,$@:) + +## Run static type checkers for type errors (strict). +lint-typing-strict: \ + lint-typing-strict-commons \ + lint-typing-strict-types \ + ; + +## Run static type checkers for type errors (strict). +lint-typing-strict-%: + @$(call print_help,$(subst $*,%,$@:)) + @mypy \ + --cache-dir .mypy_cache-openfisca_core.$* \ + --implicit-reexport \ + --strict \ + --package openfisca_core.$* + @$(call print_pass,$@:) + +## Run code formatters to correct style errors. +format-style: $(shell git ls-files "*.py") + @$(call print_help,$@:) + @autopep8 $? + @$(call print_pass,$@:) diff --git a/openfisca_tasks/publish.mk b/openfisca_tasks/publish.mk new file mode 100644 index 0000000000..28a4b40546 --- /dev/null +++ b/openfisca_tasks/publish.mk @@ -0,0 +1,12 @@ +.PHONY: build + +## Install openfisca-core for deployment and publishing. +build: + @## This allows us to be sure tests are run against the packaged version + @## of openfisca-core, the same we put in the hands of users and reusers. + @$(call print_help,$@:) + @pip install --upgrade pip build twine + @python -m build + @pip uninstall --yes openfisca-core + @find dist -name "*.whl" -exec pip install {}[dev] \; + @$(call print_pass,$@:) diff --git a/openfisca_tasks/serve.mk b/openfisca_tasks/serve.mk new file mode 100644 index 0000000000..efad0be6cb --- /dev/null +++ b/openfisca_tasks/serve.mk @@ -0,0 +1,6 @@ +## Serve the openfisca Web API. +api: + @$(call print_help,$@:) + @openfisca serve \ + --country-package openfisca_country_template \ + --extensions openfisca_extension_template diff --git a/openfisca_tasks/test_code.mk b/openfisca_tasks/test_code.mk new file mode 100644 index 0000000000..4abcce6aed --- /dev/null +++ b/openfisca_tasks/test_code.mk @@ -0,0 +1,53 @@ +## The openfisca command module. +openfisca = openfisca_core.scripts.openfisca_command + +## The path to the installed packages. +python_packages = $(shell python -c "import sysconfig; print(sysconfig.get_paths()[\"purelib\"])") + +## Run openfisca-core & country/extension template tests. +test-code: test-core test-country test-extension + @## Usage: + @## + @## make test [pytest_args="--ARG"] [openfisca_args="--ARG"] + @## + @## Examples: + @## + @## make test + @## make test pytest_args="--exitfirst" + @## make test openfisca_args="--performance" + @## make test pytest_args="--exitfirst" openfisca_args="--performance" + @## + @$(call print_pass,$@:) + +## Run openfisca-core tests. +test-core: $(shell pytest --quiet --quiet --collect-only | cut -f 1 -d ":") + @$(call print_help,$@:) + @PYTEST_ADDOPTS="$${PYTEST_ADDOPTS} ${pytest_args}" \ + coverage run -m \ + ${openfisca} test $? \ + ${openfisca_args} + @$(call print_pass,$@:) + +## Run country-template tests. +test-country: + @$(call print_help,$@:) + @PYTEST_ADDOPTS="$${PYTEST_ADDOPTS} ${pytest_args}" \ + openfisca test ${python_packages}/openfisca_country_template/tests \ + --country-package openfisca_country_template \ + ${openfisca_args} + @$(call print_pass,$@:) + +## Run extension-template tests. +test-extension: + @$(call print_help,$@:) + @PYTEST_ADDOPTS="$${PYTEST_ADDOPTS} ${pytest_args}" \ + openfisca test ${python_packages}/openfisca_extension_template/tests \ + --country-package openfisca_country_template \ + --extensions openfisca_extension_template \ + ${openfisca_args} + @$(call print_pass,$@:) + +## Print the coverage report. +test-cov: + @$(call print_help,$@:) + @coverage report diff --git a/openfisca_tasks/test_doc.mk b/openfisca_tasks/test_doc.mk new file mode 100644 index 0000000000..bce952fe81 --- /dev/null +++ b/openfisca_tasks/test_doc.mk @@ -0,0 +1,78 @@ +## The repository of the documentation. +repo = https://github.com/openfisca/openfisca-doc + +## The current working branch. +branch = $(shell git branch --show-current) + +## Check that the current changes do not break the doc. +test-doc: + @## Usage: + @## + @## make test-doc [branch=BRANCH] + @## + @## Examples: + @## + @## # Will check the current branch in openfisca-doc. + @## make test-doc + @## + @## # Will check "test-doc" in openfisca-doc. + @## make test-doc branch=test-doc + @## + @## # Will check "master" if "asdf1234" does not exist. + @## make test-doc branch=asdf1234 + @## + @$(call print_help,$@:) + @${MAKE} test-doc-checkout + @${MAKE} test-doc-install + @${MAKE} test-doc-build + @$(call print_pass,$@:) + +## Update the local copy of the doc. +test-doc-checkout: + @$(call print_help,$@:) + @[ ! -d doc ] && git clone ${repo} doc || : + @cd doc && { \ + git reset --hard ; \ + git fetch --all ; \ + [ "$$(git branch --show-current)" != "master" ] && git checkout master || : ; \ + [ "${branch}" != "master" ] \ + && { \ + { \ + >&2 echo "$(print_info) Trying to checkout the branch 'openfisca-doc/${branch}'..." ; \ + git branch -D ${branch} 2> /dev/null ; \ + git checkout ${branch} 2> /dev/null ; \ + } \ + && git pull --ff-only origin ${branch} \ + || { \ + >&2 echo "$(print_warn) The branch 'openfisca-doc/${branch}' was not found, falling back to 'openfisca-doc/master'..." ; \ + >&2 echo "" ; \ + >&2 echo "$(print_info) This is perfectly normal, one of two things can ensue:" ; \ + >&2 echo "$(print_info)" ; \ + >&2 echo "$(print_info) $$(tput setaf 2)[If tests pass]$$(tput sgr0)" ; \ + >&2 echo "$(print_info) * No further action required on your side..." ; \ + >&2 echo "$(print_info)" ; \ + >&2 echo "$(print_info) $$(tput setaf 1)[If tests fail]$$(tput sgr0)" ; \ + >&2 echo "$(print_info) * Create the branch '${branch}' in 'openfisca-doc'... " ; \ + >&2 echo "$(print_info) * Push your fixes..." ; \ + >&2 echo "$(print_info) * Run 'make test-doc' again..." ; \ + >&2 echo "" ; \ + >&2 echo "$(print_work) Checking out 'openfisca-doc/master'..." ; \ + git pull --ff-only origin master ; \ + } \ + } \ + || 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..bb3ff50fc5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,23 +1,55 @@ -; 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) +; C011X: We (progressively) document the code base. +; D10X: We (progressively) check docstrings (see https://www.pydocstyle.org/en/2.1.1/error_codes.html#grouping). +; DARXXX: We (progressively) check docstrings (see https://github.com/terrencepreilly/darglint#error-codes). +; 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. +; R0401: We avoid cyclic imports —required for unit/doc tests. +; RST301: We use Google Python Style (see https://pypi.org/project/flake8-rst-docstrings/) +; 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 +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 +rst-directives = attribute, deprecated, seealso, versionadded, versionchanged +rst-roles = any, attr, class, exc, func, meth, obj +strictness = short + +[pylint.message_control] +disable = all +enable = C0115,C0116,R0401 +score = no + +[coverage:paths] +source = . */site-packages + +[coverage:run] +branch = true +source = openfisca_core, openfisca_web_api + +[coverage:report] +fail_under = 75 +show_missing = true +skip_covered = true +skip_empty = true [tool:pytest] -addopts = --showlocals --doctest-modules --disable-pytest-warnings -testpaths = tests -python_files = **/*.py +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 [mypy] ignore_missing_imports = True +install_types = True +non_interactive = True + +[mypy-openfisca_core.commons.tests.*] +ignore_errors = True [mypy-openfisca_core.scripts.*] -ignore_errors = True +ignore_errors = True diff --git a/setup.py b/setup.py index b32e600bcb..6b4ef20dcf 100644 --- a/setup.py +++ b/setup.py @@ -7,36 +7,41 @@ general_requirements = [ 'dpath >= 1.5.0, < 2.0.0', - 'pytest >= 4.4.1, < 6.0.0', # For openfisca test + 'nptyping == 1.4.4', + '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', + 'typing-extensions == 3.10.0.2', ] 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', + 'coverage == 6.0.2', + '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', + 'flake8-rst-docstrings == 0.2.3', + 'mypy == 0.910', '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', ] + api_requirements setup( name = 'OpenFisca-Core', - version = '35.6.0', + version = '35.8.0', 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_formulas.py b/tests/core/test_formulas.py index 876ca239d1..8851671755 100644 --- a/tests/core/test_formulas.py +++ b/tests/core/test_formulas.py @@ -94,3 +94,89 @@ def test_compare_multiplication_and_switch(simulation, month): uses_multiplication = simulation.calculate('uses_multiplication', period = month) uses_switch = simulation.calculate('uses_switch', period = month) assert numpy.all(uses_switch == uses_multiplication) + + +def test_group_encapsulation(): + """Projects a calculation to all members of an entity. + + When a household contains more than one family + Variables can be defined for the the household + And calculations are projected to all the member families. + + """ + from openfisca_core.taxbenefitsystems import TaxBenefitSystem + from openfisca_core.entities import build_entity + from openfisca_core.periods import ETERNITY + + person_entity = build_entity( + key="person", + plural="people", + label="A person", + is_person=True, + ) + family_entity = build_entity( + key="family", + plural="families", + label="A family (all members in the same household)", + containing_entities=["household"], + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + household_entity = build_entity( + key="household", + plural="households", + label="A household, containing one or more families", + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + + entities = [person_entity, family_entity, household_entity] + + system = TaxBenefitSystem(entities) + + class household_level_variable(Variable): + value_type = int + entity = household_entity + definition_period = ETERNITY + + class projected_family_level_variable(Variable): + value_type = int + entity = family_entity + definition_period = ETERNITY + + def formula(family, period): + return family.household("household_level_variable", period) + + system.add_variables(household_level_variable, projected_family_level_variable) + + simulation = SimulationBuilder().build_from_dict(system, { + "people": { + "person1": {}, + "person2": {}, + "person3": {} + }, + "families": { + "family1": { + "members": ["person1", "person2"] + }, + "family2": { + "members": ["person3"] + }, + }, + "households": { + "household1": { + "members": ["person1", "person2", "person3"], + "household_level_variable": { + "eternity": 5 + } + } + } + }) + + assert (simulation.calculate("projected_family_level_variable", "2021-01-01") == 5).all() diff --git a/tests/core/test_projectors.py b/tests/core/test_projectors.py new file mode 100644 index 0000000000..be401bbec8 --- /dev/null +++ b/tests/core/test_projectors.py @@ -0,0 +1,316 @@ +from openfisca_core.simulations.simulation_builder import SimulationBuilder +from openfisca_core.taxbenefitsystems import TaxBenefitSystem +from openfisca_core.entities import build_entity +from openfisca_core.model_api import Enum, Variable, ETERNITY +import numpy as np + + +def test_shortcut_to_containing_entity_provided(): + """ + Tests that, when an entity provides a containing entity, + the shortcut to that containing entity is provided. + """ + person_entity = build_entity( + key="person", + plural="people", + label="A person", + is_person=True, + ) + family_entity = build_entity( + key="family", + plural="families", + label="A family (all members in the same household)", + containing_entities=["household"], + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + household_entity = build_entity( + key="household", + plural="households", + label="A household, containing one or more families", + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + + entities = [person_entity, family_entity, household_entity] + + system = TaxBenefitSystem(entities) + simulation = SimulationBuilder().build_from_dict(system, {}) + assert simulation.populations["family"].household.entity.key == "household" + + +def test_shortcut_to_containing_entity_not_provided(): + """ + Tests that, when an entity doesn't provide a containing + entity, the shortcut to that containing entity is not provided. + """ + person_entity = build_entity( + key="person", + plural="people", + label="A person", + is_person=True, + ) + family_entity = build_entity( + key="family", + plural="families", + label="A family (all members in the same household)", + containing_entities=[], + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + household_entity = build_entity( + key="household", + plural="households", + label="A household, containing one or more families", + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + + entities = [person_entity, family_entity, household_entity] + + system = TaxBenefitSystem(entities) + simulation = SimulationBuilder().build_from_dict(system, {}) + try: + simulation.populations["family"].household + raise AssertionError() + except AttributeError: + pass + + +def test_enum_projects_downwards(): + """ + Test that an Enum-type household-level variable projects + values onto its members correctly. + """ + + person = build_entity( + key="person", + plural="people", + label="A person", + is_person=True, + ) + household = build_entity( + key="household", + plural="households", + label="A household", + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + + entities = [person, household] + + system = TaxBenefitSystem(entities) + + class enum(Enum): + FIRST_OPTION = "First option" + SECOND_OPTION = "Second option" + + class household_enum_variable(Variable): + value_type = Enum + possible_values = enum + default_value = enum.FIRST_OPTION + entity = household + definition_period = ETERNITY + + class projected_enum_variable(Variable): + value_type = Enum + possible_values = enum + default_value = enum.FIRST_OPTION + entity = person + definition_period = ETERNITY + + def formula(person, period): + return person.household("household_enum_variable", period) + + system.add_variables(household_enum_variable, projected_enum_variable) + + simulation = SimulationBuilder().build_from_dict(system, { + "people": { + "person1": {}, + "person2": {}, + "person3": {} + }, + "households": { + "household1": { + "members": ["person1", "person2", "person3"], + "household_enum_variable": { + "eternity": "SECOND_OPTION" + } + } + } + }) + + assert (simulation.calculate("projected_enum_variable", "2021-01-01").decode_to_str() == np.array(["SECOND_OPTION"] * 3)).all() + + +def test_enum_projects_upwards(): + """ + Test that an Enum-type person-level variable projects + values onto its household (from the first person) correctly. + """ + + person = build_entity( + key="person", + plural="people", + label="A person", + is_person=True, + ) + household = build_entity( + key="household", + plural="households", + label="A household", + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + + entities = [person, household] + + system = TaxBenefitSystem(entities) + + class enum(Enum): + FIRST_OPTION = "First option" + SECOND_OPTION = "Second option" + + class household_projected_variable(Variable): + value_type = Enum + possible_values = enum + default_value = enum.FIRST_OPTION + entity = household + definition_period = ETERNITY + + def formula(household, period): + return household.value_from_first_person(household.members("person_enum_variable", period)) + + class person_enum_variable(Variable): + value_type = Enum + possible_values = enum + default_value = enum.FIRST_OPTION + entity = person + definition_period = ETERNITY + + system.add_variables(household_projected_variable, person_enum_variable) + + simulation = SimulationBuilder().build_from_dict(system, { + "people": { + "person1": { + "person_enum_variable": { + "ETERNITY": "SECOND_OPTION" + } + }, + "person2": {}, + "person3": {} + }, + "households": { + "household1": { + "members": ["person1", "person2", "person3"], + } + } + }) + + assert (simulation.calculate("household_projected_variable", "2021-01-01").decode_to_str() == np.array(["SECOND_OPTION"])).all() + + +def test_enum_projects_between_containing_groups(): + """ + Test that an Enum-type person-level variable projects + values onto its household (from the first person) correctly. + """ + + person_entity = build_entity( + key="person", + plural="people", + label="A person", + is_person=True, + ) + family_entity = build_entity( + key="family", + plural="families", + label="A family (all members in the same household)", + containing_entities=["household"], + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + household_entity = build_entity( + key="household", + plural="households", + label="A household, containing one or more families", + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + + entities = [person_entity, family_entity, household_entity] + + system = TaxBenefitSystem(entities) + + class enum(Enum): + FIRST_OPTION = "First option" + SECOND_OPTION = "Second option" + + class household_level_variable(Variable): + value_type = Enum + possible_values = enum + default_value = enum.FIRST_OPTION + entity = household_entity + definition_period = ETERNITY + + class projected_family_level_variable(Variable): + value_type = Enum + possible_values = enum + default_value = enum.FIRST_OPTION + entity = family_entity + definition_period = ETERNITY + + def formula(family, period): + return family.household("household_level_variable", period) + + system.add_variables(household_level_variable, projected_family_level_variable) + + simulation = SimulationBuilder().build_from_dict(system, { + "people": { + "person1": {}, + "person2": {}, + "person3": {} + }, + "families": { + "family1": { + "members": ["person1", "person2"] + }, + "family2": { + "members": ["person3"] + }, + }, + "households": { + "household1": { + "members": ["person1", "person2", "person3"], + "household_level_variable": { + "eternity": "SECOND_OPTION" + } + } + } + }) + + assert (simulation.calculate("projected_family_level_variable", "2021-01-01").decode_to_str() == np.array(["SECOND_OPTION"])).all() 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() diff --git a/tests/core/test_yaml.py b/tests/core/test_yaml.py index 35ede618fa..23e24554df 100644 --- a/tests/core/test_yaml.py +++ b/tests/core/test_yaml.py @@ -1,4 +1,3 @@ -import pkg_resources import os import subprocess @@ -7,9 +6,9 @@ from openfisca_core.tools.test_runner import run_tests +from tests.fixtures import yaml_tests -openfisca_core_dir = pkg_resources.get_distribution('OpenFisca-Core').location -yaml_tests_dir = os.path.join(openfisca_core_dir, 'tests', 'core', 'yaml_tests') +yaml_tests_dir = os.path.dirname(yaml_tests.__file__) EXIT_OK = 0 EXIT_TESTSFAILED = 1 diff --git a/tests/fixtures/yaml_tests/__init__.py b/tests/fixtures/yaml_tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/core/yaml_tests/directory/subdirectory/test_4.yaml b/tests/fixtures/yaml_tests/directory/subdirectory/test_4.yaml similarity index 100% rename from tests/core/yaml_tests/directory/subdirectory/test_4.yaml rename to tests/fixtures/yaml_tests/directory/subdirectory/test_4.yaml diff --git a/tests/core/yaml_tests/directory/test_1.yaml b/tests/fixtures/yaml_tests/directory/test_1.yaml similarity index 100% rename from tests/core/yaml_tests/directory/test_1.yaml rename to tests/fixtures/yaml_tests/directory/test_1.yaml diff --git a/tests/core/yaml_tests/directory/test_2.yaml b/tests/fixtures/yaml_tests/directory/test_2.yaml similarity index 100% rename from tests/core/yaml_tests/directory/test_2.yaml rename to tests/fixtures/yaml_tests/directory/test_2.yaml diff --git a/tests/core/yaml_tests/failing_test_absolute_error_margin.yaml b/tests/fixtures/yaml_tests/failing_test_absolute_error_margin.yaml similarity index 100% rename from tests/core/yaml_tests/failing_test_absolute_error_margin.yaml rename to tests/fixtures/yaml_tests/failing_test_absolute_error_margin.yaml diff --git a/tests/core/yaml_tests/failing_test_relative_error_margin.yaml b/tests/fixtures/yaml_tests/failing_test_relative_error_margin.yaml similarity index 100% rename from tests/core/yaml_tests/failing_test_relative_error_margin.yaml rename to tests/fixtures/yaml_tests/failing_test_relative_error_margin.yaml diff --git a/tests/core/yaml_tests/test_absolute_error_margin.yaml b/tests/fixtures/yaml_tests/test_absolute_error_margin.yaml similarity index 100% rename from tests/core/yaml_tests/test_absolute_error_margin.yaml rename to tests/fixtures/yaml_tests/test_absolute_error_margin.yaml diff --git a/tests/core/yaml_tests/test_failure.yaml b/tests/fixtures/yaml_tests/test_failure.yaml similarity index 100% rename from tests/core/yaml_tests/test_failure.yaml rename to tests/fixtures/yaml_tests/test_failure.yaml diff --git a/tests/core/yaml_tests/test_name_filter.yaml b/tests/fixtures/yaml_tests/test_name_filter.yaml similarity index 100% rename from tests/core/yaml_tests/test_name_filter.yaml rename to tests/fixtures/yaml_tests/test_name_filter.yaml diff --git a/tests/core/yaml_tests/test_relative_error_margin.yaml b/tests/fixtures/yaml_tests/test_relative_error_margin.yaml similarity index 100% rename from tests/core/yaml_tests/test_relative_error_margin.yaml rename to tests/fixtures/yaml_tests/test_relative_error_margin.yaml diff --git a/tests/core/yaml_tests/test_success.yml b/tests/fixtures/yaml_tests/test_success.yml similarity index 100% rename from tests/core/yaml_tests/test_success.yml rename to tests/fixtures/yaml_tests/test_success.yml diff --git a/tests/core/yaml_tests/test_with_anchors.yaml b/tests/fixtures/yaml_tests/test_with_anchors.yaml similarity index 100% rename from tests/core/yaml_tests/test_with_anchors.yaml rename to tests/fixtures/yaml_tests/test_with_anchors.yaml diff --git a/tests/core/yaml_tests/test_with_extension.yaml b/tests/fixtures/yaml_tests/test_with_extension.yaml similarity index 100% rename from tests/core/yaml_tests/test_with_extension.yaml rename to tests/fixtures/yaml_tests/test_with_extension.yaml diff --git a/tests/core/yaml_tests/test_with_reform.yaml b/tests/fixtures/yaml_tests/test_with_reform.yaml similarity index 100% rename from tests/core/yaml_tests/test_with_reform.yaml rename to tests/fixtures/yaml_tests/test_with_reform.yaml diff --git a/tests/core/yaml_tests/test_with_reform_2.yaml b/tests/fixtures/yaml_tests/test_with_reform_2.yaml similarity index 100% rename from tests/core/yaml_tests/test_with_reform_2.yaml rename to tests/fixtures/yaml_tests/test_with_reform_2.yaml