From 800394e1db7dc04de0faf784ac2e5ddf9895394d Mon Sep 17 00:00:00 2001 From: Marcus Read Date: Thu, 24 Aug 2023 14:32:26 +0100 Subject: [PATCH] Initial commit --- .flake8 | 28 + .github/release-drafter.yml | 28 + .github/workflows/build-test.yml | 50 + .github/workflows/draft-release-notes.yml | 16 + .github/workflows/release.yml | 63 + .gitignore | 149 ++ .pre-commit-config.yaml | 19 + .pylintrc | 87 + LICENSE.txt | 21 + MANIFEST.in | 2 + README.md | 194 ++ docs/tutorials/tutorial.ipynb | 2089 +++++++++++++++++++++ mypy.ini | 15 + pyproject.toml | 73 + pytest.ini | 6 + requirements.txt | 6 + requirements_dev.txt | 119 ++ requirements_tests.txt | 54 + setup.py | 8 + src/valimp/__init__.py | 31 + src/valimp/valimp.py | 937 +++++++++ tests/test_valimp.py | 1590 ++++++++++++++++ 22 files changed, 5585 insertions(+) create mode 100644 .flake8 create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/build-test.yml create mode 100644 .github/workflows/draft-release-notes.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .pylintrc create mode 100644 LICENSE.txt create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 docs/tutorials/tutorial.ipynb create mode 100644 mypy.ini create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 requirements_dev.txt create mode 100644 requirements_tests.txt create mode 100644 setup.py create mode 100644 src/valimp/__init__.py create mode 100644 src/valimp/valimp.py create mode 100644 tests/test_valimp.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..197e351 --- /dev/null +++ b/.flake8 @@ -0,0 +1,28 @@ +[flake8] + +ignore = + # E203 (not PEP 8 compliant) ignored to not conflict with black + E203, + # W503 (not PEP 8 compliant) ignored to not conflict with black + W503, + # D105 Missing docstring in magic method. I have no issue with this. + # D Let pylint pick up all the doc errors + D + +exclude = + *.ipynb_checkpoints + + +per-file-ignores = + # to permit importing * to __init__ + src/valimp/__init__.py:F403,F401 + tests/test_valimp.py:E101,E501,E741,W191 + # D103 Missing docstring in public function - not required for all tests + # D102 Missing docstring in public function - not required for all tests + # D401 First line should be in imperative moood - not useful to describe fixtures + tests/*.py:D103,D102,D401 + +#max-line-length extended in line with black default style +max-line-length = 100 + +docstring-convention=numpy diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..38adffc --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,28 @@ +name-template: 'v$NEXT_PATCH_VERSION ✅' +tag-template: '$NEXT_PATCH_VERSION' +categories: + - title: 'New Features' + label: 'enhancement' + - title: 'Deprecation' + label: 'deprecation' + - title: 'Bug Fixes' + labels: + - 'bug' + - 'Fix' + - title: 'Documentation' + label: 'documentation' + - title: 'Maintenance' + labels: + - 'maintenance' + - 'dependencies' + - 'github_actions' + - title: 'Testing' + label: 'tests' + - title: 'Under the Bonnet' + label: 'code improvement' + - title: 'Continuous Integration' + label: 'CI' +template: | + # What's Changed + + $CHANGES diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 0000000..fa8c11d --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,50 @@ +name: Build and test + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + check-black: + # fail it if doesn't conform to black + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: psf/black@stable + with: + options: "--check --verbose" + + build-and-test: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.9", "3.11"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'requirements_tests.txt' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements_tests.txt + pip install -e . + - name: Lint with flake8 + # fail it if doesn't pass flake8 + run: | + flake8 . --statistics + - name: Test with pytest + # fail it if doesn't pass test suite + run: | + pytest \ No newline at end of file diff --git a/.github/workflows/draft-release-notes.yml b/.github/workflows/draft-release-notes.yml new file mode 100644 index 0000000..739280f --- /dev/null +++ b/.github/workflows/draft-release-notes.yml @@ -0,0 +1,16 @@ +name: Release Drafter + +on: + push: + branches: + - main + +# Updates next release notes on any push to main. Label a PR to categorize it +# in accordance with .github/release_drafter.yml. +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dd0f48c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +# Workflow to upload a Python Package using Twine when a release is created +name: Release to PyPI + +on: + release: + types: [released] + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package + run: python -m build + + - name: Publish to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_TEST_API_TOKEN }} + repository_url: https://test.pypi.org/legacy/ + + - name: Install from testpypi and import + run: | + i=0 + while [ $i -lt 12 ] && [ "${{ github.ref_name }}" != $(pip index versions -i https://test.pypi.org/simple --pre valimp | cut -d'(' -f2 | cut -d')' -f1 | sed 1q) ];\ + do echo "waiting for package to appear in test index, i is $i, sleeping 5s"; sleep 5s; echo "woken up"; ((i++)); echo "next i is $i"; done + pip install --index-url https://test.pypi.org/simple valimp==${{ github.ref_name }} --no-deps + pip install -r requirements.txt + python -c 'import valimp;print(valimp.__version__)' + + - name: Clean pip + run: | + pip uninstall -y valimp + pip cache purge + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Install and import + run: | + i=0 + while [ $i -lt 12 ] && [ "${{ github.ref_name }}" != $(pip index versions -i https://pypi.org/simple --pre valimp | cut -d'(' -f2 | cut -d')' -f1 | sed 1q) ];\ + do echo "waiting for package to appear in index, i is $i, sleeping 5s"; sleep 5s; echo "woken up"; ((i++)); echo "next i is $i"; done + pip install --index-url https://pypi.org/simple valimp==${{ github.ref_name }} + python -c 'import valimp;print(valimp.__version__)' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64577a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,149 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +_version.py + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ +tests/_temp* + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv*/ +env +venv*/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +etc/lunar-ecliptic-longitude/* +etc/solar-ecliptic-longitude/* + +# Vscode +.vscode + +# Local files not intended for inclusion public project +_local \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ee2d8ee --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-yaml + - repo: https://github.com/psf/black + rev: 23.7.0 + hooks: + - id: black + # It is recommended to specify the latest version of Python + # supported by your project here, or alternatively use + # pre-commit's default_language_version, see + # https://pre-commit.com/#top_level-default_language_version + language_version: python3.11 + - repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + additional_dependencies: [flake8-docstrings] \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..0d9c4a0 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,87 @@ +# Following comments can be placed at the top of a test file to +# disable errors not considered relevant for testing under pytest. + +# pylint: disable=missing-function-docstring, missing-type-doc, missing-class-docstring +# pylint: disable=missing-param-doc, missing-any-param-doc, redefined-outer-name +# pylint: disable=too-many-public-methods, too-many-arguments, too-many-locals +# pylint: disable=too-many-statements +# pylint: disable=protected-access, no-self-use, unused-argument, invalid-name +# missing-fuction-docstring: doc not required for all tests +# protected-access: not required for tests +# not compatible with use of fixtures to parameterize tests: +# too-many-arguments, too-many-public-methods +# not compatible with pytest fixtures: +# redefined-outer-name, no-self-use, missing-any-param-doc, missing-type-doc +# unused-argument: not compatible with pytest fixtures, caught by pylance anyway. +# invalid-name: names in tests not expected to strictly conform with snake_case. + +# Any flake8 disabled violations handled via per-file-ignores on .flake8 + +[MASTER] + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins=pylint.extensions.broad_try_clause, + pylint.extensions.confusing_elif, + pylint.extensions.comparetozero, + pylint.extensions.bad_builtin, + pylint.extensions.mccabe, + pylint.extensions.docstyle, + pylint.extensions.check_elif, + pylint.extensions.overlapping_exceptions, + pylint.extensions.empty_comment, + pylint.extensions.typing, + pylint.extensions.docparams + +[MESSAGES CONTROL] + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=missing-raises-doc, # most exceptions are not worthy of documenting + missing-return-doc, # only included to public published documentation + missing-yield-doc, # only included to public published documentation + missing-yield-type-doc, # only included to public published documentation + no-else-return, # prefer explict code paths + no-else-raise, # prefer explict code paths + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=colorized + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, j, + k, v, + n, + a, b, + +[FORMAT] + +# Maximum number of characters on a single line. +# max-line-length extended in line with black default style +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=2000 + +[TYPING] +py-version=3.9 +runtime-typing=no + +[PARAMETER_DOCUMENTATION] +accept-no-param-doc=no +accept-no-raise-doc=yes +accept-no-return-doc=no +accept-no-yields-doc=no +default-docstring-type=numpy diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..1000410 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023, Marcus Read + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..0ce852a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-exclude .github * +recursive-exclude tests * diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f60dae --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ + +# valimp + + +[![PyPI](https://img.shields.io/pypi/v/valimp)](https://pypi.org/project/valimp/) ![Python Support](https://img.shields.io/pypi/pyversions/valimp) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) + +In Python use type hints to validate, parse and coerce inputs to **public functions**. + +This is the sole use of `valimp`. It's a single short module with no depenencies that does one thing and makes it simple to do. + +Works like this: +```python +from valimp import parse, Parser, Coerce +from typing import Annotated, Union, Optional, Any + +@parse # add the `valimp.parse`` decorator to a public function or method +def public_function( + # validate against built-in or custom types + a: str, + # support for type unions + b: Union[int, float], # or from Python 3.10 `int | float` + # validate type of container items + c: dict[str, Union[int, float]], # dict[str, int | float] + # coerce input to a specific type + d: Annotated[ + Union[int, float, str], # int | float | str + Coerce(int) + ], + # parse input with reference to earlier inputs... + e: Annotated[ + str, + Parser(lambda name, obj, params: obj + f"_{name}_{params['a']}") + ], + # coerce and parse input... + f: Annotated[ + Union[str, int], # str | int + Coerce(str), + Parser(lambda name, obj, _: obj + f"_{name}") + ], + *, + # support for optional types + g: Optional[str], # str | None + # define default values dynamically with reference to earlier inputs + h: Annotated[ + Optional[float], # float | None + Parser(lambda _, obj, params: params["b"] if obj is None else obj) + ] = None, +) -> dict[str, Any]: + return {"a":a, "b":b, "c":c, "d":d, "e":e, "f":f, "g":g, "h":h} + +public_function( + # NB parameters 'a' through 'f' could be passed positionally + a="zero", + b=1.0, + c={"two": 2}, + d=3.3, # will be coerced from float to int, i.e. to 3 + e="four", # will be parsed to "four_e_zero" + f=5, # will be coerced to str and then parsed to "5_f" + g="keyword_arg_g", + # h, not passed, will be assigned dynamically as parameter b (i.e. 1.0) +) +``` +returns: +``` +{'a': 'zero', + 'b': 1.0, + 'c': {'two': 2}, + 'd': 3, + 'e': 'four_e_zero', + 'f': '5_f', + 'g': 'keyword_arg_g', + 'h': 1.0} + ``` + And if there are invalid inputs... +```python +public_function( + a=["not a string"], # INVALID + b="not an int or a float", # INVALID + c={2: "two"}, # INVALID, key not a str and value not an int or float + d=3.2, # valid input + e="valid input", + f=5.0, # INVALID, not a str or an int + g="valid input", +) +``` +raises: +``` +InputsError: The following inputs to 'public_function' do not conform with the corresponding type annotation: + +a + Takes type although received '['not a string']' of type . + +b + Takes input that conforms with <(, )> although received 'not an int or a float' of type . + +c + Takes type with keys that conform to the first argument and values that conform to the second argument of , although the received dictionary contains an item with key '2' of type and value 'two' of type . + +f + Takes input that conforms with <(, )> although received '5.0' of type . +``` +And if the inputs do not match the signature... +```python +public_function( + "zero", + "invalid input", # invalid (not int or float), included in errors + {"two": 2}, + 3.2, + # no argument passed for required positional arg 'e' + # no argument passed for required positional arg 'f' + a="a again", # passing multiple values for parameter 'a' + # no argument passed for required keyword arg 'g' + not_a_kwarg="not a kwarg", # including an unexpected kwarg +) +``` +raises: +``` +InputsError: Inputs to 'public_function' do not conform with the function signature: + +Got multiple values for argument: 'a'. + +Got unexpected keyword argument: 'not_a_kwarg'. + +Missing 2 positional arguments: 'e' and 'f'. + +Missing 1 keyword-only argument: 'g'. + +The following inputs to 'public_function' do not conform with the corresponding type annotation: + +b + Takes input that conforms with <(, )> although received 'invalid input' of type . +``` + +## Installation + +`$ pip install valimp` + +No dependencies! + +## Documentation +[tutorial.ipynb](https://github.com/maread99/valimp/blob/master/docs/tutorials/tutorial.ipynb) offers a walk-through of all the functionality. + +Further documentation can be found in the module docstring of [valimp.py](https://github.com/maread99/valimp/blob/master/src/valimp/valimp.py). + +## Why another validation library!? + +### Why even validate input type? +Some may argue that validating the type of public inputs is not pythonic and we can 'duck' out of it and let the errors arise where they may. I'd argue that for the sake of adding a decorator I'd rather raise an intelligible error message than have to respond to an issue asking 'why am I getting this error...'. + +> :information_source: `valimp` is only intended for handling inputs to **public functions**. For internal validation, consider using a type checker (for example, [mypy](https://github.com/python/mypy)). + +Also, I like the option of abstracting away all parsing, coercion and validation of public inputs and just receiving the formal parameter as required. For example, public methods in [market-prices](https://github.com/maread99/market_prices) often include a 'date' parameter. I like to offer users the convenience to pass this as either a `str`, a `datetime.date` or a `pandas.Timestamp`, although internally I want it as a `pandas.Timestamp`. I can do this with Valimp by simply including `Coerce(pandas.Timestamp)` to the metadata of the type annotation of each 'date' parameter. I also need to validate that the input is timezone-naive and does indeed represent a date rather than a time. I can do this be defining a single `valimp.Parser` and similarly including it to the annotation metadata of the 'date' parameters. Everything's abstracted away. With a little understanding of type annotations the user can see what's going on by simple inspection of the function's signature (as included within the standard help). + +### Why wouldn't I just use Pydantic? +[Pydantic](https://github.com/pydantic/pydantic) is orientated towards the validation of inputs to dataclasses. If that's what you're after then you should definitely be looking there. + +The Pydantic V2 `@validate_call` decorator does provide for validating function input against type hints. However, I think it's [fair to say](https://github.com/pydantic/pydantic/issues/6794) that it's less functional than the `@validate_arguments` decorator of Pydantic V1 (of note, in V2 it's not possible to validate later parameters based on values received by earlier parameters). This loss of functionality, together with finding `@validate_arguments` somewhat clunky to do anything beyond simple type validation, led me to write `valimp`. + +If you only want to validate the type of function inputs then Pydantic V2 `@validate_call` will do the trick. If you're after additional validation, parsing or coercion then chances are you'll find `valimp` to be a simpler option. If you want to be able to dynamically reference earlier parameters when parsing/validating then definitely have a look at `valimp`. + +## Limitations and Development + +`valimp` does not currently support: +* variable length arguments (i.e. *args in the function signature). +* variable length keyword arguments (i.e. **kwargs in the function signature). +* precluding positional-only arguments being passed as keyword arguments. + +`valimp` currently supports: +* use of the following type annotations: + * built-in classes, for example `int`, `str`, `list`, `dict` etc + * custom classes + * `collections.abc.Sequence` + * `collections.abc.Mapping` + * typing.Any + * typing.Literal + * typing.Union ( `|` from 3.10 ) + * typing.Optional ( ` | None` from 3.10) + * collections.abc.Callable, although validation of subscripted types is **not** supported +* validation of container items for the following generic classes: + * `list` + * `dict` + * `tuple` + * `set` + * `collections.abc.Sequence` + * `collections.abc.Mapping` + +That's it for now, although the library has been built with development in mind and PRs are very much welcome! + +## License + +[MIT License][license] + + +[license]: https://github.com/maread99/valimp/blob/master/LICENSE.txt diff --git a/docs/tutorials/tutorial.ipynb b/docs/tutorials/tutorial.ipynb new file mode 100644 index 0000000..48daffd --- /dev/null +++ b/docs/tutorials/tutorial.ipynb @@ -0,0 +1,2089 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "fd6e975b-cc16-4e59-a3d3-0ba34d43377f", + "metadata": {}, + "source": [ + "# Valimp tutorial\n", + "\n", + "## Index\n", + "\n", + "- [Introduction](#Introduction)\n", + "- [Type validation](#Type-validation)\n", + " - [Built-in and custom types](#Built-in-and-custom-types)\n", + " - [Methods](#Methods)\n", + " - [Type unions and optional types (the `|` operator)](#Type-unions-and-optional-types-(the-|-operator))\n", + " - [Validation of container items](#Validation-of-container-items)\n", + " - [`list`](#list-and-collections.abc.Sequence) and [`collections.abc.Sequence`](#list-and-collections.abc.Sequence)\n", + " - [`dict`](#dict-and-collections.abc.Mapping) and [`collections.abc.Mapping`](#dict-and-collections.abc.Mapping)\n", + " - [`tuple`](#tuple)\n", + " - [`set`](#set)\n", + " - [`valimp.NO_ITEM_VALIDATION`](#valimp.NO_ITEM_VALIDATION)\n", + " - [Nested containers](#Nested-containers)\n", + " - [`collections.abc.Callable`](#collections.abc.Callable)\n", + " - [`typing.Literal`](#typing.Literal)\n", + "- [Signature validation](#Signature-validation)\n", + "- [Coerce](#Coerce)\n", + " - [Type checkers](#Type-checkers)\n", + "- [Parsing](#Parsing)\n", + " - [Custom validation](#Custom-validation)\n", + " - [Dynamic default values](#Dynamic-default-values)\n", + " - [parse_none](#parse_none)\n", + "- [Coerce and parse](#Coerce-and-parse)\n", + "\n", + "## Notes\n", + "\n", + "### Error messages\n", + "Tracebacks have been curtailed in some displayed error messages. To see a full exception as raised just execute the corresponding cells." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "914d73db-7010-4ebc-8632-d2f057af9960", + "metadata": {}, + "source": [ + "## Introduction\n", + "\n", + "Valimp uses type annotations (hints) to easily validate, parse and coerce inputs to public functions and methods.\n", + "\n", + "Adding the `valimp.parse` decorator to a 'type-annotated' function or method will:\n", + " - validate all inputs against the type annotation, including optional type validation of items in containers.\n", + " - validate inputs against the function signature.\n", + " - provide for coercing inputs to a specific type.\n", + " - provide for user-defined parsing and custom validation." + ] + }, + { + "cell_type": "markdown", + "id": "00dd4b35-9a2c-4a5f-bfad-1b61d58c290d", + "metadata": {}, + "source": [ + "## Type validation\n", + "\n", + "### Built-in and custom types\n", + "\n", + "Just add the `@valimp.parse` decorator to a type-annotated function. That's it!" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "560cb1f4-94b8-47c6-85d5-1677ee49a730", + "metadata": {}, + "outputs": [], + "source": [ + "from valimp import parse\n", + "from typing import Any\n", + "\n", + "class MyCustomType(int):\n", + " \"\"\"I don't add much.\"\"\"\n", + "\n", + "@parse\n", + "def public_function(\n", + " a: str,\n", + " b: int,\n", + " c: list,\n", + " *,\n", + " kw_a: bool,\n", + " kw_b: MyCustomType,\n", + "):\n", + " return" + ] + }, + { + "cell_type": "markdown", + "id": "0f25f449-dcef-424e-8c61-5a4392b0a14e", + "metadata": {}, + "source": [ + "all the following inputs are valid..." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "27271903-7384-48b7-a4e5-eb4d92d4f4dd", + "metadata": {}, + "outputs": [], + "source": [ + "rtrn = public_function(\n", + " \"a string\",\n", + " 1,\n", + " [2],\n", + " kw_a=True,\n", + " kw_b=MyCustomType(4)\n", + ")\n", + "assert rtrn is None" + ] + }, + { + "cell_type": "markdown", + "id": "a4a31269-56d9-40bf-9b82-c9980e0f99ac", + "metadata": {}, + "source": [ + "If at least one input is not valid then a `valimp.InputsError` is raised advising of each parameter for which an input does not conform with the corresponding type annotation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "107892a2-34a6-4eb1-800e-ecc314553ee3", + "metadata": {}, + "outputs": [], + "source": [ + "public_function(\n", + " a=[\"not a string\", \"but a list\"], # INVALID, not a str\n", + " b=\"not an int\", # INVALID, not a str\n", + " c=2, # INVALID, not a list\n", + " kw_a=None, # INVALID, None not an option\n", + " kw_b=True, # INVALID, not the required custom type\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "9aa90dee-1724-4ba5-b771-02523940be93", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[3], line 1\n", + "----> 1 public_function(\n", + "\n", + "InputsError: The following inputs to 'public_function' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes type although received '['not a string', 'but a list']' of type .\n", + "\n", + "b\n", + "\tTakes type although received 'not an int' of type .\n", + "\n", + "c\n", + "\tTakes type although received '2' of type .\n", + "\n", + "kw_a\n", + "\tTakes type although received 'None' of type .\n", + "\n", + "kw_b\n", + "\tTakes type although received 'True' of type .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "dcbd51c6-3d1a-4fbe-969e-f4dc485f9707", + "metadata": {}, + "source": [ + "### Methods\n", + "\n", + "Works just the same for a method:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "659899bb-86af-49f3-aa96-182ad63d5418", + "metadata": {}, + "outputs": [], + "source": [ + "class SomeClass:\n", + " \n", + " @parse\n", + " def public_method(\n", + " self,\n", + " a: str,\n", + " *,\n", + " kw_a: MyCustomType,\n", + " ):\n", + " return\n", + "\n", + "sc = SomeClass()\n", + "# valid inputs...\n", + "assert sc.public_method(\"a string\", kw_a=MyCustomType(4)) is None" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5d4a609b-0aa0-46bf-9bee-06a91191830e", + "metadata": {}, + "outputs": [], + "source": [ + "sc.public_method(0, kw_a=None)" + ] + }, + { + "cell_type": "markdown", + "id": "3e134918-c16a-4d41-9a0d-acab1ae091b2", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[5], line 1\n", + "----> 1 sc.public_method(0, kw_a=None)\n", + "\n", + "InputsError: The following inputs to 'public_method' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes type although received '0' of type .\n", + "\n", + "kw_a\n", + "\tTakes type although received 'None' of type .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "0ff51e9d-2fda-4de5-a2d0-2a934ef98045", + "metadata": {}, + "source": [ + "Henceforth all examples in this tutorial will be defined as functions, although everything works in the same way for methods." + ] + }, + { + "cell_type": "markdown", + "id": "85710b04-55bb-41c6-a867-57cd39ee1992", + "metadata": {}, + "source": [ + "### Type unions and optional types (the `|` operator)\n", + "\n", + "Valimp supports annotations for type unions and optional types.\n", + "\n", + "From python 3.9 the `typing.Union` and `typing.Optional` annotations can be used to define type hints. From python 3.10 the alternative `|` operator can be used." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "ffbcfe86-686a-4bc4-b285-e57a202b8dd7", + "metadata": {}, + "outputs": [], + "source": [ + "# for python >=3.9\n", + "from typing import Union, Optional\n", + "\n", + "@parse\n", + "def pf(\n", + " a: Union[int, float], \n", + " b: Union[int, float, None], # NB can include None directly within Union\n", + " c: Union[int, float, None],\n", + " d: Optional[int],\n", + " e: Optional[int] = None,\n", + "):\n", + " return" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "367d67e1-f0bc-4576-ad24-62417f6ffecb", + "metadata": {}, + "outputs": [], + "source": [ + "# for python >=3.10\n", + "@parse\n", + "def pf_310(\n", + " a: int | float, \n", + " b: int | float | None, # NB can include None directly within Union\n", + " c: int | float | None,\n", + " d: int | None,\n", + " e: int | None = None,\n", + "):\n", + " return" + ] + }, + { + "cell_type": "markdown", + "id": "b234634f-f4a9-41ca-9b0d-013374affa72", + "metadata": {}, + "source": [ + "**NOTE** All other examples in this tutorial that provide for type unions or optional typing use the `typing.Union` and `typing.Optional` classes. If using python >=3.10 then the `|` operator could alternatively be used." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "9ce5f4a7-656e-44ff-9854-38cf687be2fd", + "metadata": {}, + "outputs": [], + "source": [ + "assert pf(0, 1.1, None, 3, None) is None # valid inputs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "73951ddd-74e5-434f-afda-ddc0c94d3204", + "metadata": {}, + "outputs": [], + "source": [ + "pf(\n", + " a=None, # INVALID as None not an option in the Union\n", + " b=\"not valid\", # INVALID as not an int, float or None\n", + " c=None, # valid\n", + " d=\"not_valid\", # INVALID as not an int or None\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "96c06576-dad3-4a83-9e73-9fdbb6677851", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[8], line 1\n", + "----> 1 pf(\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes input that conforms with <(, )> although received 'None' of type .\n", + "\n", + "b\n", + "\tTakes input that conforms with <(, , )> although received 'not valid' of type .\n", + "\n", + "d\n", + "\tTakes input that conforms with <(, )> although received 'not_valid' of type .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "d35242d2-8698-4f4e-a0c6-8a5c85f81541", + "metadata": {}, + "source": [ + "### Validation of container items\n", + "\n", + "By default Valimp will validate that the items of a container conform with any type subscriptions. For example an input to a parameter annotated as `param: list[int]` will be validated as a list and all items containined in that list will be validated as being of type `int`.\n", + "\n", + "The [valimp.NO_ITEM_VALIDATION](#valimp.NO_ITEM_VALIDATION) section shows how to **not** validate the type of contained items.\n", + "\n", + "#### `list` and `collections.abc.Sequence`\n", + "`list` and `collections.abc.Sequence` can both be subscripted with a single argument defining the annotation that all contained items should conform to." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "ccc170e7-22fb-4d40-bc04-653848249f69", + "metadata": {}, + "outputs": [], + "source": [ + "from collections.abc import Sequence\n", + "\n", + "@parse\n", + "def pf(\n", + " a: list[int],\n", + " b: Sequence[str],\n", + " c: list[Union[int, float]],\n", + " d: list[Union[int, float]],\n", + " e: Sequence[Optional[int]],\n", + "):\n", + " return" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "22cd31c0-abdf-4051-8914-bd1c1b66ea02", + "metadata": {}, + "outputs": [], + "source": [ + "rtrn = pf( # all valid inputs\n", + " a=[0, 1, 2],\n", + " b=[\"b\", \"bb\", \"bbb\"],\n", + " c=[2, 2.1, 2.2],\n", + " d=[3, 3, 3.3],\n", + " e=(4, None, 5, None),\n", + ")\n", + "assert rtrn is None" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "92cc41cc-501c-44a1-a2d3-378ffb667637", + "metadata": {}, + "outputs": [], + "source": [ + "pf(\n", + " a=[0, \"1\", 2], # INVALID, contains a str\n", + " b=[\"b\", 0, \"bbb\"], # INVALID, contains an int\n", + " c=[2, \"2.1\", 2.2], # INVALID, contains a str\n", + " d=[3, None, 3.3], # INVALID, contains None\n", + " e=(4, None, 5.0, None), # INVALID, contains a float\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "ead5a8bd-3387-48b7-bf56-c6ee6462c5b9", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[11], line 1\n", + "----> 1 pf(\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes type containing items that conform with , although the received container contains item '1' of type .\n", + "\n", + "b\n", + "\tTakes type containing items that conform with , although the received container contains item '0' of type .\n", + "\n", + "c\n", + "\tTakes type containing items that conform with , although the received container contains item '2.1' of type .\n", + "\n", + "d\n", + "\tTakes type containing items that conform with , although the received container contains item 'None' of type .\n", + "\n", + "e\n", + "\tTakes type containing items that conform with , although the received container contains item '5.0' of type .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "d529b5cb-a8e5-49af-acf4-6058a7d16edb", + "metadata": {}, + "source": [ + "#### `dict` and `collections.abc.Mapping`\n", + "\n", + "Valimp will validate both the keys and values of inputs annotated with `dict` or `collections.abc.Mapping`.\n", + "\n", + "NB Annotations for these classes take two subscriptions, the first representing the keys and the second representing the values." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "3631a73a-0bd5-4780-b0d8-6d6ceab98ac8", + "metadata": {}, + "outputs": [], + "source": [ + "from collections.abc import Mapping\n", + "\n", + "@parse\n", + "def pf(\n", + " a: dict[str, int],\n", + " b: dict[str, int],\n", + " c: dict[str, int],\n", + " d: Mapping[str, Union[int, float]],\n", + " e: dict[str, Optional[str]],\n", + "):\n", + " return" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "f6849283-06e9-41de-830a-93211c58d165", + "metadata": {}, + "outputs": [], + "source": [ + "rtrn = pf( # valid inputs \n", + " a={\"a\": 0, \"aa\": 0},\n", + " b={\"b\": 1, \"bb\": 1},\n", + " c={\"c\": 2, \"cc\": 2},\n", + " d={\"d\": 3, \"dd\": 3.3},\n", + " e={\"e\": \"four\", \"ee\": None},\n", + ")\n", + "assert rtrn is None" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1df868c1-91f3-455c-a15e-6e0c46e427f8", + "metadata": {}, + "outputs": [], + "source": [ + "pf(\n", + " a={0: 0, \"aa\": 1}, # INVALID, has a key as an int\n", + " b={\"b\": 1, \"bb\": \"one\"}, # INVALID, has a value as a str\n", + " c={2: \"two\", \"cc\": 2}, # INVALID, has a key as int and value as str\n", + " d={\"d\": 3, \"dd\": None}, # INVALID, has a value as None\n", + " e={\"e\": 4, \"ee\": None}, # INVALID, has a value as an int\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "aca98d58-533c-464d-b2ee-b72c56951243", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[14], line 1\n", + "----> 1 pf(\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes type with keys that conform to the first argument of , although the received dictionary contains key '0' of type .\n", + "\n", + "b\n", + "\tTakes type with values that conform to the second argument of , although the received dictionary contains value 'one' of type .\n", + "\n", + "c\n", + "\tTakes type with keys that conform to the first argument and values that conform to the second argument of , although the received dictionary contains an item with key '2' of type and value 'two' of type .\n", + "\n", + "d\n", + "\tTakes type with values that conform to the second argument of , although the received mapping contains value 'None' of type .\n", + "\n", + "e\n", + "\tTakes type with values that conform to the second argument of , although the received dictionary contains value '4' of type .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "9e630082-a619-45b1-b8e5-8011399e8bc7", + "metadata": {}, + "source": [ + "#### `tuple`\n", + "\n", + "Python provides two ways to annotate a tuple, both of which are supported by Valimp.\n", + "* To declare that a tuple **behaves like a generic sequence**, of arbitrary length with all items conforming to the same annotation, the tuple annotation takes two arguments. The first argument defines the annotation to which all items should conform. The second takes `Ellipsis`. For example, `tuple[Union[int, float], ...]` declares that an input takes an arbitrary length tuple containing objects of either `int` or `float`.\n", + "* To declare a tuple of **specific length** the annotation takes the same number of arguments as the tuple's length. Each argument takes the annotation that the item at the corresponding position should conform to. For example, `tuple[int, str, Optional[str]]` declares a 3-tuple which must contain an `int` at index 0, a `str` at index 1, and either a `str` or `None` at index 2.\n", + " * For annotations defined in this form Valimp will additionally validate that inputs are of the declared length." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "efc80de9-e69c-4518-a394-c50e84d9ac23", + "metadata": {}, + "outputs": [], + "source": [ + "@parse\n", + "def pf(\n", + " a: tuple[int, ...],\n", + " b: tuple[Union[int, float], ...],\n", + " c: Union[tuple[int, ...], tuple[float, ...]], # not the same as b!\n", + " d: Union[tuple[int, ...], tuple[float, ...]], # not the same as b!\n", + " e: tuple[str, int, Optional[str]],\n", + " f: tuple[str, int, Optional[str]],\n", + " g: tuple[str, int, float, int],\n", + " h: tuple[str, int, float, int],\n", + "):\n", + " return" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "b79ccb44-173c-46ca-ad3b-dc81322637c0", + "metadata": {}, + "outputs": [], + "source": [ + "rtrn = pf( # valid inputs\n", + " a=(0, 0, 0),\n", + " b=(1.1, 2, 2.2, 3),\n", + " c=(2, 3, 4),\n", + " d=(3.3, 4.4, 5.5),\n", + " e=(\"four\", 4, None),\n", + " f=(\"five\", 5, \"opt_five\"),\n", + " g=(\"six\", 6, 6.0, 6),\n", + " h=(\"seven\", 7, 7.0, 7),\n", + ")\n", + "assert rtrn is None" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5b83bc7e-4301-468a-8b98-182a177a0322", + "metadata": {}, + "outputs": [], + "source": [ + "pf(\n", + " a=(0, \"zero\", 0), # INVALID as includes str\n", + " b=(1.1, \"one\", 2.2, 3), # INVALID as includes str\n", + " c=(2, 3.3, 4), # INVALID as includes float and int (should be all float or all int)\n", + " d=(3.3, 4.4, 5.5), # valid\n", + " e=(4, 4, None), # INVALID as item at index 0 is not str\n", + " f=(\"five\", 5, 3), # INVALID as item at index 2 is not str or None\n", + " g=(\"six\", 6, 6.0), # INVALID as too short, should have length 4\n", + " h=(\"seven\", 7, 7.0, 7, 7.0), # INVALID as too long, should have length 4\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "62a58459-8875-444c-9f50-13855b929431", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[17], line 1\n", + "----> 1 pf(\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes type containing items that conform with , although the received container contains item 'zero' of type .\n", + "\n", + "b\n", + "\tTakes type containing items that conform with , although the received container contains item 'one' of type .\n", + "\n", + "c\n", + "\tTakes input that conforms with <(tuple[int, ...], tuple[float, ...])> although received '(2, 3.3, 4)' of type .\n", + "\n", + "e\n", + "\tTakes type containing items that conform with , although the item in position 0 is '4' of type .\n", + "\n", + "f\n", + "\tTakes type containing items that conform with , although the item in position 2 is '3' of type .\n", + "\n", + "g\n", + "\tTakes type of length 4 although received '('six', 6, 6.0)' of length 3.\n", + "\n", + "h\n", + "\tTakes type of length 4 although received '('seven', 7, 7.0, 7, 7.0)' of length 5.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "8da72f79-2af3-4911-9eec-cafa8e3f60df", + "metadata": {}, + "source": [ + "#### `set`\n", + "\n", + "`set` can be subscripted with a single argument that defines the annotation that all contained items should confirm to." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "6433ad69-214b-489c-8dd3-b02c8cf4156f", + "metadata": {}, + "outputs": [], + "source": [ + "@parse\n", + "def pf(\n", + " a: set[str],\n", + " b: set[Union[int, float]],\n", + " c: Union[set[int], set[float]], # not the same as b!\n", + " d: Union[set[int], set[float]], # not the same as b!\n", + "):\n", + " return\n", + "\n", + "rtrn = pf( # valid inputs\n", + " a={\"a\", \"aa\", \"aaa\"},\n", + " b={1, 1.1, 1.11},\n", + " c={2, 3, 4},\n", + " d={3.3, 4.4, 5.5},\n", + ")\n", + "assert rtrn is None" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb3b125c-57de-411a-ba77-99d15d5593d9", + "metadata": {}, + "outputs": [], + "source": [ + "pf(\n", + " a={\"a\", 2, \"aaa\"}, # INVALID as contains an int\n", + " b={1, 1.1, 1.11}, # valid\n", + " c={2, 3.3, 4}, # INVALID as contains a float (should be all int or all float)\n", + " d={3.3, 4.4, 5.5}, # valid\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "e305399e-4d73-4ecc-a4e5-2c536db0eb2b", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[19], line 1\n", + "----> 1 pf(\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes type containing items that conform with , although the received container contains item '2' of type .\n", + "\n", + "c\n", + "\tTakes input that conforms with <(set[int], set[float])> although received '{2, 3.3, 4}' of type .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "04095eb6-dcfc-4c0c-a11b-6a7fb7e75142", + "metadata": {}, + "source": [ + "#### `valimp.NO_ITEM_VALIDATION`\n", + "\n", + "To **not** validate a container's items just include the `valimp.NO_ITEM_VALIDATION` constant in an annotation's metadata.\n", + "\n", + "Annotation metadata is defined by simply wrapping the annotation in `typing.Annotated`. The first argument of the `typing.Annotated` subscription takes the wrapped annotation, all further arguments are consumed as annotation metadata.\n", + "\n", + "Notice how in the following example the parameters with `valimp.NO_ITEM_VALIDATION` are not included in the error message - they are considered valid as the container type is correct and the contained items are not validated." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "b38c3307-6fbe-4fb8-ba75-16a1955cc2a8", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Annotated\n", + "from valimp import NO_ITEM_VALIDATION\n", + "\n", + "@parse\n", + "def pf(\n", + " a: tuple[str, ...],\n", + " a1: Annotated[tuple[str, ...], NO_ITEM_VALIDATION],\n", + " b: dict[str, int],\n", + " b1: Annotated[dict[str, int], NO_ITEM_VALIDATION],\n", + " c: set[int],\n", + " c1: Annotated[set[int], NO_ITEM_VALIDATION],\n", + "):\n", + " return" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4bd89977-3f22-4208-9d70-22415f8d2bd3", + "metadata": {}, + "outputs": [], + "source": [ + "pf(\n", + " a=(\"a\", 0), # INVALID as contains int\n", + " a1=(\"a\", 0), # ...INVALID for same reason but will not raise error\n", + " b={\"bkey\": \"bval\"}, # INVALID as has value as str\n", + " b1={\"bkey\": \"bval\"}, # ...INVALID for same reason but will not raise error\n", + " c={0, 1, 2.2}, # INVALID as contains a float\n", + " c1={0, 1, 2.2}, # ...INVALID for same reason but will not raise error\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "80a18cfd-eb49-41f3-b70d-30e5bf4d6178", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[21], line 1\n", + "----> 1 pf(\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes type containing items that conform with , although the received container contains item '0' of type .\n", + "\n", + "b\n", + "\tTakes type with values that conform to the second argument of , although the received dictionary contains value 'bval' of type .\n", + "\n", + "c\n", + "\tTakes type containing items that conform with , although the received container contains item '2.2' of type .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "b2f6c8e3-26ca-44be-bd65-c8150dcab8a9", + "metadata": {}, + "source": [ + "#### Nested containers\n", + "\n", + "Valimp will by default recursively validate the types of contained items in nested containers." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "c5159fb4-bb68-445b-ba45-07509458d305", + "metadata": {}, + "outputs": [], + "source": [ + "@parse\n", + "def pf(\n", + " a: tuple[list[set[Union[int, float]]], ...],\n", + "):\n", + " return\n", + "\n", + "rtrn = pf(\n", + " (\n", + " [\n", + " {0, 0.1}, {1, 1.1}\n", + " ],\n", + " ),\n", + ")\n", + "assert rtrn is None" + ] + }, + { + "cell_type": "markdown", + "id": "6687cb32-eb6f-4048-aa9d-b7522d9b145d", + "metadata": {}, + "source": [ + "If the first nested container (the list) contains an invalid item then an error is raised (although the error message could be more insightful)..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a1a1961-56a0-481a-b49d-d637681a9472", + "metadata": {}, + "outputs": [], + "source": [ + "pf(\n", + " (\n", + " [\n", + " {0, 0.1}, {1, 1.1},\n", + " \"not a set\",\n", + " ],\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "d988bdb6-653e-4810-a73f-126a30ccfb43", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[23], line 1\n", + "----> 1 pf(\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes type containing items that conform with , although the received container contains item '[{0, 0.1}, {1, 1.1}, 'not a set']' of type .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "98bf95e1-7f71-4987-b6d1-bb514ba79f7d", + "metadata": {}, + "source": [ + "An error is also raised if the list contents are valid although one of the second level of nested containers (the sets) contains an invalid item..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5170a9b6-2791-4586-a142-e61d5f484e1d", + "metadata": {}, + "outputs": [], + "source": [ + "pf(\n", + " (\n", + " [\n", + " {0, 0.1}, {1, 1.1, \"one\"},\n", + " ],\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "27868c1f-ea54-4180-9cc8-57038df44264", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[24], line 1\n", + "----> 1 pf(\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes type containing items that conform with , although the received container contains item '[{0, 0.1}, {1, 'one', 1.1}]' of type .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "280d5776-ec00-4d8a-944a-7ab6e2957555", + "metadata": {}, + "source": [ + "Including `valimp.NO_ITEM_VALIDATION` to an annotation's metadata will result in the contained items not being validated at any level of nesting." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "ed78b4bd-4196-4268-b19f-5afd02b89826", + "metadata": {}, + "outputs": [], + "source": [ + "@parse\n", + "def pf(\n", + " a: Annotated[tuple[list[set[Union[int, float]]], ...], NO_ITEM_VALIDATION],\n", + ") -> tuple[list[set[Union[int, float]]], ...]:\n", + " return a" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "6edc0fb2-75b7-4d7d-b7c2-12ff6cada321", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "([{0, 0.1}, {1, 1.1, 'one'}, 'not a set'], 'not a list')" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pf(\n", + " (\n", + " [\n", + " {0, 0.1}, {1, 1.1, \"one\"},\n", + " \"not a set\",\n", + " ],\n", + " \"not a list\",\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cb1265f0-3a50-4c4d-b474-e858bad53bd8", + "metadata": {}, + "source": [ + "Indeed, with `valimp.NO_ITEM_VALIDATION` as long as the input's a tuple, it'll validate..." + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "f09f1647-4059-4e31-8513-3d1aa81892f2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(\"I'm a tuple\", 1, 2.0)" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pf((\"I'm a tuple\", 1, 2.0))" + ] + }, + { + "cell_type": "markdown", + "id": "af40fc35-dce8-40aa-8fd5-39e9d75bcbfc", + "metadata": {}, + "source": [ + "NB It isn't currently possible to ignore validation only from a specific level of nesting - PRs welcome!" + ] + }, + { + "cell_type": "markdown", + "id": "fcd08875-402c-4e74-8890-c343b6cd5e0f", + "metadata": {}, + "source": [ + "### `collections.abc.Callable`\n", + "\n", + "Valimp validates that inputs to parameters annotated with `collections.abc.Callable` are indeed callable. However, it will **not** validate any subscriptions.\n", + "\n", + "Notice how the inputs in the following example do not conform with the subscriptions although the input is validated regardless. (The first argument to `collections.abc.Callable` takes a sequence of annotations describing the callable's arguments, the second argument takes the callable's return type.)" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "44232c3f-b399-492b-a66c-1ab4ce983cc2", + "metadata": {}, + "outputs": [], + "source": [ + "from collections.abc import Callable\n", + "\n", + "@parse\n", + "def pf(\n", + " a: Callable,\n", + " b: Callable[[str], str],\n", + " c: Callable[[str, int], str],\n", + " d: Callable[..., str],\n", + "):\n", + " return\n", + "\n", + "def some_func(a: float, b: float) -> float:\n", + " return a + b\n", + "\n", + "# inputs are validated even though they do not conform with the subscriptions\n", + "pf(some_func, some_func, some_func, some_func)" + ] + }, + { + "cell_type": "markdown", + "id": "f306c6fd-4007-46c9-82c9-19d59e7b053f", + "metadata": {}, + "source": [ + "Although an error will be raised if the input is not callable..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1b93e18e-7121-4fce-a958-d2048f7c280c", + "metadata": {}, + "outputs": [], + "source": [ + "pf(3, \"can't call me\", some_func, some_func)" + ] + }, + { + "cell_type": "markdown", + "id": "81377cab-1d9c-47ce-accb-6e56b4db09ca", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[29], line 1\n", + "----> 1 pf(3, \"can't call me\", some_func, some_func)\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes type although received '3' of type .\n", + "\n", + "b\n", + "\tTakes type although received 'can't call me' of type .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "6af1bf68-31d9-496a-9233-7e51a1622199", + "metadata": {}, + "source": [ + "### `typing.Literal`\n", + "\n", + "By default inputs to parameters annotated with `typing.Literal` are considered valid if the input *compares as equal* to a value defined within the subscriptions.\n", + "\n", + "The `typing.Literal` annotation can be useful to validate that an input is within a set of members of an `Enum`." + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "f9041474-ec0a-4488-8e80-ddcbbcbe6069", + "metadata": {}, + "outputs": [], + "source": [ + "from enum import Enum\n", + "from typing import Literal\n", + "\n", + "Monty = Enum('Monty', 'FOO SPAM BAR')\n", + "\n", + "@parse\n", + "def pf(\n", + " a: Literal[\"one\", \"two\"],\n", + " b: Literal[1, 2, 3],\n", + " c: Literal[Monty.FOO, Monty.SPAM, Monty.BAR],\n", + " d: Literal[Monty.FOO, Monty.SPAM, Monty.BAR],\n", + " e: Literal[Monty.FOO, Monty.SPAM],\n", + "):\n", + " return\n", + "\n", + "rtrn = pf( # valid inputs\n", + " \"one\",\n", + " 2.0, # NB not included in annotation, but valid as compares equal to 2\n", + " Monty.BAR,\n", + " Monty.SPAM,\n", + " Monty.FOO,\n", + ")\n", + "assert rtrn is None" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ea40fe5e-09db-46ff-8007-818be0262c18", + "metadata": {}, + "outputs": [], + "source": [ + "pf(\n", + " a=1, # INVALID as does not compare equal with \"one\" or \"two\"\n", + " b=1, # valid\n", + " c=\"BAR\", # INVALID as does not compare equal with unique enum\n", + " d=3, # INVALID as does not compare equal with unique enum\n", + " e=Monty.BAR # INVALID as member not included to annotation\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "41054103-17c1-4586-ae50-3d7fe9065594", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[31], line 1\n", + "----> 1 pf(\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes a value from <('one', 'two')> although received '1'.\n", + "\n", + "c\n", + "\tTakes a value from <(, , )> although received 'BAR'.\n", + "\n", + "d\n", + "\tTakes a value from <(, , )> although received '3'.\n", + "\n", + "e\n", + "\tTakes a value from <(, )> although received 'Monty.BAR'.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "69ed4275-f766-4160-8d81-32e74c1855c7", + "metadata": {}, + "source": [ + "The `valimp.STRICT_LITERAL` constant can be included to the annotation metadata to only validate input if it *is* the same object as one of the objects defined within the subscriptions, i.e. it's not enough for the object to merely compare as equal, rather they have to be one and the same. (NB consider using an Enum before resorting to `valimp.STRICT_LITERAL`)." + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "c6162215-5749-4be5-a8d2-683ac53748f0", + "metadata": {}, + "outputs": [], + "source": [ + "from valimp import STRICT_LITERAL\n", + "\n", + "LIT = \"spam\"\n", + "\n", + "@parse\n", + "def pf(\n", + " a: Literal[LIT],\n", + " b: Annotated[Literal[LIT], STRICT_LITERAL],\n", + "):\n", + " return\n", + "\n", + "pf(LIT, LIT) # valid as both parameters are receiving the same object" + ] + }, + { + "cell_type": "markdown", + "id": "c5ed91ab-ecf2-4b3b-a1ca-8737660199f2", + "metadata": {}, + "source": [ + "If we define a different object which compares equal to LIT..." + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "12a4aca1-005f-469c-b895-e8473ac50e86", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'spam'" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "diff_obj = \"spamx\"[:-1]\n", + "assert diff_obj == LIT\n", + "diff_obj" + ] + }, + { + "cell_type": "markdown", + "id": "2ddee1b9-70a1-4433-8005-de72b7a252f6", + "metadata": {}, + "source": [ + "Then whilst this is valid..." + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "bb10d385-cf0e-412f-ae74-2a98aa6aea95", + "metadata": {}, + "outputs": [], + "source": [ + "pf(diff_obj, LIT)" + ] + }, + { + "cell_type": "markdown", + "id": "a026d139-2c50-492e-8e60-6a44dd9e224f", + "metadata": {}, + "source": [ + "This isn't (at least not for parameter b)..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "635dc9e4-429c-4083-b2ea-dc2928b1bd43", + "metadata": {}, + "outputs": [], + "source": [ + "pf(diff_obj, diff_obj)" + ] + }, + { + "cell_type": "markdown", + "id": "9b5785a8-4294-4919-9f15-cc51049dc283", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[35], line 1\n", + "----> 1 pf(diff_obj, diff_obj)\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "b\n", + "\tTakes a literal from <('spam',)> although received 'spam'.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "ef3aaca2-0619-4bf4-a05a-4047b941e373", + "metadata": {}, + "source": [ + "## Signature validation\n", + "\n", + "Valimp also validates that inputs conform with a function's signature.\n", + "\n", + "A `valimp.InputsError` will be raised if at least one of the following is true.\n", + "* A required argument is not passed (missing positional argument).\n", + "* A required keyword-only argument is not passed (missing keyword-only argument).\n", + "* A keyword argument is passed that is not represented in the signature (unexpected keyword argument).\n", + "* More arguments are passed positionally than accommodated for by the signature (excess positional arguments).\n", + "* A parameter is passed both positionally and as a keyword argument (got multiple values).\n", + "\n", + "All signature errors are advised in the error message, together with any errors relating to invalid types." + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "id": "eff9ce77-2c87-40ca-81c9-4faa54be0997", + "metadata": {}, + "outputs": [], + "source": [ + "@parse\n", + "def pf(\n", + " a: int,\n", + " b: int,\n", + " *,\n", + " kw_a: int,\n", + "):\n", + " return" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e399401-0155-458e-8175-5b2d5d64d2d6", + "metadata": {}, + "outputs": [], + "source": [ + "pf(3, \"not an int\", 5, a=3, not_a_kwarg=3)" + ] + }, + { + "cell_type": "markdown", + "id": "ba656763-bd27-42fb-a07e-6494402fc5b5", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[37], line 1\n", + "----> 1 pf(3, \"not an int\", 5, a=3, not_a_kwarg=3)\n", + "\n", + "InputsError: Inputs to 'pf' do not conform with the function signature:\n", + "\n", + "Got multiple values for argument: 'a'.\n", + "\n", + "Received 1 excess positional argument as:\n", + "\t'5' of type .\n", + "\n", + "Got unexpected keyword argument: 'not_a_kwarg'.\n", + "\n", + "Missing 1 keyword-only argument: 'kw_a'.\n", + "\n", + "The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "b\n", + "\tTakes type although received 'not an int' of type .\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "197b5f0d-1147-4f99-9b5c-a2af2ff75145", + "metadata": {}, + "outputs": [], + "source": [ + "pf(3, kw_a=3)" + ] + }, + { + "cell_type": "markdown", + "id": "9dfb0c35-890d-4526-8f79-8ec6e070b6dc", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[38], line 1\n", + "----> 1 pf(3, kw_a=3)\n", + "\n", + "InputsError: Inputs to 'pf' do not conform with the function signature:\n", + "\n", + "Missing 1 positional argument: 'b'.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "e6381f38-e647-41d8-9620-e8627dc84b83", + "metadata": {}, + "source": [ + "## Coerce\n", + "\n", + "Valimp provides for coercing a parameter's input to a specific type.\n", + "\n", + "This is useful if it's convenient for the user to pass an input as one of various types although you don't want to clutter up your function by then getting the input into the type that you'll be using internally.\n", + "\n", + "An input can be coerced simply by including an instance of `valimp.Coerce` to the parameter's annotation metadata. `valimp.Coerce` takes a single argument as the class that the input is to be coerced to (under-the-bonnet the `@parse` decorator simply passes the input to the constructor of the class being coerced to)." + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "id": "aa10cb66-5db5-425a-ba4e-fad8e7e8d3eb", + "metadata": {}, + "outputs": [], + "source": [ + "from valimp import Coerce\n", + "\n", + "@parse\n", + "def pf(\n", + " a: Annotated[Union[float, int, str], Coerce(float)],\n", + " b: Annotated[Union[float, int, str], Coerce(float)],\n", + " c: Annotated[Union[float, int, str], Coerce(float)],\n", + " d: Annotated[Union[float, int, str], Coerce(float)],\n", + " e: Annotated[Union[float, int, str, None], Coerce(float)],\n", + ") -> dict[str, Optional[float]]:\n", + " return {\"a\":a, \"b\":b, \"c\":c, \"d\":d, \"e\":e}" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "8aba30cd-bf06-4b86-b9fd-958bb226d29f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'a': 0.0, 'b': 1.1, 'c': 2.0, 'd': 3.3, 'e': None}" + ] + }, + "execution_count": 40, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pf(0, 1.1, '2', '3.3', None) " + ] + }, + { + "cell_type": "markdown", + "id": "eda48990-ad81-4cd0-94cf-6ec2d0db6928", + "metadata": {}, + "source": [ + "Note that Valimp will not try to coerce a `None` input.\n", + "\n", + "### Type checkers\n", + "If you're using a type checker it's unlikely that it'll work out that the `@parse` decorator has coerced the input to a specific type. Hence, in the above example the checker will expect that the input could still be a string and will start advising of errors when the received object is treated as a float.\n", + "\n", + "In this case to 'right' the type checker it's necessary to narrow the type by including a type guard expression at the start of the function. For example in `mypy`:\n", + "```python\n", + "@parse\n", + "def f(param: typing.Annotated[Union[float, int, str], Coerce(float)):\n", + " if typing.TYPE_CHECKING:\n", + " assert isinstance(param, float)\n", + "```\n", + "\n", + "PR's very much welcome from anyone who knows how to abstract this requirement away to within Valimp!" + ] + }, + { + "cell_type": "markdown", + "id": "1d69c27a-69e8-4f6d-93de-fb72d1ca9e7d", + "metadata": {}, + "source": [ + "## Parsing" + ] + }, + { + "cell_type": "markdown", + "id": "c0190863-4a3f-4bee-86ad-a605cc86ef48", + "metadata": {}, + "source": [ + "Valimp also provides for abstracting away the parsing and/or custom validation of inputs.\n", + "\n", + "This is done by including an instance of `valimp.Parser` to the metadata of a parameter's annotation. The first argument of `Parser` takes a callable which should return the object as to be receieved by the decorated funcion's formal parameter. The callable should have the following signature:\n", + "\n", + "```python\n", + "def parser_func(name: str, obj: Any, params: dict[str, Any]) -> Any:\n", + "```\n", + "where:\n", + "```\n", + "PARAMETERS\n", + "\n", + "name :\n", + " Name of the parameter being parsed.\n", + "\n", + "obj :\n", + " The input as passed by the client.\n", + "\n", + " `obj` will be received as coerced by any `Coerce` instance\n", + " only if the Coerce instance is passed to typing.Annotated\n", + " ahead of the Parser instance.\n", + "\n", + "params :\n", + " Shallow copy of earlier inputs that have already been\n", + " validated and, if applicable, parsed and/or coerced.\n", + "\n", + "RETURNS\n", + "\n", + "parsed :\n", + " Parsed, validated object.\n", + "```\n", + "\n", + "So, to simply add a suffix to a str input..." + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "id": "872b9027-3aff-4901-859b-f8b1163b5a54", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'a': 'input_a', 'b': 'input_b_suffix'}" + ] + }, + "execution_count": 41, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from valimp import Parser\n", + "\n", + "def add_suffix(name: str, obj: str, params: dict[str: str]) -> str:\n", + " return obj + \"_suffix\"\n", + "\n", + "@parse\n", + "def pf(\n", + " a: str,\n", + " b: Annotated[str, Parser(add_suffix)],\n", + ") -> dict[str, str]:\n", + " return {\"a\":a, \"b\":b}\n", + "\n", + "pf(\"input_a\", \"input_b\")" + ] + }, + { + "cell_type": "markdown", + "id": "df93fcff-691b-4cb1-bf74-635faea3a2f5", + "metadata": {}, + "source": [ + "The following offers a trivial example of how the parameter name and previous inputs are available to the parser." + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "id": "3639b3c6-eb1f-493c-a62c-47b36e460fcb", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'a': 'input_a', 'b': 'input_b_b', 'c': 'input_c_input_a'}" + ] + }, + "execution_count": 42, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from valimp import Parser\n", + "\n", + "def concat_param_name(name: str, obj: str, params: dict[str: str]) -> str:\n", + " return obj + f\"_{name}\"\n", + "\n", + "my_parser = Parser(concat_param_name)\n", + "\n", + "def concat_earlier_input(name: str, obj: str, params: dict[str: str]) -> str:\n", + " return obj + f\"_{params['a']}\"\n", + "\n", + "@parse\n", + "def pf(\n", + " a: str,\n", + " b: Annotated[str, my_parser], # passes a reusable Parser\n", + " c: Annotated[str, Parser(concat_earlier_input)], \n", + ") -> dict[str, str]:\n", + " return {\"a\":a, \"b\":b, \"c\":c}\n", + "\n", + "pf(\"input_a\", \"input_b\", \"input_c\")" + ] + }, + { + "cell_type": "markdown", + "id": "6ffe459b-91df-408d-a582-2e0ab8ef4862", + "metadata": {}, + "source": [ + "Note that the input is only passed to the parser if it's type is valid. Accordingly the type of the parser's `obj` parameter can be narrowed in the knowledge that to have got this far the input type will be valided. For example, above the `obj` parameter of the parser callables can be annotated as `str` rather than `Any`.\n", + "\n", + "And as invalid inputs can't reach the parser function, you won't get an error with an obtuse message raised, such as when in the above example the parser function would otherwise come across `int` + `str`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6da02fc7-293f-4dcd-97f5-b30c58f26017", + "metadata": {}, + "outputs": [], + "source": [ + "pf(\"valid\", \"valid\", 3)" + ] + }, + { + "cell_type": "markdown", + "id": "9f7ffb36-678c-412a-9a38-36659bd5671f", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[43], line 1\n", + "----> 1 pf(\"valid\", \"valid\", 3)\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "c\n", + "\tTakes type although received '3' of type .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "0d4715be-6d90-4562-8d45-c0eac8b9b000", + "metadata": {}, + "source": [ + "### Custom validation\n", + "\n", + "`Parser` provides for easily abstracting away custom input validations. Just raise an error within the parser function if the input doesn't validate.\n", + "\n", + "For example, to ensure that an input is greater that a specific value, or greater than the value passed for a prior parameter..." + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "id": "aa1c9d23-908c-4b07-a5b1-59bc043c6d7c", + "metadata": {}, + "outputs": [], + "source": [ + "def simple_validator(name: str, obj: int, params: dict[str, int]) -> int:\n", + " if obj < 10:\n", + " raise ValueError(\n", + " f\"The value of parameter {name} cannot be less than 10.\"\n", + " )\n", + " return obj\n", + "\n", + "def validator(name: str, obj: int, params: dict[str, int]) -> int:\n", + " if obj <= params[\"a\"]:\n", + " raise ValueError(\n", + " f\"The value of parameter '{name}' cannot be less than\"\n", + " \" the value of parameter 'a', although received 'a' as\"\n", + " f\" {params['a']} and '{name}' as {obj}.\"\n", + " )\n", + " return obj\n", + "\n", + "@parse\n", + "def pf(\n", + " a: Annotated[int, Parser(simple_validator)],\n", + " b: Annotated[int, Parser(validator)],\n", + "):\n", + " return\n", + "\n", + "assert pf(10, 15) is None" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "559c2c1c-d665-4689-a08f-0fe6c44e04ea", + "metadata": {}, + "outputs": [], + "source": [ + "pf(5, 4)" + ] + }, + { + "cell_type": "markdown", + "id": "3b50c9a3-7870-43c5-82b6-f41e1817bd10", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "ValueError Traceback (most recent call last)\n", + "Cell In[45], line 1\n", + "----> 1 pf(5, 4)\n", + "\n", + "ValueError: The value of parameter a cannot be less than 10.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "d38325df-ac78-4bd8-b098-6b278eb22ab5", + "metadata": {}, + "source": [ + "Note that in the case of custom validation the user-defined error is raised directly for the first input that fails validation, i.e. although the second input here would also fail the validation no such advices are offered to the user, to whom it probably won't become apparent until they then try..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6fb363a9-7671-4dd9-a315-fb00de28efbb", + "metadata": {}, + "outputs": [], + "source": [ + "pf(10, 4)" + ] + }, + { + "cell_type": "markdown", + "id": "d289ac44-75f3-4089-a42a-6e88c3587b76", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "ValueError Traceback (most recent call last)\n", + "Cell In[46], line 1\n", + "----> 1 pf(10, 4)\n", + "\n", + "ValueError: The value of parameter 'b' cannot be less than the value of parameter 'a', although received 'a' as 10 and 'b' as 4.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "30e2d8a1-75da-43f2-a556-a49e9967c78a", + "metadata": {}, + "source": [ + "### Dynamic default values\n", + "\n", + "Default values can be defined dynamcially with reference to earlier inputs.\n", + "\n", + "For example, to simply set a parameter's default to the value of an earlier required input..." + ] + }, + { + "cell_type": "code", + "execution_count": 47, + "id": "bf0921b4-a961-450e-b719-f7bdea866c66", + "metadata": {}, + "outputs": [], + "source": [ + "@parse\n", + "def pf(\n", + " a: int,\n", + " b: Annotated[\n", + " Optional[int],\n", + " Parser(lambda _, obj, params: obj if obj is not None else params[\"a\"]),\n", + " ] = None,\n", + ") -> tuple[int, int]:\n", + " return a, b" + ] + }, + { + "cell_type": "code", + "execution_count": 48, + "id": "2e85bfd6-05c9-48c1-ada4-82f684476311", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(10, 10)" + ] + }, + "execution_count": 48, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pf(10)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "ca4c6551-e3cc-4c2b-89ae-23a51b394f75", + "metadata": {}, + "source": [ + "### parse_none\n", + "\n", + "Passing the `None` value to the parser function is what provides for setting a default value. But what if `None` is a valid value in itself which you don't want to send to the parser function? This is what the `Parser` `parse_none` keyword argument is for. It's `True` by default, although if passed as `False` then a `None` input will be passed straight through to the formal parameter rather than to the parser function." + ] + }, + { + "cell_type": "code", + "execution_count": 49, + "id": "5e8d4eba-bcf8-4c11-978a-49dcba926250", + "metadata": {}, + "outputs": [], + "source": [ + "@parse\n", + "def pf(\n", + " a: Annotated[\n", + " Optional[int],\n", + " Parser(lambda _, obj, params: obj + 5, parse_none=False),\n", + " ] = None,\n", + "):\n", + " return a" + ] + }, + { + "cell_type": "code", + "execution_count": 50, + "id": "4c50f083-b166-4a62-bb13-5ceec8ddca81", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "8" + ] + }, + "execution_count": 50, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pf(3)" + ] + }, + { + "cell_type": "code", + "execution_count": 51, + "id": "05f9433c-64af-48b3-bb70-80cbcec9c03d", + "metadata": {}, + "outputs": [], + "source": [ + "assert pf(None) is None" + ] + }, + { + "cell_type": "markdown", + "id": "e2cd63d6-a46c-4b45-bf47-f5bc9b308fd3", + "metadata": {}, + "source": [ + "Note what would happen if `parse_none` had not been passed to `Parser` as `False`:" + ] + }, + { + "cell_type": "code", + "execution_count": 52, + "id": "7f00520f-0c4f-48e1-8f65-fd4cde8c2202", + "metadata": {}, + "outputs": [], + "source": [ + "@parse\n", + "def pf(\n", + " a: Annotated[\n", + " Optional[int],\n", + " Parser(lambda _, obj, params: obj + 5),\n", + " ] = None,\n", + "):\n", + " return a" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61e1bccb-3f99-4eca-ba60-5e2e7616ac89", + "metadata": {}, + "outputs": [], + "source": [ + "pf(None)" + ] + }, + { + "cell_type": "markdown", + "id": "89a80d95-2422-4438-afae-b8b3774a59ed", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "TypeError Traceback (most recent call last)\n", + "Cell In[53], line 1\n", + "----> 1 pf(None)\n", + "\n", + "File ~\\valimp\\src\\valimp\\valimp.py:931, in parse..wrapped_f(*args, **kwargs)\n", + " 929 if obj is None and not data.parse_none:\n", + " 930 continue\n", + "--> 931 obj = data.function(name, obj, new_as_kwargs.copy())\n", + " 933 new_as_kwargs[name] = obj\n", + " 935 return f(**new_as_kwargs)\n", + "\n", + "Cell In[52], line 5, in (_, obj, params)\n", + " 1 @parse\n", + " 2 def pf(\n", + " 3 a: Annotated[\n", + " 4 Optional[int],\n", + "----> 5 Parser(lambda _, obj, params: obj + 5),\n", + " 6 ] = None,\n", + " 7 ):\n", + " 8 return a\n", + "\n", + "TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "766af490-58f5-4708-a49e-f967850a2f1a", + "metadata": {}, + "source": [ + "## Coerce and parse" + ] + }, + { + "cell_type": "markdown", + "id": "a26a7ab4-212d-4415-bc30-bb69f9d52d5d", + "metadata": {}, + "source": [ + "Instances of `valimp.Coerce` and `valimp.Parser` can both be passed to the same type annotation.\n", + "\n", + "In this case, order matters. If the `Coerce` instance is passed ahead of the `Parser` instance then the parser function will receive the input post-coercion. If however the `Parser` instance is passed ahead of the `Coerce` instance then the parser function will receive the user's input directly and the parser's output will be subsequently coerced.\n", + "\n", + "The following offers an example of the more common case of coercing then validating an input." + ] + }, + { + "cell_type": "code", + "execution_count": 54, + "id": "0171f026-b3d0-434c-bd19-118a829c07a3", + "metadata": {}, + "outputs": [], + "source": [ + "def _validate(name: str, obj: float, params: dict[str: Any]) -> float:\n", + " if obj < params[\"a\"]:\n", + " raise ValueError(\n", + " f\"If passed then the value of parameter '{name}' cannot be less\"\n", + " f\" than the value of parameter 'a', although received 'a' as\"\n", + " f\" {params['a']} and '{name}' as {obj}.\"\n", + " )\n", + " return obj \n", + "\n", + "validate = Parser(_validate, parse_none=False)\n", + "\n", + "@parse\n", + "def pf(\n", + " a: Annotated[Union[float, int, str], Coerce(float)],\n", + " b: Annotated[\n", + " Union[float, int, str, None],\n", + " Coerce(float),\n", + " validate,\n", + " ] = None,\n", + ") -> tuple[float, Optional[float]]:\n", + " return a, b" + ] + }, + { + "cell_type": "code", + "execution_count": 55, + "id": "b5fc576a-1c65-467c-bbb2-a67b412d3297", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(2.2, None)" + ] + }, + "execution_count": 55, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pf(\"2.2\")" + ] + }, + { + "cell_type": "code", + "execution_count": 56, + "id": "e05c6d24-60a5-4ec1-9e1b-b98f9f8ee094", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(2.2, 3.3)" + ] + }, + "execution_count": 56, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pf(\"2.2\", \"3.3\")" + ] + }, + { + "cell_type": "code", + "execution_count": 57, + "id": "78cc5f4e-ac44-42bb-ada0-106dd023803f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(2.2, 3.0)" + ] + }, + "execution_count": 57, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pf(\"2.2\", 3)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d9eb64e3-eb85-4006-8f02-b8a945d9be21", + "metadata": {}, + "outputs": [], + "source": [ + "pf(\"2.2\", \"1.8\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0c695a1-612c-497a-ab61-bf5daf3d16dc", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "ValueError Traceback (most recent call last)\n", + "Cell In[58], line 1\n", + "----> 1 pf(\"2.2\", \"1.8\")\n", + "\n", + "File ~\\valimp\\src\\valimp\\valimp.py:931, in parse..wrapped_f(*args, **kwargs)\n", + " 929 if obj is None and not data.parse_none:\n", + " 930 continue\n", + "--> 931 obj = data.function(name, obj, new_as_kwargs.copy())\n", + " 933 new_as_kwargs[name] = obj\n", + " 935 return f(**new_as_kwargs)\n", + "\n", + "Cell In[54], line 3, in _validate(name, obj, params)\n", + " 1 def _validate(name: str, obj: float, params: dict[str: Any]) -> float:\n", + " 2 if obj < params[\"a\"]:\n", + "----> 3 raise ValueError(\n", + " 4 f\"If passed then the value of parameter '{name}' cannot be less\"\n", + " 5 f\" than the value of parameter 'a', although received 'a' as\"\n", + " 6 f\" {params['a']} and '{name}' as {obj}.\"\n", + " 7 )\n", + " 8 return obj\n", + "\n", + "ValueError: If passed then the value of parameter 'b' cannot be less than the value of parameter 'a', although received 'a' as 2.2 and 'b' as 1.8.\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "valimp_39", + "language": "python", + "name": "valimp_39" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..4dd3a38 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,15 @@ +[mypy] +; use underscores in args +; NO comments on same line as an option + +warn_unreachable = True +warn_redundant_casts = True +warn_unused_ignores = True +strict_equality = True +show_error_codes = True + +; ignore_missing_imports = True + +; or silence by library, for example: +; [mypy-exchange_calendars] +; ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cce9e9c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,73 @@ +[build-system] +requires = ["setuptools>=43.0.0", "wheel", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "valimp" +description = "Validate and parse function inputs" +authors = [ + {email = "marcusaread.prog@proton.me"}, + {name = "Marcus Read"}, +] +readme = "README.md" +license = {text = "MIT License"} +keywords = [ + "validation", + "parsing", + "validate", + "parse", + "coerce", + "input", + "function" +] +requires-python = "~=3.9" + +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities", +] + +dynamic = ["version"] + +[project.optional-dependencies] +tests = [ + "black", + "flake8", + "flake8-docstrings", + "pytest", +] +dev = [ + "black", + "flake8", + "flake8-docstrings", + "pytest", + "mypy", + "pip-tools", + "pre-commit", + "pylint", +] + +[project.urls] +homepage = "https://github.com/maread99/valimp" +documentation = "https://github.com/maread99/valimp" +"Issue Tracker" = "https://github.com/maread99/valimp/issues" +"Source Code" = "https://github.com/maread99/valimp" + +[tool.setuptools_scm] +write_to = "src/valimp/_version.py" + +[tool.black] +line-length = 88 +target-version = ['py39', 'py310', 'py311'] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a55098b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +addopts = -rxXs --strict-markers --doctest-modules --capture=no + +testpaths = + tests + src/valimp/ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..18de4ba --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: +# +# pip-compile pyproject.toml +# diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..da7574f --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,119 @@ +# +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: +# +# pip-compile --extra=dev --output-file=requirements_dev.txt pyproject.toml +# +astroid==2.15.6 + # via pylint +black==23.7.0 + # via valimp (pyproject.toml) +build==0.10.0 + # via pip-tools +cfgv==3.4.0 + # via pre-commit +click==8.1.6 + # via + # black + # pip-tools +colorama==0.4.6 + # via + # build + # click + # pylint + # pytest +dill==0.3.7 + # via pylint +distlib==0.3.7 + # via virtualenv +exceptiongroup==1.1.3 + # via pytest +filelock==3.12.2 + # via virtualenv +flake8==6.1.0 + # via + # flake8-docstrings + # valimp (pyproject.toml) +flake8-docstrings==1.7.0 + # via valimp (pyproject.toml) +identify==2.5.26 + # via pre-commit +iniconfig==2.0.0 + # via pytest +isort==5.12.0 + # via pylint +lazy-object-proxy==1.9.0 + # via astroid +mccabe==0.7.0 + # via + # flake8 + # pylint +mypy==1.5.1 + # via valimp (pyproject.toml) +mypy-extensions==1.0.0 + # via + # black + # mypy +nodeenv==1.8.0 + # via pre-commit +packaging==23.1 + # via + # black + # build + # pytest +pathspec==0.11.2 + # via black +pip-tools==7.3.0 + # via valimp (pyproject.toml) +platformdirs==3.10.0 + # via + # black + # pylint + # virtualenv +pluggy==1.2.0 + # via pytest +pre-commit==3.3.3 + # via valimp (pyproject.toml) +pycodestyle==2.11.0 + # via flake8 +pydocstyle==6.3.0 + # via flake8-docstrings +pyflakes==3.1.0 + # via flake8 +pylint==2.17.5 + # via valimp (pyproject.toml) +pyproject-hooks==1.0.0 + # via build +pytest==7.4.0 + # via valimp (pyproject.toml) +pyyaml==6.0.1 + # via pre-commit +snowballstemmer==2.2.0 + # via pydocstyle +tomli==2.0.1 + # via + # black + # build + # mypy + # pip-tools + # pylint + # pyproject-hooks + # pytest +tomlkit==0.12.1 + # via pylint +typing-extensions==4.7.1 + # via + # astroid + # black + # mypy + # pylint +virtualenv==20.24.3 + # via pre-commit +wheel==0.41.1 + # via pip-tools +wrapt==1.15.0 + # via astroid + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements_tests.txt b/requirements_tests.txt new file mode 100644 index 0000000..29eae38 --- /dev/null +++ b/requirements_tests.txt @@ -0,0 +1,54 @@ +# +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: +# +# pip-compile --extra=tests --output-file=requirements_tests.txt pyproject.toml +# +black==23.7.0 + # via valimp (pyproject.toml) +click==8.1.6 + # via black +colorama==0.4.6 + # via + # click + # pytest +exceptiongroup==1.1.3 + # via pytest +flake8==6.1.0 + # via + # flake8-docstrings + # valimp (pyproject.toml) +flake8-docstrings==1.7.0 + # via valimp (pyproject.toml) +iniconfig==2.0.0 + # via pytest +mccabe==0.7.0 + # via flake8 +mypy-extensions==1.0.0 + # via black +packaging==23.1 + # via + # black + # pytest +pathspec==0.11.2 + # via black +platformdirs==3.10.0 + # via black +pluggy==1.2.0 + # via pytest +pycodestyle==2.11.0 + # via flake8 +pydocstyle==6.3.0 + # via flake8-docstrings +pyflakes==3.1.0 + # via flake8 +pytest==7.4.0 + # via valimp (pyproject.toml) +snowballstemmer==2.2.0 + # via pydocstyle +tomli==2.0.1 + # via + # black + # pytest +typing-extensions==4.7.1 + # via black diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6802980 --- /dev/null +++ b/setup.py @@ -0,0 +1,8 @@ +# This file is currently (2022-06) required in order for pip to be able to create +# editable installs when build meta is included to pyproject.toml. Likely that at +# some future point this will no longer be required and file can be removed. +# Reference: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html + +from setuptools import setup + +setup() diff --git a/src/valimp/__init__.py b/src/valimp/__init__.py new file mode 100644 index 0000000..99b9b04 --- /dev/null +++ b/src/valimp/__init__.py @@ -0,0 +1,31 @@ +"""valimp.""" + +from . import valimp +from .valimp import * + +__all__ = [valimp] + +__copyright__ = "Copyright (c) 2023 Marcus Read" + +# Resolve version +__version__ = None + +from importlib.metadata import version + +try: + # get version from installed package + __version__ = version("valimp") +except ImportError: + pass + +if __version__ is None: + try: + # if package not installed, get version as set when package built + from ._version import version + except Exception: + # If package not installed and not built, leave __version__ as None + pass + else: + __version__ = version + +del version diff --git a/src/valimp/valimp.py b/src/valimp/valimp.py new file mode 100644 index 0000000..ef22149 --- /dev/null +++ b/src/valimp/valimp.py @@ -0,0 +1,937 @@ +"""Validation and parsing of inputs to public functions. + +The `parse` decorator provides for: + - validating public function/method inputs against function signature + and type annotations, including optional type validation of items in + containers. + - coercing inputs to a specific type. + - user-defined parsing and validation. + +`parse` does NOT currently support: + - variable length arguments (i.e. *args in the function signature). + - variable length keyword arguments (i.e. **kwargs in the function + signature). + - precluding positional-only arguments being passed as keyword arguments. + +See the tutorial for a walk-through of all functionality: +https://github.com/maread99/valimp/blob/master/docs/tutorials/tutorial.ipynb + +A version of the following example is included to the README with +explanatory comments and inputs: + from valimp import parse, Parser, Coerce + from typing import Annotated, Union, Optional, Any + + @parse + def public_function( + a: str, + b: Union[int, float], # or from python 3.10, `int | float` + c: dict[str, Union[int, float]], # dict[str, int | float] + d: Annotated[ + Union[int, float, str], # int | float | str + Coerce(int) + ], + e: Annotated[ + str, + Parser(lambda name, obj, params: obj + f"_{name}_{params['a']}") + ], + f: Annotated[ + Union[str, int], # str | int + Coerce(str), + Parser(lambda name, obj, _: obj + f"_{name}") + ], + *, + g: Optional[str], # str | None + h: Annotated[ + Optional[float], # float | None + Parser(lambda _, obj, params: params["b"] if obj is None else obj) + ] = None, + ) -> dict[str, Any]: + return {"a":a, "b":b, "c":c, "d":d, "e":e, "f":f, "g":g, "h":h} + +Type validation +--------------- + + - All inputs that are annotated with a type will be validated as + conforming with the type annotation. + + - Container items are by default validated as conforming with subscripted + types (for example, list[str]). + + - Inputs annoated with a typing.Literal type can be validated as either + comparing equal (default) or being equal. + +From Python 3.10 use of the | operator is supported to define annotations +describing type unions and optional types. + +The following type annotations are supported: + + A single type, builtin or user defined. Examples: + f(param: int) + f(param: CustomType) + + typing.Union or (from python 3.10) the | operator. Examples: + f(param: typing.Union[int, float]) + f(param: int | float) # from python 3.10 + + `list`. Example: + f(param: list) + If the type of the list items is included then all items will be + validated against that type. For example, to validate that all list + items are of type str: + f(param: list[str]) + + `tuple`. Example: + f(param: tuple) + If the type(s) of the tuple items is included then all tuple items will + be validated against the type(s). + Examples: + To validate that all tuple items are of type int: + f(param: tuple[int, ...]) + To validate the types of the items of a 4-tuple: + f( + param: tuple[int, str, list[str], dict[str, int]] + ) + + `dict`. Example: + (param: dict) + If the type of the dict keys and values is included then all dict keys + and values will be validated against those types. For example: + f(param: dict[str, int]) + f(param: dict[str, Union[int, float]) + + `set`. Example: + f(param: set) + If the type of the set items is included then all set items will be + validated against that type. For example, to validate that all set + items are of type int: + f(param: set[int]) + + typing.Optional or (from python 3.10) | None. Examples: + f(param: typing.Optional[str]) + f(param: str | None) # from python 3.10 + + typing.Literal. Examples: + f(param: typing.Literal["foo", "spam"]) + f(param: typing.Literal[1, 2, 3]) + By default input will be validated as comparing equal with one of the + arguments to Literal. This can be changed to validate based on strictly + being the same object by using `typing.Annotated` to define the + annotation and including the `valimp.STRICT_LITERAL` constant to + the metadata. For example: + f( + param: typing.Annotated[ + typing.Literal[FOO, SPAM], STRICT_LITERAL, + ] + ) + + collections.abc.Sequence. + Validation as for `list`. + + collections.abc.Mapping. + Validation as for `dict`. + + collections.abc.Callable. Example: + f(param: collections.abc.Callable) + Subscriptions of Callable are ignored, i.e. the following will be + validated in the same way as the above unsubscripted example: + f(param: collections.abc.Callable[[str, int], int]) + +NO_ITEM_VALIDATION +Validation of the type of items in a container can be skipped for any +parameter by using `typing.Annotated` to define the annotation and +including the `valimp.NO_ITEM_VALIDATION` constant to the metadata. For +example, the following will validate that 'param' receives a dictionary, +but not that the keys are str or that the values are either int or float: + f( + param: typing.Annotated[ + dict[str, typing.Union[int | float]], + NO_ITEM_VALIDATION, + ] + ) + +Nested containers +Items in nested containers will by default by validated against the +subscripted annoations. For example the following will validate that the +outer tuple contains any number of lists, that those lists contain only +sets and that the sets contains only int or float. + f(param: tuple[list[set[Union[int, float]]], ...]) + +Including NO_ITEM_VALIDATION to the annotation's metadata will result in +the contained items not being validated at any level of nesting. + +Coercion and Parsing +-------------------- +An input can be coerced to a specific type by annotating the parameter with +typing.Annotated and including an instance of the `valimp.Coerce` class to +the metadata. For example to coerce an int or str input to a float: + f( + param: typing.Annotated[ + typing.Union[float, int, str], Coerce(float) + ] + ) + f( + param: typing.Annotated[float | int | str, Coerce(float)] + ) # from python 3.10 + +NB An input receieved as None is not coerced. + +NB Type checkers will continue to assume that a coerced object could be of +any type indicated in the type annotation. To 'right' the type checker it's +necessary to narrow the type by including type guard expression at the +start of the function. For example in mypy: + @parse + def f(param: typing.Annotated[Union[float, int, str], Coerce(float)): + if TYPE_CHECKING: + assert isinstance(param, float) + +An input can be parsed and/or validated by a user-defined function by +including an instance of the `valimp.Parser` class to the typing.Annotation +metadata. For exmaple, to add a suffix to a string before the function +receives the input: + f( + param: typing.Annotated[ + str, + Parser(lambda name, obj, params: obj + "suffix"), + ] + ) + +Parser takes its first argument as a callable which should parse and/or +validate the input. The callable should have the signature: + (name: str, obj: Any, params: dict[str, Any]) -> Any + where the parameters will be receieved as: + name: + Name of the function argument being parsed. + obj: + The input object being parsed. This `obj` will be received + as coerced by any `Coerce` instance if the Coerce instance + is passed to the Annotated metadata ahead of the Parser, + otherwrise Parser will receive the input as passed by the + client. + params: + Shallow copy of prior inputs that have already been parsed + and, if applicable, coerced. + -> : + Parsed object. + +User-defined validation can be included within the callable by simply +raising an appropriate exception in the event the validation fails. This +error will be raised directly. + +By default inputs received as None are passed to the Parser function. This +provides for dynamically setting default values. The 'parse_none' argument +can be passed to Parser as False to not parse a None value. For example: + f( + param: typing.Annotated[ + Optional[str], + Parser( + lambda name, obj, params: obj + "suffix", + parse_none=False, + ), + ] = None + ) + +Coerce and Parser can be used in the same annoation. In this case the +parser function will receieve the coerced input if Coerce is included in +the Annotated metadata before the Parser instance, for example: + f( + param: typing.Annotated[ + typing.Union[str, int, float], + Coerce(str), + Parser(lambda name, obj, params: obj + "suffix", + ] + ) + +If Coerce follows Parser then the parser function will receive the input as +passed by the client and the output from the parser function will be +subsequently coreced. + +Notes +----- +`typing.Annotated` should only be used once in a type annotation and must +be used as the outermost wrapper. For exmaple, do NOT do either of: + param: typing.Union[typing.Annotated[str, Parser(parser_func)], int] + param: typing.Annotated[ + typing.Union[typing.Annotated[str, Parser(parser_func)], int] + ] +rather do this: + param: typing.Annotated[typing.Union[str, int], Parser(parser_func)] +If you require the parser to only apply to one of the acceptable types then +this should be handled within the parser function, for example: + def parser_func(name: str, obj: Any, params: Dict[str, Any]): + if isinstance(obj, str): + # validation/parsing code here + return obj + +Prior to python 3.11 if Optional is ommited from a type annotation then it +will be added if the variable has None as a default value. valimp does NOT +follow this behaviour (at least not if the type annotation is wrapped in +Annotated). If an input can take None, then this should be explicitly +definined within the type annotation. So, do NOT do this: + param: typing.Annotated[str, Parser(parser_func)] = None +Rather, do any of these: + param: typing.Annotated[Optional[str], Parser(parser_func)] = None + param: typing.Annotated[Union[str, None], Parser(parser_func)] = None + param: typing.Annotated[ + str | None, Parser(parser_func) + ] = None # from python 3.10 +""" + +from __future__ import annotations + +import collections +import functools +import inspect +import sys +import types +import typing +from typing import Any + + +def is_annotated(hint: typing._Final) -> bool: + """Query if a type hint is wrapped in typing.Annotated.""" + return hasattr(hint, "__metadata__") + + +class Coerce: + """Holds user-defined coercion class. + + Parameters + ---------- + coerce_to : type[Any] + Type to coerce an input to. + """ + + def __init__(self, coerce_to: type[Any]): + self.coerce_to = coerce_to + + +class Parser: + """Holds user-defined parsing and/or validation code. + + If simply coercing input to a different type, consider using + `Coerce`. + + Parameters + ---------- + func : Callable + Function to parse input. Should have signature: + (name: str, obj: Any, params: dict[str, Any]) -> Any + where: + name: + Name of the argument being parsed. + obj: + The input object being parsed. This `obj` will be received + as coerced by any `Coerce` metadata. + params: + Shallow copy of prior inputs that have already been parsed + and, if applicable, coerced. + -> : + Parsed object. + + Function should raise an exception if validation fails. + + parse_none : bool + True (default): pass input received as None to `func`. This + behavior provides for dynamically setting default values. + + False: do not pass input received as None `func`, rather pass + through None value. + """ + + def __init__( + self, + func: collections.abc.Callable[[str, Any, dict[str, Any]], Any], + parse_none: bool = True, + ): + self.function = func + self.parse_none = parse_none + + +VALIDATED = (True, None) +FAILED_SIMPLE = (False, None) +NO_ITEM_VALIDATION = "dlkj3ow61" +STRICT_LITERAL = "dlkj3ow62" + + +def validates_against_hint( + obj: Any, + hint: type[Any] | typing._Final, + annotated: typing._AnnotatedAlias | None, + rtrn_error: bool = True, +) -> tuple[bool, ValueError | TypeError | None]: + """Query if object conforms with type hint. + + Parameters + ---------- + obj + Object to validate against `hint`. + + hint + Type hint against which to validate `obj`. + + annotated + typing.Annotated instance that wraps `hint`. None if `hint` not + wrapped. + + rtrn_error + Whether to include an error in the return if the validation fails. + See Returns section. + + NB not returning an error will be more performant, especially for + validating hints comprising of nested hints, for example + Union[str, List[str], Dict[str, Union[int, float]]. + + Returns + ------- + tuple[bool, ValueError | TypeError | None] + [0] bool indicating if `obj` conforms to type. + + [1] None if 'obj' conforms to hint or `rtrn_error` is False, + otherwise error advising why validaition failed. + """ + origin = typing.get_origin(hint) + + # handle Union hint + if origin is typing.Union or ( + hasattr(types, "UnionType") and isinstance(hint, types.UnionType) + ): + hint_args = typing.get_args(hint) + for hint_ in hint_args: + validated, _ = validates_against_hint(obj, hint_, annotated, False) + if validated: + return VALIDATED + if not rtrn_error: + return FAILED_SIMPLE + return False, ValueError( + f"Takes input that conforms with <{hint_args}> although received" + f" '{obj}' of type {type(obj)}." + ) + + # handle annotation defining a single type + # NOTE ASSUMES only supported hint that does not have an __origin__ is + # a union defined with | operation from python 3.10 + if origin is None: + if isinstance(obj, hint): + return VALIDATED + if not rtrn_error: + return FAILED_SIMPLE + return False, ValueError( + f"Takes type {hint} although received '{obj}' of type {type(obj)}." + ) + + hint_args = typing.get_args(hint) + + # handle literals hints + if origin is typing.Literal: + strict = annotated is not None and STRICT_LITERAL in annotated.__metadata__ + if not strict: + if obj in hint_args: + return VALIDATED + else: + try: + _ = next((lit for lit in hint_args if id(obj) == id(lit))) + except StopIteration: + pass + else: + return VALIDATED + if not rtrn_error: + return FAILED_SIMPLE + return False, ValueError( + f"Takes a {'literal' if strict else 'value'} from <{hint_args}>" + f" although received '{obj}'." + ) + + # validate object is instance of the origin 'type' + if not isinstance(obj, origin): + if not rtrn_error: + return FAILED_SIMPLE + return False, ValueError( + f"Takes type {origin} although received '{obj}' of type {type(obj)}." + ) + + if origin is collections.abc.Callable: + # validation of any subscripted types is not currently supported + return VALIDATED + + if origin is tuple and hint_args[-1] is not Ellipsis: + if len(obj) != len(hint_args): + if not rtrn_error: + return FAILED_SIMPLE + return False, ValueError( + f"Takes type {origin} of length {len(hint_args)} although received" + f" '{obj}' of length {len(obj)}." + ) + + # Validation of container ITEMS + + if not obj or ( + annotated is not None and NO_ITEM_VALIDATION in annotated.__metadata__ + ): + return VALIDATED + + if origin in (list, set, collections.abc.Sequence) or ( + origin is tuple and hint_args[-1] is Ellipsis + ): + sub_hint = hint_args[0] + for e in obj: + validated, _ = validates_against_hint(e, sub_hint, None, False) + if not validated: + if not rtrn_error: + return FAILED_SIMPLE + return False, TypeError( + f"Takes type {origin} containing items that conform with <{hint}>," + f" although the received container contains item '{e}' of type" + f" {type(e)}." + ) + return VALIDATED + + if origin is tuple: + for i, (e, sub_hint) in enumerate(zip(obj, hint_args)): + validated, _ = validates_against_hint(e, sub_hint, None, False) + if not validated: + if not rtrn_error: + return FAILED_SIMPLE + return False, TypeError( + f"Takes type {origin} containing items that conform with <{hint}>," + f" although the item in position {i} is '{e}' of type {type(e)}." + ) + return VALIDATED + + if origin in (dict, collections.abc.Mapping): + key_hint = hint_args[0] + key_error = False + for i_k, k in enumerate(obj.keys()): + validated, _ = validates_against_hint(k, key_hint, None, False) + if not validated: + if not rtrn_error: + return FAILED_SIMPLE + key_error = True + break + + val_hint = hint_args[1] + val_error = False + for i_v, v in enumerate(obj.values()): + validated, _ = validates_against_hint(v, val_hint, None, False) + if not validated: + if not rtrn_error: + return FAILED_SIMPLE + val_error = True + break + + inset = "dictionary" if origin is dict else "mapping" + if key_error and val_error and i_k == i_v: + return False, TypeError( + f"Takes type {origin} with keys that conform to the first argument and" + f" values that conform to the second argument of <{hint}>, although the" + f" received {inset} contains an item with key '{k}' of type {type(k)}" + f" and value '{v}' of type {type(v)}." + ) + if key_error: + return False, TypeError( + f"Takes type {origin} with keys that conform to the first argument" + f" of <{hint}>, although the received {inset} contains key '{k}'" + f" of type {type(k)}." + ) + if val_error: + return False, TypeError( + f"Takes type {origin} with values that conform to the second" + f" argument of <{hint}>, although the received {inset} contains" + f" value '{v}' of type {type(v)}." + ) + + return VALIDATED + + raise TypeError( + f"The following type annotation is not currently supported by `valimp`:" + f"\n{hint}.\n\n The object receieved against this type annotation was'{obj}'" + f" of type {type(obj)}." + ) + + +def validate_against_hints( + kwargs: dict[str, Any], + hints: dict[str, type[Any] | typing._Final], +) -> dict[str, ValueError | TypeError]: + """Validate inputs against hints. + + Parameters + ---------- + hints + Dictionary of hints with key as parameter name and value as typing + hint for parameter. + + kwargs + All parameter inputs to be validated. Key as parameter name, value + as object received by parameter. + + Returns + ------- + errors + Dictionary of any errors. Key as name of parameter for which + validation failed, value as corresponding error. + """ + errors = {} + for name, obj in kwargs.items(): + if name not in hints: + continue # type not annotated for parameter + hint = hints[name] + if is_annotated(hint): + annotated: typing._AnnotatedAlias | None = hint + hint = hint.__origin__ + else: + annotated = None + + validated, error = validates_against_hint(obj, hint, annotated) + if not validated: + assert error is not None + errors[name] = error + return errors + + +def args_name_inset(arg_names: list[str]) -> str: + """Get string of argument names. + + Parameters + ---------- + arg_names + List of argument names to be included in inset. + + Examples + -------- + >>> args_name_inset(["spam", "foo", "bar"]) + "'spam', 'foo' and 'bar'" + >>> args_name_inset(["spam", "foo"]) + "'spam' and 'foo'" + >>> args_name_inset(["spam"]) + "'spam'" + """ + inset = f"'{arg_names[0]}'" + for name in arg_names[1:]: + if name == arg_names[-1]: + inset += f" and '{name}'" + else: + inset += f", '{name}'" + return inset + + +def get_missing_arg_error(missing: list[str], positional: bool = True) -> TypeError: + """Get a TypeError for a missing positional or keyword-only argument. + + Parameters + ---------- + missing + List of names of missing arguments. + + positional + True: Missing arguments are positional arguments. + False: Missing arguments are keyword-only arguments. + """ + inset = args_name_inset(missing) + pos_inset = "positional" if positional else "keyword-only" + return TypeError( + f"Missing {len(missing)} {pos_inset}" + f" argument{'s' if len(missing) > 1 else ''}: {inset}." + ) + + +def validate_against_signature( + args_as_kwargs: dict[str, Any], + kwargs: dict[str, Any], + req_args: list[str], + req_kwargs: list[str], + all_arg_names: list[str], +) -> list[TypeError]: + """Validate inputs against arguments expected by signature. + + Parameters + ---------- + args_as_kwargs + Inputs for arguments received positionaly. Key as argument + name, value as received input (i.e. as if were received as a + keyword argument). + + NB module does not support positional-only arguments (i.e. these + could have been receieved as keyword args). + + kwargs + Inputs for arguments receieved as keyword arguments. Key + as argument name, value as received input. + + req_args + List of names of required positional arguments. + + req_kwargs + List of names of required keyword-only arguments. + + all_arg_names + List of all possible argument names (positional and + keyword only). + + Returns + ------- + errors + List of any TypeError instances relating to inputs that are + invalid for the given signature information. + """ + errors = [] + + # duplicated arguments + duplicated = [a for a in args_as_kwargs if a in kwargs] + if duplicated: + errors.append( + TypeError( + f"Got multiple values for argument{'s' if len(duplicated) > 1 else ''}:" + f" {args_name_inset(duplicated)}." + ) + ) + + # excess arguments + extra_args = [a for a in args_as_kwargs if a.startswith("_xtra")] + if extra_args: + obj_0 = args_as_kwargs[extra_args[0]] + msg_end = f"\t'{obj_0}' of type {type(obj_0)}." + for a in extra_args[1:]: + obj = args_as_kwargs[a] + msg_end += f"\n\t'{obj}' of type {type(obj)}." + errors.append( + TypeError( + f"Received {len(extra_args)} excess positional" + f" argument{'s' if len(extra_args) > 1 else ''} as:\n{msg_end}" + ) + ) + + extra_kwargs = [a for a in kwargs if a not in all_arg_names] + if extra_kwargs: + errors.append( + TypeError( + f"Got unexpected keyword" + f" argument{'s' if len(extra_kwargs) > 1 else ''}" + f": {args_name_inset(extra_kwargs)}." + ) + ) + + # missing required arguments + all_as_kwargs = args_as_kwargs | kwargs + missing = [a for a in req_args if a not in all_as_kwargs] + if missing: + errors.append(get_missing_arg_error(missing, True)) + + missing_kw = [kwarg for kwarg in req_kwargs if kwarg not in all_as_kwargs] + if missing_kw: + errors.append(get_missing_arg_error(missing_kw, False)) + + return errors + + +class InputsError(Exception): + """Inputs do not conform with signature and/or type annotations. + + Consolidates TypeError and ValueErrors errors relating to inputs not + conforming with signature and associated type annotations. + """ + + def __init__( + self, + func_name: str, + sig_errors: list[TypeError], + ann_errors: dict[str, TypeError | ValueError], + ): + msg = "" + if sig_errors: + msg += ( + f"Inputs to '{func_name}' do not conform with the" + " function signature:" + ) + for e_sig in sig_errors: + msg += f"\n\n{e_sig.args[0]}" + + if ann_errors: + if sig_errors: + msg += "\n\n" + msg += ( + f"The following inputs to '{func_name}' do not conform with the" + " corresponding type annotation:" + ) + for param_name, e in ann_errors.items(): + msg += f"\n\n{param_name}\n\t{e.args[0]}" + self._msg = msg + + def __str__(self) -> str: + return self._msg + + +# NOTE: can be removed from when min supported python version advances to 3.11 +def fix_hints_for_none_default( + hints: dict[str, type[Any] | typing._Final], + spec: inspect.FullArgSpec, +) -> dict[str, type[Any] | typing._Final]: + """Implement fix for differing behaviour between python versions. + + Prior to py3.11 type annotations of parameters that take None by default + would be wrapped in typing.Optional if the annotation didn't already + include Optional. This resulted in typing.Annotation being wrapped in + typing.Optional, such that the annotation: + a: typing.Annotation[Optional[str], "some meta"] = None + has a hint as: + 'a': typing.Optional[typing.Annotated[typing.Optional[str], 'some_meta']] + + This fix removes the outer typing.Optional wrapper. + + Returns + ------- + hints + As received, updated by removing any outer typing.Optional wrapper + around a typing.Annotation. + """ + if sys.version_info.minor >= 11: + return hints + + def update_hints(arg: str, dflt: Any): + if arg not in hints: + return + if dflt is not None: + return + hint = hints[arg] + if not typing.get_origin(hint) is typing.Union: + return + first_hint_arg = typing.get_args(hint)[0] + if is_annotated(first_hint_arg): + hints[arg] = first_hint_arg + + if spec.defaults is not None and None in spec.defaults: + not_req_args = spec.args[-len(spec.defaults) :] + for arg, dflt in zip(not_req_args, spec.defaults): + update_hints(arg, dflt) + + if spec.kwonlydefaults is not None: + for k, v in spec.kwonlydefaults.items(): + update_hints(k, v) + + return hints + + +def get_unreceived_args( + spec: inspect.FullArgSpec, names_received: list[str] +) -> dict[str, Any]: + """Get dictionary of unreceived args. + + Parameters + ---------- + spec + Function specification. + + names_received + List of names of all received parameters.. + + Returns + ------- + unreceived_args + Keys as names of args that were not receieved. + Values as default values of those unrecieved args. + """ + if spec.defaults is None: + return {} + unreceived = {} + not_req_args = spec.args[-len(spec.defaults) :] + for arg, dflt in zip(not_req_args, spec.defaults): + if arg not in names_received: + unreceived[arg] = dflt + return unreceived + + +def get_unreceived_kwargs( + spec: inspect.FullArgSpec, names_received: list[str] +) -> dict[str, Any]: + """Get dictionary of unreceived kwargs. + + Parameters + ---------- + spec + Function specification. + + names_received + List of names of all received parameters.. + + Returns + ------- + unreceived_kwargs + Keys as names of kwargs that were not receieved. + Values as default values of those unrecieved kwargs. + """ + if spec.kwonlydefaults is None: + return {} + unreceived = {} + for k, v in spec.kwonlydefaults.items(): + if k not in names_received: + unreceived[k] = v + return unreceived + + +def parse(f) -> collections.abc.Callable: + """Decorator to validate and parse user inputs. + + See valimp module doc (valimp.__doc__). + """ + spec = inspect.getfullargspec(f) + hints = typing.get_type_hints(f, include_extras=True) + hints = fix_hints_for_none_default(hints, spec) + req_args = spec.args if spec.defaults is None else spec.args[: -len(spec.defaults)] + if spec.kwonlydefaults is None: + req_kwargs = spec.kwonlyargs + else: + req_kwargs = [a for a in spec.kwonlyargs if a not in spec.kwonlydefaults] + all_param_names = spec.args + ( + spec.kwonlyargs if spec.kwonlyargs is not None else [] + ) + + @functools.wraps(f) + def wrapped_f(*args, **kwargs) -> Any: + args_as_kwargs = {name: obj for obj, name in zip(args, spec.args)} + if len(args) > len(spec.args): + for i, obj in enumerate(args[len(spec.args) :]): + args_as_kwargs["_xtra" + str(i)] = obj + + sig_errors = validate_against_signature( + args_as_kwargs, kwargs, req_args, req_kwargs, all_param_names + ) + + params_as_kwargs = { # remove arguments not in signature + k: v for k, v in (args_as_kwargs | kwargs).items() if k in all_param_names + } + ann_errors = validate_against_hints(params_as_kwargs, hints) + + if sig_errors or ann_errors: + raise InputsError(f.__name__, sig_errors, ann_errors) + + # coerce and validate + # add in parameters that were not receieved and will take default value. + param_names_received = list(params_as_kwargs) + not_received_args = get_unreceived_args(spec, param_names_received) + not_received_kwargs = get_unreceived_kwargs(spec, param_names_received) + all_as_kwargs = ( + args_as_kwargs | not_received_args | kwargs | not_received_kwargs + ) + + new_as_kwargs = {} + for name, obj in all_as_kwargs.items(): + if name not in hints: + new_as_kwargs[name] = obj + continue + hint = hints[name] + if is_annotated(hint): + meta = hint.__metadata__ + for data in meta: + # let order of coercion and parsing depend on their + # order within metadata + if obj is not None and isinstance(data, Coerce): + obj = data.coerce_to(obj) + if isinstance(data, Parser): + if obj is None and not data.parse_none: + continue + obj = data.function(name, obj, new_as_kwargs.copy()) + + new_as_kwargs[name] = obj + + return f(**new_as_kwargs) + + return wrapped_f diff --git a/tests/test_valimp.py b/tests/test_valimp.py new file mode 100644 index 0000000..7e1039d --- /dev/null +++ b/tests/test_valimp.py @@ -0,0 +1,1590 @@ +"""Tests for market_prices.input module.""" + +from collections import abc +import inspect +import re +import sys +import typing +from typing import ( + Union, + Literal, + Annotated, + Optional, + get_type_hints, + Any, +) + +import pytest + +import valimp as m + +# pylint: disable=line-too-long +# pylint: disable=missing-function-docstring, missing-type-doc +# pylint: disable=missing-param-doc, missing-any-param-doc, redefined-outer-name +# pylint: disable=too-many-public-methods, too-many-arguments, too-many-locals +# pylint: disable=too-many-statements +# pylint: disable=protected-access, no-self-use, unused-argument, invalid-name +# pylint: disable=unused-variable +# missing-fuction-docstring: doc not required for all tests +# protected-access: not required for tests +# not compatible with use of fixtures to parameterize tests: +# too-many-arguments, too-many-public-methods +# not compatible with pytest fixtures: +# redefined-outer-name, no-self-use, missing-any-param-doc, missing-type-doc +# unused-argument: not compatible with pytest fixtures, caught by pylance anyway. +# invalid-name: names in tests not expected to strictly conform with snake_case. + +# Any flake8 disabled violations handled via per-file-ignores on .flake8 + + +def test_decorator_wrapped(): + """Verify post-decoration function retains hints and docstring.""" + + @m.parse + def f( + a: int, b: Annotated[float, "third-party-meta-b"], c: str = "some_default_str" + ) -> tuple[int, float, str]: + """Decorated func docstring.""" + return a, b, c + + assert f.__doc__ == "Decorated func docstring." + expected_hints = { + "a": int, + "b": typing.Annotated[float, "third-party-meta-b"], + "c": str, + "return": tuple[int, float, str], + } + assert typing.get_type_hints(f, include_extras=True) == expected_hints + + +@pytest.fixture +def f() -> abc.Iterator[abc.Callable]: + """Function with type annotations.""" + + @m.parse + def func( + a: Annotated[int, m.Parser(lambda n, obj, _: obj + 4)], + b: Annotated[Union[str, int, float], m.Coerce(int)], + c: Annotated[ + Union[str, int], m.Coerce(str), m.Parser(lambda n, obj, p: obj + "_suffix") + ], + d: Annotated[ + Union[float, int], m.Parser(lambda n, obj, p: obj + 10), m.Coerce(str) + ], + e: str, + f: Annotated[str, m.Parser(lambda name, obj, _: name + "_" + obj)], + g: Annotated[ + str, m.Parser(lambda _, obj, params: obj + "_" + params["e"] + "_bar") + ], + h: Annotated[ + str, + m.Parser( + lambda name, obj, params: name + "_" + obj + "_" + params["e"] + "_bar" + ), + ], + i: Annotated[int, "spam meta", "foo meta"], + j: Literal["spam", "foo"], + k: list[str], + l: dict[str, int], + m: abc.Mapping[str, int], + n: tuple[str, ...], + o: tuple[str, int, set], + p: set[str], + q: abc.Sequence[Union[str, int, set]], + r: abc.Sequence[str], + s: abc.Callable[[str], str], + t: abc.Callable[[str, int], str], + u: abc.Callable[..., str], + v: abc.Callable[..., str], + w: Union[int, str, None, Literal["spam", "foo"]], + x: Annotated[Union[str, int, float], "spam meta", "foo meta"], + y: Annotated[ + Optional[Union[str, int, float]], "foo meta", m.Parser(lambda n, o, p: o) + ], + z: Annotated[ + Optional[Union[str, int, float]], "spam meta", m.Parser(lambda n, o, p: o) + ] = None, # this annotation requires fixing by `fix_hints_for_none_default`` + aa: Annotated[Literal[3, 4, 5], "spam meta", "foo meta"] = 4, + bb: Optional[int] = None, + cc: bool = True, + *, + kwonly_req_a: bool, + kwonly_req_b: typing.Annotated[Optional[bool], "meta"], + kwonly_opt: typing.Annotated[Optional[bool], "meta"] = None, + ) -> dict[str, Any]: + return dict( + a=a, + b=b, + c=c, + d=d, + e=e, + f=f, + g=g, + h=h, + i=i, + j=j, + k=k, + l=l, + m=m, + n=n, + o=o, + p=p, + q=q, + r=r, + s=s, + t=t, + u=u, + v=v, + w=w, + x=x, + y=y, + z=z, + aa=aa, + bb=bb, + cc=cc, + kwonly_req_a=kwonly_req_a, + kwonly_req_b=kwonly_req_b, + kwonly_opt=kwonly_opt, + ) + + yield func + + +@pytest.fixture +def inst() -> abc.Iterator[object]: + """Instance of class with method with type annotations. + + Method as 'func' fixture. + """ + + class A: + """Class to hold decorated instance method.""" + + @m.parse + def func( + self, + a: Annotated[int, m.Parser(lambda n, obj, _: obj + 4)], + b: Annotated[Union[str, int, float], m.Coerce(int)], + c: Annotated[ + Union[str, int], + m.Coerce(str), + m.Parser(lambda n, obj, p: obj + "_suffix"), + ], + d: Annotated[ + Union[float, int], m.Parser(lambda n, obj, p: obj + 10), m.Coerce(str) + ], + e: str, + f: Annotated[str, m.Parser(lambda name, obj, _: name + "_" + obj)], + g: Annotated[ + str, m.Parser(lambda _, obj, params: obj + "_" + params["e"] + "_bar") + ], + h: Annotated[ + str, + m.Parser( + lambda name, obj, params: name + + "_" + + obj + + "_" + + params["e"] + + "_bar" + ), + ], + i: Annotated[int, "spam meta", "foo meta"], + j: Literal["spam", "foo"], + k: list[str], + l: dict[str, int], + m: abc.Mapping[str, int], + n: tuple[str, ...], + o: tuple[str, int, set], + p: set[str], + q: abc.Sequence[Union[str, int, set]], + r: abc.Sequence[str], + s: abc.Callable[[str], str], + t: abc.Callable[[str, int], str], + u: abc.Callable[..., str], + v: abc.Callable[..., str], + w: Union[int, str, None, Literal["spam", "foo"]], + x: Annotated[Union[str, int, float], "spam meta", "foo meta"], + y: Annotated[ + Optional[Union[str, int, float]], + "foo meta", + m.Parser(lambda n, o, p: o), + ], + z: Annotated[ + Optional[Union[str, int, float]], + "spam meta", + m.Parser(lambda n, o, p: o), + ] = None, # this annotation requires fixing by `fix_hints_for_none_default`` + aa: Annotated[Literal[3, 4, 5], "spam meta", "foo meta"] = 4, + bb: Optional[int] = None, + cc: bool = True, + *, + kwonly_req_a: bool, + kwonly_req_b: typing.Annotated[Optional[bool], "meta"], + kwonly_opt: typing.Annotated[Optional[bool], "meta"] = None, + ) -> dict[str, Any]: + return dict( + a=a, + b=b, + c=c, + d=d, + e=e, + f=f, + g=g, + h=h, + i=i, + j=j, + k=k, + l=l, + m=m, + n=n, + o=o, + p=p, + q=q, + r=r, + s=s, + t=t, + u=u, + v=v, + w=w, + x=x, + y=y, + z=z, + aa=aa, + bb=bb, + cc=cc, + kwonly_req_a=kwonly_req_a, + kwonly_req_b=kwonly_req_b, + kwonly_opt=kwonly_opt, + ) + + yield A() + + +@pytest.fixture +def dflt_values() -> abc.Iterator[dict[str, Any]]: + """Default values of optional arguments (positional and keyword-only).""" + yield dict(z=None, aa=4, bb=None, cc=True, kwonly_opt=None) + + +def _func(x: Any) -> Any: + return x + + +FUNC = _func + + +# FIXTURES valid args +@pytest.fixture +def valid_args_req_as_kwargs() -> abc.Iterator[tuple[dict[str, Any], dict[str, Any]]]: + """Valid values and expected returns for required positional arguments. + + Verifying inputs against expected tests: + Parser and Coerce, independently and together + Tests each of name, object and params fields of `Parser.function` + simple type annotation (e.g, param: str) + annotations: List, Dict, Literal, Set, Tuple, Sequence, Callable + """ + inputs = dict( + a=4, + b="3", + c="7", + d=4.5, + e="param_e", + f="param_f", + g="param_g", + h="param_h", + i=3, + j="foo", + k=["foo", "bar"], + l={"foo": 1}, + m={"foo": 1}, + n=("foo", "bar"), + o=("foo", 1, {"spam", "bar"}), + p={"spam", "bar"}, + q=("foo", 1, {"spam", "bar"}), + r=["foo", "bar"], + s=FUNC, + t=FUNC, + u=FUNC, + v=FUNC, + w=3, + x=3, + y=3, + ) + expected_rtrns = dict( + a=8, # parsed + b=3, # coerced + c="7_suffix", # coerced then parsed + d="14.5", # parsed then coerced - verifies order of Parser and Coerce matters + e="param_e", # as passed + f="f_param_f", # parses using name parser's name arg + g="param_g_param_e_bar", # parses using value from earlier parsed parameter + # parses using value from earlier parsed parameter and includes name + h="h_param_h_param_e_bar", + # as passed... + i=3, + j="foo", + k=["foo", "bar"], + l={"foo": 1}, + m={"foo": 1}, + n=("foo", "bar"), + o=("foo", 1, {"spam", "bar"}), + p={"spam", "bar"}, + q=("foo", 1, {"spam", "bar"}), + r=["foo", "bar"], + s=FUNC, + t=FUNC, + u=FUNC, + v=FUNC, + w=3, + x=3, + y=3, + ) + yield inputs, expected_rtrns + + +@pytest.fixture +def valid_args_opt_as_kwargs() -> abc.Iterator[tuple[dict[str, Any], dict[str, Any]]]: + """Valid values and returns for optional positional arguments. + + Values differ from default values. + """ + inputs = dict( + z=3, + aa=3, + bb=3, + cc=False, + ) + expected_rtrns = inputs.copy() # all returned as passed + yield inputs, expected_rtrns + + +@pytest.fixture +def valid_args_req(valid_args_req_as_kwargs) -> abc.Iterator[list[Any]]: + """Valid values for required positional arguments.""" + yield list(valid_args_req_as_kwargs[0].values()) + + +@pytest.fixture +def valid_args_opt(valid_args_opt_as_kwargs) -> abc.Iterator[list[Any]]: + """Valid values for optional positional arguments.""" + yield list(valid_args_opt_as_kwargs[0].values()) + + +@pytest.fixture +def valid_args(valid_args_req, valid_args_opt) -> abc.Iterator[list[Any]]: + """Valid values for arguments that can be passed positionally.""" + yield valid_args_req + valid_args_opt + + +@pytest.fixture +def valid_args_as_kwargs( + valid_args_req_as_kwargs, valid_args_opt_as_kwargs +) -> abc.Iterator[tuple[dict[str, Any], dict[str, Any]]]: + """Valid values and returns for arguments that can be passed positionally.""" + inputs = valid_args_req_as_kwargs[0] | valid_args_opt_as_kwargs[0] + expected_rtrns = valid_args_req_as_kwargs[1] | valid_args_opt_as_kwargs[1] + yield inputs, expected_rtrns + + +# FIXTURES valid kwargs +@pytest.fixture +def valid_kwargs_req() -> abc.Iterator[tuple[dict[str, Any], dict[str, Any]]]: + """Valid values and expected returns for required keyword-only arguments.""" + inputs = dict(kwonly_req_a=False, kwonly_req_b=None) + expected_rtrns = dict(kwonly_req_a=False, kwonly_req_b=None) + yield inputs, expected_rtrns + + +@pytest.fixture +def valid_kwargs_opt() -> abc.Iterator[tuple[dict[str, Any], dict[str, Any]]]: + """Valid values and returns for optional keyword-only arguments. + + Values differ from default values. + """ + inputs = dict(kwonly_opt=True) + expected_rtrns = inputs.copy() # all returned as passed + yield inputs, expected_rtrns + + +@pytest.fixture +def valid_kwargs( + valid_kwargs_req, valid_kwargs_opt +) -> abc.Iterator[tuple[dict[str, Any], dict[str, Any]]]: + """Valid values for all keyword-only arguments.""" + inputs = valid_kwargs_req[0] | valid_kwargs_opt[0] + expected_rtrns = valid_kwargs_req[1] | valid_kwargs_opt[1] + yield inputs, expected_rtrns + + +# FIXTURES valid args for ALL parameters +@pytest.fixture +def valid_args_all( + valid_args_as_kwargs, valid_kwargs +) -> abc.Iterator[tuple[dict[str, Any], dict[str, Any]]]: + """Valid values and expected returns for all arguments.""" + inputs = valid_args_as_kwargs[0] | valid_kwargs[0] + expected_rtrns = valid_args_as_kwargs[1] | valid_kwargs[1] + yield inputs, expected_rtrns + + +def assertion(rtrn: Any, expected: Any): + if rtrn is None: + assert expected is None + else: + assert rtrn == expected + + +def test_general_valid( + f, + inst, + valid_args_req_as_kwargs, + valid_kwargs_req, + dflt_values, + valid_args_req, + valid_args_opt_as_kwargs, + valid_args_opt, + valid_kwargs_opt, +): + """General for valid inputs. + + Tests return as expected when passing: + pos req args as kwargs, passing no optional args and verifying defaults. + pos req args positionally, passing no optional args and verifying defaults. + pos req args positionally, pos opt args positionally, all keyword-only + pos req args positionally, pos opt args as kwarg, all keyword-only + """ + for func in (f, inst.func): + rtrns0 = func(**valid_args_req_as_kwargs[0], **valid_kwargs_req[0]) + rtrns1 = func(*valid_args_req, **valid_kwargs_req[0]) + rtrns2 = func( + *valid_args_req, + *valid_args_opt, + **valid_kwargs_req[0], + **valid_kwargs_opt[0], + ) + rtrns3 = func( + *valid_args_req, + **valid_args_opt_as_kwargs[0], + **valid_kwargs_req[0], + **valid_kwargs_opt[0], + ) + + for k, v in valid_args_req_as_kwargs[1].items(): + assertion(rtrns0[k], v) + assertion(rtrns1[k], v) + assertion(rtrns2[k], v) + assertion(rtrns3[k], v) + + for k, v in valid_kwargs_req[1].items(): + assertion(rtrns0[k], v) + assertion(rtrns1[k], v) + assertion(rtrns2[k], v) + assertion(rtrns3[k], v) + + # as default values + for k, v in dflt_values.items(): + assertion(rtrns0[k], v) + assertion(rtrns1[k], v) + + # as expected non-default values + + for k, v in valid_args_opt_as_kwargs[1].items(): + assertion(rtrns2[k], v) + assertion(rtrns3[k], v) + for k, v in valid_kwargs_opt[1].items(): + assertion(rtrns2[k], v) + assertion(rtrns3[k], v) + + +def test_union_optional_valid(f, valid_args_all): + """Additional tests for Union and Optional annotations. + + Furthers `test_general_valid` to cover additional valid inputs to + parameters annotated with Union and Option. + """ + inputs, expected_rtrns = valid_args_all[0].copy(), valid_args_all[1].copy() + chgs = dict( + w="param_w", + x="param_x", + y="param_y", + z="param_z", + # + bb=None, + kwonly_req_b=False, + ) + inputs |= chgs + expected_rtrns |= chgs + + rtrns = f(**inputs) + for k, v in rtrns.items(): + assertion(v, expected_rtrns[k]) + + chgs_1 = dict( + w=None, + x=1.0, + y=1.0, + z=1.0, + ) + inputs |= chgs_1 + expected_rtrns |= chgs_1 + + rtrns = f(**inputs) + for k, v in rtrns.items(): + assertion(v, expected_rtrns[k]) + + chgs_2 = dict( + y=None, + z=None, + ) + inputs |= chgs_2 + expected_rtrns |= chgs_2 + + rtrns = f(**inputs) + for k, v in rtrns.items(): + assertion(v, expected_rtrns[k]) + + +def test_tuple_valid(): + """Test valid inputs for annotation of format tuple[Union[, ], ...].""" + + @m.parse + def f( + a: tuple[Union[str, int, set], ...], + b: Union[str, tuple[Union[str, int, set], ...]], + c: Optional[tuple[Union[str, int, set], ...]], + d: Annotated[tuple[Union[str, int, set], ...], "some_meta_d"], + e: Annotated[Union[str, tuple[Union[str, int, set], ...]], "some_meta_e"], + f: tuple[Union[str, int, set], ...], + ) -> dict[str, Any]: + return dict(a=a, b=b, c=c, d=d, e=e, f=f) + + rtrn = f( + ("zero", "one", "two"), + (0, 1, 2), + ({0.0, "zero", (0.0, 0.1)}, {1.1, "one", (1.0, 1.1)}, {2.2, "two", (2.0, 2.1)}), + ("zero", 1, {2.2}), + ("zero", "one", "two", 3), + (0, 1, 2, {3}), + ) + + assert rtrn["a"] == ("zero", "one", "two") + assert rtrn["b"] == (0, 1, 2) + expected_c = ( + {0.0, "zero", (0.0, 0.1)}, + {1.1, "one", (1.0, 1.1)}, + {2.2, "two", (2.0, 2.1)}, + ) + assert rtrn["c"] == expected_c + assert rtrn["d"] == ("zero", 1, {2.2}) + assert rtrn["e"] == ("zero", "one", "two", 3) + assert rtrn["f"] == (0, 1, 2, {3}) + + +INVALID_MSG = re.escape( + """The following inputs to 'func' do not conform with the corresponding type annotation: + +h + Takes type although received '3' of type . + +i + Takes type although received 'three' of type . + +j + Takes a value from <('spam', 'foo')> although received 'not in literal'. + +k + Takes type although received '{'not': 'a list'}' of type . + +l + Takes type although received '['not a dict']' of type . + +m + Takes type although received '['not a mapping']' of type . + +n + Takes type although received '['not a tuple']' of type . + +p + Takes type although received '['not a set']' of type . + +q + Takes type although received '{'not': 'a sequence'}' of type . + +s + Takes type although received 'not callable' of type . + +w + Takes input that conforms with <(, , , typing.Literal['spam', 'foo'])> although received '['list not in union']' of type . + +x + Takes input that conforms with <(, , )> although received '{'dict': 'not in annotated union'}' of type . + +y + Takes input that conforms with <(, , , )> although received '{'dict': 'not in annotated union'}' of type . + +z + Takes input that conforms with <(, , , )> although received '{'dict': 'not in annotated union'}' of type . + +aa + Takes a value from <(3, 4, 5)> although received '6'. + +bb + Takes input that conforms with <(, )> although received 'not an int' of type . + +cc + Takes type although received 'not a bool' of type . + +kwonly_req_a + Takes type although received 'not a bool' of type .""" +) + + +def test_invalid_types(f, inst, valid_args): + regex = re.compile("^" + INVALID_MSG + "$") + for func in (f, inst.func): + with pytest.raises(m.InputsError, match=regex): + func( + *valid_args[:7], + h=3, + i="three", + j="not in literal", + k={"not": "a list"}, + l=["not a dict"], + m=["not a mapping"], + n=["not a tuple"], + o=("foo", 1, {"spam", "bar"}), # valid + p=["not a set"], + q={"not": "a sequence"}, + r=["foo", "bar"], # valid + s="not callable", + t=lambda x: x, # valid + u=lambda x: x, # valid + v=lambda x: x, # valid + w=["list not in union"], + x={"dict": "not in annotated union"}, + y={"dict": "not in annotated union"}, + z={"dict": "not in annotated union"}, + aa=6, + bb="not an int", + cc="not a bool", + kwonly_req_a="not a bool", + kwonly_req_b=None, # valid + ) + + +INVALID_MSG_SIG_SINGLE = re.escape( + """Inputs to 'func' do not conform with the function signature: + +Got multiple values for argument: 'a'. + +Got unexpected keyword argument: 'not_a_kwarg'. + +Missing 1 positional argument: 'y'. + +Missing 1 keyword-only argument: 'kwonly_req_b'. + +The following inputs to 'func' do not conform with the corresponding type annotation: + +b + Takes input that conforms with <(, , )> although received '[1]' of type .""" +) + +INVALID_MSG_SIG_SINGLE_1 = re.escape( + """Inputs to 'func' do not conform with the function signature: + +Got multiple values for argument: 'a'. + +Received 1 excess positional argument as: + '4' of type . + +Got unexpected keyword argument: 'not_a_kwarg'. + +Missing 1 keyword-only argument: 'kwonly_req_b'. + +The following inputs to 'func' do not conform with the corresponding type annotation: + +b + Takes input that conforms with <(, , )> although received '[1]' of type .""" +) + + +def test_invalid_sig_single(f, valid_args_req, valid_args, valid_kwargs_req): + """Test for invalid signature errors. + + Matches expected message when one argument fails for each error. + """ + regex = re.compile("^" + INVALID_MSG_SIG_SINGLE + "$") + with pytest.raises(m.InputsError, match=regex): + f( + 0, + [1], + *valid_args_req[2:-1], + a=2, + kwonly_req_a=valid_kwargs_req[0]["kwonly_req_a"], + not_a_kwarg="foo", + ) + + # as above, although tests for an excess pos arg as opposed to a missing pos arg + regex = re.compile("^" + INVALID_MSG_SIG_SINGLE_1 + "$") + with pytest.raises(m.InputsError, match=regex): + f( + 0, + [1], + *valid_args[2:], + 4, + a=2, + kwonly_req_a=valid_kwargs_req[0]["kwonly_req_a"], + not_a_kwarg="foo", + ) + + +INVALID_MSG_MULT_SINGLE = re.escape( + """Inputs to 'func' do not conform with the function signature: + +Got multiple values for arguments: 'a' and 'b'. + +Got unexpected keyword arguments: 'not_a_kwarg' and 'another_not_a_kwarg'. + +Missing 2 positional arguments: 'x' and 'y'. + +Missing 2 keyword-only arguments: 'kwonly_req_a' and 'kwonly_req_b'. + +The following inputs to 'func' do not conform with the corresponding type annotation: + +c + Takes input that conforms with <(, )> although received '2.0' of type . + +e + Takes type although received '4' of type .""" +) + +INVALID_MSG_MULT_SINGLE_1 = re.escape( + """Inputs to 'func' do not conform with the function signature: + +Got multiple values for arguments: 'a' and 'b'. + +Received 2 excess positional arguments as: + '4' of type . + '[5]' of type . + +Got unexpected keyword arguments: 'not_a_kwarg' and 'another_not_a_kwarg'. + +Missing 2 keyword-only arguments: 'kwonly_req_a' and 'kwonly_req_b'. + +The following inputs to 'func' do not conform with the corresponding type annotation: + +c + Takes input that conforms with <(, )> although received '2.0' of type . + +e + Takes type although received '4' of type .""" +) + + +def test_invalid_sig_multiple(f, valid_args_req, valid_args): + """Test for invalid signature errors. + + Matches expected message when two arguments fail for each error. + """ + regex = re.compile("^" + INVALID_MSG_MULT_SINGLE + "$") + with pytest.raises(m.InputsError, match=regex): + f( + 0, + 1, + 2.0, + 3, # valid + 4, + *valid_args_req[5:-2], + a=2, + b=3, + not_a_kwarg="foo", + another_not_a_kwarg="bar", + ) + + # as above, although tests for excess pos args as opposed to missing pos args + regex = re.compile("^" + INVALID_MSG_MULT_SINGLE_1 + "$") + with pytest.raises(m.InputsError, match=regex): + f( + 0, + 1, + 2.0, + 3, # valid + 4, + *valid_args[5:], + 4, + [5], + a=2, + b=3, + not_a_kwarg="foo", + another_not_a_kwarg="bar", + ) + + +INVALID_MSG_SET_ITEMS = re.escape( + """The following inputs to 'f' do not conform with the corresponding type annotation: + +a + Takes type containing items that conform with , although the received container contains item 'zero' of type . + +b + Takes input that conforms with <(, set[int])> although received '{'one', 'zero'}'. + +c + Takes input that conforms with <(set[int], )> although received '{0, 2, 'one'}'. + +d + Takes type containing items that conform with , although the received container contains item 'two' of type . + +e + Takes input that conforms with <(, set[int])> although received '{1, 2, 'zero'}'.""" +) + + +def test_invalid_inputs_set_items(): + @m.parse + def f( + a: set[int], + b: Union[str, set[int]], + c: Optional[set[int]], + d: Annotated[set[int], "some_meta_c"], + e: Annotated[Union[str, set[int]], "some_meta_d"], + ): + return + + # selected parts or error message only given unable to replicate ordering of a set + msg_a = re.escape( + "Takes type containing items that conform with , although the received container contains item 'zero' of type ." + ) + msg_b = re.escape( + "Takes input that conforms with <(, set[int])> although received '" + ) + msg_c = re.escape( + "Takes input that conforms with <(set[int], )> although received '" + ) + msg_d = re.escape( + "Takes type containing items that conform with , although the received container contains item 'two' of type ." + ) + msg_e = re.escape( + "Takes input that conforms with <(, set[int])> although received '" + ) + + pat = f".*(?=a\n\t{msg_a}).*(?=b\n\t{msg_b}).*(?=c\n\t{msg_c}).*(?=d\n\t{msg_d}).*(?=e\n\t{msg_e}).*" + + regex = re.compile("^" + pat + "$", re.DOTALL) + with pytest.raises(m.InputsError, match=regex): + f( + {"zero", 1}, # includes non-int + {"zero", "one"}, # all non-int + {0, "one", 2}, + {0, 1, "two"}, + {"zero", 1, 2}, + ) + + +INVALID_MSG_LIST_ITEMS = re.escape( + """The following inputs to 'f' do not conform with the corresponding type annotation: + +a + Takes type containing items that conform with , although the received container contains item 'one' of type . + +b + Takes input that conforms with <(, list[int])> although received '['one', 'one']' of type . + +c + Takes input that conforms with <(list[int], )> although received '[1, 'one', 1]' of type . + +d + Takes type containing items that conform with , although the received container contains item 'one' of type . + +e + Takes input that conforms with <(, list[int])> although received '['one', 1, 1]' of type .""" +) + + +def test_invalid_inputs_list_items(): + @m.parse + def f( + a: list[int], + ax: Annotated[list[int], m.NO_ITEM_VALIDATION], + b: Union[str, list[int]], + bx: Annotated[Union[str, list[int]], m.NO_ITEM_VALIDATION], + c: Optional[list[int]], + cx: Annotated[Optional[list[int]], m.NO_ITEM_VALIDATION], + d: Annotated[list[int], "some_meta_d"], + dx: Annotated[list[int], "some_meta_d", m.NO_ITEM_VALIDATION], + e: Annotated[Union[str, list[int]], "some_meta_e"], + ex: Annotated[Union[str, list[int]], m.NO_ITEM_VALIDATION, "some_meta_e"], + ): + return + + regex = re.compile("^" + INVALID_MSG_LIST_ITEMS + "$") + with pytest.raises(m.InputsError, match=regex): + f( + ["one", 1], # list includes non-int + ["one", 1], # again, now valid as NO_ITEM_VALIDATION + ["one", "one"], # all non-int + ["one", "one"], # again, now valid as NO_ITEM_VALIDATION + [1, "one", 1], + [1, "one", 1], # again, now valid as NO_ITEM_VALIDATION + [1, 1, "one"], + [1, 1, "one"], # again, now valid as NO_ITEM_VALIDATION + ["one", 1, 1], + ["one", 1, 1], # again, now valid as NO_ITEM_VALIDATION + ) + + +INVALID_MSG_SEQ_ITEMS = re.escape( + """The following inputs to 'f' do not conform with the corresponding type annotation: + +a + Takes type containing items that conform with , although the received container contains item 'one' of type . + +b + Takes input that conforms with <(, collections.abc.Sequence[int])> although received '['one', 'one']' of type . + +c + Takes input that conforms with <(collections.abc.Sequence[int], )> although received '[1, 'one', 1]' of type . + +d + Takes type containing items that conform with , although the received container contains item 'one' of type . + +e + Takes input that conforms with <(, collections.abc.Sequence[int])> although received '['one', 1, 1]' of type .""" +) + + +def test_invalid_inputs_seq_items(): + @m.parse + def f( + a: abc.Sequence[int], + ax: Annotated[abc.Sequence[int], m.NO_ITEM_VALIDATION], + b: Union[str, abc.Sequence[int]], + bx: Annotated[Union[str, abc.Sequence[int]], m.NO_ITEM_VALIDATION], + c: Optional[abc.Sequence[int]], + cx: Annotated[Optional[abc.Sequence[int]], m.NO_ITEM_VALIDATION], + d: Annotated[abc.Sequence[int], "some_meta_d"], + dx: Annotated[abc.Sequence[int], "some_meta_d", m.NO_ITEM_VALIDATION], + e: Annotated[Union[str, abc.Sequence[int]], "some_meta_e"], + ex: Annotated[ + Union[str, abc.Sequence[int]], m.NO_ITEM_VALIDATION, "some_meta_e" + ], + ): + return + + regex = re.compile("^" + INVALID_MSG_SEQ_ITEMS + "$") + with pytest.raises(m.InputsError, match=regex): + f( + ["one", 1], # list includes non-int + ["one", 1], # again, now valid as NO_ITEM_VALIDATION + ["one", "one"], # all non-int + ["one", "one"], # again, now valid as NO_ITEM_VALIDATION + [1, "one", 1], + [1, "one", 1], # again, now valid as NO_ITEM_VALIDATION + [1, 1, "one"], + [1, 1, "one"], # again, now valid as NO_ITEM_VALIDATION + ["one", 1, 1], + ["one", 1, 1], # again, now valid as NO_ITEM_VALIDATION + ) + + +INVALID_MSG_TUPLE_ITEMS = re.escape( + """The following inputs to 'f' do not conform with the corresponding type annotation: + +a + Takes type containing items that conform with , although the received container contains item '0.0' of type . + +a1 + Takes type containing items that conform with , although the received container contains item '0.0' of type . + +b + Takes input that conforms with <(, tuple[int, ...])> although received '(0.0, 1.1, 2.2)' of type . + +b1 + Takes input that conforms with <(, tuple[typing.Union[str, int, set], ...])> although received '(0.0, 1.1, 2.2)' of type . + +c + Takes input that conforms with <(tuple[int, ...], )> although received '(0, 1, 2.2)' of type . + +c1 + Takes input that conforms with <(tuple[typing.Union[str, int, set], ...], )> although received '('zero', 1, {2.222}, 3.3)' of type . + +d + Takes type containing items that conform with , although the received container contains item '1.1' of type . + +d1 + Takes type containing items that conform with , although the received container contains item '2.2' of type . + +e + Takes input that conforms with <(, tuple[int, ...])> although received '(0.0, 1, 2)' of type . + +e1 + Takes input that conforms with <(, tuple[typing.Union[str, int, set], ...])> although received '(0.0, 1, 'two', {3.333})' of type . + +f + Takes type containing items that conform with , although the item in position 0 is '0' of type . + +g + Takes input that conforms with <(, tuple[str, int, set])> although received '(0.0, 1, {2.2})' of type . + +h + Takes input that conforms with <(tuple[str, int, set], )> although received '('zero', 1, 2.2)' of type . + +i + Takes type containing items that conform with , although the item in position 1 is '1.1' of type . + +j + Takes input that conforms with <(, tuple[str, int, set])> although received '('zero', {1}, 2.2)' of type . + +k + Takes type of length 3 although received '(0, {1})' of length 2. + +krpt + Takes type of length 3 although received '(0, {1})' of length 2. + +l + Takes type of length 3 although received '(0, {1}, 'two', 'three')' of length 4.""" +) + + +def test_invalid_inputs_tuple_items(): + @m.parse + def f( + a: tuple[int, ...], + a1: tuple[Union[str, int, set], ...], + b: Union[str, tuple[int, ...]], + b1: Union[str, tuple[Union[str, int, set], ...]], + c: Optional[tuple[int, ...]], + c1: Optional[tuple[Union[str, int, set], ...]], + d: Annotated[tuple[int, ...], "some_meta_c"], + d1: Annotated[tuple[Union[str, int, set], ...], "some_meta_c"], + e: Annotated[Union[str, tuple[int, ...]], "some_meta_d"], + e1: Annotated[Union[str, tuple[Union[str, int, set], ...]], "some_meta_d"], + # + f: tuple[str, int, set], + fx: Annotated[tuple[str, int, set], m.NO_ITEM_VALIDATION], + g: Union[str, tuple[str, int, set]], + h: Optional[tuple[str, int, set]], + i: Annotated[tuple[str, int, set], "some_meta_c"], + j: Annotated[Union[str, tuple[str, int, set]], "some_meta_d"], + # + k: tuple[str, int, set], + krpt: tuple[str, int, set], + l: tuple[str, int, set], + ): + return + + regex = re.compile("^" + INVALID_MSG_TUPLE_ITEMS + "$") + with pytest.raises(m.InputsError, match=regex): + f( + (0.0,), # single item invalid + (0.0,), + (0.0, 1.1, 2.2), # all invalid + (0.0, 1.1, 2.2), + (0, 1, 2.2), # includes one invalid at end + ("zero", 1, {2.222}, 3.3), + (0, 1.1, 2), # includes one invalid in middle + (0, "one", 2.2, {3.333}), + (0.0, 1, 2), # includes one invalid at start + (0.0, 1, "two", {3.333}), + # + (0, {1}, "two"), # all invalid + (0, {1}, "two"), # all invalid but not checking as NO_ITEM_VALIDATION + (0.0, 1, {2.2}), # first invalid + ("zero", 1, 2.2), # last invalid + ("zero", 1.1, {2.2}), # middle invalid + ("zero", {1}, 2.2), # only first valid + (0, {1}), # too short + (0, {1}), # too short, NO_ITEM_VALIDATION shoudn't make any difference + (0, {1}, "two", "three"), # too long + ) + + +INVALID_MSG_DICT_ITEMS = re.escape( + """The following inputs to 'f' do not conform with the corresponding type annotation: + +a + Takes type with keys that conform to the first argument of , although the received dictionary contains key '0' of type . + +a1 + Takes type with values that conform to the second argument of , although the received dictionary contains value 'val0' of type . + +a2 + Takes type with keys that conform to the first argument and values that conform to the second argument of , although the received dictionary contains an item with key '0' of type and value 'val0' of type . + +b + Takes input that conforms with <(, dict[str, int])> although received '{0: 0, 1: 1}' of type . + +b1 + Takes input that conforms with <(, dict[str, int])> although received '{'key0': 'val0', 'key1': 'val1'}' of type . + +b2 + Takes input that conforms with <(, dict[str, int])> although received '{0: 'val0', 1: 'val1'}' of type . + +c + Takes input that conforms with <(dict[str, int], )> although received '{'key0': 0, 1: 1, 'key2': 2}' of type . + +c1 + Takes input that conforms with <(dict[str, int], )> although received '{'key0': 0, 'key1': 'val1', 'key2': 2}' of type . + +c2 + Takes input that conforms with <(dict[str, int], )> although received '{'key0': 0, 1: 'val1', 'key2': 2}' of type . + +d + Takes type with keys that conform to the first argument of , although the received dictionary contains key '2' of type . + +d1 + Takes type with values that conform to the second argument of , although the received dictionary contains value 'val2' of type . + +d2 + Takes type with keys that conform to the first argument and values that conform to the second argument of , although the received dictionary contains an item with key '2' of type and value 'val2' of type . + +e + Takes input that conforms with <(, dict[str, int])> although received '{0: 0, 'key1': 1, 'key2': 2}' of type . + +e1 + Takes input that conforms with <(, dict[str, int])> although received '{'key0': 'val0', 'key1': 1, 'key2': 2}' of type . + +e2 + Takes input that conforms with <(, dict[str, int])> although received '{0: 'val0', 'key1': 1, 'key2': 2}' of type .""" +) + + +def test_invalid_inputs_dict_items(): + @m.parse + def f( + a: dict[str, int], + a1: dict[str, int], + a2: dict[str, int], + ax: Annotated[dict[str, int], m.NO_ITEM_VALIDATION], + b: Union[str, dict[str, int]], + b1: Union[str, dict[str, int]], + b2: Union[str, dict[str, int]], + bx: Annotated[Union[str, dict[str, int]], m.NO_ITEM_VALIDATION], + c: Optional[dict[str, int]], + c1: Optional[dict[str, int]], + c2: Optional[dict[str, int]], + cx: Annotated[Optional[dict[str, int]], m.NO_ITEM_VALIDATION], + d: Annotated[dict[str, int], "some_meta_d"], + d1: Annotated[dict[str, int], "some_meta_d"], + d2: Annotated[dict[str, int], "some_meta_d"], + dx: Annotated[dict[str, int], "some_meta_d", m.NO_ITEM_VALIDATION], + e: Annotated[Union[str, dict[str, int]], "some_meta_e"], + e1: Annotated[Union[str, dict[str, int]], "some_meta_e"], + e2: Annotated[Union[str, dict[str, int]], "some_meta_e"], + ex: Annotated[Union[str, dict[str, int]], m.NO_ITEM_VALIDATION, "some_meta_e"], + ): + return + + regex = re.compile("^" + INVALID_MSG_DICT_ITEMS + "$") + with pytest.raises(m.InputsError, match=regex): + f( + {0: 0, "key1": 1}, # includes non-str key + {"key0": "val0", "key1": 1}, # includes non-int value + {0: "val0", "key1": 1}, # includes non-str key and non-int value + {0: "val0", "key1": 1}, # ...again, but not checking as NO_ITEM_VALIDATION + # + {0: 0, 1: 1}, # all keys non-str + {"key0": "val0", "key1": "val1"}, # all values non-int + {0: "val0", 1: "val1"}, # all keys non-str and all values non-int + {0: "val0", 1: "val1"}, # ...again, but not checking as NO_ITEM_VALIDATION + # + {"key0": 0, 1: 1, "key2": 2}, # includes non-str key + {"key0": 0, "key1": "val1", "key2": 2}, # includes non-int value + {"key0": 0, 1: "val1", "key2": 2}, # includes non-str key and non-int value + { + "key0": 0, + 1: "val1", + "key2": 2, + }, # ...again, but not checking as NO_ITEM_VALIDATION + # + {"key0": 0, "key1": 1, 2: 2}, # includes non-str key + {"key0": 0, "key1": 1, "key2": "val2"}, # includes non-int value + {"key0": 0, "key1": 1, 2: "val2"}, # includes non-str key and non-int value + { + "key0": 0, + "key1": 1, + 2: "val2", + }, # ...again, but not checking as NO_ITEM_VALIDATION + # + {0: 0, "key1": 1, "key2": 2}, # includes non-str key + {"key0": "val0", "key1": 1, "key2": 2}, # includes non-int value + {0: "val0", "key1": 1, "key2": 2}, # includes non-str key and non-int value + { + 0: "val0", + "key1": 1, + "key2": 2, + }, # ...again, but not checking as NO_ITEM_VALIDATION + ) + + +INVALID_MSG_MAPPING_ITEMS = re.escape( + """The following inputs to 'f' do not conform with the corresponding type annotation: + +a + Takes type with keys that conform to the first argument of , although the received mapping contains key '0' of type . + +a1 + Takes type with values that conform to the second argument of , although the received mapping contains value 'val0' of type . + +a2 + Takes type with keys that conform to the first argument and values that conform to the second argument of , although the received mapping contains an item with key '0' of type and value 'val0' of type . + +b + Takes input that conforms with <(, collections.abc.Mapping[str, int])> although received '{0: 0, 1: 1}' of type . + +b1 + Takes input that conforms with <(, collections.abc.Mapping[str, int])> although received '{'key0': 'val0', 'key1': 'val1'}' of type . + +b2 + Takes input that conforms with <(, collections.abc.Mapping[str, int])> although received '{0: 'val0', 1: 'val1'}' of type . + +c + Takes input that conforms with <(collections.abc.Mapping[str, int], )> although received '{'key0': 0, 1: 1, 'key2': 2}' of type . + +c1 + Takes input that conforms with <(collections.abc.Mapping[str, int], )> although received '{'key0': 0, 'key1': 'val1', 'key2': 2}' of type . + +c2 + Takes input that conforms with <(collections.abc.Mapping[str, int], )> although received '{'key0': 0, 1: 'val1', 'key2': 2}' of type . + +d + Takes type with keys that conform to the first argument of , although the received mapping contains key '2' of type . + +d1 + Takes type with values that conform to the second argument of , although the received mapping contains value 'val2' of type . + +d2 + Takes type with keys that conform to the first argument and values that conform to the second argument of , although the received mapping contains an item with key '2' of type and value 'val2' of type . + +e + Takes input that conforms with <(, collections.abc.Mapping[str, int])> although received '{0: 0, 'key1': 1, 'key2': 2}' of type . + +e1 + Takes input that conforms with <(, collections.abc.Mapping[str, int])> although received '{'key0': 'val0', 'key1': 1, 'key2': 2}' of type . + +e2 + Takes input that conforms with <(, collections.abc.Mapping[str, int])> although received '{0: 'val0', 'key1': 1, 'key2': 2}' of type .""" +) + + +def test_invalid_inputs_mapping_items(): + @m.parse + def f( + a: abc.Mapping[str, int], + a1: abc.Mapping[str, int], + a2: abc.Mapping[str, int], + ax: Annotated[abc.Mapping[str, int], m.NO_ITEM_VALIDATION], + b: Union[str, abc.Mapping[str, int]], + b1: Union[str, abc.Mapping[str, int]], + b2: Union[str, abc.Mapping[str, int]], + bx: Annotated[Union[str, abc.Mapping[str, int]], m.NO_ITEM_VALIDATION], + c: Optional[abc.Mapping[str, int]], + c1: Optional[abc.Mapping[str, int]], + c2: Optional[abc.Mapping[str, int]], + cx: Annotated[Optional[abc.Mapping[str, int]], m.NO_ITEM_VALIDATION], + d: Annotated[abc.Mapping[str, int], "some_meta_d"], + d1: Annotated[abc.Mapping[str, int], "some_meta_d"], + d2: Annotated[abc.Mapping[str, int], "some_meta_d"], + dx: Annotated[abc.Mapping[str, int], "some_meta_d", m.NO_ITEM_VALIDATION], + e: Annotated[Union[str, abc.Mapping[str, int]], "some_meta_e"], + e1: Annotated[Union[str, abc.Mapping[str, int]], "some_meta_e"], + e2: Annotated[Union[str, abc.Mapping[str, int]], "some_meta_e"], + ex: Annotated[ + Union[str, abc.Mapping[str, int]], m.NO_ITEM_VALIDATION, "some_meta_e" + ], + ): + return + + regex = re.compile("^" + INVALID_MSG_MAPPING_ITEMS + "$") + with pytest.raises(m.InputsError, match=regex): + f( + {0: 0, "key1": 1}, # includes non-str key + {"key0": "val0", "key1": 1}, # includes non-int value + {0: "val0", "key1": 1}, # includes non-str key and non-int value + {0: "val0", "key1": 1}, # ...again, but not checking as NO_ITEM_VALIDATION + # + {0: 0, 1: 1}, # all keys non-str + {"key0": "val0", "key1": "val1"}, # all values non-int + {0: "val0", 1: "val1"}, # all keys non-str and all values non-int + {0: "val0", 1: "val1"}, # ...again, but not checking as NO_ITEM_VALIDATION + # + {"key0": 0, 1: 1, "key2": 2}, # includes non-str key + {"key0": 0, "key1": "val1", "key2": 2}, # includes non-int value + {"key0": 0, 1: "val1", "key2": 2}, # includes non-str key and non-int value + { + "key0": 0, + 1: "val1", + "key2": 2, + }, # ...again, but not checking as NO_ITEM_VALIDATION + # + {"key0": 0, "key1": 1, 2: 2}, # includes non-str key + {"key0": 0, "key1": 1, "key2": "val2"}, # includes non-int value + {"key0": 0, "key1": 1, 2: "val2"}, # includes non-str key and non-int value + { + "key0": 0, + "key1": 1, + 2: "val2", + }, # ...again, but not checking as NO_ITEM_VALIDATION + # + {0: 0, "key1": 1, "key2": 2}, # includes non-str key + {"key0": "val0", "key1": 1, "key2": 2}, # includes non-int value + {0: "val0", "key1": 1, "key2": 2}, # includes non-str key and non-int value + { + 0: "val0", + "key1": 1, + "key2": 2, + }, # ...again, but not checking as NO_ITEM_VALIDATION + ) + + +INVALID_MSG_NEATED_ITEMS_0 = re.escape( + """The following inputs to 'f' do not conform with the corresponding type annotation: + +a + Takes type containing items that conform with , although the received container contains item '[{0}, {1.0}, 'not a set']' of type .""" +) + + +INVALID_MSG_NEATED_ITEMS_1 = re.escape( + """The following inputs to 'f' do not conform with the corresponding type annotation: + +a + Takes type containing items that conform with , although the received container contains item '[{0}, {'one'}]' of type .""" +) + + +def test_nested_containers(): + """Test contents of subscripted nested containers are validated.""" + + @m.parse + def f( + a: tuple[list[set[Union[int, float]]], ...], + ) -> tuple[list[set[Union[int, float]]], ...]: + return a + + assert f(([{0}, {1.0}],)) == ([{0}, {1.0}],) + + # invalid as inner list contains an invalid item + regex = re.compile("^" + INVALID_MSG_NEATED_ITEMS_0 + "$") + with pytest.raises(m.InputsError, match=regex): + f(([{0}, {1.0}, "not a set"],)) + + # invalid as a set in second level of nesting contains an invalid object + regex = re.compile("^" + INVALID_MSG_NEATED_ITEMS_1 + "$") + with pytest.raises(m.InputsError, match=regex): + f(([{0}, {"one"}],)) + + @m.parse + def f_no_validation( + a: Annotated[tuple[list[set[int, float]], ...], m.NO_ITEM_VALIDATION], + ) -> tuple[list[set[int, float]], ...]: + return a + + rtrn = f_no_validation(([{0}, {"one"}, "not a set"],)) + assert rtrn == ([{0}, {"one"}, "not a set"],) + assert f_no_validation(("any 'ol tuple", 1, 2.0)) == ("any 'ol tuple", 1, 2.0) + + +LIT = "spam" + + +def test_strict_literal(): + @m.parse + def f( + a: Annotated[Literal[LIT], m.STRICT_LITERAL], + b: Literal[LIT], + ): + return + + assert f(LIT, LIT) is None + same_val_diff_obj = "spamX"[:-1] + assert f(LIT, same_val_diff_obj) is None + + # verify error message, including that parameter b does not appear + msg = ( + "The following inputs to 'f' do not conform with the corresponding type" + " annotation:\n\na\n\tTakes a literal from <('spam',)> although received" + " 'spam'." + ) + pat = f"^{re.escape(msg)}$" # ensure nothing either side of expected msg + with pytest.raises(m.InputsError, match=re.compile(pat, re.DOTALL)): + f(same_val_diff_obj, LIT) + + msg = re.escape("b\n\tTakes a value from <('spam',)> although received 'notspam'.") + with pytest.raises(m.InputsError, match=msg): + f(LIT, "notspam") + + +# NOTE: can be removed from when min supported python version advances to 3.11 +def test_fix_hints_for_none_default(): + if sys.version_info.minor >= 11: + pytest.skip("only applicable to py <11.") + + def f( + a: Union[int, str], + b: Optional[str], + c: Optional[Union[int, str]], + d: Annotated[Union[str, int, float], "some_meta"], + e: Annotated[Optional[Union[str, int, float]], "some_meta"], + f: Optional[Union[int, str]] = None, + g: Annotated[Optional[str], "some_meta"] = None, + h: Annotated[Optional[Union[str, int, float]], "some_meta"] = None, + *, + i: Annotated[Optional[Union[str, int, float]], "some_meta"] = None, + ): + return + + spec = inspect.getfullargspec(f) + + # hints as would be returned by py3.9 and py3.10 + # typing.get_type_hints(f, include_extras=True) + # 'f' and 'h' wrapped with optional. Others ensure fix does not alter + # expected behavior for other annotations. + hints = { + "a": Union[int, str], + "b": Optional[str], + "c": Optional[Union[int, str]], + "d": Annotated[Union[str, int, float], "some_meta"], + "e": Annotated[Optional[Union[str, int, float]], "some_meta"], + "f": Optional[Union[int, str]], + "g": Optional[Annotated[Optional[str], "some_meta"]], + "h": Optional[Annotated[Optional[Union[str, int, float]], "some_meta"]], + "i": Annotated[Optional[Union[str, int, float]], "some_meta"], + } + + rtrn = m.fix_hints_for_none_default(hints, spec) + + NoneType = type(None) + expected = { + "a": Union[int, str], + "b": Optional[str], + "c": Union[int, str, NoneType], + "d": Annotated[Union[str, int, float], "some_meta"], + "e": Annotated[Union[str, int, float, NoneType], "some_meta"], + "f": Union[int, str, NoneType], + "g": Annotated[Optional[str], "some_meta"], # unwrapped + "h": Annotated[Union[str, int, float, NoneType], "some_meta"], # unwrapped + "i": Annotated[Union[str, int, float, NoneType], "some_meta"], + } + assert rtrn == expected + + # validate as expected when default not None + def f( + f: Optional[Union[int, str]] = 1, + g: Annotated[Optional[str], "some_meta"] = "foo", + h: Annotated[Optional[Union[str, int, float]], "some_meta"] = 2, + *, + i: Annotated[Optional[Union[str, int, float]], "some_meta"] = 3, + ): + return + + spec = inspect.getfullargspec(f) + hints = get_type_hints(f, include_extras=True) + rtrn = m.fix_hints_for_none_default(hints, spec) + + expected = {k: v for k, v in expected.items() if k in hints} + assert rtrn == expected + + # validate as expected when no default values + def f( + f: Optional[Union[int, str]], + g: Annotated[Optional[str], "some_meta"], + h: Annotated[Optional[Union[str, int, float]], "some_meta"], + *, + i: Annotated[Optional[Union[str, int, float]], "some_meta"], + ): + return + + spec = inspect.getfullargspec(f) + hints = get_type_hints(f, include_extras=True) + rtrn = m.fix_hints_for_none_default(hints, spec) + assert rtrn == expected + + # validate as expected when no arg default values but kwarg defaults + def f( + f: Optional[Union[int, str]], + g: Annotated[Optional[str], "some_meta"], + h: Annotated[Optional[Union[str, int, float]], "some_meta"], + *, + i: Annotated[Optional[Union[str, int, float]], "some_meta"] = None, + ): + return + + spec = inspect.getfullargspec(f) + hints = get_type_hints(f, include_extras=True) + rtrn = m.fix_hints_for_none_default(hints, spec) + assert rtrn == expected + + # validate as expected when no kwonly params + + def f( + f: Optional[Union[int, str]], + g: Annotated[Optional[str], "some_meta"], + h: Annotated[Optional[Union[str, int, float]], "some_meta"] = None, + ): + return + + spec = inspect.getfullargspec(f) + hints = get_type_hints(f, include_extras=True) + rtrn = m.fix_hints_for_none_default(hints, spec) + expected.pop("i") + assert rtrn == expected + + +def test_coerce_none(): + """Test None input is not coerced.""" + + @m.parse + def f( + a: Annotated[Optional[int], m.Coerce(str)], + b: Annotated[Optional[int], m.Coerce(str)], + c: Annotated[Union[int, str, None], m.Coerce(str)], + b1: Annotated[Optional[int], m.Coerce(str)] = None, + c1: Annotated[Union[int, str, None], m.Coerce(str)] = None, + ) -> dict[str, Any]: + return dict(a=a, b=b, c=c, b1=b1, c1=c1) + + assert f(0, 1, 2, 3, 4) == {"a": "0", "b": "1", "c": "2", "b1": "3", "c1": "4"} + expected = { + "a": "0", + "b": None, + "c": None, + "b1": None, + "c1": None, + } + assert f(0, None, None) == expected + assert f(0, None, None, None, None) == expected + + +def parse_b(name: str, obj: int, params: dict[str, Any]) -> int: + return obj + 1 + + +def parse_c(name: str, obj: Optional[int], params: dict[str, Any]) -> int: + """Dynamically define default value for parameter c.""" + return params["a"] if obj is None else obj + + +def test_parse_none(): + """Test effect of parse_none. + + Includes verifying dynamically definition of default values. + """ + + @m.parse + def f( + a: int, + b: Annotated[Optional[int], m.Parser(parse_b, parse_none=False)], + b2: Annotated[Optional[int], m.Parser(parse_b, parse_none=False)] = None, + c: Annotated[Optional[int], m.Parser(parse_c)] = None, + ) -> dict[str, Any]: + return dict(a=a, b=b, b2=b2, c=c) + + expected = {"a": 7, "b": 11, "b2": 21, "c": 30} + assert f(7, 10, 20, 30) == expected + + # verify that parse_none results in None being passed through (b and b2) + # verify that None provides for setting default values dynamically (c) + assert f(3, None) == {"a": 3, "b": None, "b2": None, "c": 3}