├── .github └── workflows │ └── base.yml ├── .gitignore ├── LICENSE ├── README.md ├── ci_tools ├── .pylintrc ├── check_python_version.py ├── flake8-requirements.txt ├── github_release.py └── nox_utils.py ├── docs ├── api_reference.md ├── changelog.md ├── imgs │ └── autocomplete1.png ├── index.md ├── long_description.md └── why.md ├── mkdocs.yml ├── noxfile-requirements.txt ├── noxfile.py ├── pyfields ├── __init__.py ├── autofields_.py ├── core.py ├── helpers.py ├── init_makers.py ├── init_makers.pyi ├── py.typed ├── tests │ ├── __init__.py │ ├── _test_benchmarks.py │ ├── _test_py36.py │ ├── issues │ │ ├── __init__.py │ │ ├── _test_py36.py │ │ ├── _test_py36_pep563.py │ │ ├── test_issue_12.py │ │ ├── test_issue_51.py │ │ ├── test_issue_53.py │ │ ├── test_issue_67.py │ │ ├── test_issue_73.py │ │ ├── test_issue_81.py │ │ └── test_issue_84.py │ ├── test_autofields.py │ ├── test_core.py │ ├── test_helpers.py │ ├── test_init.py │ ├── test_readme.py │ └── test_so.py ├── typing_utils.py ├── validate_n_convert.py └── validate_n_convert.pyi ├── pyproject.toml ├── setup.cfg └── setup.py /.github/workflows/base.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/base.yml 2 | name: Build 3 | on: 4 | # this one is to trigger the workflow manually from the interface 5 | workflow_dispatch: 6 | 7 | push: 8 | tags: 9 | - '*' 10 | branches: 11 | - main 12 | pull_request: 13 | branches: 14 | - main 15 | jobs: 16 | # pre-job to read nox tests matrix - see https://stackoverflow.com/q/66747359/7262247 17 | list_nox_test_sessions: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: actions/setup-python@v1 22 | with: 23 | python-version: 3.7 24 | architecture: x64 25 | 26 | - name: Install noxfile requirements 27 | shell: bash -l {0} 28 | run: pip install -r noxfile-requirements.txt 29 | 30 | - name: List 'tests' nox sessions 31 | id: set-matrix 32 | run: echo "::set-output name=matrix::$(nox -s gha_list -- tests)" 33 | outputs: 34 | matrix: ${{ steps.set-matrix.outputs.matrix }} # save nox sessions list to outputs 35 | 36 | run_all_tests: 37 | needs: list_nox_test_sessions 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | os: [ ubuntu-latest ] # , macos-latest, windows-latest] 42 | # all nox sessions: manually > dynamically from previous job 43 | # nox_session: ["tests-2.7", "tests-3.7"] 44 | nox_session: ${{ fromJson(needs.list_nox_test_sessions.outputs.matrix) }} 45 | 46 | name: ${{ matrix.os }} ${{ matrix.nox_session }} # ${{ matrix.name_suffix }} 47 | runs-on: ${{ matrix.os }} 48 | steps: 49 | - uses: actions/checkout@v2 50 | 51 | # Conda install 52 | - name: Install conda v3.7 53 | uses: conda-incubator/setup-miniconda@v2 54 | with: 55 | # auto-update-conda: true 56 | python-version: 3.7 57 | activate-environment: noxenv 58 | - run: conda info 59 | shell: bash -l {0} # so that conda works 60 | - run: conda list 61 | shell: bash -l {0} # so that conda works 62 | 63 | # Nox install + run 64 | - name: Install noxfile requirements 65 | shell: bash -l {0} # so that conda works 66 | run: pip install -r noxfile-requirements.txt 67 | - run: conda list 68 | shell: bash -l {0} # so that conda works 69 | - run: nox -s "${{ matrix.nox_session }}" 70 | shell: bash -l {0} # so that conda works 71 | 72 | # Share ./docs/reports so that they can be deployed with doc in next job 73 | - name: Share reports with other jobs 74 | # if: matrix.nox_session == '...': not needed, if empty wont be shared 75 | uses: actions/upload-artifact@master 76 | with: 77 | name: reports_dir 78 | path: ./docs/reports 79 | 80 | publish_release: 81 | needs: run_all_tests 82 | runs-on: ubuntu-latest 83 | if: github.event_name == 'push' 84 | steps: 85 | - name: GitHub context to debug conditional steps 86 | env: 87 | GITHUB_CONTEXT: ${{ toJSON(github) }} 88 | run: echo "$GITHUB_CONTEXT" 89 | 90 | - uses: actions/checkout@v2 91 | with: 92 | fetch-depth: 0 # so that gh-deploy works 93 | 94 | # 1) retrieve the reports generated previously 95 | - name: Retrieve reports 96 | uses: actions/download-artifact@master 97 | with: 98 | name: reports_dir 99 | path: ./docs/reports 100 | 101 | # Conda install 102 | - name: Install conda v3.7 103 | uses: conda-incubator/setup-miniconda@v2 104 | with: 105 | # auto-update-conda: true 106 | python-version: 3.7 107 | activate-environment: noxenv 108 | - run: conda info 109 | shell: bash -l {0} # so that conda works 110 | - run: conda list 111 | shell: bash -l {0} # so that conda works 112 | 113 | # Nox install 114 | - name: Install noxfile requirements 115 | shell: bash -l {0} # so that conda works 116 | run: pip install -r noxfile-requirements.txt 117 | - run: conda list 118 | shell: bash -l {0} # so that conda works 119 | 120 | # 5) Run the flake8 report and badge 121 | - name: Run flake8 analysis and generate corresponding badge 122 | shell: bash -l {0} # so that conda works 123 | run: nox -s flake8 124 | 125 | # -------------- only on Ubuntu + MAIN PUSH (no pull request, no tag) ----------- 126 | 127 | # 5) Publish the doc and test reports 128 | - name: \[not on TAG\] Publish documentation, tests and coverage reports 129 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads') # startsWith(matrix.os,'ubuntu') 130 | shell: bash -l {0} # so that conda works 131 | run: nox -s publish 132 | 133 | # 6) Publish coverage report 134 | - name: \[not on TAG\] Create codecov.yaml with correct paths 135 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads') 136 | shell: bash 137 | run: | 138 | cat << EOF > codecov.yml 139 | # codecov.yml 140 | fixes: 141 | - "/home/runner/work/smarie/python-pyfields/::" # Correct paths 142 | EOF 143 | - name: \[not on TAG\] Publish coverage report 144 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads') 145 | uses: codecov/codecov-action@v1 146 | with: 147 | files: ./docs/reports/coverage/coverage.xml 148 | 149 | # -------------- only on Ubuntu + TAG PUSH (no pull request) ----------- 150 | 151 | # 7) Create github release and build the wheel 152 | - name: \[TAG only\] Build wheel and create github release 153 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 154 | shell: bash -l {0} # so that conda works 155 | run: nox -s release -- ${{ secrets.GITHUB_TOKEN }} 156 | 157 | # 8) Publish the wheel on PyPi 158 | - name: \[TAG only\] Deploy on PyPi 159 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 160 | uses: pypa/gh-action-pypi-publish@release/v1 161 | with: 162 | user: __token__ 163 | password: ${{ secrets.PYPI_API_TOKEN }} 164 | 165 | delete-artifacts: 166 | needs: publish_release 167 | runs-on: ubuntu-latest 168 | if: github.event_name == 'push' 169 | steps: 170 | - uses: kolpav/purge-artifacts-action@v1 171 | with: 172 | token: ${{ secrets.GITHUB_TOKEN }} 173 | expire-in: 0 # Setting this to 0 will delete all artifacts 174 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | pyfields/_version.py 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv*/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # PyCharm development 133 | /.idea 134 | 135 | # OSX 136 | .DS_Store 137 | 138 | # JUnit and coverage reports 139 | docs/reports 140 | 141 | # ODSClient cache 142 | .odsclient 143 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, smarie 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-pyfields 2 | 3 | *Define fields in python classes. Easily.* 4 | 5 | [![Python versions](https://img.shields.io/pypi/pyversions/pyfields.svg)](https://pypi.python.org/pypi/pyfields/) [![Build Status](https://github.com/smarie/python-pyfields/actions/workflows/base.yml/badge.svg)](https://github.com/smarie/python-pyfields/actions/workflows/base.yml) [![Tests Status](https://smarie.github.io/python-pyfields/reports/junit/junit-badge.svg?dummy=8484744)](https://smarie.github.io/python-pyfields/reports/junit/report.html) [![Coverage Status](https://smarie.github.io/python-pyfields/reports/coverage/coverage-badge.svg?dummy=8484744)](https://smarie.github.io/python-pyfields/reports/coverage/index.html) [![codecov](https://codecov.io/gh/smarie/python-pyfields/branch/main/graph/badge.svg)](https://codecov.io/gh/smarie/python-pyfields) [![Flake8 Status](https://smarie.github.io/python-pyfields/reports/flake8/flake8-badge.svg?dummy=8484744)](https://smarie.github.io/python-pyfields/reports/flake8/index.html) 6 | 7 | [![Documentation](https://img.shields.io/badge/doc-latest-blue.svg)](https://smarie.github.io/python-pyfields/) [![PyPI](https://img.shields.io/pypi/v/pyfields.svg)](https://pypi.python.org/pypi/pyfields/) [![Downloads](https://pepy.tech/badge/pyfields)](https://pepy.tech/project/pyfields) [![Downloads per week](https://pepy.tech/badge/pyfields/week)](https://pepy.tech/project/pyfields) [![GitHub stars](https://img.shields.io/github/stars/smarie/python-pyfields.svg)](https://github.com/smarie/python-pyfields/stargazers) 8 | 9 | **This is the readme for developers.** The documentation for users is available here: [https://smarie.github.io/python-pyfields/](https://smarie.github.io/python-pyfields/) 10 | 11 | ## Want to contribute ? 12 | 13 | Contributions are welcome ! Simply fork this project on github, commit your contributions, and create pull requests. 14 | 15 | Here is a non-exhaustive list of interesting open topics: [https://github.com/smarie/python-pyfields/issues](https://github.com/smarie/python-pyfields/issues) 16 | 17 | ## `nox` setup 18 | 19 | This project uses `nox` to define all lifecycle tasks. In order to be able to run those tasks, you should create python 3.7 environment and install the requirements: 20 | 21 | ```bash 22 | >>> conda create -n noxenv python="3.7" 23 | >>> activate noxenv 24 | (noxenv) >>> pip install -r noxfile-requirements.txt 25 | ``` 26 | 27 | You should then be able to list all available tasks using: 28 | 29 | ``` 30 | >>> nox --list 31 | Sessions defined in \noxfile.py: 32 | 33 | * tests-2.7 -> Run the test suite, including test reports generation and coverage reports. 34 | * tests-3.5 -> Run the test suite, including test reports generation and coverage reports. 35 | * tests-3.6 -> Run the test suite, including test reports generation and coverage reports. 36 | * tests-3.8 -> Run the test suite, including test reports generation and coverage reports. 37 | * tests-3.7 -> Run the test suite, including test reports generation and coverage reports. 38 | - docs-3.7 -> Generates the doc and serves it on a local http server. Pass '-- build' to build statically instead. 39 | - publish-3.7 -> Deploy the docs+reports on github pages. Note: this rebuilds the docs 40 | - release-3.7 -> Create a release on github corresponding to the latest tag 41 | ``` 42 | 43 | ## Running the tests and generating the reports 44 | 45 | This project uses `pytest` so running `pytest` at the root folder will execute all tests on current environment. However it is a bit cumbersome to manage all requirements by hand ; it is easier to use `nox` to run `pytest` on all supported python environments with the correct package requirements: 46 | 47 | ```bash 48 | nox 49 | ``` 50 | 51 | Tests and coverage reports are automatically generated under `./docs/reports` for one of the sessions (`tests-3.7`). 52 | 53 | If you wish to execute tests on a specific environment, use explicit session names, e.g. `nox -s tests-3.6`. 54 | 55 | 56 | ## Editing the documentation 57 | 58 | This project uses `mkdocs` to generate its documentation page. Therefore building a local copy of the doc page may be done using `mkdocs build -f docs/mkdocs.yml`. However once again things are easier with `nox`. You can easily build and serve locally a version of the documentation site using: 59 | 60 | ```bash 61 | >>> nox -s docs 62 | nox > Running session docs-3.7 63 | nox > Creating conda env in .nox\docs-3-7 with python=3.7 64 | nox > [docs] Installing requirements with pip: ['mkdocs-material', 'mkdocs', 'pymdown-extensions', 'pygments'] 65 | nox > python -m pip install mkdocs-material mkdocs pymdown-extensions pygments 66 | nox > mkdocs serve -f ./docs/mkdocs.yml 67 | INFO - Building documentation... 68 | INFO - Cleaning site directory 69 | INFO - The following pages exist in the docs directory, but are not included in the "nav" configuration: 70 | - long_description.md 71 | INFO - Documentation built in 1.07 seconds 72 | INFO - Serving on http://127.0.0.1:8000 73 | INFO - Start watching changes 74 | ... 75 | ``` 76 | 77 | While this is running, you can edit the files under `./docs/` and browse the automatically refreshed documentation at the local [http://127.0.0.1:8000](http://127.0.0.1:8000) page. 78 | 79 | Once you are done, simply hit `` to stop the session. 80 | 81 | Publishing the documentation (including tests and coverage reports) is done automatically by [the continuous integration engine](https://github.com/smarie/python-pyfields/actions), using the `nox -s publish` session, this is not needed for local development. 82 | 83 | ## Packaging 84 | 85 | This project uses `setuptools_scm` to synchronise the version number. Therefore the following command should be used for development snapshots as well as official releases: `python setup.py sdist bdist_wheel`. However this is not generally needed since [the continuous integration engine](https://github.com/smarie/python-pyfields/actions) does it automatically for us on git tags. For reference, this is done in the `nox -s release` session. 86 | 87 | ### Merging pull requests with edits - memo 88 | 89 | Ax explained in github ('get commandline instructions'): 90 | 91 | ```bash 92 | git checkout -b - master 93 | git pull https://github.com//python-pyfields.git --no-commit --ff-only 94 | ``` 95 | 96 | if the second step does not work, do a normal auto-merge (do not use **rebase**!): 97 | 98 | ```bash 99 | git pull https://github.com//python-pyfields.git --no-commit 100 | ``` 101 | 102 | Finally review the changes, possibly perform some modifications, and commit. 103 | -------------------------------------------------------------------------------- /ci_tools/.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | # init-hook="import pyfields" 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore= 13 | 14 | # Pickle collected data for later comparisons. 15 | persistent=no 16 | 17 | # List of plugins (as comma separated values of python modules names) to load, 18 | # usually to register additional checkers. 19 | load-plugins= 20 | 21 | # Use multiple processes to speed up Pylint. 22 | # DO NOT CHANGE THIS VALUES >1 HIDE RESULTS!!!!! 23 | jobs=1 24 | 25 | # Allow loading of arbitrary C extensions. Extensions are imported into the 26 | # active Python interpreter and may run arbitrary code. 27 | unsafe-load-any-extension=no 28 | 29 | # A comma-separated list of package or module names from where C extensions may 30 | # be loaded. Extensions are loading into the active Python interpreter and may 31 | # run arbitrary code 32 | extension-pkg-whitelist= 33 | 34 | # Allow optimization of some AST trees. This will activate a peephole AST 35 | # optimizer, which will apply various small optimizations. For instance, it can 36 | # be used to obtain the result of joining multiple strings with the addition 37 | # operator. Joining a lot of strings can lead to a maximum recursion error in 38 | # Pylint and this flag can prevent that. It has one side effect, the resulting 39 | # AST will be different than the one from reality. 40 | optimize-ast=no 41 | 42 | 43 | [MESSAGES CONTROL] 44 | 45 | # Only show warnings with the listed confidence levels. Leave empty to show 46 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 47 | confidence= 48 | 49 | # Enable the message, report, category or checker with the given id(s). You can 50 | # either give multiple identifier separated by comma (,) or put this option 51 | # multiple time. See also the "--disable" option for examples. 52 | disable=all 53 | 54 | enable=import-error, 55 | import-self, 56 | reimported, 57 | wildcard-import, 58 | misplaced-future, 59 | relative-import, 60 | deprecated-module, 61 | unpacking-non-sequence, 62 | invalid-all-object, 63 | undefined-all-variable, 64 | used-before-assignment, 65 | cell-var-from-loop, 66 | global-variable-undefined, 67 | redefined-builtin, 68 | redefine-in-handler, 69 | unused-import, 70 | unused-wildcard-import, 71 | global-variable-not-assigned, 72 | undefined-loop-variable, 73 | global-statement, 74 | global-at-module-level, 75 | bad-open-mode, 76 | redundant-unittest-assert, 77 | boolean-datetime, 78 | # Has common issues with our style due to 79 | # https://github.com/PyCQA/pylint/issues/210 80 | unused-variable 81 | 82 | # Things we'd like to enable someday: 83 | # redefined-outer-name (requires a bunch of work to clean up our code first) 84 | # undefined-variable (re-enable when pylint fixes https://github.com/PyCQA/pylint/issues/760) 85 | # no-name-in-module (giving us spurious warnings https://github.com/PyCQA/pylint/issues/73) 86 | # unused-argument (need to clean up or code a lot, e.g. prefix unused_?) 87 | 88 | # Things we'd like to try. 89 | # Procedure: 90 | # 1. Enable a bunch. 91 | # 2. See if there's spurious ones; if so disable. 92 | # 3. Record above. 93 | # 4. Remove from this list. 94 | # deprecated-method, 95 | # anomalous-unicode-escape-in-string, 96 | # anomalous-backslash-in-string, 97 | # not-in-loop, 98 | # function-redefined, 99 | # continue-in-finally, 100 | # abstract-class-instantiated, 101 | # star-needs-assignment-target, 102 | # duplicate-argument-name, 103 | # return-in-init, 104 | # too-many-star-expressions, 105 | # nonlocal-and-global, 106 | # return-outside-function, 107 | # return-arg-in-generator, 108 | # invalid-star-assignment-target, 109 | # bad-reversed-sequence, 110 | # nonexistent-operator, 111 | # yield-outside-function, 112 | # init-is-generator, 113 | # nonlocal-without-binding, 114 | # lost-exception, 115 | # assert-on-tuple, 116 | # dangerous-default-value, 117 | # duplicate-key, 118 | # useless-else-on-loop, 119 | # expression-not-assigned, 120 | # confusing-with-statement, 121 | # unnecessary-lambda, 122 | # pointless-statement, 123 | # pointless-string-statement, 124 | # unnecessary-pass, 125 | # unreachable, 126 | # eval-used, 127 | # exec-used, 128 | # bad-builtin, 129 | # using-constant-test, 130 | # deprecated-lambda, 131 | # bad-super-call, 132 | # missing-super-argument, 133 | # slots-on-old-class, 134 | # super-on-old-class, 135 | # property-on-old-class, 136 | # not-an-iterable, 137 | # not-a-mapping, 138 | # format-needs-mapping, 139 | # truncated-format-string, 140 | # missing-format-string-key, 141 | # mixed-format-string, 142 | # too-few-format-args, 143 | # bad-str-strip-call, 144 | # too-many-format-args, 145 | # bad-format-character, 146 | # format-combined-specification, 147 | # bad-format-string-key, 148 | # bad-format-string, 149 | # missing-format-attribute, 150 | # missing-format-argument-key, 151 | # unused-format-string-argument, 152 | # unused-format-string-key, 153 | # invalid-format-index, 154 | # bad-indentation, 155 | # mixed-indentation, 156 | # unnecessary-semicolon, 157 | # lowercase-l-suffix, 158 | # fixme, 159 | # invalid-encoded-data, 160 | # unpacking-in-except, 161 | # import-star-module-level, 162 | # parameter-unpacking, 163 | # long-suffix, 164 | # old-octal-literal, 165 | # old-ne-operator, 166 | # backtick, 167 | # old-raise-syntax, 168 | # print-statement, 169 | # metaclass-assignment, 170 | # next-method-called, 171 | # dict-iter-method, 172 | # dict-view-method, 173 | # indexing-exception, 174 | # raising-string, 175 | # standarderror-builtin, 176 | # using-cmp-argument, 177 | # cmp-method, 178 | # coerce-method, 179 | # delslice-method, 180 | # getslice-method, 181 | # hex-method, 182 | # nonzero-method, 183 | # oct-method, 184 | # setslice-method, 185 | # apply-builtin, 186 | # basestring-builtin, 187 | # buffer-builtin, 188 | # cmp-builtin, 189 | # coerce-builtin, 190 | # old-division, 191 | # execfile-builtin, 192 | # file-builtin, 193 | # filter-builtin-not-iterating, 194 | # no-absolute-import, 195 | # input-builtin, 196 | # intern-builtin, 197 | # long-builtin, 198 | # map-builtin-not-iterating, 199 | # range-builtin-not-iterating, 200 | # raw_input-builtin, 201 | # reduce-builtin, 202 | # reload-builtin, 203 | # round-builtin, 204 | # unichr-builtin, 205 | # unicode-builtin, 206 | # xrange-builtin, 207 | # zip-builtin-not-iterating, 208 | # logging-format-truncated, 209 | # logging-too-few-args, 210 | # logging-too-many-args, 211 | # logging-unsupported-format, 212 | # logging-not-lazy, 213 | # logging-format-interpolation, 214 | # invalid-unary-operand-type, 215 | # unsupported-binary-operation, 216 | # no-member, 217 | # not-callable, 218 | # redundant-keyword-arg, 219 | # assignment-from-no-return, 220 | # assignment-from-none, 221 | # not-context-manager, 222 | # repeated-keyword, 223 | # missing-kwoa, 224 | # no-value-for-parameter, 225 | # invalid-sequence-index, 226 | # invalid-slice-index, 227 | # too-many-function-args, 228 | # unexpected-keyword-arg, 229 | # unsupported-membership-test, 230 | # unsubscriptable-object, 231 | # access-member-before-definition, 232 | # method-hidden, 233 | # assigning-non-slot, 234 | # duplicate-bases, 235 | # inconsistent-mro, 236 | # inherit-non-class, 237 | # invalid-slots, 238 | # invalid-slots-object, 239 | # no-method-argument, 240 | # no-self-argument, 241 | # unexpected-special-method-signature, 242 | # non-iterator-returned, 243 | # protected-access, 244 | # arguments-differ, 245 | # attribute-defined-outside-init, 246 | # no-init, 247 | # abstract-method, 248 | # signature-differs, 249 | # bad-staticmethod-argument, 250 | # non-parent-init-called, 251 | # super-init-not-called, 252 | # bad-except-order, 253 | # catching-non-exception, 254 | # bad-exception-context, 255 | # notimplemented-raised, 256 | # raising-bad-type, 257 | # raising-non-exception, 258 | # misplaced-bare-raise, 259 | # duplicate-except, 260 | # broad-except, 261 | # nonstandard-exception, 262 | # binary-op-exception, 263 | # bare-except, 264 | # not-async-context-manager, 265 | # yield-inside-async-function, 266 | 267 | # ... 268 | [REPORTS] 269 | 270 | # Set the output format. Available formats are text, parseable, colorized, msvs 271 | # (visual studio) and html. You can also give a reporter class, eg 272 | # mypackage.mymodule.MyReporterClass. 273 | output-format=parseable 274 | 275 | # Put messages in a separate file for each module / package specified on the 276 | # command line instead of printing them on stdout. Reports (if any) will be 277 | # written in a file name "pylint_global.[txt|html]". 278 | files-output=no 279 | 280 | # Tells whether to display a full report or only the messages 281 | reports=no 282 | 283 | # Python expression which should return a note less than 10 (10 is the highest 284 | # note). You have access to the variables errors warning, statement which 285 | # respectively contain the number of errors / warnings messages and the total 286 | # number of statements analyzed. This is used by the global evaluation report 287 | # (RP0004). 288 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 289 | 290 | # Template used to display messages. This is a python new-style format string 291 | # used to format the message information. See doc for all details 292 | #msg-template= 293 | 294 | 295 | [LOGGING] 296 | 297 | # Logging modules to check that the string format arguments are in logging 298 | # function parameter format 299 | logging-modules=logging 300 | 301 | 302 | [FORMAT] 303 | 304 | # Maximum number of characters on a single line. 305 | max-line-length=100 306 | 307 | # Regexp for a line that is allowed to be longer than the limit. 308 | ignore-long-lines=^\s*(# )??$ 309 | 310 | # Allow the body of an if to be on the same line as the test if there is no 311 | # else. 312 | single-line-if-stmt=no 313 | 314 | # List of optional constructs for which whitespace checking is disabled. `dict- 315 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 316 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 317 | # `empty-line` allows space-only lines. 318 | no-space-check=trailing-comma,dict-separator 319 | 320 | # Maximum number of lines in a module 321 | max-module-lines=1000 322 | 323 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 324 | # tab). 325 | indent-string=' ' 326 | 327 | # Number of spaces of indent required inside a hanging or continued line. 328 | indent-after-paren=4 329 | 330 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 331 | expected-line-ending-format= 332 | 333 | 334 | [TYPECHECK] 335 | 336 | # Tells whether missing members accessed in mixin class should be ignored. A 337 | # mixin class is detected if its name ends with "mixin" (case insensitive). 338 | ignore-mixin-members=yes 339 | 340 | # List of module names for which member attributes should not be checked 341 | # (useful for modules/projects where namespaces are manipulated during runtime 342 | # and thus existing member attributes cannot be deduced by static analysis. It 343 | # supports qualified module names, as well as Unix pattern matching. 344 | ignored-modules= 345 | 346 | # List of classes names for which member attributes should not be checked 347 | # (useful for classes with attributes dynamically set). This supports can work 348 | # with qualified names. 349 | ignored-classes= 350 | 351 | # List of members which are set dynamically and missed by pylint inference 352 | # system, and so shouldn't trigger E1101 when accessed. Python regular 353 | # expressions are accepted. 354 | generated-members= 355 | 356 | 357 | [VARIABLES] 358 | 359 | # Tells whether we should check for unused import in __init__ files. 360 | init-import=no 361 | 362 | # A regular expression matching the name of dummy variables (i.e. expectedly 363 | # not used). 364 | dummy-variables-rgx=^_|^dummy 365 | 366 | # List of additional names supposed to be defined in builtins. Remember that 367 | # you should avoid to define new builtins when possible. 368 | additional-builtins= 369 | 370 | # List of strings which can identify a callback function by name. A callback 371 | # name must start or end with one of those strings. 372 | callbacks=cb_,_cb 373 | 374 | 375 | [SIMILARITIES] 376 | 377 | # Minimum lines number of a similarity. 378 | min-similarity-lines=4 379 | 380 | # Ignore comments when computing similarities. 381 | ignore-comments=yes 382 | 383 | # Ignore docstrings when computing similarities. 384 | ignore-docstrings=yes 385 | 386 | # Ignore imports when computing similarities. 387 | ignore-imports=no 388 | 389 | 390 | [SPELLING] 391 | 392 | # Spelling dictionary name. Available dictionaries: none. To make it working 393 | # install python-enchant package. 394 | spelling-dict= 395 | 396 | # List of comma separated words that should not be checked. 397 | spelling-ignore-words= 398 | 399 | # A path to a file that contains private dictionary; one word per line. 400 | spelling-private-dict-file= 401 | 402 | # Tells whether to store unknown words to indicated private dictionary in 403 | # --spelling-private-dict-file option instead of raising a message. 404 | spelling-store-unknown-words=no 405 | 406 | 407 | [MISCELLANEOUS] 408 | 409 | # List of note tags to take in consideration, separated by a comma. 410 | notes=FIXME,XXX,TODO 411 | 412 | 413 | [BASIC] 414 | 415 | # List of builtins function names that should not be used, separated by a comma 416 | bad-functions=map,filter,input 417 | 418 | # Good variable names which should always be accepted, separated by a comma 419 | good-names=i,j,k,ex,Run,_ 420 | 421 | # Bad variable names which should always be refused, separated by a comma 422 | bad-names=foo,bar,baz,toto,tutu,tata 423 | 424 | # Colon-delimited sets of names that determine each other's naming style when 425 | # the name regexes allow several styles. 426 | name-group= 427 | 428 | # Include a hint for the correct naming format with invalid-name 429 | include-naming-hint=no 430 | 431 | # Regular expression matching correct function names 432 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 433 | 434 | # Naming hint for function names 435 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 436 | 437 | # Regular expression matching correct variable names 438 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 439 | 440 | # Naming hint for variable names 441 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 442 | 443 | # Regular expression matching correct constant names 444 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 445 | 446 | # Naming hint for constant names 447 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 448 | 449 | # Regular expression matching correct attribute names 450 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 451 | 452 | # Naming hint for attribute names 453 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 454 | 455 | # Regular expression matching correct argument names 456 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 457 | 458 | # Naming hint for argument names 459 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 460 | 461 | # Regular expression matching correct class attribute names 462 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 463 | 464 | # Naming hint for class attribute names 465 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 466 | 467 | # Regular expression matching correct inline iteration names 468 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 469 | 470 | # Naming hint for inline iteration names 471 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 472 | 473 | # Regular expression matching correct class names 474 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 475 | 476 | # Naming hint for class names 477 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 478 | 479 | # Regular expression matching correct module names 480 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 481 | 482 | # Naming hint for module names 483 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 484 | 485 | # Regular expression matching correct method names 486 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 487 | 488 | # Naming hint for method names 489 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 490 | 491 | # Regular expression which should only match function or class names that do 492 | # not require a docstring. 493 | no-docstring-rgx=^_ 494 | 495 | # Minimum line length for functions/classes that require docstrings, shorter 496 | # ones are exempt. 497 | docstring-min-length=-1 498 | 499 | 500 | [ELIF] 501 | 502 | # Maximum number of nested blocks for function / method body 503 | max-nested-blocks=5 504 | 505 | 506 | [IMPORTS] 507 | 508 | # Deprecated modules which should not be used, separated by a comma 509 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 510 | 511 | # Create a graph of every (i.e. internal and external) dependencies in the 512 | # given file (report RP0402 must not be disabled) 513 | import-graph= 514 | 515 | # Create a graph of external dependencies in the given file (report RP0402 must 516 | # not be disabled) 517 | ext-import-graph= 518 | 519 | # Create a graph of internal dependencies in the given file (report RP0402 must 520 | # not be disabled) 521 | int-import-graph= 522 | 523 | 524 | [DESIGN] 525 | 526 | # Maximum number of arguments for function / method 527 | max-args=5 528 | 529 | # Argument names that match this expression will be ignored. Default to name 530 | # with leading underscore 531 | ignored-argument-names=_.* 532 | 533 | # Maximum number of locals for function / method body 534 | max-locals=15 535 | 536 | # Maximum number of return / yield for function / method body 537 | max-returns=6 538 | 539 | # Maximum number of branch for function / method body 540 | max-branches=12 541 | 542 | # Maximum number of statements in function / method body 543 | max-statements=50 544 | 545 | # Maximum number of parents for a class (see R0901). 546 | max-parents=7 547 | 548 | # Maximum number of attributes for a class (see R0902). 549 | max-attributes=7 550 | 551 | # Minimum number of public methods for a class (see R0903). 552 | min-public-methods=2 553 | 554 | # Maximum number of public methods for a class (see R0904). 555 | max-public-methods=20 556 | 557 | # Maximum number of boolean expressions in a if statement 558 | max-bool-expr=5 559 | 560 | 561 | [CLASSES] 562 | 563 | # List of method names used to declare (i.e. assign) instance attributes. 564 | defining-attr-methods=__init__,__new__,setUp 565 | 566 | # List of valid names for the first argument in a class method. 567 | valid-classmethod-first-arg=cls 568 | 569 | # List of valid names for the first argument in a metaclass class method. 570 | valid-metaclass-classmethod-first-arg=mcs 571 | 572 | # List of member names, which should be excluded from the protected access 573 | # warning. 574 | exclude-protected=_asdict,_fields,_replace,_source,_make 575 | 576 | 577 | [EXCEPTIONS] 578 | 579 | # Exceptions that will emit a warning when being caught. Defaults to 580 | # "Exception" 581 | overgeneral-exceptions=Exception 582 | -------------------------------------------------------------------------------- /ci_tools/check_python_version.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if __name__ == "__main__": 4 | # Execute only if run as a script. 5 | # Check the arguments 6 | nbargs = len(sys.argv[1:]) 7 | if nbargs != 1: 8 | raise ValueError("a mandatory argument is required: ") 9 | 10 | expected_version_str = sys.argv[1] 11 | try: 12 | expected_version = tuple(int(i) for i in expected_version_str.split(".")) 13 | except Exception as e: 14 | raise ValueError("Error while parsing expected version %r: %r" % (expected_version, e)) 15 | 16 | if len(expected_version) < 1: 17 | raise ValueError("At least a major is expected") 18 | 19 | if sys.version_info[0] != expected_version[0]: 20 | raise AssertionError("Major version does not match. Expected %r - Actual %r" % (expected_version_str, sys.version)) 21 | 22 | if len(expected_version) >= 2 and sys.version_info[1] != expected_version[1]: 23 | raise AssertionError("Minor version does not match. Expected %r - Actual %r" % (expected_version_str, sys.version)) 24 | 25 | if len(expected_version) >= 3 and sys.version_info[2] != expected_version[2]: 26 | raise AssertionError("Patch version does not match. Expected %r - Actual %r" % (expected_version_str, sys.version)) 27 | 28 | print("SUCCESS - Actual python version %r matches expected one %r" % (sys.version, expected_version_str)) 29 | -------------------------------------------------------------------------------- /ci_tools/flake8-requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools_scm>=3,<4 2 | flake8>=3.6,<4 3 | flake8-html>=0.4,<1 4 | flake8-bandit>=2.1.1,<3 5 | bandit<1.7.3 # To revert later 6 | flake8-bugbear>=20.1.0,<21.0.0 7 | flake8-docstrings>=1.5,<2 8 | flake8-print>=3.1.1,<4 9 | flake8-tidy-imports>=4.2.1,<5 10 | flake8-copyright==0.2.2 # Internal forked repo to fix an issue, keep specific version 11 | pydocstyle>=5.1.1,<6 12 | pycodestyle>=2.6.0,<3 13 | mccabe>=0.6.1,<1 14 | naming>=0.5.1,<1 15 | pyflakes>=2.2,<3 16 | genbadge[flake8] 17 | -------------------------------------------------------------------------------- /ci_tools/github_release.py: -------------------------------------------------------------------------------- 1 | # a clone of the ruby example https://gist.github.com/valeriomazzeo/5491aee76f758f7352e2e6611ce87ec1 2 | import os 3 | from os import path 4 | 5 | import re 6 | 7 | import click 8 | from click import Path 9 | from github import Github, UnknownObjectException 10 | # from valid8 import validate not compliant with python 2.7 11 | 12 | 13 | @click.command() 14 | @click.option('-u', '--user', help='GitHub username') 15 | @click.option('-p', '--pwd', help='GitHub password') 16 | @click.option('-s', '--secret', help='GitHub access token') 17 | @click.option('-r', '--repo-slug', help='Repo slug. i.e.: apple/swift') 18 | @click.option('-cf', '--changelog-file', help='Changelog file path') 19 | @click.option('-d', '--doc-url', help='Documentation url') 20 | @click.option('-df', '--data-file', help='Data file to upload', type=Path(exists=True, file_okay=True, dir_okay=False, 21 | resolve_path=True)) 22 | @click.argument('tag') 23 | def create_or_update_release(user, pwd, secret, repo_slug, changelog_file, doc_url, data_file, tag): 24 | """ 25 | Creates or updates (TODO) 26 | a github release corresponding to git tag . 27 | """ 28 | # 1- AUTHENTICATION 29 | if user is not None and secret is None: 30 | # using username and password 31 | # validate('user', user, instance_of=str) 32 | assert isinstance(user, str) 33 | # validate('pwd', pwd, instance_of=str) 34 | assert isinstance(pwd, str) 35 | g = Github(user, pwd) 36 | elif user is None and secret is not None: 37 | # or using an access token 38 | # validate('secret', secret, instance_of=str) 39 | assert isinstance(secret, str) 40 | g = Github(secret) 41 | else: 42 | raise ValueError("You should either provide username/password OR an access token") 43 | click.echo("Logged in as {user_name}".format(user_name=g.get_user())) 44 | 45 | # 2- CHANGELOG VALIDATION 46 | regex_pattern = "[\s\S]*[\n][#]+[\s]*(?P[\S ]*%s[\S ]*)[\n]+?(?P<body>[\s\S]*?)[\n]*?(\n#|$)" % re.escape(tag) 47 | changelog_section = re.compile(regex_pattern) 48 | if changelog_file is not None: 49 | # validate('changelog_file', changelog_file, custom=os.path.exists, 50 | # help_msg="changelog file should be a valid file path") 51 | assert os.path.exists(changelog_file), "changelog file should be a valid file path" 52 | with open(changelog_file) as f: 53 | contents = f.read() 54 | 55 | match = changelog_section.match(contents).groupdict() 56 | if match is None or len(match) != 2: 57 | raise ValueError("Unable to find changelog section matching regexp pattern in changelog file.") 58 | else: 59 | title = match['title'] 60 | message = match['body'] 61 | else: 62 | title = tag 63 | message = '' 64 | 65 | # append footer if doc url is provided 66 | message += "\n\nSee [documentation page](%s) for details." % doc_url 67 | 68 | # 3- REPOSITORY EXPLORATION 69 | # validate('repo_slug', repo_slug, instance_of=str, min_len=1, help_msg="repo_slug should be a non-empty string") 70 | assert isinstance(repo_slug, str) and len(repo_slug) > 0, "repo_slug should be a non-empty string" 71 | repo = g.get_repo(repo_slug) 72 | 73 | # -- Is there a tag with that name ? 74 | try: 75 | tag_ref = repo.get_git_ref("tags/" + tag) 76 | except UnknownObjectException: 77 | raise ValueError("No tag with name %s exists in repository %s" % (tag, repo.name)) 78 | 79 | # -- Is there already a release with that tag name ? 80 | click.echo("Checking if release %s already exists in repository %s" % (tag, repo.name)) 81 | try: 82 | release = repo.get_release(tag) 83 | if release is not None: 84 | raise ValueError("Release %s already exists in repository %s. Please set overwrite to True if you wish to " 85 | "update the release (Not yet supported)" % (tag, repo.name)) 86 | except UnknownObjectException: 87 | # Release does not exist: we can safely create it. 88 | click.echo("Creating release %s on repo: %s" % (tag, repo.name)) 89 | click.echo("Release title: '%s'" % title) 90 | click.echo("Release message:\n--\n%s\n--\n" % message) 91 | repo.create_git_release(tag=tag, name=title, 92 | message=message, 93 | draft=False, prerelease=False) 94 | 95 | # add the asset file if needed 96 | if data_file is not None: 97 | release = None 98 | while release is None: 99 | release = repo.get_release(tag) 100 | release.upload_asset(path=data_file, label=path.split(data_file)[1], content_type="application/gzip") 101 | 102 | # --- Memo --- 103 | # release.target_commitish # 'master' 104 | # release.tag_name # '0.5.0' 105 | # release.title # 'First public release' 106 | # release.body # markdown body 107 | # release.draft # False 108 | # release.prerelease # False 109 | # # 110 | # release.author 111 | # release.created_at # datetime.datetime(2018, 11, 9, 17, 49, 56) 112 | # release.published_at # datetime.datetime(2018, 11, 9, 20, 11, 10) 113 | # release.last_modified # None 114 | # # 115 | # release.id # 13928525 116 | # release.etag # 'W/"dfab7a13086d1b44fe290d5d04125124"' 117 | # release.url # 'https://api.github.com/repos/smarie/python-pyfields/releases/13928525' 118 | # release.html_url # 'https://github.com/smarie/python-pyfields/releases/tag/0.5.0' 119 | # release.tarball_url # 'https://api.github.com/repos/smarie/python-pyfields/tarball/0.5.0' 120 | # release.zipball_url # 'https://api.github.com/repos/smarie/python-pyfields/zipball/0.5.0' 121 | # release.upload_url # 'https://uploads.github.com/repos/smarie/python-pyfields/releases/13928525/assets{?name,label}' 122 | 123 | 124 | if __name__ == '__main__': 125 | create_or_update_release() 126 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### 1.7.2 - bugfix 4 | 5 | - Fixed `TypeError: Neither typeguard not pytypes is installed` even with `typeguard` installed. 6 | Fixed [#91] (https://github.com/smarie/python-pyfields/issues/91) 7 | 8 | ### 1.7.1 - Compatibility fix for typeguard `3.0.0` 9 | 10 | - Fixed `TypeError: check_type() takes 2 positional arguments but 3 were given` triggering erroneous `FieldTypeError` 11 | when `typeguard>=3.0.0` is used. Fixed [#87](https://github.com/smarie/python-pyfields/issues/87) 12 | 13 | ### 1.7.0 - Better support for non-deep-copiable default values in `@autofields` 14 | 15 | - `@autofields` and `@autoclass` now raise an error when a field definition can not be valid, because the default value can not be deep-copied. This will help users detect issues such as [#84](https://github.com/smarie/python-pyfields/issues/84) earlier. Implementation is done through a new `autocheck` option in the `copy_value` factory. 16 | 17 | - `@autofields` and `@autoclass` now provide an `exclude` (resp. `af_exclude`) list, to list names for fields that should not be created. By default this contains a reserved name from `abc.ABCMeta`, for convenience. Fixes [#84](https://github.com/smarie/python-pyfields/issues/84). 18 | 19 | 20 | ### 1.6.2 - CI/CD migration 21 | 22 | - This is a technical release with no code change, to validate the new Github Actions workflow. 23 | 24 | ### 1.6.1 - Bugfix 25 | 26 | - Fixed an issue with `autofields` (and therefore `autoclass` too) where a field would be mistakenly recreated on a subclass when that subclass does not define type hints while the parent class defines type hints. Fixes [#81](https://github.com/smarie/python-pyfields/issues/81) 27 | 28 | ### 1.6.0 - we now have our own version of `@autoclass` 29 | 30 | - Copied the relevant contents from `autoclass` so as to get rid of the dependency. Since we are in a `pyfields` context there were many things that could be dropped and remaining code could be easily copied over. Also took this opportunity to replace the dict view with a `to_dict`/`from_dict` pair of methods, this seems less intrusive in the class design. Finally the parameter names have been simplified, see [API reference](./api_reference.md#autoclass) for details. Fixes [#79](https://github.com/smarie/python-pyfields/issues/79) 31 | 32 | ### 1.5.0 - updated `@autoclass` signature 33 | 34 | - Improved `@autoclass` so that it is much easier to access the relevant arguments from underlying `@autofields` and `@autoclass`. Fixed [#78](https://github.com/smarie/python-pyfields/issues/78) 35 | 36 | ### 1.4.0 - new `@autoclass` decorator 37 | 38 | - New `@autoclass` decorator directly available from `pyfields`. It is merely equivalent to the original `@autoclass` with option `autofields=True`, which makes it easier to use on classes with automatic fields. Fixes [#75](https://github.com/smarie/python-pyfields/issues/75) 39 | 40 | ### 1.3.2 - bugfix 41 | 42 | - Fields order are preserved by `@autofields` even in the case of an explicit `field()` with all others implicit. Fixed [#77](https://github.com/smarie/python-pyfields/issues/77) 43 | 44 | ### 1.3.1 - bugfix 45 | 46 | - Fields order are preserved by `@autofields` even in the case of a field with just a type annotation. Fixed [#76](https://github.com/smarie/python-pyfields/issues/76) 47 | 48 | ### 1.3.0 - Support for Forward references, PEP563 and class-level access 49 | 50 | - String forward references in type hints, and PEP563 behaviour, is now supported. When this case happense, the type hint resolution is delayed until the field is first accessed. Fixes [#73](https://github.com/smarie/python-pyfields/issues/73) 51 | 52 | - Accessing a field definition from a class directly is now enabled, since PyCharm [fixed their autocompletion bug](https://youtrack.jetbrains.com/issue/PY-38151). Fixes [#12](https://github.com/smarie/python-pyfields/issues/12) 53 | 54 | 55 | ### 1.2.0 - `getfields` improvements and new `get_field_values` 56 | 57 | - `getfields` can now be executed on an instance, and provides a `public_only` option. Fixes [#69](https://github.com/smarie/python-pyfields/issues/69) 58 | 59 | - New `get_field_values` method to get an ordered dict-like of field name: value. Fixes [#70](https://github.com/smarie/python-pyfields/issues/70) 60 | 61 | ### 1.1.5 - bugfix 62 | 63 | - `@autofields` now correctly skips `@property` and more generally, descriptor members. Fixes [#67](https://github.com/smarie/python-pyfields/issues/67) 64 | 65 | ### 1.1.4 - better python 2 packaging 66 | 67 | - packaging improvements: set the "universal wheel" flag to 1, and cleaned up the `setup.py`. In particular removed dependency to `six`. Fixes [#66](https://github.com/smarie/python-pyfields/issues/66) 68 | 69 | ### 1.1.3 - smaller wheel 70 | 71 | - `tests` folder is now excluded from generated package wheel. Fixed [#65](https://github.com/smarie/python-pyfields/issues/65) 72 | 73 | ### 1.1.2 - type hint fix (minor) 74 | 75 | - Now `converters={'*': ...}` does not appear as a type hint error. Fixed [#64](https://github.com/smarie/python-pyfields/issues/64) 76 | 77 | ### 1.1.1 - PEP561 compatibility 78 | 79 | - **Misc**: Package is now PEP561 compatible. Fixed [#61](https://github.com/smarie/python-pyfields/issues/61) 80 | 81 | ### 1.1.0 - @autofields and default values improvements 82 | 83 | - **New `@autofields` decorator**. This decorator can be used to drastically reduce boilerplate code, similar to `pydantic` and `attrs`. This is compliant with python 2.7 and 3.5+ but is more useful when the type hints can be provided in class member annotations, so from 3.6+. Fixed [#55](https://github.com/smarie/python-pyfields/issues/55) 84 | 85 | - **Default values are now validated/converted as normal values**. If the default value is provided in `default=<value>` or as a `default_factory=copy_value(<value>)`, this is done only **once per field**, to accelerate future access. If the value was converted on the way, the converted value is used to replace the default value, or the default value copied by the factory. Fixed [#57](https://github.com/smarie/python-pyfields/issues/57) 86 | 87 | - **Misc**: removed `makefun` usage in `validate_n_convert.py` : was overkill. Also fixed a few type hints. 88 | 89 | ### 1.0.3 - bugfix 90 | 91 | * Fixed bug with `super().__init__` not behaving as expected. Fixed [#53](https://github.com/smarie/python-pyfields/issues/53) 92 | 93 | ### 1.0.2 - bugfixes 94 | 95 | * User-provided `nonable` status was wrongly overriden automatically when the field was attached to the class. Fixed [#51](https://github.com/smarie/python-pyfields/issues/51) 96 | * Fixed an issue with type validation when `typeguard` is used and a tuple of types is provided instead of a `Union`. Fixed [#52](https://github.com/smarie/python-pyfields/issues/52) 97 | 98 | ### 1.0.1 - `pyproject.toml` 99 | 100 | Added `pyproject.toml` 101 | 102 | ### 1.0.0 - Stable version 103 | 104 | Overall behaviour stabilized and compliance with `@autoclass` to cover most use cases. 105 | 106 | The only bug that has not yet been fixed is [#12](https://github.com/smarie/python-pyfields/issues/12) 107 | 108 | ### 0.14.0 - helpers, bugfix, and ancestor-first option in init makers 109 | 110 | **API** 111 | 112 | - new helper methods `get_field`, `yield_fields`, `has_fields` and `get_fields` (new name of `collect_all_fields`) so that other libraries such as `autoclass` can easily access the various information. `fix_fields` removed. Fixed [#48](https://github.com/smarie/python-pyfields/issues/48) 113 | 114 | - New `ancestor_fields_first` option in all the `__init__` makers (`make_init` and `@init_fields`). Fixed [#50](https://github.com/smarie/python-pyfields/issues/50) 115 | 116 | **Bugfixes** 117 | 118 | - Bugfixes in all the `__init__` makers (`make_init` and `@init_fields`): 119 | 120 | - bugfix in case of inheritance with override: [#49](https://github.com/smarie/python-pyfields/issues/49) 121 | 122 | - the argument order used for fields initialization (inside the generated init method body) was sometimes incorrect. This would trigger a bug when one field was requiring another one to initialize. 123 | 124 | - when the list of fields received by `InitDescriptor` was an empty tuple and not `None`, the constructor was not created properly 125 | 126 | ### 0.13.0 - `nonable` fields 127 | 128 | - Fields can now be `nonable`, so as to bypass type and value validation when `None` is received. Fixed [#44](https://github.com/smarie/python-pyfields/issues/44) 129 | 130 | ### 0.12.0 - Minor improvements 131 | 132 | - Now all type validation errors are `FieldTypeError`. Fixed [#40](https://github.com/smarie/python-pyfields/issues/40). 133 | - Fixed bug with python < 3.6 where fields were not automatically attached to their class when used from within a subclass first. Fixed [#41](https://github.com/smarie/python-pyfields/issues/41) 134 | 135 | ### 0.11.0 - Better initialization orders in generated `__init__` 136 | 137 | Fixed fields initialization order in generated constructor methods: 138 | 139 | - the order is now the same than the order of appearance in the class (and not reversed as it was). Fixed [#36](https://github.com/smarie/python-pyfields/issues/36). 140 | - the above is true, even in python < 3.6. Fixed [#38](https://github.com/smarie/python-pyfields/issues/38) 141 | - the order now takes into account first the ancestors and then the subclasses, for the most intuitive behaviour. Fixed [#37](https://github.com/smarie/python-pyfields/issues/37). 142 | 143 | 144 | ### 0.10.0 - Read-only fields + minor improvements 145 | 146 | **Read-only fields** 147 | 148 | - Read-only fields are now supported through `field(read_only=True)`. Fixes [#33](https://github.com/smarie/python-pyfields/issues/33). 149 | 150 | **Misc** 151 | 152 | - All core exceptions now derive from a common `FieldError`, for easier exception handling. 153 | - Now raising an explicit `ValueError` when a descriptor field is used with an old-style class in python 2. Fixes [#34](https://github.com/smarie/python-pyfields/issues/34) 154 | 155 | ### 0.9.1 - Minor improvements 156 | 157 | - Minor performance improvement: `Converter.create_from_fun()` does not generate a new `type` everytime a converter needs to be created from a callable - now a single class `ConverterWithFuncs` is used. Fixed [#32](https://github.com/smarie/python-pyfields/issues/32). 158 | 159 | ### 0.9.0 - Converters 160 | 161 | **converters** 162 | 163 | - Fields can now be equipped with converters by using `field(converters=...)`. Fixes [#5](https://github.com/smarie/python-pyfields/issues/5) 164 | - New method `trace_convert` to debug conversion issues. It is available both as an independent function and as a method on `Field`. Fixes [#31](https://github.com/smarie/python-pyfields/issues/31) 165 | - New decorator `@<field>.converter` to add a converter to a field. Fixed [#28](https://github.com/smarie/python-pyfields/issues/28). 166 | 167 | **misc** 168 | 169 | - The base `Field` class is now exposed at package level. 170 | 171 | ### 0.8.0 - PEP484 support 172 | 173 | **PEP484 type hints support** 174 | 175 | - Now type hints relying on the `typing` module (PEP484) are correctly checked using whatever 3d party type checking library is available (`typeguard` is first looked for, then `pytypes` as a fallback). If none of these providers are available, a fallback implementation is provided, basically flattening `Union`s and replacing `TypeVar`s before doing `is_instance`. It is not guaranteed to support all `typing` subtelties. Fixes [#7](https://github.com/smarie/python-pyfields/issues/7) 176 | 177 | 178 | ### 0.7.0 - more ways to define validators 179 | 180 | **validators** 181 | 182 | - New decorator `@<field>.validator` to add a validator to a field. Fixed [#9](https://github.com/smarie/python-pyfields/issues/9). 183 | - Native fields are automatically transformed into descriptor fields when validators are added this way. Fixes [#1](https://github.com/smarie/python-pyfields/issues/1). 184 | 185 | 186 | ### 0.6.0 - default factories and slots 187 | 188 | **default value factories** 189 | 190 | - `default_factory` callables now receive one argument: the object instance. Fixes [#6](https://github.com/smarie/python-pyfields/issues/6) 191 | - New decorator `@<field>.default_factory` to define a default value factory. Fixed [#27](https://github.com/smarie/python-pyfields/issues/27) 192 | - New `copy_value`, `copy_field` and `copy_attr` helper functions to create default value factories. Fixed [#26](https://github.com/smarie/python-pyfields/issues/26) 193 | 194 | **support for slots** 195 | 196 | - `field` now automatically detects when a native field is attached to a class with slots and no `__dict__` is present. In that case, the native field is replaced with a descriptor field. Fixed [#20](https://github.com/smarie/python-pyfields/issues/20). 197 | 198 | ### 0.5.0 - First public version 199 | 200 | **fields** 201 | 202 | - `field()` method to easily define class fields without necessarily defining a `__init__`. 203 | 204 | - "native" fields are created by default, or if `native=True` is set. A `NativeField` is a non-data descriptor that replaces itself automatically with a native python attribute after the first read, to get the same performance level on later access. 205 | 206 | - "descriptor" fields are created when type or value validation is required, or if `native=False` is set. A `DescriptorField` uses the standard python descriptor protocol so that type and value can be validated on all future access without messing with the `__setattr__` method. 207 | 208 | - support for `type_hint` declaration to declare the type of a field. If `validate_type` provided, the descriptor will *not* be replaced with a native field, and the type will be checked on every value modification. A `TypeError` will be raised if type does not comply. Type hints are correctly defined so that IDEs can pick them. Fixes [#10](https://github.com/smarie/python-pyfields/issues/10) 209 | 210 | - support for `validators` relying on `valid8`. Validators can receive `(val)`, `(obj, val)` or `(obj, field, val)` to support validation based on several fields. The only requirement is to return `True` or `None` in case of success. Fixes [#3](https://github.com/smarie/python-pyfields/issues/3) 211 | 212 | **init** 213 | 214 | - `make_init` method to create an entire `__init__` method with control of which fields are injected, and with possibility to blend a post-init callback in. Fixes [#14](https://github.com/smarie/python-pyfields/issues/14). 215 | 216 | - `@init_fields` decorator to auto-init fields before your `__init__` method. 217 | 218 | - `@inject_fields` decorator to easily inject `fields` in an init method and perform the assignment precisely when users want (for easy debugging). Fixes [#13](https://github.com/smarie/python-pyfields/issues/13) 219 | 220 | **misc** 221 | 222 | - `__weakref__` added in all relevant classes. Fixes [#21](https://github.com/smarie/python-pyfields/issues/21) 223 | 224 | - Now using stubs [#17](https://github.com/smarie/python-pyfields/issues/17) 225 | 226 | - Fixed bug [#11](https://github.com/smarie/python-pyfields/issues/11). 227 | 228 | - Fixed `ValueError` with mini-lambda < 2.2. Fixed [#22](https://github.com/smarie/python-pyfields/issues/22) 229 | 230 | - Because of a [limitation in PyCharm type hints](https://youtrack.jetbrains.com/issue/PY-38151) we had to remove support for class-level field access. This created [#12](https://github.com/smarie/python-pyfields/issues/12) which will be fixed as soon as PyCharm issue is fixed. 231 | 232 | ### 0.1.0 - unpublished first draft 233 | 234 | Extracted from [`mixture`](https://smarie.github.io/python-mixture/). 235 | -------------------------------------------------------------------------------- /docs/imgs/autocomplete1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smarie/python-pyfields/246cc243cf414cc3e2766de79f1b45ffb1da7a6c/docs/imgs/autocomplete1.png -------------------------------------------------------------------------------- /docs/long_description.md: -------------------------------------------------------------------------------- 1 | # python-pyfields 2 | 3 | *Define fields in python classes. Easily.* 4 | 5 | [![Python versions](https://img.shields.io/pypi/pyversions/pyfields.svg)](https://pypi.python.org/pypi/pyfields/) [![Build Status](https://github.com/smarie/python-pyfields/actions/workflows/base.yml/badge.svg)](https://github.com/smarie/python-pyfields/actions/workflows/base.yml) [![Tests Status](https://smarie.github.io/python-pyfields/reports/junit/junit-badge.svg?dummy=8484744)](https://smarie.github.io/python-pyfields/reports/junit/report.html) [![Coverage Status](https://smarie.github.io/python-pyfields/reports/coverage/coverage-badge.svg?dummy=8484744)](https://smarie.github.io/python-pyfields/reports/coverage/index.html) [![codecov](https://codecov.io/gh/smarie/python-pyfields/branch/main/graph/badge.svg)](https://codecov.io/gh/smarie/python-pyfields) [![Flake8 Status](https://smarie.github.io/python-pyfields/reports/flake8/flake8-badge.svg?dummy=8484744)](https://smarie.github.io/python-pyfields/reports/flake8/index.html) 6 | 7 | [![Documentation](https://img.shields.io/badge/doc-latest-blue.svg)](https://smarie.github.io/python-pyfields/) [![PyPI](https://img.shields.io/pypi/v/pyfields.svg)](https://pypi.python.org/pypi/pyfields/) [![Downloads](https://pepy.tech/badge/pyfields)](https://pepy.tech/project/pyfields) [![Downloads per week](https://pepy.tech/badge/pyfields/week)](https://pepy.tech/project/pyfields) [![GitHub stars](https://img.shields.io/github/stars/smarie/python-pyfields.svg)](https://github.com/smarie/python-pyfields/stargazers) 8 | 9 | The documentation for users is available here: [https://smarie.github.io/python-pyfields/](https://smarie.github.io/python-pyfields/) 10 | 11 | A readme for developers is available here: [https://github.com/smarie/python-pyfields](https://github.com/smarie/python-pyfields) 12 | -------------------------------------------------------------------------------- /docs/why.md: -------------------------------------------------------------------------------- 1 | # Why `pyfields` ? 2 | 3 | During the few years I spent exploring the python world, I tried several times to find a "good" way to create classes where fields could be 4 | 5 | - declared explicitly in a compact way 6 | - with optional validation and conversion 7 | - with as little call overhead as possible 8 | - without messing with the `__init__` and `__setattr__` methods 9 | 10 | I discovered: 11 | 12 | - [`@property`](https://docs.python.org/3/library/functions.html#property), that is a good start but adds a python call cost on access and lacks the possibility to add validation and conversion in a compact declaration. It relies on the generic python [descriptors](https://docs.python.org/3/howto/descriptor.html) mechanism. 13 | 14 | - [`attrs`](http://www.attrs.org/), a great way to define classes with many out-of-the-box features (representation, hashing, constructor, ...). Its philosophy is that objects should be immutable (They *can* be mutable, actually they are by default, but [the validators are not executed on value modification](https://github.com/python-attrs/attrs/issues/160#issuecomment-284726744) as of 0.19). The way it works is by creating a "smart" `__init__` script that contains all the logic (see [here](https://github.com/python-attrs/attrs/blob/22b8cb1c4cdb155dea0ca01648f94804b7b3fbfc/src/attr/_make.py#L1392)), and possibly a `__setattr__` if you ask for immutable objects with `frozen=True`. 15 | 16 | - [`autoclass`](https://smarie.github.io/python-autoclass/) was one of my first open-source projects in python: I tried to create a less optimized version of `attrs`, but at least something that would support basic use cases. The main difference with `attrs` is that fields are defined using the `__init__` signature, instead of class attributes, and it is possible to define custom setters to perform validation, that are effectively called on value modification. I also developed at the time a validation lib [`valid8`](https://smarie.github.io/python-valid8/)) that works with `autoclass` and `attrs`. The result has been used in industrial projects. But it is still not satisfying because relying on the `__init__` signature to define the fields is not very elegant and flexible in particular in case of multiple inheritance. 17 | 18 | - [PEP557 `dataclasses`](https://docs.python.org/3/library/dataclasses.html) was largely inspired by and is roughly equivalent to `attrs`, although a few design choices differ and its scope seems more limited. 19 | 20 | In parallel I discovered a few libraries oriented towards data modelling and serialization: 21 | 22 | - [`marshmallow`](https://marshmallow.readthedocs.io/en/stable/), an ORM / ODM / framework-agnostic library for converting complex datatypes, such as objects, to and from native Python datatypes. 23 | 24 | - [`related`](https://github.com/genomoncology/related) is also a library oriented towards converting data models from/to json/yaml/python 25 | 26 | - [`colander`](https://docs.pylonsproject.org/projects/colander/en/latest/) 27 | 28 | - [`django` forms](https://docs.djangoproject.com/en/2.2/ref/forms/api/#django.forms.Form) 29 | 30 | This topic was left aside for a moment, until half 2019 where I thought that I had accumulated enough python expertise (with [`makefun`](https://smarie.github.io/python-makefun/), [`decopatch`](https://smarie.github.io/python-decopatch/) and [many pytest libraries](https://github.com/smarie/ALL_OF_THE_ABOVE#python)) to have a fresh look on it. In the meantime I had discovered: 31 | 32 | - [`traitlets`](https://traitlets.readthedocs.io/en/stable/) which provides a quite elegant way to define typed fields and define validation, but requires the classes to inherit from `HasTraits`, and does not allow users to define converters. 33 | 34 | - [`traits`](https://docs.enthought.com/traits/) 35 | 36 | - werkzeug's [`@cached_property`](https://werkzeug.palletsprojects.com/en/0.15.x/utils/#werkzeug.utils.cached_property) and sagemath's [`@lazy_attribute`](http://doc.sagemath.org/html/en/reference/misc/sage/misc/lazy_attribute.html), that both rely on the descriptor protocol to define class fields, but lack compacity 37 | 38 | - [`zopeinterface`](https://zopeinterface.readthedocs.io/en/latest/README.html), targeting definition of strict interfaces (but including attributes in their definition). It also defines the concept of "invariants" 39 | 40 | - [`pydantic`](https://pydantic-docs.helpmanual.io/) embraces python 3.6+ type hints (that can be defined on class attributes). It is quite elegant, is compliant with `dataclasses`, and supports validators that can act on single or multiple fields. It requires classes to inherit from a `BaseModel`. It does not seem to support converters as of version 0.32, rather, some type conversion happens behind the scenes (see for example [this issue](https://github.com/samuelcolvin/pydantic/issues/453)). But it looks definitely promising. 41 | 42 | - [`trellis`](http://peak.telecommunity.com/DevCenter/Trellis) which provides an event-driven framework for class attributes with linked effects 43 | 44 | I was still not satisfied by the landscape :(. So I wrote this alternative, maybe it can fit in *some* use cases ! Do not hesitate to provide feedback in the issues page. 45 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: pyfields 2 | # site_description: 'A short description of my project' 3 | repo_url: https://github.com/smarie/python-pyfields 4 | #docs_dir: . 5 | #site_dir: ../site 6 | # default branch is main instead of master now on github 7 | edit_uri : ./edit/main/docs 8 | nav: 9 | - Home: index.md 10 | - Why fields: why.md 11 | - API reference: api_reference.md 12 | - Changelog: changelog.md 13 | 14 | theme: material # readthedocs mkdocs 15 | 16 | markdown_extensions: 17 | - pymdownx.highlight # see https://squidfunk.github.io/mkdocs-material/reference/code-blocks/#highlight 18 | - pymdownx.superfences # same as above as well as code blocks inside other blocks 19 | - admonition # to add notes such as http://squidfunk.github.io/mkdocs-material/extensions/admonition/ 20 | # - codehilite: 21 | # guess_lang: false 22 | - toc: 23 | permalink: true 24 | -------------------------------------------------------------------------------- /noxfile-requirements.txt: -------------------------------------------------------------------------------- 1 | nox 2 | toml 3 | makefun 4 | setuptools_scm # used in 'release' 5 | keyring # used in 'release' 6 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from itertools import product 2 | from json import dumps 3 | import logging 4 | 5 | import nox # noqa 6 | from pathlib import Path # noqa 7 | import sys 8 | 9 | # add parent folder to python path so that we can import noxfile_utils.py 10 | # note that you need to "pip install -r noxfile-requiterements.txt" for this file to work. 11 | sys.path.append(str(Path(__file__).parent / "ci_tools")) 12 | from nox_utils import PY27, PY37, PY36, PY35, PY38, PY39, PY310, power_session, rm_folder, rm_file, PowerSession # noqa 13 | 14 | 15 | pkg_name = "pyfields" 16 | gh_org = "smarie" 17 | gh_repo = "python-pyfields" 18 | 19 | ENVS = { 20 | # --- python 3.9 - first in list to catch obvious bugs on local executions 21 | (PY39, "no-typechecker"): {"coverage": False, "type_checker": None, "pkg_specs": {"pip": ">19"}}, 22 | # (PY38, "pytypes"): {"coverage": False, "type_checker": "pytypes", "pkg_specs": {"pip": ">19"}}, 23 | (PY39, "typeguard"): {"coverage": False, "type_checker": "typeguard", "pkg_specs": {"pip": ">19"}}, 24 | # --- python 3.8 25 | (PY38, "no-typechecker"): {"coverage": False, "type_checker": None, "pkg_specs": {"pip": ">19"}}, 26 | # (PY38, "pytypes"): {"coverage": False, "type_checker": "pytypes", "pkg_specs": {"pip": ">19"}}, 27 | (PY38, "typeguard"): {"coverage": False, "type_checker": "typeguard", "pkg_specs": {"pip": ">19"}}, 28 | # --- python 2.7 29 | (PY27, "no-typechecker"): {"coverage": False, "type_checker": None, "pkg_specs": {"pip": ">19"}}, 30 | (PY27, "pytypes"): {"coverage": False, "type_checker": "pytypes", "pkg_specs": {"pip": ">19"}}, 31 | # --- python 3.5.3 > requires free channel > hard to make it work on GHA 32 | # ("3.5.3", "no-typechecker"): {"coverage": False, "type_checker": None, "pkg_specs": {"pip": ">19"}}, 33 | # ("3.5.3", "pytypes"): {"coverage": False, "type_checker": "pytypes", "pkg_specs": {"pip": ">19"}}, 34 | # ("3.5.3", "typeguard"): {"coverage": False, "type_checker": "typeguard", "pkg_specs": {"pip": ">19"}}, 35 | # --- python 3.5 36 | (PY35, "no-typechecker"): {"coverage": False, "type_checker": None, "pkg_specs": {"pip": ">19"}}, 37 | (PY35, "pytypes"): {"coverage": False, "type_checker": "pytypes", "pkg_specs": {"pip": ">19"}}, 38 | (PY35, "typeguard"): {"coverage": False, "type_checker": "typeguard", "pkg_specs": {"pip": ">19"}}, 39 | # --- python 3.6 40 | (PY36, "no-typechecker"): {"coverage": False, "type_checker": None, "pkg_specs": {"pip": ">19"}}, 41 | (PY36, "pytypes"): {"coverage": False, "type_checker": "pytypes", "pkg_specs": {"pip": ">19"}}, 42 | (PY36, "typeguard"): {"coverage": False, "type_checker": "typeguard", "pkg_specs": {"pip": ">19"}}, 43 | # --- python 3.7 44 | (PY37, "no-typechecker"): {"coverage": False, "type_checker": None, "pkg_specs": {"pip": ">19"}}, 45 | #(PY37, "pytypes"): {"coverage": False, "type_checker": "pytypes", "pkg_specs": {"pip": ">19"}}, 46 | # IMPORTANT: this should be last so that the folder docs/reports is not deleted afterwards 47 | (PY37, "typeguard"): {"coverage": True, "type_checker": "typeguard", "pkg_specs": {"pip": ">19"}} 48 | } 49 | 50 | 51 | # set the default activated sessions, minimal for CI 52 | nox.options.sessions = ["tests", "flake8"] # , "docs", "gh_pages" 53 | nox.options.reuse_existing_virtualenvs = True # this can be done using -r 54 | # if platform.system() == "Windows": >> always use this for better control 55 | nox.options.default_venv_backend = "conda" 56 | # os.environ["NO_COLOR"] = "True" # nox.options.nocolor = True does not work 57 | # nox.options.verbose = True 58 | 59 | nox_logger = logging.getLogger("nox") 60 | # nox_logger.setLevel(logging.INFO) NO !!!! this prevents the "verbose" nox flag to work ! 61 | 62 | 63 | class Folders: 64 | root = Path(__file__).parent 65 | ci_tools = root / "ci_tools" 66 | runlogs = root / Path(nox.options.envdir or ".nox") / "_runlogs" 67 | runlogs.mkdir(parents=True, exist_ok=True) 68 | dist = root / "dist" 69 | site = root / "site" 70 | site_reports = site / "reports" 71 | reports_root = root / "docs" / "reports" 72 | test_reports = reports_root / "junit" 73 | test_xml = test_reports / "junit.xml" 74 | test_html = test_reports / "report.html" 75 | test_badge = test_reports / "junit-badge.svg" 76 | coverage_reports = reports_root / "coverage" 77 | coverage_xml = coverage_reports / "coverage.xml" 78 | coverage_intermediate_file = root / ".coverage" 79 | coverage_badge = coverage_reports / "coverage-badge.svg" 80 | flake8_reports = reports_root / "flake8" 81 | flake8_intermediate_file = root / "flake8stats.txt" 82 | flake8_badge = flake8_reports / "flake8-badge.svg" 83 | 84 | 85 | @power_session(envs=ENVS, logsdir=Folders.runlogs) 86 | def tests(session: PowerSession, coverage, type_checker, pkg_specs): 87 | """Run the test suite, including test reports generation and coverage reports. """ 88 | 89 | # As soon as this runs, we delete the target site and coverage files to avoid reporting wrong coverage/etc. 90 | rm_folder(Folders.site) 91 | rm_folder(Folders.reports_root) 92 | # delete the .coverage files if any (they are not supposed to be any, but just in case) 93 | rm_file(Folders.coverage_intermediate_file) 94 | rm_file(Folders.root / "coverage.xml") 95 | 96 | # CI-only dependencies 97 | # Did we receive a flag through positional arguments ? (nox -s tests -- <flag>) 98 | # install_ci_deps = False 99 | # if len(session.posargs) == 1: 100 | # assert session.posargs[0] == "keyrings.alt" 101 | # install_ci_deps = True 102 | # elif len(session.posargs) > 1: 103 | # raise ValueError("Only a single positional argument is accepted, received: %r" % session.posargs) 104 | 105 | # uncomment and edit if you wish to uninstall something without deleting the whole env 106 | # session.run2("pip uninstall pytest-asyncio --yes") 107 | 108 | # install all requirements 109 | session.install_reqs(setup=True, install=True, tests=True, versions_dct=pkg_specs) 110 | 111 | # install optional typechecker 112 | if type_checker is not None: 113 | session.install2(type_checker) 114 | 115 | # install CI-only dependencies 116 | # if install_ci_deps: 117 | # session.install2("keyrings.alt") 118 | 119 | # list all (conda list alone does not work correctly on github actions) 120 | # session.run2("conda list") 121 | conda_prefix = Path(session.bin) 122 | if conda_prefix.name == "bin": 123 | conda_prefix = conda_prefix.parent 124 | session.run2("conda list", env={"CONDA_PREFIX": str(conda_prefix), "CONDA_DEFAULT_ENV": session.get_session_id()}) 125 | 126 | # Fail if the assumed python version is not the actual one 127 | session.run2("python ci_tools/check_python_version.py %s" % session.python) 128 | 129 | # install self so that it is recognized by pytest 130 | session.run2("pip install -e . --no-deps") 131 | # session.install("-e", ".", "--no-deps") 132 | 133 | # check that it can be imported even from a different folder 134 | # Important: do not surround the command into double quotes as in the shell ! 135 | session.run('python', '-c', 'import os; os.chdir(\'./docs/\'); import %s' % pkg_name) 136 | 137 | # finally run all tests 138 | if not coverage: 139 | # simple: pytest only 140 | session.run2("python -m pytest --cache-clear -v %s/tests/" % pkg_name) 141 | else: 142 | # coverage + junit html reports + badge generation 143 | session.install_reqs(phase="coverage", 144 | phase_reqs=["coverage", "pytest-html", "genbadge[tests,coverage]"], 145 | versions_dct=pkg_specs) 146 | 147 | # --coverage + junit html reports 148 | session.run2("coverage run --source {pkg_name} " 149 | "-m pytest --cache-clear --junitxml={test_xml} --html={test_html} -v {pkg_name}/tests/" 150 | "".format(pkg_name=pkg_name, test_xml=Folders.test_xml, test_html=Folders.test_html)) 151 | session.run2("coverage report") 152 | session.run2("coverage xml -o {covxml}".format(covxml=Folders.coverage_xml)) 153 | session.run2("coverage html -d {dst}".format(dst=Folders.coverage_reports)) 154 | # delete this intermediate file, it is not needed anymore 155 | rm_file(Folders.coverage_intermediate_file) 156 | 157 | # --generates the badge for the test results and fail build if less than x% tests pass 158 | nox_logger.info("Generating badge for tests coverage") 159 | # Use our own package to generate the badge 160 | session.run2("genbadge tests -i %s -o %s -t 100" % (Folders.test_xml, Folders.test_badge)) 161 | session.run2("genbadge coverage -i %s -o %s" % (Folders.coverage_xml, Folders.coverage_badge)) 162 | 163 | 164 | @power_session(python=PY38, logsdir=Folders.runlogs) 165 | def flake8(session: PowerSession): 166 | """Launch flake8 qualimetry.""" 167 | 168 | session.install("-r", str(Folders.ci_tools / "flake8-requirements.txt")) 169 | session.install("genbadge[flake8]") 170 | session.run2("pip install -e .[flake8]") 171 | 172 | rm_folder(Folders.flake8_reports) 173 | Folders.flake8_reports.mkdir(parents=True, exist_ok=True) 174 | rm_file(Folders.flake8_intermediate_file) 175 | 176 | # Options are set in `setup.cfg` file 177 | session.run("flake8", pkg_name, "--exit-zero", "--format=html", "--htmldir", str(Folders.flake8_reports), 178 | "--statistics", "--tee", "--output-file", str(Folders.flake8_intermediate_file)) 179 | # generate our badge 180 | session.run2("genbadge flake8 -i %s -o %s" % (Folders.flake8_intermediate_file, Folders.flake8_badge)) 181 | rm_file(Folders.flake8_intermediate_file) 182 | 183 | 184 | @power_session(python=[PY37]) 185 | def docs(session: PowerSession): 186 | """Generates the doc and serves it on a local http server. Pass '-- build' to build statically instead.""" 187 | 188 | session.install_reqs(phase="docs", phase_reqs=["mkdocs-material", "mkdocs", "pymdown-extensions", "pygments"]) 189 | 190 | if session.posargs: 191 | # use posargs instead of "serve" 192 | session.run2("mkdocs %s" % " ".join(session.posargs)) 193 | else: 194 | session.run2("mkdocs serve") 195 | 196 | 197 | @power_session(python=[PY37]) 198 | def publish(session: PowerSession): 199 | """Deploy the docs+reports on github pages. Note: this rebuilds the docs""" 200 | 201 | session.install_reqs(phase="mkdocs", phase_reqs=["mkdocs-material", "mkdocs", "pymdown-extensions", "pygments"]) 202 | 203 | # possibly rebuild the docs in a static way (mkdocs serve does not build locally) 204 | session.run2("mkdocs build") 205 | 206 | # check that the doc has been generated with coverage 207 | if not Folders.site_reports.exists(): 208 | raise ValueError("Test reports have not been built yet. Please run 'nox -s tests-3.7' first") 209 | 210 | # publish the docs 211 | session.run2("mkdocs gh-deploy") 212 | 213 | # publish the coverage - now in github actions only 214 | # session.install_reqs(phase="codecov", phase_reqs=["codecov", "keyring"]) 215 | # # keyring set https://app.codecov.io/gh/<org>/<repo> token 216 | # import keyring # (note that this import is not from the session env but the main nox env) 217 | # codecov_token = keyring.get_password("https://app.codecov.io/gh/<org>/<repo>>", "token") 218 | # # note: do not use --root nor -f ! otherwise "There was an error processing coverage reports" 219 | # session.run2('codecov -t %s -f %s' % (codecov_token, Folders.coverage_xml)) 220 | 221 | 222 | @power_session(python=[PY37]) 223 | def release(session: PowerSession): 224 | """Create a release on github corresponding to the latest tag""" 225 | 226 | # Get current tag using setuptools_scm and make sure this is not a dirty/dev one 227 | from setuptools_scm import get_version # (note that this import is not from the session env but the main nox env) 228 | from setuptools_scm.version import guess_next_dev_version 229 | version = [] 230 | 231 | def my_scheme(version_): 232 | version.append(version_) 233 | return guess_next_dev_version(version_) 234 | current_tag = get_version(".", version_scheme=my_scheme) 235 | 236 | # create the package 237 | session.install_reqs(phase="setup.py#dist", phase_reqs=["setuptools_scm"]) 238 | rm_folder(Folders.dist) 239 | session.run2("python setup.py sdist bdist_wheel") 240 | 241 | if version[0].dirty or not version[0].exact: 242 | raise ValueError("You need to execute this action on a clean tag version with no local changes.") 243 | 244 | # Did we receive a token through positional arguments ? (nox -s release -- <token>) 245 | if len(session.posargs) == 1: 246 | # Run from within github actions - no need to publish on pypi 247 | gh_token = session.posargs[0] 248 | publish_on_pypi = False 249 | 250 | elif len(session.posargs) == 0: 251 | # Run from local commandline - assume we want to manually publish on PyPi 252 | publish_on_pypi = True 253 | 254 | # keyring set https://docs.github.com/en/rest token 255 | import keyring # (note that this import is not from the session env but the main nox env) 256 | gh_token = keyring.get_password("https://docs.github.com/en/rest", "token") 257 | assert len(gh_token) > 0 258 | 259 | else: 260 | raise ValueError("Only a single positional arg is allowed for now") 261 | 262 | # publish the package on PyPi 263 | if publish_on_pypi: 264 | # keyring set https://upload.pypi.org/legacy/ your-username 265 | # keyring set https://test.pypi.org/legacy/ your-username 266 | session.install_reqs(phase="PyPi", phase_reqs=["twine"]) 267 | session.run2("twine upload dist/* -u smarie") # -r testpypi 268 | 269 | # create the github release 270 | session.install_reqs(phase="release", phase_reqs=["click", "PyGithub"]) 271 | session.run2("python ci_tools/github_release.py -s {gh_token} " 272 | "--repo-slug {gh_org}/{gh_repo} -cf ./docs/changelog.md " 273 | "-d https://{gh_org}.github.io/{gh_repo}/changelog {tag}" 274 | "".format(gh_token=gh_token, gh_org=gh_org, gh_repo=gh_repo, tag=current_tag)) 275 | 276 | 277 | @nox.session(python=False) 278 | def gha_list(session): 279 | """(mandatory arg: <base_session_name>) Prints all sessions available for <base_session_name>, for GithubActions.""" 280 | 281 | # see https://stackoverflow.com/q/66747359/7262247 282 | 283 | # get the desired base session to generate the list for 284 | if len(session.posargs) != 1: 285 | raise ValueError("This session has a mandatory argument: <base_session_name>") 286 | session_func = globals()[session.posargs[0]] 287 | 288 | # list all sessions for this base session 289 | try: 290 | session_func.parametrize 291 | except AttributeError: 292 | sessions_list = ["%s-%s" % (session_func.__name__, py) for py in session_func.python] 293 | else: 294 | sessions_list = ["%s-%s(%s)" % (session_func.__name__, py, param) 295 | for py, param in product(session_func.python, session_func.parametrize)] 296 | 297 | # print the list so that it can be caught by GHA. 298 | # Note that json.dumps is optional since this is a list of string. 299 | # However it is to remind us that GHA expects a well-formatted json list of strings. 300 | print(dumps(sessions_list)) 301 | 302 | 303 | # if __name__ == '__main__': 304 | # # allow this file to be executable for easy debugging in any IDE 305 | # nox.run(globals()) 306 | -------------------------------------------------------------------------------- /pyfields/__init__.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain MARIE <sylvain.marie@se.com> 2 | # + All contributors to <https://github.com/smarie/python-pyfields> 3 | # 4 | # License: 3-clause BSD, <https://github.com/smarie/python-pyfields/blob/master/LICENSE> 5 | from .typing_utils import FieldTypeError 6 | from .core import field, Field, FieldError, MandatoryFieldInitError, UnsupportedOnNativeFieldError, \ 7 | ReadOnlyFieldError, NoneError 8 | from .validate_n_convert import Converter, ConversionError, DetailedConversionResults, trace_convert 9 | from .init_makers import inject_fields, make_init, init_fields 10 | from .helpers import copy_value, copy_field, copy_attr, has_fields, get_fields, yield_fields, get_field, \ 11 | get_field_values 12 | from .autofields_ import autofields, autoclass 13 | 14 | try: 15 | # Distribution mode : import from _version.py generated by setuptools_scm during release 16 | from ._version import version as __version__ 17 | except ImportError: 18 | # Source mode : use setuptools_scm to get the current version from src using git 19 | from setuptools_scm import get_version as _gv 20 | from os import path as _path 21 | __version__ = _gv(_path.join(_path.dirname(__file__), _path.pardir)) 22 | 23 | __all__ = [ 24 | '__version__', 25 | # submodules 26 | 'core', 'validate_n_convert', 'init_makers', 'helpers', 'autofields_', 27 | # symbols 28 | 'field', 'Field', 'FieldError', 'MandatoryFieldInitError', 'UnsupportedOnNativeFieldError', 29 | 'ReadOnlyFieldError', 'FieldTypeError', 'NoneError', 30 | 'Converter', 'ConversionError', 'DetailedConversionResults', 'trace_convert', 31 | 'inject_fields', 'make_init', 'init_fields', 32 | 'copy_value', 'copy_field', 'copy_attr', 'has_fields', 'get_fields', 'yield_fields', 'get_field', 33 | 'get_field_values', 34 | 'autofields', 'autoclass' 35 | ] 36 | -------------------------------------------------------------------------------- /pyfields/helpers.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain MARIE <sylvain.marie@se.com> 2 | # + All contributors to <https://github.com/smarie/python-pyfields> 3 | # 4 | # License: 3-clause BSD, <https://github.com/smarie/python-pyfields/blob/master/LICENSE> 5 | import sys 6 | from copy import copy, deepcopy 7 | from inspect import getmro, isclass 8 | 9 | try: 10 | from typing import Union, Type, TypeVar 11 | T = TypeVar('T') 12 | except ImportError: 13 | pass 14 | 15 | from pyfields.core import Field, ClassFieldAccessError, PY36, get_type_hints 16 | 17 | 18 | class NotAFieldError(TypeError): 19 | """ Raised by `get_field` when the class member with that name is not a field """ 20 | __slots__ = 'name', 'cls' 21 | 22 | def __init__(self, cls, name): 23 | self.name = name 24 | self.cls = cls 25 | 26 | 27 | def get_field(cls, name): 28 | """ 29 | Utility method to return the field member with name `name` in class `cls`. 30 | If the member is not a field, a `NotAFieldError` is raised. 31 | 32 | :param cls: 33 | :param name: 34 | :return: 35 | """ 36 | try: 37 | member = getattr(cls, name) 38 | except ClassFieldAccessError as e: 39 | # we know it is a field :) 40 | return e.field 41 | except Exception: 42 | # any other exception that can happen with a descriptor for example 43 | raise NotAFieldError(cls, name) 44 | else: 45 | # it is a field if is it an instance of Field 46 | if isinstance(member, Field): 47 | return member 48 | else: 49 | raise NotAFieldError(cls, name) 50 | 51 | 52 | def yield_fields(cls, 53 | include_inherited=True, # type: bool 54 | remove_duplicates=True, # type: bool 55 | ancestors_first=True, # type: bool 56 | public_only=False, # type: bool 57 | _auto_fix_fields=False # type: bool 58 | ): 59 | """ 60 | Similar to `get_fields` but as a generator. 61 | 62 | :param cls: 63 | :param include_inherited: 64 | :param remove_duplicates: 65 | :param ancestors_first: 66 | :param public_only: 67 | :param _auto_fix_fields: 68 | :return: 69 | """ 70 | # List the classes where we should be looking for fields 71 | if include_inherited: 72 | where_cls = reversed(getmro(cls)) if ancestors_first else getmro(cls) 73 | else: 74 | where_cls = (cls,) 75 | 76 | # Init 77 | _already_found_names = set() if remove_duplicates else None # a reference set of already yielded field names 78 | _cls_pep484_member_type_hints = None # where to hold found type hints if needed 79 | _all_fields_for_cls = None # temporary list when we have to reorder 80 | 81 | # finally for each class, gather all fields in order 82 | for _cls in where_cls: 83 | if not PY36: 84 | # in python < 3.6 we'll need to sort the fields at the end as class member order is not preserved 85 | _all_fields_for_cls = [] 86 | elif _auto_fix_fields: 87 | # in python >= 3.6, pep484 type hints can be available as member annotation, grab them 88 | _cls_pep484_member_type_hints = get_type_hints(_cls) 89 | 90 | for member_name in vars(_cls): 91 | # if not member_name.startswith('__'): not stated in the doc: too dangerous to have such implicit filter 92 | 93 | # avoid infinite recursion as this method is called in the descriptor for __init__ 94 | if not member_name == '__init__': 95 | try: 96 | field = get_field(_cls, member_name) 97 | except NotAFieldError: 98 | continue 99 | 100 | if _auto_fix_fields: 101 | # take this opportunity to set the name and type hints 102 | field.set_as_cls_member(_cls, member_name, owner_cls_type_hints=_cls_pep484_member_type_hints) 103 | 104 | if public_only and member_name.startswith('_'): 105 | continue 106 | 107 | if remove_duplicates: 108 | if member_name in _already_found_names: 109 | continue 110 | else: 111 | _already_found_names.add(member_name) 112 | 113 | # maybe the field is overridden, in that case we should directly yield the new one 114 | if _cls is not cls: 115 | try: 116 | overridden_field = get_field(cls, member_name) 117 | except NotAFieldError: 118 | overridden_field = None 119 | else: 120 | overridden_field = None 121 | 122 | # finally yield it... 123 | if PY36: # ...immediately in recent python versions because order is correct already 124 | yield field if overridden_field is None else overridden_field 125 | else: # ...or wait for this class to be collected, because the order needs to be fixed 126 | _all_fields_for_cls.append((field, overridden_field)) 127 | 128 | if not PY36: 129 | # order is random in python < 3.6 - we need to explicitly sort according to instance creation number 130 | _all_fields_for_cls.sort(key=lambda f: f[0].__fieldinstcount__) 131 | for field, overridden_field in _all_fields_for_cls: 132 | yield field if overridden_field is None else overridden_field 133 | 134 | 135 | def has_fields(cls, 136 | include_inherited=True # type: bool 137 | ): 138 | """ 139 | Returns True if class `cls` defines at least one `pyfields` field. 140 | If `include_inherited` is `True` (default), the method will return `True` if at least a field is defined in the 141 | class or one of its ancestors. If `False`, the fields need to be defined on the class itself. 142 | 143 | :param cls: 144 | :param include_inherited: 145 | :return: 146 | """ 147 | return any(yield_fields(cls, include_inherited=include_inherited)) 148 | 149 | 150 | if sys.version_info >= (3, 7): 151 | ODict = dict 152 | else: 153 | from collections import OrderedDict 154 | ODict = OrderedDict 155 | 156 | 157 | def get_field_values(obj, 158 | include_inherited=True, # type: bool 159 | remove_duplicates=True, # type: bool 160 | ancestors_first=True, # type: bool 161 | public_only=False, # type: bool 162 | container_type=ODict, # type: Type[T] 163 | _auto_fix_fields=False # type: bool 164 | ): 165 | """ 166 | Utility method to collect all field names and values defined on an object, including all inherited or not. 167 | 168 | By default duplicates are removed and ancestor fields are included and appear first. If a field is overridden, 169 | it will appear at the position of the overridden field in the order. 170 | 171 | The result is an ordered dictionary (a `dict` in python 3.7, an `OrderedDict` otherwise) of {name: value} pairs. 172 | One can change the container type with the `container_type` attribute though, that will receive an iterable of 173 | (key, value) pairs. 174 | 175 | :param obj: 176 | :param include_inherited: 177 | :param remove_duplicates: 178 | :param ancestors_first: 179 | :param public_only: 180 | :param container_type: 181 | :param _auto_fix_fields: 182 | :return: 183 | """ 184 | fields_gen = yield_fields(obj.__class__, include_inherited=include_inherited, public_only=public_only, 185 | remove_duplicates=remove_duplicates, ancestors_first=ancestors_first, 186 | _auto_fix_fields=_auto_fix_fields) 187 | 188 | return container_type((f.name, getattr(obj, f.name)) for f in fields_gen) 189 | 190 | 191 | def safe_isclass(obj # type: object 192 | ): 193 | # type: (...) -> bool 194 | """Ignore any exception via isinstance on Python 3.""" 195 | try: 196 | return isclass(obj) 197 | except Exception: 198 | return False 199 | 200 | 201 | def get_fields(cls_or_obj, 202 | include_inherited=True, # type: bool 203 | remove_duplicates=True, # type: bool 204 | ancestors_first=True, # type: bool 205 | public_only=False, # type: bool 206 | container_type=tuple, # type: Type[T] 207 | _auto_fix_fields=False # type: bool 208 | ): 209 | # type: (...) -> T 210 | """ 211 | Utility method to collect all fields defined in a class, including all inherited or not, in definition order. 212 | 213 | By default duplicates are removed and ancestor fields are included and appear first. If a field is overridden, 214 | it will appear at the position of the overridden field in the order. 215 | 216 | If an object is provided, `get_fields` will be executed on its class. 217 | 218 | :param cls_or_obj: 219 | :param include_inherited: 220 | :param remove_duplicates: 221 | :param ancestors_first: 222 | :param public_only: 223 | :param container_type: 224 | :param _auto_fix_fields: 225 | :return: the fields (by default, as a tuple) 226 | """ 227 | if not safe_isclass(cls_or_obj): 228 | cls_or_obj = cls_or_obj.__class__ 229 | 230 | return container_type(yield_fields(cls_or_obj, include_inherited=include_inherited, public_only=public_only, 231 | remove_duplicates=remove_duplicates, ancestors_first=ancestors_first, 232 | _auto_fix_fields=_auto_fix_fields)) 233 | 234 | 235 | # def ordered_dir(cls, 236 | # ancestors_first=False # type: bool 237 | # ): 238 | # """ 239 | # since `dir` does not preserve order, lets have our own implementation 240 | # 241 | # :param cls: 242 | # :param ancestors_first: 243 | # :return: 244 | # """ 245 | # classes = reversed(getmro(cls)) if ancestors_first else getmro(cls) 246 | # 247 | # for _cls in classes: 248 | # for k in vars(_cls): 249 | # yield k 250 | 251 | 252 | def copy_value(val, 253 | deep=True, # type: bool 254 | autocheck=True # type: bool 255 | ): 256 | """ 257 | Returns a default value factory to be used in a `field(default_factory=...)`. 258 | 259 | That factory will create a copy of the provided `val` everytime it is called. Handy if you wish to use mutable 260 | objects as default values for your fields ; for example lists. 261 | 262 | :param val: the (mutable) value to copy 263 | :param deep: by default deep copies will be created. You can change this behaviour by setting this to `False` 264 | :param autocheck: if this is True (default), an initial copy will be created when the method is called, so as to 265 | alert the user early if this leads to errors. 266 | :return: 267 | """ 268 | if deep: 269 | if autocheck: 270 | try: 271 | # autocheck: make sure that we will be able to create copies later 272 | deepcopy(val) 273 | except Exception as e: 274 | raise ValueError("The provided default value %r can not be deep-copied: caught error %r" % (val, e)) 275 | 276 | def create_default(obj): 277 | return deepcopy(val) 278 | else: 279 | if autocheck: 280 | try: 281 | # autocheck: make sure that we will be able to create copies later 282 | copy(val) 283 | except Exception as e: 284 | raise ValueError("The provided default value %r can not be copied: caught error %r" % (val, e)) 285 | 286 | def create_default(obj): 287 | return copy(val) 288 | 289 | # attach a method to easily get a new factory with a new value 290 | def get_copied_value(): 291 | return val 292 | 293 | def clone_with_new_val(newval): 294 | return copy_value(newval, deep) 295 | 296 | create_default.get_copied_value = get_copied_value 297 | create_default.clone_with_new_val = clone_with_new_val 298 | return create_default 299 | 300 | 301 | def copy_field(field_or_name, # type: Union[str, Field] 302 | deep=True # type: bool 303 | ): 304 | """ 305 | Returns a default value factory to be used in a `field(default_factory=...)`. 306 | 307 | That factory will create a copy of the value in the given field. You can provide a field or a field name, in which 308 | case this method is strictly equivalent to `copy_attr`. 309 | 310 | :param field_or_name: the field or name of the field for which the value needs to be copied 311 | :param deep: by default deep copies will be created. You can change this behaviour by setting this to `False` 312 | :return: 313 | """ 314 | if isinstance(field_or_name, Field): 315 | if field_or_name.name is None: 316 | # Name not yet available, we'll get it later 317 | if deep: 318 | def create_default(obj): 319 | return deepcopy(getattr(obj, field_or_name.name)) 320 | else: 321 | def create_default(obj): 322 | return copy(getattr(obj, field_or_name.name)) 323 | 324 | return create_default 325 | else: 326 | # use the field name 327 | return copy_attr(field_or_name.name, deep=deep) 328 | else: 329 | # this is already a field name 330 | return copy_attr(field_or_name, deep=deep) 331 | 332 | 333 | def copy_attr(attr_name, # type: str 334 | deep=True # type: bool 335 | ): 336 | """ 337 | Returns a default value factory to be used in a `field(default_factory=...)`. 338 | 339 | That factory will create a copy of the value in the given attribute. 340 | 341 | :param attr_name: the name of the attribute for which the value will be copied 342 | :param deep: by default deep copies will be created. You can change this behaviour by setting this to `False` 343 | :return: 344 | """ 345 | if deep: 346 | def create_default(obj): 347 | return deepcopy(getattr(obj, attr_name)) 348 | else: 349 | def create_default(obj): 350 | return copy(getattr(obj, attr_name)) 351 | 352 | return create_default 353 | -------------------------------------------------------------------------------- /pyfields/init_makers.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain MARIE <sylvain.marie@se.com> 2 | # + All contributors to <https://github.com/smarie/python-pyfields> 3 | # 4 | # License: 3-clause BSD, <https://github.com/smarie/python-pyfields/blob/master/LICENSE> 5 | import sys 6 | from inspect import isfunction, getmro 7 | from itertools import islice 8 | 9 | try: 10 | from inspect import signature, Parameter, Signature 11 | except ImportError: 12 | from funcsigs import signature, Parameter, Signature 13 | 14 | 15 | try: # python 3.5+ 16 | from typing import List, Callable, Any, Union, Iterable, Tuple 17 | use_type_hints = sys.version_info > (3, 0) 18 | except ImportError: 19 | use_type_hints = False 20 | 21 | 22 | from makefun import wraps, with_signature 23 | 24 | from pyfields.core import PY36, USE_FACTORY, EMPTY, Field 25 | from pyfields.helpers import get_fields 26 | 27 | 28 | def init_fields(*fields, # type: Union[Field, Any] 29 | **kwargs 30 | ): 31 | """ 32 | Decorator for an init method, so that fields are initialized before entering the method. 33 | 34 | By default, when the decorator is used without arguments or when `fields` is empty, all fields defined in the class 35 | are initialized. Fields inherited from parent classes are included, following the mro. The signature of the init 36 | method is modified so that it can receive values for these fields: 37 | 38 | >>> import sys, pytest 39 | >>> if sys.version_info < (3, 7): pytest.skip('doctest skipped') # 3.6 help() is different on travis 40 | 41 | >>> from pyfields import field, init_fields 42 | >>> class Wall: 43 | ... height: int = field(doc="Height of the wall in mm.") 44 | ... color: str = field(default='white', doc="Color of the wall.") 45 | ... 46 | ... @init_fields 47 | ... def __init__(self, msg: str = 'hello'): 48 | ... print("post init ! height=%s, color=%s, msg=%s" % (self.height, self.color, msg)) 49 | ... self.non_field_attr = msg 50 | ... 51 | >>> help(Wall.__init__) 52 | Help on function __init__ in module pyfields.init_makers: 53 | <BLANKLINE> 54 | __init__(self, height: int, msg: str = 'hello', color: str = 'white') 55 | The `__init__` method generated for you when you use `@init_fields` 56 | or `make_init` with a non-None `post_init_fun` method. 57 | <BLANKLINE> 58 | >>> w = Wall(2) 59 | post init ! height=2, color=white, msg=hello 60 | 61 | 62 | The list of fields can be explicitly provided in `fields`. 63 | 64 | By default the init arguments will appear before the fields in the signature, wherever possible (mandatory args 65 | before mandatory fields, optional args before optional fields). You can change this behaviour by setting 66 | `init_args_before` to `False`. 67 | 68 | :param fields: list of fields to initialize before entering the decorated `__init__` method. For each of these 69 | fields a corresponding argument will be added in the method's signature. If an empty list is provided, all 70 | fields from the class will be used including inherited fields following the mro. In case of inherited fields, 71 | they will appear before the class fields if `ancestor_fields_first` is `True` (default), after otherwise. 72 | :param init_args_before: If set to `True` (default), arguments from the decorated init method will appear before 73 | the fields when possible. If set to `False` the contrary will happen. 74 | :param ancestor_fields_first: If set to `True` (default behaviour), when the provided list of fields is empty, 75 | ancestor-inherited fields will appear before the class fields when possible (even for fields overridden in the 76 | subclass). If set to `False` the contrary will happen. 77 | :return: 78 | """ 79 | init_args_before, ancestor_fields_first = pop_kwargs(kwargs, [('init_args_before', True), 80 | ('ancestor_fields_first', None)], allow_others=False) 81 | 82 | if len(fields) == 1: 83 | # used without argument ? 84 | f = fields[0] 85 | if isfunction(f) and not isinstance(f, Field) and init_args_before: 86 | # @init_fields decorator used without parenthesis 87 | 88 | # The list of fields is NOT explicit: we have no way to gather this list without creating a descriptor 89 | return InitDescriptor(user_init_fun=f, user_init_is_injected=False, 90 | ancestor_fields_first=ancestor_fields_first) 91 | 92 | def apply_decorator(init_fun): 93 | # @init_fields(...) 94 | 95 | # The list of fields is explicit AND names/type hints have been set already: 96 | # it is not easy to be sure of this (names yes, but annotations?) > prefer the descriptor anyway 97 | return InitDescriptor(fields=fields, user_init_fun=init_fun, user_init_args_before=init_args_before, 98 | user_init_is_injected=False, ancestor_fields_first=ancestor_fields_first) 99 | 100 | return apply_decorator 101 | 102 | 103 | def inject_fields(*fields # type: Union[Field, Any] 104 | ): 105 | """ 106 | A decorator for `__init__` methods, to make them automatically expose arguments corresponding to all `*fields`. 107 | It can be used with or without arguments. If the list of fields is empty, it means "all fields from the class". 108 | 109 | The decorated `__init__` method should have an argument named `'fields'`. This argument will be injected with an 110 | object so that users can manually execute the fields initialization. This is done with `fields.init()`. 111 | 112 | >>> import sys, pytest 113 | >>> if sys.version_info < (3, 6): pytest.skip('doctest skipped') 114 | 115 | >>> from pyfields import field, inject_fields 116 | ... 117 | >>> class Wall(object): 118 | ... height = field(doc="Height of the wall in mm.") 119 | ... color = field(default='white', doc="Color of the wall.") 120 | ... 121 | ... @inject_fields(height, color) 122 | ... def __init__(self, fields): 123 | ... # initialize all fields received 124 | ... fields.init(self) 125 | ... 126 | ... def __repr__(self): 127 | ... return "Wall<height=%r, color=%r>" % (self.height, self.color) 128 | ... 129 | >>> Wall() 130 | Traceback (most recent call last): 131 | ... 132 | TypeError: __init__() missing 1 required positional argument: 'height' 133 | >>> Wall(1) 134 | Wall<height=1, color='white'> 135 | 136 | :param fields: list of fields to initialize before entering the decorated `__init__` method. For each of these 137 | fields a corresponding argument will be added in the method's signature. If an empty list is provided, all 138 | fields from the class will be used including inherited fields following the mro. 139 | :return: 140 | """ 141 | if len(fields) == 1: 142 | # used without argument ? 143 | f = fields[0] 144 | if isfunction(f) and not isinstance(f, Field): 145 | # @inject_fields decorator used without parenthesis 146 | 147 | # The list of fields is NOT explicit: we have no way to gather this list without creating a descriptor 148 | return InitDescriptor(user_init_fun=f, user_init_is_injected=True) 149 | 150 | def apply_decorator(init_fun): 151 | # @inject_fields(...) 152 | 153 | # The list of fields is explicit AND names/type hints have been set already: 154 | # it is not easy to be sure of this (names yes, but annotations?) > prefer the descriptor anyway 155 | return InitDescriptor(user_init_fun=init_fun, fields=fields, user_init_is_injected=True) 156 | 157 | return apply_decorator 158 | 159 | 160 | def make_init(*fields, # type: Union[Field, Any] 161 | **kwargs 162 | ): 163 | # type: (...) -> InitDescriptor 164 | """ 165 | Creates a constructor based on the provided fields. 166 | 167 | If `fields` is empty, all fields from the class will be used in order of appearance, then the ancestors (following 168 | the mro) 169 | 170 | >>> import sys, pytest 171 | >>> if sys.version_info < (3, 6): pytest.skip('doctest skipped') 172 | 173 | >>> from pyfields import field, make_init 174 | ... 175 | >>> class Wall: 176 | ... height = field(doc="Height of the wall in mm.") 177 | ... color = field(default='white', doc="Color of the wall.") 178 | ... __init__ = make_init() 179 | >>> w = Wall(1, color='blue') 180 | >>> assert vars(w) == {'color': 'blue', 'height': 1} 181 | 182 | If `fields` is not empty, only the listed fields will appear in the constructor and will be initialized upon init. 183 | 184 | >>> class Wall: 185 | ... height = field(doc="Height of the wall in mm.") 186 | ... color = field(default='white', doc="Color of the wall.") 187 | ... __init__ = make_init(height) 188 | >>> w = Wall(1, color='blue') 189 | Traceback (most recent call last): 190 | ... 191 | TypeError: __init__() got an unexpected keyword argument 'color' 192 | 193 | `fields` can contain fields that do not belong to this class: typically they can be fields defined in a parent 194 | class. Note however that any field can be used, it is not mandatory to use class or inherited fields. 195 | 196 | >>> class Wall: 197 | ... height: int = field(doc="Height of the wall in mm.") 198 | ... 199 | >>> class ColoredWall(Wall): 200 | ... color: str = field(default='white', doc="Color of the wall.") 201 | ... __init__ = make_init(Wall.__dict__['height']) 202 | ... 203 | >>> w = ColoredWall(1) 204 | >>> vars(w) 205 | {'height': 1} 206 | 207 | If a `post_init_fun` is provided, it should be a function with `self` as first argument. This function will be 208 | executed after all declared fields have been initialized. The signature of the resulting `__init__` function 209 | created will be constructed by blending all mandatory/optional fields with the mandatory/optional args in the 210 | `post_init_fun` signature. The ones from the `post_init_fun` will appear first except if `post_init_args_before` 211 | is set to `False` 212 | 213 | >>> class Wall: 214 | ... height: int = field(doc="Height of the wall in mm.") 215 | ... color: str = field(default='white', doc="Color of the wall.") 216 | ... 217 | ... def post_init(self, msg='hello'): 218 | ... print("post init ! height=%s, color=%s, msg=%s" % (self.height, self.color, msg)) 219 | ... self.non_field_attr = msg 220 | ... 221 | ... # only `height` and `foo` will be in the constructor 222 | ... __init__ = make_init(height, post_init_fun=post_init) 223 | ... 224 | >>> w = Wall(1, 'hey') 225 | post init ! height=1, color=white, msg=hey 226 | >>> assert vars(w) == {'height': 1, 'color': 'white', 'non_field_attr': 'hey'} 227 | 228 | :param fields: the fields to include in the generated constructor signature. If no field is provided, all fields 229 | defined in the class will be included, as well as inherited ones following the mro. 230 | :param post_init_fun: (default: `None`) an optional function to call once all fields have been initialized. This 231 | function should have `self` as first argument. The rest of its signature will be blended with the fields in the 232 | generated constructor signature. 233 | :param post_init_args_before: boolean. Defines if the arguments from the `post_init_fun` should appear before 234 | (default: `True`) or after (`False`) the fields in the generated signature. Of course in all cases, mandatory 235 | arguments will appear after optional arguments, so as to ensure that the created signature is valid. 236 | :param ancestor_fields_first: If set to `True` (default behaviour), when the provided list of fields is empty, 237 | ancestor-inherited fields will appear before the class fields when possible (even for fields overridden in the 238 | subclass). If set to `False` the contrary will happen. 239 | :return: a constructor method to be used as `__init__` 240 | """ 241 | # python <3.5 compliance: pop the kwargs following the varargs 242 | post_init_fun, post_init_args_before, ancestor_fields_first = pop_kwargs(kwargs, [ 243 | ('post_init_fun', None), ('post_init_args_before', True), ('ancestor_fields_first', None) 244 | ], allow_others=False) 245 | 246 | return InitDescriptor(fields=fields, user_init_fun=post_init_fun, user_init_args_before=post_init_args_before, 247 | user_init_is_injected=False, ancestor_fields_first=ancestor_fields_first) 248 | 249 | 250 | class InitDescriptor(object): 251 | """ 252 | A class member descriptor for the `__init__` method that we create with `make_init`. 253 | The first time people access `cls.__init__`, the actual method will be created and injected in the class. 254 | This descriptor will then disappear and the class will behave normally. 255 | 256 | The reason why we do not create the init method directly is that we require all fields to be attached to the class 257 | so that they have names and type hints. 258 | 259 | Inspired by https://stackoverflow.com/a/3412743/7262247 260 | """ 261 | __slots__ = 'fields', 'user_init_is_injected', 'user_init_fun', 'user_init_args_before', 'ancestor_fields_first', \ 262 | 'ownercls' 263 | 264 | def __init__(self, fields=None, user_init_is_injected=False, user_init_fun=None, user_init_args_before=True, 265 | ancestor_fields_first=None): 266 | if fields is not None and len(fields) == 0: 267 | fields = None 268 | self.fields = fields 269 | self.user_init_is_injected = user_init_is_injected 270 | self.user_init_fun = user_init_fun 271 | self.user_init_args_before = user_init_args_before 272 | if ancestor_fields_first is None: 273 | ancestor_fields_first = True 274 | elif fields is not None: 275 | raise ValueError("`ancestor_fields_first` is only applicable when `fields` is empty") 276 | self.ancestor_fields_first = ancestor_fields_first 277 | self.ownercls = None 278 | 279 | def __set_name__(self, owner, name): 280 | """ 281 | There is a python issue with init descriptors with super() access. To fix it we need to 282 | remember the owner class type separately as we cant' trust the one received in __get__. 283 | See https://github.com/smarie/python-pyfields/issues/53 284 | """ 285 | self.ownercls = owner 286 | 287 | def __get__(self, obj, objtype): 288 | # type: (...) -> Callable 289 | """ 290 | THIS IS NOT THE INIT METHOD ! THIS IS THE CREATOR OF THE INIT METHOD (first time only) 291 | Python Descriptor protocol - this is called when the __init__ method is required for the first time, 292 | it creates the `__init__` method, replaces itself with it, and returns it. Subsequent calls will directly 293 | be routed to the new init method and not here. 294 | """ 295 | # objtype is not reliable: when called through super() it does not contain the right class. 296 | # see https://github.com/smarie/python-pyfields/issues/53 297 | if self.ownercls is not None: 298 | objtype = self.ownercls 299 | elif objtype is not None: 300 | # workaround in case of python < 3.6: at least, when a subclass init is created, make sure that all super 301 | # classes init have their owner class properly set, . 302 | # That way, when the subclass __init__ will be called, containing potential calls to super(), the parents' 303 | # __init__ method descriptors will be correctly configured. 304 | for _c in reversed(getmro(objtype)[1:-1]): 305 | try: 306 | _init_member = _c.__dict__['__init__'] 307 | except KeyError: 308 | continue 309 | else: 310 | if isinstance(_init_member, InitDescriptor): 311 | if _init_member.ownercls is None: 312 | # call __set_name__ explicitly (python < 3.6) to register the descriptor with the class 313 | _init_member.__set_name__(_c, '__init__') 314 | 315 | # <objtype>.__init__ has been accessed. Create the modified init 316 | fields = self.fields 317 | if fields is None: 318 | # fields have not been provided explicitly, collect them all. 319 | fields = get_fields(objtype, include_inherited=True, ancestors_first=self.ancestor_fields_first, 320 | _auto_fix_fields=not PY36) 321 | elif not PY36: 322 | # take this opportunity to apply all field names including inherited 323 | # TODO set back inherited = False when the bug with class-level access is solved -> make_init will be ok 324 | get_fields(objtype, include_inherited=True, ancestors_first=self.ancestor_fields_first, 325 | _auto_fix_fields=True) 326 | 327 | # create the init method 328 | new_init = create_init(fields=fields, inject_fields=self.user_init_is_injected, 329 | user_init_fun=self.user_init_fun, user_init_args_before=self.user_init_args_before) 330 | 331 | # replace it forever in the class 332 | # setattr(objtype, '__init__', new_init) 333 | objtype.__init__ = new_init 334 | 335 | # return the new init 336 | return new_init.__get__(obj, objtype) 337 | 338 | 339 | class InjectedInitFieldsArg(object): 340 | """ 341 | The object that is injected in the users' `__init__` method as the `fields` argument, 342 | when it has been decorated with `@inject_fields`. 343 | 344 | All field values received from the generated `__init__` are available in `self.field_values`, and 345 | a `init()` method allows users to perform the initialization per se. 346 | """ 347 | __slots__ = 'field_values' 348 | 349 | def __init__(self, **init_field_values): 350 | self.field_values = init_field_values 351 | 352 | def init(self, obj): 353 | """ 354 | Initializes all fields on the provided object 355 | :param obj: 356 | :return: 357 | """ 358 | for field_name, field_value in self.field_values.items(): 359 | if field_value is not USE_FACTORY: 360 | # init the field with the provided value or the injected default value 361 | setattr(obj, field_name, field_value) 362 | else: 363 | # init the field with its factory 364 | getattr(obj, field_name) 365 | 366 | 367 | def create_init(fields, # type: Iterable[Field] 368 | user_init_fun=None, # type: Callable[[...], Any] 369 | inject_fields=False, # type: bool 370 | user_init_args_before=True # type: bool 371 | ): 372 | """ 373 | Creates the new init function that will replace `init_fun`. 374 | It requires that all fields have correct names and type hints so we usually execute it from within a __init__ 375 | descriptor. 376 | 377 | :param fields: 378 | :param user_init_fun: 379 | :param inject_fields: 380 | :param user_init_args_before: 381 | :return: 382 | """ 383 | # the list of parameters that should be exposed 384 | params = [Parameter('self', kind=Parameter.POSITIONAL_OR_KEYWORD)] 385 | 386 | if user_init_fun is None: 387 | # A - no function provided: expose a signature containing 'self' + fields 388 | field_names, _ = _insert_fields_at_position(fields, params, 1) 389 | new_sig = Signature(parameters=params) 390 | 391 | # and create the new init method 392 | @with_signature(new_sig, func_name='__init__') 393 | def init_fun(*args, **kwargs): 394 | """ 395 | The `__init__` method generated for you when you use `make_init` 396 | """ 397 | # 1. get 'self' 398 | try: 399 | # most of the time 'self' will be received like that 400 | self = kwargs['self'] 401 | except IndexError: 402 | self = args[0] 403 | 404 | # 2. self-assign all fields 405 | for field_name in field_names: 406 | field_value = kwargs[field_name] 407 | if field_value is not USE_FACTORY: 408 | # init the field with the provided value or the injected default value 409 | setattr(self, field_name, field_value) 410 | else: 411 | # init the field with its factory, by just getting it 412 | getattr(self, field_name) 413 | 414 | return init_fun 415 | 416 | else: 417 | # B - function provided - expose a signature containing 'self' + the function params + fields 418 | # start by inserting all fields 419 | field_names, _idx = _insert_fields_at_position(fields, params, 1) 420 | 421 | # then get the function signature 422 | user_init_sig = signature(user_init_fun) 423 | 424 | # Insert all parameters from the function except 'self' 425 | if user_init_args_before: 426 | mandatory_insert_idx, optional_insert_idx = 1, _idx 427 | else: 428 | mandatory_insert_idx, optional_insert_idx = _idx, len(params) 429 | 430 | fields_arg_found = False 431 | for p in islice(user_init_sig.parameters.values(), 1, None): # remove the 'self' argument 432 | if inject_fields and p.name == 'fields': 433 | # injected argument 434 | fields_arg_found = True 435 | continue 436 | if p.default is p.empty: 437 | # mandatory 438 | params.insert(mandatory_insert_idx, p) 439 | mandatory_insert_idx += 1 440 | optional_insert_idx += 1 441 | else: 442 | # optional 443 | params.insert(optional_insert_idx, p) 444 | optional_insert_idx += 1 445 | 446 | if inject_fields and not fields_arg_found: 447 | # 'fields' argument not found in __init__ signature: impossible to inject, raise an error 448 | try: 449 | name = user_init_fun.__qualname__ 450 | except AttributeError: 451 | name = user_init_fun.__name__ 452 | raise ValueError("Error applying `@inject_fields` on `%s%s`: " 453 | "no 'fields' argument is available in the signature." % (name, user_init_sig)) 454 | 455 | # replace the signature with the newly created one 456 | new_sig = user_init_sig.replace(parameters=params) 457 | 458 | # and create the new init method 459 | if inject_fields: 460 | @wraps(user_init_fun, new_sig=new_sig) 461 | def __init__(self, *args, **kwargs): 462 | """ 463 | The `__init__` method generated for you when you use `@inject_fields` on your `__init__` 464 | """ 465 | # 1. remove all field values received from the outer signature 466 | _fields = dict() 467 | for f_name in field_names: 468 | _fields[f_name] = kwargs.pop(f_name) 469 | 470 | # 2. inject our special variable 471 | kwargs['fields'] = InjectedInitFieldsArg(**_fields) 472 | 473 | # 3. call your __init__ method 474 | return user_init_fun(self, *args, **kwargs) 475 | 476 | else: 477 | @wraps(user_init_fun, new_sig=new_sig) 478 | def __init__(self, *args, **kwargs): 479 | """ 480 | The `__init__` method generated for you when you use `@init_fields` 481 | or `make_init` with a non-None `post_init_fun` method. 482 | """ 483 | # 1. self-assign all fields 484 | for field_name in field_names: 485 | field_value = kwargs.pop(field_name) 486 | if field_value is not USE_FACTORY: 487 | # init the field with the provided value or the injected default value 488 | setattr(self, field_name, field_value) 489 | else: 490 | # init the field with its factory, by just getting it 491 | getattr(self, field_name) 492 | 493 | # 2. call your post-init method 494 | return user_init_fun(self, *args, **kwargs) 495 | 496 | return __init__ 497 | 498 | 499 | def _insert_fields_at_position(fields_to_insert, 500 | params, 501 | i, 502 | field_names=None 503 | ): 504 | """ 505 | Note: preserve order as much as possible, but automatically place all mandatory fields first so that the 506 | signature is valid. 507 | 508 | :param fields_to_insert: 509 | :param field_names: 510 | :param i: 511 | :param params: 512 | :return: 513 | """ 514 | if field_names is None: 515 | field_names = [] 516 | 517 | initial_i = i 518 | last_mandatory_idx = i 519 | for _field in reversed(fields_to_insert): 520 | # Is this field optional ? 521 | if _field.is_mandatory: 522 | # mandatory 523 | where_to_insert = i 524 | last_mandatory_idx += 1 525 | default = Parameter.empty 526 | elif _field.is_default_factory: 527 | # optional with a default value factory: place a specific symbol in the signature to indicate it 528 | default = USE_FACTORY 529 | where_to_insert = last_mandatory_idx 530 | else: 531 | # optional with a default value 532 | default = _field.default 533 | where_to_insert = last_mandatory_idx 534 | 535 | # Are there annotations on the field ? 536 | annotation = _field.type_hint if _field.type_hint is not EMPTY else Parameter.empty 537 | 538 | # remember the list of field names for later use - but in the right order 539 | field_names.insert(where_to_insert - initial_i, _field.name) 540 | 541 | # finally inject the new parameter in the signature 542 | new_param = Parameter(_field.name, kind=Parameter.POSITIONAL_OR_KEYWORD, default=default, annotation=annotation) 543 | params.insert(where_to_insert, new_param) 544 | 545 | return field_names, last_mandatory_idx 546 | 547 | 548 | def pop_kwargs(kwargs, 549 | names_with_defaults, # type: List[Tuple[str, Any]] 550 | allow_others=False 551 | ): 552 | """ 553 | Internal utility method to extract optional arguments from kwargs. 554 | 555 | :param kwargs: 556 | :param names_with_defaults: 557 | :param allow_others: if False (default) then an error will be raised if kwargs still contains something at the end. 558 | :return: 559 | """ 560 | all_arguments = [] 561 | for name, default_ in names_with_defaults: 562 | try: 563 | val = kwargs.pop(name) 564 | except KeyError: 565 | val = default_ 566 | all_arguments.append(val) 567 | 568 | if not allow_others and len(kwargs) > 0: 569 | raise ValueError("Unsupported arguments: %s" % kwargs) 570 | 571 | if len(names_with_defaults) == 1: 572 | return all_arguments[0] 573 | else: 574 | return all_arguments 575 | -------------------------------------------------------------------------------- /pyfields/init_makers.pyi: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain MARIE <sylvain.marie@se.com> 2 | # + All contributors to <https://github.com/smarie/python-pyfields> 3 | # 4 | # License: 3-clause BSD, <https://github.com/smarie/python-pyfields/blob/master/LICENSE> 5 | from typing import Union, Any, Callable, Iterable 6 | 7 | from pyfields.core import Field 8 | 9 | 10 | def init_fields(*fields: Union[Field, Any], 11 | init_args_before: bool = True, 12 | ancestor_fields_first: bool = True 13 | ): 14 | ... 15 | 16 | 17 | def inject_fields(*fields: Union[Field, Any], 18 | ): 19 | ... 20 | 21 | 22 | def make_init(*fields: Union[Field, Any], 23 | post_init_fun: Callable = None, 24 | post_init_args_before: bool = True, 25 | ancestor_fields_first: bool = True 26 | ) -> Callable: 27 | ... 28 | 29 | 30 | class InitDescriptor(object): 31 | def init(self, obj): ... 32 | ... 33 | 34 | 35 | class InjectedInitFieldsArg(object): 36 | ... 37 | 38 | 39 | def create_init(fields: Iterable[Field], 40 | user_init_fun: Callable[[...], Any] = None, 41 | inject_fields: bool = False, 42 | user_init_args_before: bool = True 43 | ): 44 | ... 45 | -------------------------------------------------------------------------------- /pyfields/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smarie/python-pyfields/246cc243cf414cc3e2766de79f1b45ffb1da7a6c/pyfields/py.typed -------------------------------------------------------------------------------- /pyfields/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain Marie <sylvain.marie@se.com> 2 | # 3 | # Copyright (c) Schneider Electric Industries, 2019. All right reserved. 4 | 5 | -------------------------------------------------------------------------------- /pyfields/tests/_test_benchmarks.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain Marie <sylvain.marie@se.com> 2 | # 3 | # Copyright (c) Schneider Electric Industries, 2019. All right reserved. 4 | import pytest 5 | import dataclasses as dc 6 | from attr import attrs, attrib 7 | 8 | from pyfields import field, inject_fields 9 | 10 | 11 | def _create_class_creator(type): 12 | if type == "pyfields": 13 | def _call_me(): 14 | class Wall(object): 15 | height = field(doc="Height of the wall in mm.") # type: int 16 | color = field(default='white', doc="Color of the wall.") # type: str 17 | 18 | @inject_fields 19 | def __init__(self, fields): 20 | fields.init(self) 21 | return Wall 22 | 23 | elif type == "python": 24 | def _call_me(): 25 | class Wall(object): 26 | def __init__(self, 27 | height, # type: int 28 | color='white' # type: str 29 | ): 30 | """ 31 | 32 | :param height: Height of the wall in mm. 33 | :param color: Color of the wall. 34 | """ 35 | self.height = height 36 | self.color = color 37 | return Wall 38 | elif type == "attrs": 39 | def _call_me(): 40 | @attrs 41 | class Wall(object): 42 | height: int = attrib(repr=False, cmp=False, hash=False) 43 | color: str = attrib(default='white', repr=False, cmp=False, hash=False) 44 | 45 | return Wall 46 | 47 | elif type == "dataclass": 48 | def _call_me(): 49 | @dc.dataclass 50 | class Wall(object): 51 | height: int = dc.field(init=True) 52 | color: str = dc.field(default='white', init=True) 53 | 54 | return Wall 55 | else: 56 | raise ValueError() 57 | 58 | return _call_me 59 | 60 | 61 | @pytest.mark.parametrize("type", ["python", "pyfields", "attrs", "dataclass"]) 62 | def test_timers_class(benchmark, type): 63 | # benchmark it 64 | benchmark(_create_class_creator(type)) 65 | 66 | 67 | def _instantiate(clazz): 68 | return lambda: clazz(color='hello', height=50) 69 | 70 | 71 | @pytest.mark.parametrize("type", ["python", "pyfields", "attrs", "dataclass"]) 72 | def test_timers_instance(benchmark, type): 73 | clazz = _create_class_creator(type)() 74 | 75 | benchmark(_instantiate(clazz)) 76 | 77 | 78 | def _read_field(obj): 79 | return lambda: obj.color 80 | 81 | 82 | @pytest.mark.parametrize("type", ["python", "pyfields", "attrs", "dataclass"]) 83 | def test_timers_instance_read(benchmark, type): 84 | clazz = _create_class_creator(type)() 85 | obj = clazz(color='hello', height=50) 86 | 87 | benchmark(_read_field(obj)) 88 | 89 | 90 | def _write_field(obj): 91 | obj.color = 'sky_blue' 92 | 93 | 94 | @pytest.mark.parametrize("type", ["python", "pyfields", "attrs", "dataclass"]) 95 | def test_timers_instance_write(benchmark, type): 96 | clazz = _create_class_creator(type)() 97 | obj = clazz(color='hello', height=50) 98 | 99 | benchmark(_write_field, obj) 100 | -------------------------------------------------------------------------------- /pyfields/tests/_test_py36.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain Marie <sylvain.marie@se.com> 2 | # 3 | # Copyright (c) Schneider Electric Industries, 2019. All right reserved. 4 | 5 | import pytest 6 | 7 | from typing import List, Optional 8 | from pyfields import field, inject_fields, MandatoryFieldInitError, make_init, autofields, autoclass 9 | 10 | 11 | def _test_class_annotations(): 12 | class Foo: 13 | field_with_validate_type: int = field(check_type=True) 14 | field_with_defaults: str = field() 15 | 16 | return Foo 17 | 18 | 19 | def _test_readme_type_validation(): 20 | class Wall(object): 21 | height: int = field(check_type=True, doc="Height of the wall in mm.") 22 | color: str = field(check_type=True, default='white', doc="Color of the wall.") 23 | 24 | return Wall 25 | 26 | 27 | def _test_readme_value_validation(colors): 28 | from mini_lambda import x 29 | from valid8.validation_lib import is_in 30 | 31 | class Wall(object): 32 | height: int = field(validators={'should be a positive number': x > 0, 33 | 'should be a multiple of 100': x % 100 == 0}, 34 | doc="Height of the wall in mm.") 35 | color: str = field(validators=is_in(colors), 36 | default='white', 37 | doc="Color of the wall.") 38 | 39 | return Wall 40 | 41 | 42 | def test_value_validation_advanced(validate_width): 43 | class Wall(object): 44 | height: int = field(doc="Height of the wall in mm.") 45 | width: str = field(validators=validate_width, 46 | doc="Width of the wall in mm.") 47 | return Wall 48 | 49 | 50 | def _test_readme_constructor(explicit_fields_list, init_type, native): 51 | if init_type == 'inject_fields': 52 | if explicit_fields_list: 53 | class Wall: 54 | height: int = field(doc="Height of the wall in mm.", native=native) 55 | color: str = field(default='white', doc="Color of the wall.", native=native) 56 | 57 | @inject_fields(height, color) 58 | def __init__(self, fields): 59 | with pytest.raises(MandatoryFieldInitError): 60 | print(self.height) 61 | 62 | # initialize all fields received 63 | fields.init(self) 64 | else: 65 | class Wall: 66 | height: int = field(doc="Height of the wall in mm.", native=native) 67 | color: str = field(default='white', doc="Color of the wall.", native=native) 68 | 69 | @inject_fields 70 | def __init__(self, fields): 71 | with pytest.raises(MandatoryFieldInitError): 72 | print(self.height) 73 | 74 | # initialize all fields received 75 | fields.init(self) 76 | elif init_type == 'make_init': 77 | if explicit_fields_list: 78 | class Wall(object): 79 | height: int = field(doc="Height of the wall in mm.", native=native) 80 | color: str = field(default='white', doc="Color of the wall.", native=native) 81 | __init__ = make_init(height, color) 82 | else: 83 | class Wall(object): 84 | height: int = field(doc="Height of the wall in mm.", native=native) 85 | color: str = field(default='white', doc="Color of the wall.", native=native) 86 | __init__ = make_init() 87 | elif init_type == 'make_init_with_postinit': 88 | if explicit_fields_list: 89 | class Wall(object): 90 | height: int = field(doc="Height of the wall in mm.", native=native) 91 | color: str = field(default='white', doc="Color of the wall.", native=native) 92 | 93 | def post_init(self, foo: str = 'bar'): 94 | print(self.height) 95 | print("post init man !") 96 | __init__ = make_init(height, color, post_init_fun=post_init) 97 | else: 98 | class Wall(object): 99 | height: int = field(doc="Height of the wall in mm.", native=native) 100 | color: str = field(default='white', doc="Color of the wall.", native=native) 101 | 102 | def post_init(self, foo: str = 'bar'): 103 | print(self.height) 104 | print("post init man !") 105 | 106 | __init__ = make_init(post_init_fun=post_init) 107 | else: 108 | raise ValueError(init_type) 109 | 110 | return Wall 111 | 112 | 113 | def _test_autofields(type_check): 114 | if type_check: 115 | _deco = autofields(check_types=True) 116 | else: 117 | _deco = autofields 118 | 119 | @_deco 120 | class Foo: 121 | CONSTANT: str = 's' 122 | __a__: int = 0 123 | 124 | foo: int 125 | bar = 0 126 | barcls = int 127 | barfunc = lambda x: x 128 | barbar: str 129 | 130 | class cls: 131 | pass 132 | 133 | def fct(self): 134 | return 1 135 | 136 | return Foo 137 | 138 | 139 | def _test_autofields_readme(): 140 | 141 | @autofields(make_init=True) 142 | class Item: 143 | name: str 144 | 145 | @autofields 146 | class Pocket: 147 | size: int 148 | items: List[Item] = [] 149 | 150 | @autofields 151 | class Pocket2: 152 | size: int 153 | items: List[Item] = [] 154 | def __init__(self, who): 155 | print("hello, %s" % who) 156 | 157 | return Pocket, Item, Pocket2 158 | 159 | 160 | def _test_autofields_vtypes_readme(): 161 | from vtypes import VType 162 | 163 | class PositiveInt(int, VType): 164 | __validators__ = {'should be positive': lambda x: x >= 0} 165 | 166 | @autofields(check_types=True) 167 | class Rectangle: 168 | x: PositiveInt 169 | y: PositiveInt 170 | 171 | return Rectangle 172 | 173 | 174 | def test_issue_74(): 175 | @autofields 176 | class City: 177 | name: Optional[str] 178 | buildings: List[str] = [] 179 | 180 | return City 181 | 182 | 183 | def test_issue_76(): 184 | @autofields 185 | class Foo: 186 | c: int 187 | b: str = "hello" 188 | a: int = field(default=50) 189 | 190 | return Foo 191 | 192 | 193 | def _test_autoclass2(): 194 | @autoclass() 195 | class Foo: 196 | msg: str 197 | age: int = 12 198 | height: int = field(default=50) 199 | 200 | return Foo 201 | 202 | 203 | def _test_autoclass3(): 204 | 205 | @autoclass(typecheck=True, dict=False) 206 | class Foo: 207 | msg: str 208 | age: int = 12 209 | height: int = field(default=50) 210 | 211 | return Foo 212 | -------------------------------------------------------------------------------- /pyfields/tests/issues/__init__.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain Marie <sylvain.marie@se.com> 2 | # 3 | # Copyright (c) Schneider Electric Industries, 2019. All right reserved. 4 | 5 | -------------------------------------------------------------------------------- /pyfields/tests/issues/_test_py36.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain Marie <sylvain.marie@se.com> 2 | # 3 | # Copyright (c) Schneider Electric Industries, 2019. All right reserved. 4 | 5 | from pyfields import field, init_fields, autofields, autoclass 6 | 7 | 8 | def test_issue_51(): 9 | class A: 10 | f: str = field(check_type=True, default=None, nonable=True) 11 | 12 | @init_fields 13 | def __init__(self): 14 | pass 15 | 16 | return A 17 | 18 | 19 | def test_issue_67(): 20 | @autofields 21 | class Frog: 22 | another: int = 1 23 | 24 | @property 25 | def balh(self): 26 | print('asd') 27 | 28 | return Frog 29 | 30 | 31 | def test_issue_73(): 32 | class Foo: 33 | bar: 'Foo' = field(check_type=True, nonable=True) 34 | return Foo 35 | 36 | 37 | class A: 38 | bar: 'B' = field(check_type=True, nonable=True) 39 | 40 | class B: 41 | bar: 'A' = field(check_type=True, nonable=True) 42 | 43 | 44 | def test_issue_73_cross_ref(): 45 | # note: we have to define the classes outside the function for the cross-ref to work 46 | # indeed typing.get_type_hints() will only access the globals of the defining module 47 | return A, B 48 | 49 | 50 | def test_issue_81(): 51 | 52 | # note that the issue comes from autofields actually, but it was detected using autoclass 53 | 54 | @autoclass 55 | class A: 56 | a: int = 1 57 | 58 | @autoclass 59 | class B(A): 60 | b = 0 61 | 62 | return A, B 63 | -------------------------------------------------------------------------------- /pyfields/tests/issues/_test_py36_pep563.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations # python 3.10 behaviour see https://www.python.org/dev/peps/pep-0563/ 2 | from pyfields import field 3 | 4 | 5 | def test_issue_73(): 6 | class Foo: 7 | bar: Foo = field(check_type=True, nonable=True) 8 | return Foo 9 | 10 | 11 | class A: 12 | bar: B = field(check_type=True, nonable=True) 13 | 14 | class B: 15 | bar: A = field(check_type=True, nonable=True) 16 | 17 | 18 | def test_issue_73_cross_ref(): 19 | # note: we have to define the classes outside the function for the cross-ref to work 20 | # indeed typing.get_type_hints() will only access the globals of the defining module 21 | return A, B 22 | -------------------------------------------------------------------------------- /pyfields/tests/issues/test_issue_12.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from pyfields import field 4 | from pyfields.core import NativeField 5 | 6 | 7 | def test_class_access_and_autocomplete(): 8 | """ test that https://github.com/smarie/python-pyfields/issues/12 is resolved """ 9 | class Foo: 10 | a = field(type_hint=int, default=1) 11 | 12 | assert Foo.a.name == 'a' 13 | assert isinstance(Foo.a, NativeField) 14 | assert dict(inspect.getmembers(Foo))['a'] == Foo.a 15 | 16 | f = Foo() 17 | assert f.a == 1 18 | 19 | Foo.a = 5 20 | -------------------------------------------------------------------------------- /pyfields/tests/issues/test_issue_51.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain Marie <sylvain.marie@se.com> 2 | # 3 | # Copyright (c) Schneider Electric Industries, 2019. All right reserved. 4 | import sys 5 | 6 | import pytest 7 | 8 | 9 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="member type hints not supported in python < 3.6") 10 | def test_issue_51(): 11 | from ._test_py36 import test_issue_51 12 | A = test_issue_51() 13 | a = A() 14 | -------------------------------------------------------------------------------- /pyfields/tests/issues/test_issue_53.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain Marie <sylvain.marie@se.com> 2 | # 3 | # Copyright (c) Schneider Electric Industries, 2019. All right reserved. 4 | 5 | from pyfields import field, init_fields, get_field 6 | 7 | 8 | def test_issue_53(): 9 | 10 | class A(object): 11 | a = field(str, check_type=True) 12 | 13 | @init_fields() 14 | def __init__(self): 15 | pass 16 | 17 | class B(A): 18 | b = field(str, check_type=True) 19 | 20 | @init_fields() 21 | def __init__(self): 22 | super(B, self).__init__(a=self.a) 23 | 24 | # note that with the issue, this was raising an exception 25 | print(B('a', 'b')) 26 | -------------------------------------------------------------------------------- /pyfields/tests/issues/test_issue_67.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain Marie <sylvain.marie@se.com> 2 | # 3 | # Copyright (c) Schneider Electric Industries, 2020. All right reserved. 4 | import sys 5 | 6 | import pytest 7 | 8 | from pyfields import get_fields 9 | 10 | 11 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="member type hints not supported in python < 3.6") 12 | def test_issue_67(): 13 | from ._test_py36 import test_issue_67 14 | Frog = test_issue_67() 15 | 16 | assert len(get_fields(Frog)) == 1 17 | Frog() 18 | -------------------------------------------------------------------------------- /pyfields/tests/issues/test_issue_73.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | from pyfields import FieldTypeError 5 | 6 | 7 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="class member annotations are not supported in python < 3.6") 8 | @pytest.mark.parametrize('str_hint', [False, True], ids="str_hint={}".format) 9 | @pytest.mark.parametrize('fix_in_class_field', [False, True], ids="fix_in_class_field={}".format) 10 | def test_self_referenced_class(str_hint, fix_in_class_field): 11 | """Fix https://github.com/smarie/python-pyfields/issues/73 """ 12 | if str_hint: 13 | # this is the old behaviour that happens when PEP563 is not enabled at the top of the module 14 | from ._test_py36 import test_issue_73 15 | Foo = test_issue_73() 16 | else: 17 | # this is the new behaviour that happens when PEP563 is enabled at the top of the module 18 | if sys.version_info < (3, 7): 19 | pytest.skip("python 3.6 does not support PEP563") 20 | from ._test_py36_pep563 import test_issue_73 21 | Foo = test_issue_73() 22 | 23 | if fix_in_class_field: 24 | # this will read the class fields, and the fix will happen during reading 25 | assert Foo.bar.type_hint is Foo 26 | 27 | # if the fix was not done before, it is done when the field is first used 28 | f = Foo() 29 | with pytest.raises(FieldTypeError): 30 | f.bar = 1 31 | 32 | f.bar = f 33 | assert f.bar is f 34 | 35 | if not fix_in_class_field: 36 | # we can optionally check this now, but the mere fact that the above worked is already a proof 37 | assert Foo.bar.type_hint is Foo 38 | 39 | 40 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="class member annotations are not supported in python < 3.6") 41 | @pytest.mark.parametrize('str_hint', [False, True], ids="str_hint={}".format) 42 | @pytest.mark.parametrize('fix_in_class_field', [False, True], ids="fix_in_class_field={}".format) 43 | def test_cross_referenced_class(str_hint, fix_in_class_field): 44 | if str_hint: 45 | # this is the old behaviour that happens when PEP563 is not enabled at the top of the module 46 | from ._test_py36 import test_issue_73_cross_ref 47 | A, B = test_issue_73_cross_ref() 48 | else: 49 | # this is the new behaviour that happens when PEP563 is enabled at the top of the module 50 | if sys.version_info < (3, 7): 51 | pytest.skip("python 3.6 does not support PEP563") 52 | from ._test_py36_pep563 import test_issue_73_cross_ref 53 | A, B = test_issue_73_cross_ref() 54 | 55 | if fix_in_class_field: 56 | # this will read the class fields, and the fix will happen during reading 57 | assert A.bar.type_hint is B 58 | assert B.bar.type_hint is A 59 | 60 | # if the fix was not done before, it is done when the field is first used 61 | a = A() 62 | with pytest.raises(FieldTypeError): 63 | a.bar = 1 64 | 65 | b = B() 66 | a.bar = b 67 | b.bar = a 68 | assert a.bar is b 69 | assert b.bar is a 70 | 71 | if not fix_in_class_field: 72 | # we can optionally check this now, but the mere fact that the above worked is already a proof 73 | assert A.bar.type_hint is B 74 | assert B.bar.type_hint is A 75 | -------------------------------------------------------------------------------- /pyfields/tests/issues/test_issue_81.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain MARIE <sylvain.marie@se.com> 2 | # + All contributors to <https://github.com/smarie/python-pyfields> 3 | # 4 | # License: 3-clause BSD, <https://github.com/smarie/python-pyfields/blob/master/LICENSE> 5 | import sys 6 | import pytest 7 | 8 | 9 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="class member annotations are not supported in python < 3.6") 10 | def test_issue_81(): 11 | """ See https://github.com/smarie/python-pyfields/issues/81 """ 12 | from ._test_py36 import test_issue_81 13 | A, B = test_issue_81() 14 | 15 | # before the bug fix, B.a was mistakenyl recreated py autofields as an overridden mandatory field on B 16 | assert B.a.is_mandatory is False 17 | # this was therefore raising a "Missing required positional argument" error on the generated constructor 18 | B(b=3) 19 | -------------------------------------------------------------------------------- /pyfields/tests/issues/test_issue_84.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | try: 6 | from abc import ABC 7 | except ImportError: 8 | from abc import ABCMeta 9 | 10 | class ABC: 11 | __metaclass__ = ABCMeta 12 | 13 | 14 | from pyfields import autofields, field, copy_value, autoclass 15 | 16 | 17 | @pytest.mark.skipif(sys.version_info < (3,), reason="This test does not yet reproduce the exception in python 2") 18 | @pytest.mark.parametrize("auto,deep", [(False, False), (False, True), (True, None)]) 19 | def test_issue_deepcopy_autofields(auto, deep): 20 | """Make sure that """ 21 | 22 | class NotCopiable(object): 23 | def __deepcopy__(self, memodict={}): 24 | raise NotImplementedError() 25 | 26 | def __copy__(self): 27 | raise NotImplementedError() 28 | 29 | default_value = NotCopiable() 30 | 31 | if auto: 32 | with pytest.raises(ValueError) as exc_info: 33 | @autofields 34 | class Foo: 35 | a = default_value 36 | assert str(exc_info.value).startswith("The provided default value for field 'a'=%r can not be deep-copied" 37 | % (default_value, )) 38 | else: 39 | with pytest.raises(ValueError) as exc_info: 40 | class Foo: 41 | a = field(default_factory=copy_value(default_value, deep=deep)) 42 | 43 | extra = "deep-" if deep else "" 44 | assert str(exc_info.value).startswith("The provided default value %r can not be %scopied" 45 | % (default_value, extra)) 46 | 47 | 48 | def test_issue_84_autofields(): 49 | """Make sure that the _abc_impl field from ABC is excluded automatically""" 50 | 51 | @autofields 52 | class Foo(ABC): 53 | a = 0 54 | 55 | g = Foo() 56 | assert g.a == 0 57 | 58 | if sys.version_info < (3, 7): 59 | # errors below wont be raised anyway 60 | return 61 | 62 | with pytest.raises(ValueError) as exc_info: 63 | @autofields(exclude=()) 64 | class Foo(ABC): 65 | a = 0 66 | 67 | assert str(exc_info.value).startswith("The provided default value for field '_abc_impl'=") 68 | 69 | 70 | def test_issue_84_autoclass(): 71 | """Make sure that the _abc_impl field from ABC is excluded automatically""" 72 | 73 | @autoclass 74 | class Foo(ABC): 75 | a = 0 76 | 77 | f = Foo() 78 | assert str(f) == "Foo(a=0)" 79 | 80 | if sys.version_info < (3, 7): 81 | # errors below wont be raised anyway 82 | return 83 | 84 | with pytest.raises(ValueError) as exc_info: 85 | @autoclass(af_exclude=()) 86 | class Foo(ABC): 87 | a = 0 88 | 89 | assert str(exc_info.value).startswith("The provided default value for field '_abc_impl'=") 90 | -------------------------------------------------------------------------------- /pyfields/tests/test_autofields.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain Marie <sylvain.marie@se.com> 2 | # 3 | # Copyright (c) Schneider Electric Industries, 2019. All right reserved. 4 | import sys 5 | 6 | import pytest 7 | 8 | from pyfields import autofields, field, FieldTypeError, Field, get_fields, autoclass 9 | from pyfields.core import NativeField 10 | 11 | 12 | @pytest.mark.parametrize("with_type_hints,type_check", [(False, False), (True, False), (True, True)]) 13 | def test_autofields_basic(with_type_hints, type_check): 14 | """tests that basic functionality of @autofields is ok """ 15 | 16 | if with_type_hints: 17 | if sys.version_info < (3, 6): 18 | pytest.skip("Type annotations are not supported in python < 3.6") 19 | 20 | from ._test_py36 import _test_autofields 21 | Foo = _test_autofields(type_check) 22 | 23 | # test it 24 | assert isinstance(Foo.__dict__['barcls'], NativeField) 25 | assert isinstance(Foo.__dict__['barfunc'], Field) 26 | assert not isinstance(Foo.__dict__['fct'], Field) 27 | assert not isinstance(Foo.__dict__['cls'], Field) 28 | 29 | f = Foo(foo=1, barbar='yo', barfunc=lambda x: 2, barcls=str) 30 | if type_check: 31 | with pytest.raises(FieldTypeError): 32 | f.foo = 'ha' 33 | else: 34 | f.foo = 'ha' 35 | 36 | assert f.bar == 0 37 | assert f.fct() == 1 38 | assert f.barfunc(1) == 2 39 | assert f.barcls == str 40 | 41 | else: 42 | # retrocompatbility mode for python < 3.6 43 | # note: we also use this opportunity to test with parenthesis 44 | @autofields(check_types=type_check) 45 | class Foo(object): 46 | CONSTANT = 's' 47 | __a__ = 0 48 | 49 | foo = field() 50 | bar = 0 # type: int 51 | barcls = float 52 | barfunc = lambda x: x 53 | barbar = 0 # type: str 54 | 55 | class cls: 56 | pass 57 | 58 | def fct(self): 59 | return 1 60 | 61 | # test it 62 | assert isinstance(Foo.__dict__['barcls'], Field) 63 | assert isinstance(Foo.__dict__['barfunc'], Field) 64 | assert not isinstance(Foo.__dict__['fct'], Field) 65 | assert not isinstance(Foo.__dict__['cls'], Field) 66 | 67 | f = Foo(foo=1, barfunc=lambda x: 2, barcls=str) 68 | assert f.bar == 0 69 | assert f.fct() == 1 70 | assert f.barfunc(1) == 2 71 | assert f.barcls == str 72 | 73 | 74 | def test_autofields_property_descriptors(): 75 | """Checks that properties and descriptors are correctly ignored by autofields""" 76 | 77 | @autofields 78 | class Foo(object): 79 | foo = 1 80 | @property 81 | def bar(self): 82 | return 2 83 | 84 | class MyDesc(): 85 | def __get__(self): 86 | return 1 87 | 88 | class MyDesc2(): 89 | def __get__(self): 90 | return 0 91 | def __set__(self, instance, value): 92 | return 93 | 94 | m = MyDesc() 95 | p = MyDesc2() 96 | 97 | fields = get_fields(Foo) 98 | assert len(fields) == 1 99 | assert fields[0].name == 'foo' 100 | 101 | 102 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="Annotations not supported in python < 3.6") 103 | def test_issue_74(): 104 | """test associated with the non-issue 74""" 105 | from ._test_py36 import test_issue_74 106 | City = test_issue_74() 107 | c = City(name=None) 108 | assert c.name is None 109 | assert c.buildings == [] 110 | 111 | 112 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="Annotations not supported in python < 3.6") 113 | def test_issue_76(): 114 | """ order issue 76 and 77 are fixed """ 115 | from ._test_py36 import test_issue_76 116 | Foo = test_issue_76() 117 | assert [f.name for f in get_fields(Foo)] == ['c', 'b', 'a'] 118 | 119 | 120 | def test_issue_76_bis(): 121 | """ another order issue with @autofields """ 122 | 123 | @autofields 124 | class Foo(object): 125 | msg = field(type_hint=str) 126 | age = field(default=12, type_hint=int) 127 | 128 | assert [f.name for f in get_fields(Foo)] == ['msg', 'age'] 129 | 130 | 131 | def test_autoclass(): 132 | """""" 133 | 134 | @autoclass 135 | class Foo(object): 136 | msg = field(type_hint=str) 137 | age = field(default=12, type_hint=int) 138 | 139 | f = Foo('hey') 140 | 141 | # str repr 142 | assert repr(f) == "Foo(msg='hey', age=12)" 143 | assert str(f) == repr(f) 144 | 145 | # dict and eq 146 | assert f.to_dict() == {'msg': 'hey', 'age': 12} 147 | 148 | same_dict = {'msg': 'hey', 'age': 12} 149 | assert f == same_dict 150 | assert f == Foo.from_dict(same_dict) 151 | 152 | diff_dict = {'age': 13, 'msg': 'hey'} 153 | assert f != diff_dict 154 | assert f != Foo.from_dict(diff_dict) 155 | 156 | assert f == Foo.from_dict(f.to_dict()) 157 | 158 | # hash 159 | my_set = {f, f} 160 | assert my_set == {f} 161 | assert Foo('hey') in my_set 162 | my_set.remove(Foo('hey')) 163 | assert len(my_set) == 0 164 | 165 | # subclass A 166 | class Bar(Foo): 167 | pass 168 | 169 | b = Bar(msg='hey') 170 | assert str(b) == "Bar(msg='hey', age=12)" 171 | assert b == f 172 | assert f == b 173 | 174 | # hash 175 | my_set = {f, b} 176 | assert len(my_set) == 1 # yes: since the subclass does not define additional attributes. 177 | assert my_set == {f} 178 | 179 | # subclass B 180 | @autoclass 181 | class Bar2(Foo): 182 | ho = 3 183 | 184 | b2 = Bar2('hey') 185 | assert str(b2) == "Bar2(msg='hey', age=12, ho=3)" 186 | assert b2 != f 187 | assert f != b2 188 | 189 | # hash 190 | my_set = {b2, b} 191 | assert Bar2('hey') in my_set 192 | -------------------------------------------------------------------------------- /pyfields/tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pyfields import field, get_field_values, get_fields, copy_field 4 | from pyfields.core import PY36 5 | 6 | 7 | @pytest.mark.parametrize("a_first", [False, True], ids="ancestor_first={}".format) 8 | @pytest.mark.parametrize("public_only", [False, True], ids="public_only={}".format) 9 | def test_get_fields(a_first, public_only): 10 | class A(object): 11 | a = field() 12 | _d = field(default=5) 13 | 14 | class B(object): 15 | b = field() 16 | 17 | class C(B, A): 18 | a = field(default=None) 19 | c = field(default_factory=copy_field('b')) 20 | 21 | fields = get_fields(C, include_inherited=True, ancestors_first=a_first, 22 | _auto_fix_fields=not PY36, public_only=public_only) 23 | field_names = [f.name for f in fields] 24 | if a_first: 25 | assert field_names == ['a', 'b', 'c'] if public_only else ['a', '_d', 'b', 'c'] 26 | else: 27 | assert field_names == ['a', 'c', 'b'] if public_only else ['a', 'c', 'b', '_d'] 28 | 29 | obj = C() 30 | obj.b = 2 31 | 32 | fields = get_field_values(obj, ancestors_first=a_first if a_first is not None else True, _auto_fix_fields=not PY36, 33 | container_type=list, public_only=public_only) 34 | if a_first is None or a_first: 35 | assert fields == [('a', None), ('b', 2), ('c', 2)] if public_only else [('a', None), ('_d', 5), ('b', 2), ('c', 2)] 36 | else: 37 | assert fields == [('a', None), ('c', 2), ('b', 2)] if public_only else [('a', None), ('c', 2), ('b', 2), ('_d', 5)] 38 | -------------------------------------------------------------------------------- /pyfields/tests/test_init.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain Marie <sylvain.marie@se.com> 2 | # 3 | # Copyright (c) Schneider Electric Industries, 2019. All right reserved. 4 | import sys 5 | 6 | import pytest 7 | 8 | from pyfields import field, init_fields, inject_fields, make_init, MandatoryFieldInitError, copy_field, get_fields 9 | from pyfields.core import PY36 10 | 11 | 12 | @pytest.mark.parametrize("native", [False, True], ids="native={}".format) 13 | @pytest.mark.parametrize("init_type", ['inject_fields', 'make_init', 'make_init_with_postinit'], 14 | ids="init_type={}".format) 15 | @pytest.mark.parametrize("explicit_fields_list", [False, True], ids="explicit_list={}".format) 16 | @pytest.mark.parametrize("py36_style_type_hints", [False, True], ids="py36_style_type_hints={}".format) 17 | def test_init_all_methods(py36_style_type_hints, explicit_fields_list, init_type, native): 18 | """Test of @inject_fields with selected fields """ 19 | if py36_style_type_hints: 20 | if sys.version_info < (3, 6): 21 | pytest.skip() 22 | Wall = None 23 | else: 24 | # import the test that uses python 3.6 type annotations 25 | from ._test_py36 import _test_readme_constructor 26 | Wall = _test_readme_constructor(explicit_fields_list, init_type, native) 27 | else: 28 | if init_type == 'inject_fields': 29 | if explicit_fields_list: 30 | class Wall(object): 31 | height = field(doc="Height of the wall in mm.", native=native) # type: int 32 | color = field(default='white', doc="Color of the wall.", native=native) # type: str 33 | 34 | @inject_fields(height, color) 35 | def __init__(self, fields): 36 | with pytest.raises(MandatoryFieldInitError): 37 | print(self.height) 38 | # initialize all fields received 39 | fields.init(self) 40 | print(self.height) 41 | else: 42 | class Wall(object): 43 | height = field(doc="Height of the wall in mm.", native=native) # type: int 44 | color = field(default='white', doc="Color of the wall.", native=native) # type: str 45 | 46 | @inject_fields 47 | def __init__(self, fields): 48 | with pytest.raises(MandatoryFieldInitError): 49 | print(self.height) 50 | # initialize all fields received 51 | fields.init(self) 52 | print(self.height) 53 | 54 | elif init_type == 'make_init': 55 | if explicit_fields_list: 56 | class Wall(object): 57 | height = field(doc="Height of the wall in mm.", native=native) # type: int 58 | color = field(default='white', doc="Color of the wall.", native=native) # type: str 59 | __init__ = make_init(height, color) 60 | else: 61 | class Wall(object): 62 | height = field(doc="Height of the wall in mm.", native=native) # type: int 63 | color = field(default='white', doc="Color of the wall.", native=native) # type: str 64 | __init__ = make_init() 65 | 66 | elif init_type == 'make_init_with_postinit': 67 | if explicit_fields_list: 68 | class Wall(object): 69 | height = field(doc="Height of the wall in mm.", native=native) # type: int 70 | color = field(default='white', doc="Color of the wall.", native=native) # type: str 71 | def post_init(self, foo='bar'): 72 | self.height 73 | print("post init man !") 74 | 75 | __init__ = make_init(height, color, post_init_fun=post_init) 76 | else: 77 | class Wall(object): 78 | height = field(doc="Height of the wall in mm.", native=native) # type: int 79 | color = field(default='white', doc="Color of the wall.", native=native) # type: str 80 | def post_init(self, foo='bar'): 81 | self.height 82 | print("post init man !") 83 | 84 | __init__ = make_init(post_init_fun=post_init) 85 | else: 86 | raise ValueError(init_type) 87 | 88 | # first init 89 | w = Wall(height=12) 90 | if native: 91 | assert vars(w) == {'color': 'white', 'height': 12} 92 | else: 93 | assert vars(w) == {'_color': 'white', '_height': 12} 94 | 95 | # make sure this can be done a second time (since we replaced the __init__ method now) 96 | w = Wall(color='blue', height=1) 97 | if native: 98 | assert vars(w) == {'color': 'blue', 'height': 1} 99 | else: 100 | assert vars(w) == {'_color': 'blue', '_height': 1} 101 | 102 | # type hints 103 | height_field = Wall.__dict__['height'] 104 | color_field = Wall.__dict__['color'] 105 | 106 | if py36_style_type_hints: 107 | assert height_field.type_hint is int 108 | assert color_field.type_hint is str 109 | 110 | # todo check signature of generated constructor 111 | 112 | 113 | def test_init_partial_fields(): 114 | class Wall(object): 115 | height = field(doc="Height of the wall in mm.") # type: int 116 | color = field(default='white', doc="Color of the wall.") # type: str 117 | 118 | def post_init(self, foo='bar'): 119 | print(self.height) 120 | print("post init man !") 121 | 122 | __init__ = make_init(height, post_init_fun=post_init) 123 | 124 | # 125 | # assert str(signature(Wall.__init__)) == "(self, height, foo='bar')" 126 | 127 | # first init 128 | w = Wall(12) 129 | assert vars(w) == {'height': 12} # color not initialized yet 130 | 131 | # make sure this can be done a second time (since we replaced the __init__ method now) 132 | w = Wall(foo='blue', height=1) 133 | assert vars(w) == {'height': 1} # color not initialized yet 134 | 135 | 136 | def test_init_order(): 137 | """Tests that order of initialization is the same than order of definition in the class""" 138 | 139 | class C(object): 140 | y = field() 141 | x = field(default_factory=copy_field(y)) 142 | 143 | @init_fields 144 | def __init__(self): 145 | pass 146 | 147 | c = C(y=1) 148 | print(vars(c)) 149 | 150 | 151 | @pytest.mark.parametrize("a_first", [False, True], ids="ancestor_first={}".format) 152 | def test_init_order2(a_first): 153 | """""" 154 | class A(object): 155 | a = field() 156 | d = field(default=5) 157 | 158 | class B(object): 159 | b = field() 160 | 161 | class C(B, A): 162 | a = field(default=None) 163 | c = field(default_factory=copy_field('b')) 164 | 165 | @init_fields(ancestor_fields_first=a_first) 166 | def __init__(self): 167 | pass 168 | 169 | fields = get_fields(C, include_inherited=True, ancestors_first=a_first, _auto_fix_fields=not PY36) 170 | field_names = [f.name for f in fields] 171 | if a_first: 172 | assert field_names == ['a', 'd', 'b', 'c'] 173 | else: 174 | assert field_names == ['a', 'c', 'b', 'd'] 175 | 176 | # make sure that a and c have default values and therefore just passing b is ok. 177 | c = C(1) 178 | assert vars(c) == {'b': 1, 'c': 1, 'a': None, 'd': 5} 179 | 180 | c = C(1, 2, 3) 181 | if a_first is None or a_first: 182 | assert vars(c) == {'b': 1, # 1st arg 183 | 'c': 1, # default: copy of b 184 | 'a': 2, # 2d arg 185 | 'd': 3 # 3d arg 186 | } 187 | else: 188 | assert vars(c) == {'b': 1, # 1st arg 189 | 'c': 3, # 3d arg 190 | 'a': 2, # 2d arg 191 | 'd': 5 # default: 5 192 | } 193 | 194 | 195 | def test_init_inheritance(): 196 | """Makes sure that the init method can be generated in inheritance case """ 197 | 198 | class A(object): 199 | a = field(default='hello') 200 | 201 | class B(A): 202 | b = field(default='world') 203 | 204 | def a(self): 205 | """ purposedly override the base class field name """ 206 | pass 207 | 208 | __init__ = make_init() 209 | 210 | # make sure that the 'a' field is ok 211 | b = B(a='h', b='w') 212 | assert b.a, b.b == ('h', 'w') 213 | -------------------------------------------------------------------------------- /pyfields/tests/test_readme.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import timeit 4 | 5 | import pytest 6 | from valid8 import ValidationError, ValidationFailure 7 | 8 | from pyfields import field, MandatoryFieldInitError, make_init, init_fields, ReadOnlyFieldError, NoneError, \ 9 | FieldTypeError, autoclass, get_fields 10 | 11 | 12 | def test_lazy_fields(): 13 | 14 | class Wall(object): 15 | height = field(doc="Height of the wall in mm.") # type: int 16 | color = field(default='white', doc="Color of the wall.") # type: str 17 | 18 | # create an instance 19 | w = Wall() 20 | 21 | # the field is visible in `dir` 22 | assert dir(w)[-2:] == ['color', 'height'] 23 | 24 | # but not yet in `vars` 25 | assert vars(w) == dict() 26 | 27 | # lets ask for it - default value is affected 28 | print(w.color) 29 | 30 | # now it is in `vars` too 31 | assert vars(w) == {'color': 'white'} 32 | 33 | # mandatory field 34 | with pytest.raises(MandatoryFieldInitError) as exc_info: 35 | print(w.height) 36 | assert str(exc_info.value).startswith("Mandatory field 'height' has not been initialized yet on instance <") 37 | 38 | w.height = 12 39 | assert vars(w) == {'color': 'white', 'height': 12} 40 | 41 | 42 | @pytest.mark.parametrize("use_decorator", [False, True], ids="use_decorator={}".format) 43 | def test_default_factory(use_decorator): 44 | 45 | class BadPocket(object): 46 | items = field(default=[]) 47 | 48 | p = BadPocket() 49 | p.items.append('thing') 50 | g = BadPocket() 51 | assert g.items == ['thing'] 52 | 53 | if use_decorator: 54 | class Pocket: 55 | items = field() 56 | 57 | @items.default_factory 58 | def default_items(self): 59 | return [] 60 | else: 61 | class Pocket(object): 62 | items = field(default_factory=lambda obj: []) 63 | 64 | p = Pocket() 65 | g = Pocket() 66 | p.items.append('thing') 67 | assert p.items == ['thing'] 68 | assert g.items == [] 69 | 70 | 71 | def test_readonly_field(): 72 | """ checks that the example in the readme is correct """ 73 | 74 | class User(object): 75 | name = field(read_only=True) 76 | 77 | u = User() 78 | u.name = "john" 79 | assert "name: %s" % u.name == "name: john" 80 | with pytest.raises(ReadOnlyFieldError) as exc_info: 81 | u.name = "john2" 82 | qualname = User.__dict__['name'].qualname 83 | assert str(exc_info.value) == "Read-only field '%s' has already been " \ 84 | "initialized on instance %s and cannot be modified anymore." % (qualname, u) 85 | 86 | class User(object): 87 | name = field(read_only=True, default="dummy") 88 | 89 | u = User() 90 | assert "name: %s" % u.name == "name: dummy" 91 | with pytest.raises(ReadOnlyFieldError): 92 | u.name = "john" 93 | 94 | 95 | @pytest.mark.parametrize("py36_style_type_hints", [False, True], ids="py36_style_type_hints={}".format) 96 | def test_type_validation(py36_style_type_hints): 97 | if py36_style_type_hints: 98 | if sys.version_info < (3, 6): 99 | pytest.skip() 100 | Wall = None 101 | else: 102 | # import the test that uses python 3.6 type annotations 103 | from ._test_py36 import _test_readme_type_validation 104 | Wall = _test_readme_type_validation() 105 | else: 106 | class Wall(object): 107 | height = field(type_hint=int, check_type=True, doc="Height of the wall in mm.") 108 | color = field(type_hint=str, check_type=True, default='white', doc="Color of the wall.") 109 | 110 | w = Wall() 111 | w.height = 1 112 | with pytest.raises(TypeError): 113 | w.height = "1" 114 | 115 | 116 | @pytest.mark.parametrize("py36_style_type_hints", [False, True], ids="py36_style_type_hints={}".format) 117 | def test_value_validation(py36_style_type_hints): 118 | colors = ('blue', 'red', 'white') 119 | 120 | if py36_style_type_hints: 121 | if sys.version_info < (3, 6): 122 | pytest.skip() 123 | Wall = None 124 | else: 125 | # import the test that uses python 3.6 type annotations 126 | from ._test_py36 import _test_readme_value_validation 127 | Wall = _test_readme_value_validation(colors) 128 | 129 | from mini_lambda import x 130 | from valid8.validation_lib import is_in 131 | 132 | class Wall(object): 133 | height = field(type_hint=int, 134 | validators={'should be a positive number': x > 0, 135 | 'should be a multiple of 100': x % 100 == 0}, 136 | doc="Height of the wall in mm.") 137 | color = field(type_hint=str, 138 | validators=is_in(colors), 139 | default='white', doc="Color of the wall.") 140 | 141 | w = Wall() 142 | w.height = 100 143 | with pytest.raises(ValidationError) as exc_info: 144 | w.height = 1 145 | assert "Successes: ['x > 0'] / Failures: {" \ 146 | "'x % 100 == 0': 'InvalidValue: should be a multiple of 100. Returned False.'" \ 147 | "}." in str(exc_info.value) 148 | 149 | with pytest.raises(ValidationError) as exc_info: 150 | w.color = 'magenta' 151 | assert "NotInAllowedValues: x in ('blue', 'red', 'white') does not hold for x=magenta. Wrong value: 'magenta'." \ 152 | in str(exc_info.value) 153 | 154 | 155 | @pytest.mark.parametrize("py36_style_type_hints", [False, True], ids="py36_style_type_hints={}".format) 156 | def test_value_validation_advanced(py36_style_type_hints): 157 | 158 | class InvalidWidth(ValidationFailure): 159 | help_msg = 'should be a multiple of the height ({height})' 160 | 161 | def validate_width(obj, width): 162 | if width % obj.height != 0: 163 | raise InvalidWidth(width, height=obj.height) 164 | 165 | if py36_style_type_hints: 166 | if sys.version_info < (3, 6): 167 | pytest.skip() 168 | Wall = None 169 | else: 170 | # import the test that uses python 3.6 type annotations 171 | from ._test_py36 import test_value_validation_advanced 172 | Wall = test_value_validation_advanced(validate_width) 173 | else: 174 | class Wall(object): 175 | height = field(type_hint=int, 176 | doc="Height of the wall in mm.") 177 | width = field(type_hint=str, 178 | validators=validate_width, 179 | doc="Width of the wall in mm.") 180 | 181 | w = Wall() 182 | w.height = 100 183 | w.width = 200 184 | 185 | with pytest.raises(ValidationError) as exc_info: 186 | w.width = 201 187 | assert "InvalidWidth: should be a multiple of the height (100). Wrong value: 201." in str(exc_info.value) 188 | 189 | try: 190 | from typing import Optional 191 | typing_present = True 192 | except ImportError: 193 | typing_present = False 194 | 195 | 196 | @pytest.mark.skipif(not typing_present, reason="typing module is not present") 197 | @pytest.mark.parametrize("declaration", ['typing', 'default_value', 'explicit_nonable'], ids="declaration={}".format) 198 | def test_nonable_fields(declaration): 199 | """Tests that nonable fields are supported and correctly handled""" 200 | 201 | if declaration == 'typing': 202 | from typing import Optional 203 | 204 | class Foo(object): 205 | a = field(type_hint=Optional[int], check_type=True) 206 | b = field(type_hint=Optional[int], validators={'is positive': lambda x: x > 0}) 207 | c = field(nonable=False, check_type=True) 208 | d = field(validators={'accept_all': lambda x: True}) 209 | e = field(nonable=False) 210 | 211 | elif declaration == 'default_value': 212 | class Foo(object): 213 | a = field(type_hint=int, default=None, check_type=True) 214 | b = field(type_hint=int, default=None, validators={'is positive': lambda x: x > 0}) 215 | c = field(nonable=False, check_type=True) 216 | d = field(validators={'accept_all': lambda x: True}) 217 | e = field(nonable=False) 218 | 219 | elif declaration == 'explicit_nonable': 220 | class Foo(object): 221 | a = field(type_hint=int, nonable=True, check_type=True) 222 | b = field(type_hint=int, nonable=True, validators={'is positive': lambda x: x > 0}) 223 | c = field(nonable=False, check_type=True) 224 | d = field(validators={'accept_all': lambda x: True}) 225 | e = field(nonable=False) 226 | 227 | else: 228 | raise ValueError(declaration) 229 | 230 | f = Foo() 231 | f.a = None 232 | f.b = None 233 | with pytest.raises(NoneError): 234 | f.c = None 235 | f.d = None 236 | f.e = None 237 | assert vars(f) == {'_a': None, '_b': None, '_d': None, 'e': None} 238 | 239 | 240 | def test_native_descriptors(): 241 | """""" 242 | class Foo: 243 | a = field() 244 | b = field(native=False) 245 | 246 | a_name = "test_native_descriptors.<locals>.Foo.a" if sys.version_info >= (3, 6) else "<unknown_cls>.None" 247 | b_name = "test_native_descriptors.<locals>.Foo.b" if sys.version_info >= (3, 6) else "<unknown_cls>.None" 248 | assert repr(Foo.__dict__['a']) == "<NativeField: %s>" % a_name 249 | assert repr(Foo.__dict__['b']) == "<DescriptorField: %s>" % b_name 250 | 251 | f = Foo() 252 | 253 | def set_native(): f.a = 12 254 | 255 | def set_descript(): f.b = 12 256 | 257 | def set_pynative(): f.c = 12 258 | 259 | # make sure that the access time for native field and native are identical 260 | # --get rid of the first init since it is a bit longer (replacement of the descriptor with a native field 261 | set_native() 262 | set_descript() 263 | set_pynative() 264 | 265 | # --now compare the executiong= times 266 | t_native = timeit.Timer(set_native).timeit(10000000) 267 | t_descript = timeit.Timer(set_descript).timeit(10000000) 268 | t_pynative = timeit.Timer(set_pynative).timeit(10000000) 269 | 270 | print("Average time (ns) setting the field:") 271 | print("%0.2f (normal python) ; %0.2f (native field) ; %0.2f (descriptor field)" 272 | % (t_pynative, t_native, t_descript)) 273 | 274 | ratio = t_native / t_pynative 275 | print("Ratio is %.2f" % ratio) 276 | assert ratio <= 1.2 277 | 278 | 279 | # def decompose(number): 280 | # """ decompose a number in scientific notation. from https://stackoverflow.com/a/45359185/7262247""" 281 | # (sign, digits, exponent) = Decimal(number).as_tuple() 282 | # fexp = len(digits) + exponent - 1 283 | # fman = Decimal(number).scaleb(-fexp).normalize() 284 | # return fman, fexp 285 | 286 | 287 | def test_make_init_full_defaults(): 288 | class Wall: 289 | height = field(doc="Height of the wall in mm.") # type: int 290 | color = field(default='white', doc="Color of the wall.") # type: str 291 | __init__ = make_init() 292 | 293 | # create an instance 294 | help(Wall) 295 | with pytest.raises(TypeError) as exc_info: 296 | Wall() 297 | assert str(exc_info.value).startswith("__init__()") 298 | 299 | w = Wall(2) 300 | assert vars(w) == {'color': 'white', 'height': 2} 301 | 302 | w = Wall(color='blue', height=12) 303 | assert vars(w) == {'color': 'blue', 'height': 12} 304 | 305 | 306 | def test_make_init_with_explicit_list(): 307 | class Wall: 308 | height = field(doc="Height of the wall in mm.") # type: int 309 | color = field(default='white', doc="Color of the wall.") # type: str 310 | 311 | # only `height` will be in the constructor 312 | __init__ = make_init(height) 313 | 314 | with pytest.raises(TypeError) as exc_info: 315 | Wall(1, 'blue') 316 | assert str(exc_info.value).startswith("__init__()") 317 | 318 | 319 | def test_make_init_with_inheritance(): 320 | class Wall: 321 | height = field(doc="Height of the wall in mm.") # type: int 322 | __init__ = make_init(height) 323 | 324 | class ColoredWall(Wall): 325 | color = field(default='white', doc="Color of the wall.") # type: str 326 | __init__ = make_init(Wall.height, color) 327 | 328 | w = ColoredWall(2) 329 | assert vars(w) == {'color': 'white', 'height': 2} 330 | 331 | w = ColoredWall(color='blue', height=12) 332 | assert vars(w) == {'color': 'blue', 'height': 12} 333 | 334 | 335 | def test_make_init_callback(): 336 | class Wall: 337 | height = field(doc="Height of the wall in mm.") # type: int 338 | color = field(default='white', doc="Color of the wall.") # type: str 339 | 340 | def post_init(self, msg='hello'): 341 | """ 342 | After initialization, some print message is done 343 | :param msg: the message details to add 344 | :return: 345 | """ 346 | print("post init ! height=%s, color=%s, msg=%s" % (self.height, self.color, msg)) 347 | self.non_field_attr = msg 348 | 349 | # only `height` and `foo` will be in the constructor 350 | __init__ = make_init(height, post_init_fun=post_init) 351 | 352 | w = Wall(1, 'hey') 353 | assert vars(w) == {'color': 'white', 'height': 1, 'non_field_attr': 'hey'} 354 | 355 | 356 | def test_init_fields(): 357 | class Wall: 358 | height = field(doc="Height of the wall in mm.") # type: int 359 | color = field(default='white', doc="Color of the wall.") # type: str 360 | 361 | @init_fields 362 | def __init__(self, msg='hello'): 363 | """ 364 | After initialization, some print message is done 365 | :param msg: the message details to add 366 | :return: 367 | """ 368 | print("post init ! height=%s, color=%s, msg=%s" % (self.height, self.color, msg)) 369 | self.non_field_attr = msg 370 | 371 | # create an instance 372 | help(Wall.__init__) 373 | with pytest.raises(TypeError) as exc_info: 374 | Wall() 375 | assert str(exc_info.value).startswith("__init__()") 376 | 377 | w = Wall(2) 378 | assert vars(w) == {'color': 'white', 'height': 2, 'non_field_attr': 'hello'} 379 | 380 | w = Wall(msg='hey', color='blue', height=12) 381 | assert vars(w) == {'color': 'blue', 'height': 12, 'non_field_attr': 'hey'} 382 | 383 | 384 | no_type_checker = False 385 | try: 386 | import typeguard 387 | except ImportError: 388 | try: 389 | import pytypes 390 | except ImportError: 391 | no_type_checker = True 392 | 393 | 394 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="python < 3.6 does not support class member type hints") 395 | @pytest.mark.skipif(no_type_checker, reason="no type checker is installed") 396 | def test_autofields_readme(): 397 | """Test for readme on autofields""" 398 | 399 | from ._test_py36 import _test_autofields_readme 400 | Pocket, Item, Pocket2 = _test_autofields_readme() 401 | 402 | with pytest.raises(TypeError): 403 | Item() 404 | 405 | item1 = Item(name='1') 406 | pocket1 = Pocket(size=2) 407 | pocket2 = Pocket(size=2) 408 | 409 | # make sure that custom constructor is not overridden by @autofields 410 | pocket3 = Pocket2("world") 411 | with pytest.raises(MandatoryFieldInitError): 412 | pocket3.size 413 | 414 | # make sure the items list is not the same in both (if we add the item to one, they do not appear in the 2d) 415 | assert pocket1.size == 2 416 | assert pocket1.items is not pocket2.items 417 | pocket1.items.append(item1) 418 | assert len(pocket2.items) == 0 419 | 420 | 421 | try: 422 | import pytypes 423 | except ImportError: 424 | has_pytypes = False 425 | else: 426 | has_pytypes = True 427 | 428 | 429 | @pytest.mark.skipif(has_pytypes, reason="pytypes does not correctly support vtypes - " 430 | "see https://github.com/Stewori/pytypes/issues/86") 431 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="python < 3.6 does not support class member type hints") 432 | def test_autofields_vtypes_readme(): 433 | 434 | from ._test_py36 import _test_autofields_vtypes_readme 435 | Rectangle = _test_autofields_vtypes_readme() 436 | 437 | r = Rectangle(1, 2) 438 | with pytest.raises(FieldTypeError): 439 | Rectangle(1, -2) 440 | with pytest.raises(FieldTypeError): 441 | Rectangle('1', 2) 442 | 443 | 444 | def test_autoclass(): 445 | """ Tests the example with autoclass in the doc """ 446 | @autoclass 447 | class Foo(object): 448 | msg = field(type_hint=str) 449 | age = field(default=12, type_hint=int) 450 | 451 | foo = Foo(msg='hello') 452 | 453 | assert [f.name for f in get_fields(Foo)] == ['msg', 'age'] 454 | 455 | print(foo) # automatic string representation 456 | print(foo.to_dict()) # dict view 457 | 458 | assert str(foo) == "Foo(msg='hello', age=12)" 459 | assert str(foo.to_dict()) in ("{'msg': 'hello', 'age': 12}", "{'age': 12, 'msg': 'hello'}") 460 | assert foo == Foo(msg='hello', age=12) # comparison (equality) 461 | assert foo == {'msg': 'hello', 'age': 12} # comparison with dicts 462 | 463 | 464 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="not valid for old python") 465 | def test_autoclass_2(): 466 | from ._test_py36 import _test_autoclass2 467 | Foo = _test_autoclass2() 468 | 469 | # assert [f.name for f in get_fields(Foo)] == ['msg', 'age', 'height'] 470 | 471 | foo = Foo(msg='hello') 472 | 473 | assert repr(foo) == "Foo(msg='hello', age=12, height=50)" # automatic string representation 474 | assert str(foo.to_dict()) # automatic dict view 475 | 476 | assert foo == Foo(msg='hello', age=12, height=50) # automatic equality comparison 477 | assert foo == {'msg': 'hello', 'age': 12, 'height': 50} # automatic eq comparison with dicts 478 | 479 | 480 | @pytest.mark.skipif(sys.version_info < (3, 6), reason="not valid for old python") 481 | def test_autoclass_3(): 482 | from ._test_py36 import _test_autoclass3 483 | Foo = _test_autoclass3() 484 | 485 | # assert [f.name for f in get_fields(Foo)] == ['msg', 'age', 'height'] 486 | 487 | foo = Foo(msg='hello') 488 | 489 | with pytest.raises(AttributeError): 490 | foo.to_dict() # method does not exist 491 | 492 | assert repr(foo) == "Foo(msg='hello', age=12, height=50)" # automatic string representation 493 | assert foo == Foo(msg='hello', age=12, height=50) # automatic equality comparison 494 | 495 | # type checking ON 496 | with pytest.raises(FieldTypeError): 497 | foo.msg = 1 498 | -------------------------------------------------------------------------------- /pyfields/tests/test_so.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain Marie <sylvain.marie@se.com> 2 | # 3 | # Copyright (c) Schneider Electric Industries, 2019. All right reserved. 4 | 5 | import pytest 6 | 7 | from pyfields import ReadOnlyFieldError 8 | from valid8 import ValidationError 9 | 10 | 11 | def test_so0(capsys): 12 | """ Checks answer at https://stackoverflow.com/a/58344434/7262247 """ 13 | 14 | from pyfields import field, init_fields 15 | 16 | with capsys.disabled(): 17 | class C(object): 18 | x = field(default=None, doc="the optional 'x' property") 19 | y = field(doc="the mandatory 'y' property") 20 | z = field(doc="the mandatory 'z' property") 21 | 22 | @init_fields 23 | def __init__(self): 24 | pass 25 | 26 | c = C(y=1, z=2) 27 | print(vars(c)) 28 | 29 | with capsys.disabled(): 30 | out, err = capsys.readouterr() 31 | print(out) 32 | # assert out == """{'y': 1, 'x': None, 'z': 2}\n""" 33 | assert vars(c) == {'y': 1, 'x': None, 'z': 2} 34 | 35 | 36 | def test_so1(capsys): 37 | """ Checks answer at https://stackoverflow.com/a/58344853/7262247 """ 38 | 39 | from pyfields import field, init_fields 40 | 41 | class Account(object): 42 | first = field(doc="first name") 43 | last = field(doc="last name") 44 | age = field(doc="the age in years") 45 | id = field(doc="an identifier") 46 | balance = field(doc="current balance in euros") 47 | 48 | @init_fields 49 | def __init__(self, msg): 50 | print(msg) 51 | 52 | a = Account("hello, world!", first="s", last="marie", age=135, id=0, balance=-200000) 53 | print(vars(a)) 54 | with capsys.disabled(): 55 | out, err = capsys.readouterr() 56 | print(out) 57 | assert out.splitlines()[0] == "hello, world!" 58 | assert vars(a) == {'age': 135, 'balance': -200000, 'last': 'marie', 'id': 0, 'first': 's'} 59 | 60 | 61 | def test_so2(): 62 | """ Checks that answer at https://stackoverflow.com/a/58383062/7262247 is ok """ 63 | 64 | from pyfields import field 65 | 66 | class Position(object): 67 | x = field(validators=lambda x: x > 0) 68 | y = field(validators={'y should be between 0 and 100': lambda y: y > 0 and y < 100}) 69 | 70 | p = Position() 71 | p.x = 1 72 | with pytest.raises(ValidationError) as exc_info: 73 | p.y = 101 74 | qualname = Position.y.qualname 75 | assert str(exc_info.value) == "Error validating [%s=101]. " \ 76 | "InvalidValue: y should be between 0 and 100. " \ 77 | "Function [<lambda>] returned [False] for value 101." % qualname 78 | 79 | 80 | def test_so3(): 81 | """https://stackoverflow.com/a/58391645/7262247""" 82 | 83 | from pyfields import field 84 | 85 | class Spam(object): 86 | description = field(validators={"description can not be empty": lambda s: len(s) > 0}) 87 | value = field(validators={"value must be greater than zero": lambda x: x > 0}) 88 | 89 | s = Spam() 90 | with pytest.raises(ValidationError) as exc_info: 91 | s.description = "" 92 | qualname = Spam.description.qualname 93 | assert str(exc_info.value) == "Error validating [%s='']. " \ 94 | "InvalidValue: description can not be empty. " \ 95 | "Function [<lambda>] returned [False] for value ''." % qualname 96 | 97 | 98 | def test_so4(): 99 | """check https://stackoverflow.com/a/58394381/7262247""" 100 | 101 | from pyfields import field, init_fields 102 | from valid8.validation_lib import is_in 103 | 104 | ALLOWED_COLORS = ('blue', 'yellow', 'brown') 105 | 106 | class Car(object): 107 | """ My class with many fields """ 108 | color = field(type_hint=str, check_type=True, validators=is_in(ALLOWED_COLORS)) 109 | name = field(type_hint=str, check_type=True, validators={'should be non-empty': lambda s: len(s) > 0}) 110 | wheels = field(type_hint=int, check_type=True, validators={'should be positive': lambda x: x > 0}) 111 | 112 | @init_fields 113 | def __init__(self, msg="hello world!"): 114 | print(msg) 115 | 116 | c = Car(color='blue', name='roadie', wheels=3) 117 | assert vars(c) == {'_wheels': 3, '_name': 'roadie', '_color': 'blue'} 118 | 119 | qualname = Car.wheels.qualname 120 | 121 | with pytest.raises(TypeError) as exc_info: 122 | c.wheels = 'hello' 123 | assert str(exc_info.value) == "Invalid value type provided for '%s'. " \ 124 | "Value should be of type %r. " \ 125 | "Instead, received a 'str': 'hello'" % (qualname, int) 126 | 127 | with pytest.raises(ValidationError) as exc_info: 128 | c.wheels = 0 129 | assert str(exc_info.value) == "Error validating [%s=0]. " \ 130 | "InvalidValue: should be positive. " \ 131 | "Function [<lambda>] returned [False] for value 0." % qualname 132 | 133 | 134 | def test_so5(): 135 | """https://stackoverflow.com/a/58395677/7262247""" 136 | 137 | from pyfields import field, copy_value, init_fields 138 | from valid8.validation_lib import is_in 139 | 140 | class Well(object): 141 | name = field() # Required 142 | group = field() # Required 143 | operate_list = field(default_factory=copy_value([])) # Optional 144 | monitor_list = field(default_factory=copy_value([])) # Optional 145 | geometry = field(default=None) # Optional 146 | perf = field(default=None) # Optional 147 | 148 | valid_types = ('type_A', 'type_B') 149 | 150 | class Operate(object): 151 | att = field() # Required 152 | type_ = field(type_hint=str, check_type=True, validators=is_in(valid_types)) # Required 153 | value = field(default_factory=copy_value([])) # Optional 154 | mode = field(default=None) # Optional 155 | action = field(default=None) # Optional 156 | 157 | @init_fields 158 | def __init__(self): 159 | pass 160 | 161 | o = Operate(att="foo", type_='type_A') 162 | 163 | with pytest.raises(TypeError): 164 | o.type_ = 1 # <-- raises TypeError: Invalid value type provided 165 | 166 | with pytest.raises(ValidationError): 167 | bad_o = Operate(att="foo", type_='type_WRONG') # <-- raises ValidationError: NotInAllowedValues: x in ('type_A', 'type_B') does not hold for x=type_WRONG 168 | 169 | 170 | def test_so6(): 171 | """checks that answer at https://stackoverflow.com/a/58396678/7262247 works""" 172 | from pyfields import field, init_fields 173 | 174 | class Position(object): 175 | x = field(type_hint=int, check_type=True, validators=lambda x: x > 0) 176 | y = field(type_hint=int, check_type=True, validators={'y should be between 0 and 100': lambda y: y > 0 and y < 100}) 177 | 178 | @init_fields 179 | def __init__(self, msg="hello world!"): 180 | print(msg) 181 | 182 | p = Position(x=1, y=12) 183 | with pytest.raises(TypeError) as exc_info: 184 | p.x = '1' 185 | qualname = Position.x.qualname 186 | assert str(exc_info.value) == "Invalid value type provided for '%s'. " \ 187 | "Value should be of type %r. Instead, received a 'str': '1'" % (qualname, int) 188 | 189 | with pytest.raises(ValidationError) as exc_info: 190 | p.y = 101 191 | 192 | 193 | def test_so7(): 194 | """ checks answer at https://stackoverflow.com/a/58432813/7262247 """ 195 | 196 | from pyfields import field 197 | 198 | class User(object): 199 | username = field(read_only=True, validators={'should contain more than 2 characters': lambda s: len(s) > 2}) 200 | 201 | u = User() 202 | u.username = "earthling" 203 | assert vars(u) == {'_username': "earthling"} 204 | with pytest.raises(ReadOnlyFieldError) as exc_info: 205 | u.username = "earthling2" 206 | qualname = User.username.qualname 207 | assert str(exc_info.value) == "Read-only field '%s' has already been initialized on instance %s and cannot be " \ 208 | "modified anymore." % (qualname, u) 209 | -------------------------------------------------------------------------------- /pyfields/typing_utils.py: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain MARIE <sylvain.marie@se.com> 2 | # + All contributors to <https://github.com/smarie/python-pyfields> 3 | # 4 | # License: 3-clause BSD, <https://github.com/smarie/python-pyfields/blob/master/LICENSE> 5 | import sys 6 | 7 | from pkg_resources import get_distribution 8 | 9 | 10 | class FieldTypeError(TypeError): # FieldError 11 | """ 12 | Error raised when the type of a field does not match expected type(s). 13 | """ 14 | __slots__ = ('field', 'value', 'expected_types') 15 | 16 | def __init__(self, field, value, expected_types): 17 | self.field = field 18 | self.value = value 19 | # noinspection PyBroadException 20 | try: 21 | if len(expected_types) == 1: 22 | expected_types = expected_types[0] 23 | except BaseException: 24 | pass 25 | self.expected_types = expected_types 26 | 27 | def __str__(self): 28 | # representing the object might fail, protect ourselves 29 | # noinspection PyBroadException 30 | try: 31 | val_repr = repr(self.value) 32 | except Exception as e: 33 | val_repr = "<error while trying to represent value: %s>" % e 34 | 35 | # detail error message 36 | # noinspection PyBroadException 37 | try: 38 | # tuple or iterable of types ? 39 | sub_msg = "Value type should be one of (%s)" % ', '.join(("%s" % _t for _t in self.expected_types)) 40 | except: # noqa E722 41 | # single type 42 | sub_msg = "Value should be of type %s" % (self.expected_types,) 43 | 44 | return "Invalid value type provided for '%s'. %s. Instead, received a '%s': %s"\ 45 | % (self.field.qualname, sub_msg, self.value.__class__.__name__, val_repr) 46 | 47 | 48 | def _make_assert_is_of_type(): 49 | from packaging.version import parse as parse_version 50 | try: 51 | from typeguard import check_type as ct 52 | 53 | # Note: only do this when we are sure that typeguard can be imported, otherwise this is slow 54 | # see https://github.com/smarie/python-getversion/blob/ee495acf6cf06c5e860713edeee396206368e458/getversion/main.py#L84 55 | typeguard_version = get_distribution("typeguard").version 56 | if parse_version(typeguard_version) < parse_version("3.0.0"): 57 | check_type = ct 58 | else: 59 | # Name has disappeared from 3.0.0 60 | def check_type(name, value, typ): 61 | ct(value, typ) 62 | 63 | try: 64 | from typing import Union 65 | except ImportError: 66 | # (a) typing is not available, transform iterables of types into several calls 67 | def assert_is_of_type(field, value, typ): 68 | """ 69 | Type checker relying on `typeguard` (python 3.5+) 70 | 71 | :param field: 72 | :param value: 73 | :param typ: 74 | :return: 75 | """ 76 | try: 77 | # iterate on the types 78 | t_gen = (t for t in typ) 79 | except TypeError: 80 | # not iterable : a single type 81 | try: 82 | check_type(field.qualname, value, typ) 83 | except Exception as e: 84 | # raise from 85 | new_e = FieldTypeError(field, value, typ) 86 | new_e.__cause__ = e 87 | raise new_e 88 | else: 89 | # iterate and try them all 90 | e = None 91 | for _t in t_gen: 92 | try: 93 | check_type(field.qualname, value, typ) 94 | return # success !!!! 95 | except Exception as e1: 96 | e = e1 # failed: lets try another one 97 | 98 | # raise from 99 | if e is not None: 100 | new_e = FieldTypeError(field, value, typ) 101 | new_e.__cause__ = e 102 | raise new_e 103 | 104 | else: 105 | # (b) typing is available, use a Union 106 | def assert_is_of_type(field, value, typ): 107 | """ 108 | Type checker relying on `typeguard` (python 3.5+) 109 | 110 | :param field: 111 | :param value: 112 | :param typ: 113 | :return: 114 | """ 115 | try: 116 | check_type(field.qualname, value, Union[typ]) 117 | except Exception as e: 118 | # raise from 119 | new_e = FieldTypeError(field, value, typ) 120 | new_e.__cause__ = e 121 | raise new_e 122 | 123 | except ImportError: 124 | try: 125 | from pytypes import is_of_type 126 | 127 | def assert_is_of_type(field, value, typ): 128 | """ 129 | Type checker relying on `pytypes` (python 2+) 130 | 131 | :param field: 132 | :param value: 133 | :param typ: 134 | :return: 135 | """ 136 | try: 137 | valid = is_of_type(value, typ) 138 | except Exception as e: 139 | # raise from 140 | new_e = FieldTypeError(field, value, typ) 141 | new_e.__cause__ = e 142 | raise new_e 143 | else: 144 | if not valid: 145 | raise FieldTypeError(field, value, typ) 146 | 147 | except ImportError: 148 | # from valid8.utils.typing_inspect import is_typevar, is_union_type, get_args 149 | from valid8.utils.typing_tools import resolve_union_and_typevar 150 | 151 | def assert_is_of_type(field, value, typ): 152 | """ 153 | Neither `typeguard` nor `pytypes` are available on this platform. 154 | 155 | This is a "light" implementation that basically resolves all `Union` and `TypeVar` into a flat list and 156 | then calls `isinstance`. 157 | 158 | :param field: 159 | :param value: 160 | :param typ: 161 | :return: 162 | """ 163 | types = resolve_union_and_typevar(typ) 164 | try: 165 | is_ok = isinstance(value, types) 166 | except TypeError as e: 167 | if e.args[0].startswith("Subscripted generics cannot"): 168 | raise TypeError("Neither typeguard not pytypes is installed - therefore it is not possible to " 169 | "validate subscripted typing structures such as %s" % types) 170 | else: 171 | raise 172 | else: 173 | if not is_ok: 174 | raise FieldTypeError(field, value, typ) 175 | 176 | return assert_is_of_type 177 | 178 | 179 | try: # very minimal way to check if typing it available, for runtime type checking 180 | # noinspection PyUnresolvedReferences 181 | from typing import Tuple # noqa 182 | except ImportError: 183 | assert_is_of_type = None 184 | else: 185 | assert_is_of_type = _make_assert_is_of_type() 186 | 187 | 188 | PY36 = sys.version_info >= (3, 6) 189 | get_type_hints = None 190 | if PY36: 191 | try: 192 | from typing import get_type_hints as gth 193 | 194 | def get_type_hints(obj, globalns=None, localns=None): 195 | """ 196 | Fixed version of typing.get_type_hints to handle self forward references 197 | """ 198 | if globalns is None and localns is None and isinstance(obj, type): 199 | localns = {obj.__name__: obj} 200 | return gth(obj, globalns=globalns, localns=localns) 201 | 202 | except ImportError: 203 | pass 204 | -------------------------------------------------------------------------------- /pyfields/validate_n_convert.pyi: -------------------------------------------------------------------------------- 1 | # Authors: Sylvain MARIE <sylvain.marie@se.com> 2 | # + All contributors to <https://github.com/smarie/python-pyfields> 3 | # 4 | # License: 3-clause BSD, <https://github.com/smarie/python-pyfields/blob/master/LICENSE> 5 | from valid8 import Validator, ValidationError, ValidationFailure 6 | from valid8.base import getfullargspec as v8_getfullargspec, get_callable_name, is_mini_lambda 7 | 8 | from typing import Callable, Type, Any, TypeVar, Union, Iterable, Tuple, Mapping, Optional, Dict, Literal 9 | 10 | 11 | T = TypeVar('T') 12 | 13 | # ------------- validator type hints ----------- 14 | # 1. the lowest-level user or 3d party-provided validation functions 15 | ValidationFunc = Union[Callable[[Any], Any], 16 | Callable[[Any, Any], Any], 17 | Callable[[Any, Any, Any], Any]] 18 | """A validation function is a callable with signature (val), (obj, val) or (obj, field, val), returning `True` 19 | or `None` in case of success""" 20 | 21 | try: 22 | # noinspection PyUnresolvedReferences 23 | from mini_lambda import y 24 | ValidationFuncOrLambda = Union[ValidationFunc, type(y)] 25 | except ImportError: 26 | ValidationFuncOrLambda = ValidationFunc 27 | 28 | # 2. the syntax to optionally transform them into failure raisers by providing a tuple 29 | ValidatorDef = Union[ValidationFuncOrLambda, 30 | Tuple[ValidationFuncOrLambda, str], 31 | Tuple[ValidationFuncOrLambda, Type[ValidationFailure]], 32 | Tuple[ValidationFuncOrLambda, str, Type[ValidationFailure]] 33 | ] 34 | """A validator is a validation function together with optional error message and error type""" 35 | 36 | # 3. the syntax to describe several validation functions at once 37 | VFDefinitionElement = Union[str, Type[ValidationFailure], ValidationFuncOrLambda] 38 | """This type represents one of the elements that can define a checker: help msg, failure type, callable""" 39 | 40 | OneOrSeveralVFDefinitions = Union[ValidatorDef, 41 | Iterable[ValidatorDef], 42 | Mapping[VFDefinitionElement, Union[VFDefinitionElement, 43 | Tuple[VFDefinitionElement, ...]]]] 44 | """Several validators can be provided as a singleton, iterable, or dict-like. In that case the value can be a 45 | single variable or a tuple, and it will be combined with the key to form the validator. So you can use any of 46 | the elements defining a validators as the key.""" 47 | 48 | # shortcut name used everywhere. Less explicit 49 | Validators = OneOrSeveralVFDefinitions 50 | 51 | 52 | class FieldValidator(Validator): 53 | """ 54 | Represents a `Validator` responsible to validate a `field` 55 | """ 56 | __slots__ = '__weakref__', 'validated_field', 'base_validation_funcs' 57 | 58 | def __init__(self, validated_field: 'DescriptorField', validators: Validators, 59 | error_type: 'Type[ValidationError]' = None, help_msg: str = None, 60 | none_policy: int = None, **kw_context_args): ... 61 | 62 | def add_validator(self, validation_func: ValidatorDef): ... 63 | 64 | def get_callables_creator(self) -> Callable[[ValidationFunc, str, Type[ValidationFailure], ...], 65 | Callable[[Any, ...], type(None)]]: ... 66 | 67 | def get_additional_info_for_repr(self) -> str: ... 68 | 69 | def _get_name_for_errors(self, name: str) -> str: ... 70 | 71 | def assert_valid(self, obj: Any, value: Any, error_type: Type[ValidationError] = None, 72 | help_msg: str = None, **ctx): ... 73 | 74 | 75 | # --------------- converters 76 | class Converter(object): 77 | __slots__ = ('name', ) 78 | 79 | def __init__(self, name=None): ... 80 | 81 | def accepts(self, obj, field, value) -> Optional[bool]: 82 | ... 83 | 84 | def convert(self, obj, field, value) -> Any: 85 | ... 86 | 87 | @classmethod 88 | def create_from_fun(cls, 89 | converter_fun: ConverterFuncOrLambda, 90 | validation_fun: ValidationFuncOrLambda = None 91 | ) -> Converter: ... 92 | 93 | # noinspection PyAbstractClass 94 | class ConverterWithFuncs(Converter): 95 | __slots__ = ('accepts', 'convert') 96 | 97 | def __init__(self, convert_fun, name=None, accepts_fun=None): ... 98 | 99 | # --------------converter type hints 100 | # 1. the lowest-level user or 3d party-provided validation functions 101 | ConverterFunc = Union[Callable[[Any], Any], 102 | Callable[[Any, Any], Any], 103 | Callable[[Any, Any, Any], Any]] 104 | """A converter function is a callable with signature (val), (obj, val) or (obj, field, val), returning the 105 | converted value in case of success""" 106 | 107 | try: 108 | # noinspection PyUnresolvedReferences 109 | from mini_lambda import y 110 | ConverterFuncOrLambda = Union[ConverterFunc, type(y)] 111 | except ImportError: 112 | ConverterFuncOrLambda = ConverterFunc 113 | 114 | # 2. the syntax to optionally transform them into Converter by providing a tuple 115 | ValidType = Type 116 | # noinspection PyUnboundLocalVariable 117 | ConverterFuncDefinition = Union[Converter, 118 | ConverterFuncOrLambda, 119 | Tuple[ValidationFuncOrLambda, ConverterFuncOrLambda], 120 | Tuple[ValidType, ConverterFuncOrLambda]] 121 | 122 | TypeDef = Union[Type, Tuple[Type, ...], Literal['*'], str] # todo remove str whe pycharm understands Literal 123 | OneOrSeveralConverterDefinitions = Union[Converter, 124 | ConverterFuncOrLambda, 125 | Iterable[Tuple[TypeDef, ConverterFuncOrLambda]], 126 | Mapping[TypeDef, ConverterFuncOrLambda]] 127 | Converters = OneOrSeveralConverterDefinitions 128 | 129 | 130 | def make_3params_callable(f: Union[ValidationFunc, ConverterFunc], 131 | is_mini_lambda: bool = False) -> Callable[[Any, 'Field', Any], Any]: ... 132 | 133 | 134 | def make_converter(converter_def: ConverterFuncDefinition) -> Converter: ... 135 | 136 | def make_converters_list(converters: OneOrSeveralConverterDefinitions) -> Tuple[Converter, ...]: ... 137 | 138 | def trace_convert(field: 'Field', value: Any, obj: Any = None) -> Tuple[Any, DetailedConversionResults]: ... 139 | 140 | class ConversionError(Exception): 141 | def __init__(self, value_to_convert, field, obj, err_dct): ... 142 | 143 | def err_dct_to_str(err_dct: Dict[Converter, str]) -> str: ... 144 | 145 | class DetailedConversionResults(object): ... 146 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=39.2", 4 | "setuptools_scm", 5 | "wheel" 6 | ] 7 | build-backend = "setuptools.build_meta" 8 | 9 | # pip: no ! does not work in old python 2.7 and not recommended here 10 | # https://setuptools.readthedocs.io/en/latest/userguide/quickstart.html#basic-use 11 | 12 | [tool.conda] 13 | # Declare that the following packages should be installed with conda instead of pip 14 | # Note: this includes packages declared everywhere, here and in setup.cfg 15 | conda_packages = [ 16 | "setuptools", 17 | "wheel", 18 | "pip" 19 | ] 20 | # pytest: not with conda ! does not work in old python 2.7 and 3.5 21 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # See https://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files 2 | # And this great example : https://github.com/Kinto/kinto/blob/master/setup.cfg 3 | [metadata] 4 | name = pyfields 5 | description = Define fields in python classes. Easily. 6 | description-file = README.md 7 | license = BSD 3-Clause 8 | long_description = file: docs/long_description.md 9 | long_description_content_type=text/markdown 10 | keywords = object class boilerplate oop field attr member descriptor attribute mix-in mixin validation type-check 11 | author = Sylvain MARIE <sylvain.marie@se.com> 12 | maintainer = Sylvain MARIE <sylvain.marie@se.com> 13 | url = https://github.com/smarie/python-pyfields 14 | # download_url = https://github.com/smarie/python-pyfields/tarball/master >> do it in the setup.py to get the right version 15 | classifiers = 16 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 17 | Development Status :: 5 - Production/Stable 18 | Intended Audience :: Developers 19 | License :: OSI Approved :: BSD License 20 | Topic :: Software Development :: Libraries :: Python Modules 21 | Programming Language :: Python 22 | Programming Language :: Python :: 2 23 | Programming Language :: Python :: 2.7 24 | Programming Language :: Python :: 3 25 | Programming Language :: Python :: 3.5 26 | Programming Language :: Python :: 3.6 27 | Programming Language :: Python :: 3.7 28 | Programming Language :: Python :: 3.8 29 | Programming Language :: Python :: 3.9 30 | 31 | [options] 32 | # one day these will be able to come from requirement files, see https://github.com/pypa/setuptools/issues/1951. But will it be better ? 33 | setup_requires = 34 | setuptools_scm 35 | # pytest-runner 36 | install_requires = 37 | valid8>=5.0 38 | makefun 39 | # note: do not use double quotes in these, this triggers a weird bug in PyCharm in debug mode only 40 | funcsigs;python_version<'3.3' 41 | enum34;python_version<'3.4' 42 | # 'sentinel', 43 | packaging 44 | tests_require = 45 | pytest 46 | vtypes 47 | mini-lambda 48 | autoclass>=2.2 49 | typing;python_version<'3.5' 50 | # for some reason these pytest dependencies were not declared in old versions of pytest 51 | six;python_version<'3.6' 52 | attr;python_version<'3.6' 53 | pluggy;python_version<'3.6' 54 | 55 | # test_suite = tests --> no need apparently 56 | # 57 | zip_safe = False 58 | # explicitly setting zip_safe=False to avoid downloading `ply` see https://github.com/smarie/python-getversion/pull/5 59 | # and makes mypy happy see https://mypy.readthedocs.io/en/latest/installed_packages.html 60 | packages = find: 61 | # see [options.packages.find] below 62 | # IMPORTANT: DO NOT set the `include_package_data` flag !! It triggers inclusion of all git-versioned files 63 | # see https://github.com/pypa/setuptools_scm/issues/190#issuecomment-351181286 64 | # include_package_data = True 65 | [options.packages.find] 66 | exclude = 67 | contrib 68 | docs 69 | *tests* 70 | 71 | [options.package_data] 72 | * = py.typed, *.pyi 73 | 74 | 75 | # Optional dependencies that can be installed with e.g. $ pip install -e .[dev,test] 76 | # [options.extras_require] 77 | 78 | # -------------- Packaging ----------- 79 | # [options.entry_points] 80 | 81 | # [egg_info] >> already covered by setuptools_scm 82 | 83 | [bdist_wheel] 84 | # Code is written to work on both Python 2 and Python 3. 85 | universal=1 86 | 87 | # ------------- Others ------------- 88 | # In order to be able to execute 'python setup.py test' 89 | # from https://docs.pytest.org/en/latest/goodpractices.html#integrating-with-setuptools-python-setup-py-test-pytest-runner 90 | [aliases] 91 | test = pytest 92 | 93 | # pytest default configuration 94 | [tool:pytest] 95 | testpaths = pyfields/tests/ 96 | addopts = 97 | --verbose 98 | --doctest-modules 99 | --ignore-glob='**/_*.py' 100 | 101 | # we need the 'always' for python 2 tests to work see https://github.com/pytest-dev/pytest/issues/2917 102 | filterwarnings = 103 | always 104 | ; ignore::UserWarning 105 | 106 | # Coverage config 107 | [coverage:run] 108 | branch = True 109 | omit = *tests* 110 | # this is done in nox.py (github actions) or ci_tools/run_tests.sh (travis) 111 | # source = pyfields 112 | # command_line = -m pytest --junitxml="reports/pytest_reports/pytest.xml" --html="reports/pytest_reports/pytest.html" -v pyfields/tests/ 113 | 114 | [coverage:report] 115 | fail_under = 70 116 | show_missing = True 117 | exclude_lines = 118 | # this line for all the python 2 not covered lines 119 | except ImportError: 120 | # we have to repeat this when exclude_lines is set 121 | pragma: no cover 122 | 123 | # Done in nox.py 124 | # [coverage:html] 125 | # directory = site/reports/coverage_reports 126 | # [coverage:xml] 127 | # output = site/reports/coverage_reports/coverage.xml 128 | 129 | [flake8] 130 | max-line-length = 120 131 | extend-ignore = D, E203 # D: Docstring errors, E203: see https://github.com/PyCQA/pycodestyle/issues/373 132 | copyright-check = True 133 | copyright-regexp = ^\#\s+Authors:\s+Sylvain MARIE <sylvain\.marie@se\.com>\n\#\s+\+\sAll\scontributors\sto\s<https://github\.com/smarie/python\-pyfields>\n\#\n\#\s+License:\s3\-clause\sBSD,\s<https://github\.com/smarie/python\-pyfields/blob/master/LICENSE> 134 | exclude = 135 | .git 136 | .github 137 | .nox 138 | .pytest_cache 139 | ci_tools 140 | docs 141 | */tests 142 | noxfile.py 143 | setup.py 144 | */_version.py 145 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | To understand this project's build structure 3 | 4 | - This project uses setuptools, so it is declared as the build system in the pyproject.toml file 5 | - We use as much as possible `setup.cfg` to store the information so that it can be read by other tools such as `tox` 6 | and `nox`. So `setup.py` contains **almost nothing** (see below) 7 | This philosophy was found after trying all other possible combinations in other projects :) 8 | A reference project that was inspiring to make this move : https://github.com/Kinto/kinto/blob/master/setup.cfg 9 | 10 | See also: 11 | https://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files 12 | https://packaging.python.org/en/latest/distributing.html 13 | https://github.com/pypa/sampleproject 14 | """ 15 | from setuptools import setup 16 | 17 | 18 | # (1) check required versions (from https://medium.com/@daveshawley/safely-using-setup-cfg-for-metadata-1babbe54c108) 19 | import pkg_resources 20 | 21 | pkg_resources.require("setuptools>=39.2") 22 | pkg_resources.require("setuptools_scm") 23 | 24 | 25 | # (2) Generate download url using git version 26 | from setuptools_scm import get_version # noqa: E402 27 | 28 | URL = "https://github.com/smarie/python-pyfields" 29 | DOWNLOAD_URL = URL + "/tarball/" + get_version() 30 | 31 | 32 | # (3) Call setup() with as little args as possible 33 | setup( 34 | download_url=DOWNLOAD_URL, 35 | use_scm_version={ 36 | "write_to": "pyfields/_version.py" 37 | }, # we can't put `use_scm_version` in setup.cfg yet unfortunately 38 | ) 39 | --------------------------------------------------------------------------------