diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index fa8c11d..e310221 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -14,7 +14,7 @@ jobs: # fail it if doesn't conform to black runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: psf/black@stable with: options: "--check --verbose" @@ -30,7 +30,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' diff --git a/.github/workflows/draft-release-notes.yml b/.github/workflows/draft-release-notes.yml index 739280f..057b29f 100644 --- a/.github/workflows/draft-release-notes.yml +++ b/.github/workflows/draft-release-notes.yml @@ -11,6 +11,6 @@ jobs: update_release_draft: runs-on: ubuntu-latest steps: - - uses: release-drafter/release-drafter@v5 + - uses: release-drafter/release-drafter@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd0f48c..282fa6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,10 +12,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' @@ -32,13 +32,13 @@ jobs: with: user: __token__ password: ${{ secrets.PYPI_TEST_API_TOKEN }} - repository_url: https://test.pypi.org/legacy/ + 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 + sleep 5 + while [ "${{ 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, sleeping 5s"; sleep 5s; echo "woken up"; 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__)' @@ -56,8 +56,8 @@ jobs: - 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 + sleep 5 + while [ "${{ 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, sleeping 5s"; sleep 5s; echo "woken up"; done pip install --index-url https://pypi.org/simple valimp==${{ github.ref_name }} python -c 'import valimp;print(valimp.__version__)' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee2d8ee..d4334e2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,10 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.5.0 hooks: - id: check-yaml - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 24.2.0 hooks: - id: black # It is recommended to specify the latest version of Python @@ -13,7 +13,7 @@ repos: # https://pre-commit.com/#top_level-default_language_version language_version: python3.11 - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 + rev: 7.0.0 hooks: - id: flake8 additional_dependencies: [flake8-docstrings] \ No newline at end of file diff --git a/.pylintrc b/.pylintrc index 0d9c4a0..28078ce 100644 --- a/.pylintrc +++ b/.pylintrc @@ -23,7 +23,7 @@ # usually to register additional checkers. load-plugins=pylint.extensions.broad_try_clause, pylint.extensions.confusing_elif, - pylint.extensions.comparetozero, + ; pylint.extensions.comparetozero, ; as of Nov 23 fails to load pylint.extensions.bad_builtin, pylint.extensions.mccabe, pylint.extensions.docstyle, diff --git a/README.md b/README.md index 3f60dae..3b4a736 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![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**. +In Python use type hints to validate, parse and coerce inputs to **public functions and dataclasses**. 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. @@ -37,7 +37,11 @@ def public_function( Coerce(str), Parser(lambda name, obj, _: obj + f"_{name}") ], - *, + # support for packing extra arguments if required, can be optionally typed... + *args: Annotated[ + Union[int, float, str], # int | float | str + Coerce(int) + ], # support for optional types g: Optional[str], # str | None # define default values dynamically with reference to earlier inputs @@ -45,17 +49,21 @@ def public_function( Optional[float], # float | None Parser(lambda _, obj, params: params["b"] if obj is None else obj) ] = None, + # support for packing excess kwargs if required, can be optionally typed... + # **kwargs: Union[int, float] ) -> dict[str, Any]: - return {"a":a, "b":b, "c":c, "d":d, "e":e, "f":f, "g":g, "h":h} + return {"a":a, "b":b, "c":c, "d":d, "e":e, "f":f, "args",args, "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" + "zero", # a + 1.0, # b + {"two": 2}, # c + 3.3, # d, will be coerced from float to int, i.e. to 3 + "four", # e, will be parsed to "four_e_zero" + 5, # f, will be coerced to str and then parsed to "5_f" + "10", # extra arg, will be coerced to int and packed + 20, # extra arg, will be packed g="keyword_arg_g", # h, not passed, will be assigned dynamically as parameter b (i.e. 1.0) ) @@ -68,6 +76,7 @@ returns: 'd': 3, 'e': 'four_e_zero', 'f': '5_f', + 'args': (10, 20), 'g': 'keyword_arg_g', 'h': 1.0} ``` @@ -130,7 +139,30 @@ The following inputs to 'public_function' do not conform with the corresponding b Takes input that conforms with <(, )> although received 'invalid input' of type . ``` +Use all the same functionality to validate, parse and coerce the fields of a dataclass... +```python +from valimp import parse_cls +import dataclasses + +@parse_cls # place valimp decorator above the dataclass decorator +@dataclasses.dataclass +class ADataclass: + + a: str + b: Annotated[ + Union[str, int], + Coerce(str), + Parser(lambda name, obj, params: obj + f" {name} {params['a']}") + ] +rtrn = ADataclass("I'm a and will appear at the end of b", 33) +dataclasses.asdict(rtrn) +``` +output: +``` +{'a': "I'm a and will appear at the end of b", + 'b': "33 b I'm a and will appear at the end of b"} +``` ## Installation `$ pip install valimp` @@ -147,23 +179,25 @@ Further documentation can be found in the module docstring of [valimp.py](https: ### 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)). +> :information_source: `valimp` is only intended for handling inputs to **public functions and dataclasses**. 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). +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 by 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. +[Pydantic](https://github.com/pydantic/pydantic) is orientated towards the validation of inputs to dataclasses. Whilst the Valimp `@parse_cls` decorator does this well for non-complex cases, if you're looking to do more then Pydantic is the place to go. -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`. +As for validating public function input, in the early releases of Pydantic V2 the `@validate_call` decorator failed to provide for validating later parameters based on values received by earlier parameters (a [regression](https://github.com/pydantic/pydantic/issues/6794) from the Pydantic V1 `@validate_arguments` decorator). This loss of functionality, together with finding Pydantic somewhat clunky to do anything beyond simple type validation, is what led me to write `valimp`. (I believe functionality to validate later parameters based on values receive by earlier parameters may have since been restored in Pydantic V2, see the [issue](https://github.com/pydantic/pydantic/issues/6794).) -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`. +In short, 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. ## 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` does NOT currently support: + - Positional-only arguments. Any '/' in the signature (to define + positional-only arguments) will be ignored. Consequently valimp DOES + allow intended positional-only arguments to be passed as keyword + arguments. + - Validation of subscripted types in `collections.abc.Callable` (although Valimp will verify that the passed value is callable). `valimp` currently supports: * use of the following type annotations: @@ -183,8 +217,9 @@ If you only want to validate the type of function inputs then Pydantic V2 `@vali * `set` * `collections.abc.Sequence` * `collections.abc.Mapping` +* packing and optionally coercing, parsing and validating packed objects, i.e. objects received to, for example, *args and **kwargs. -That's it for now, although the library has been built with development in mind and PRs are very much welcome! +The library has been built with development in mind and PRs are very much welcome! ## License diff --git a/docs/tutorials/tutorial.ipynb b/docs/tutorials/tutorial.ipynb index 48daffd..2c133b0 100644 --- a/docs/tutorials/tutorial.ipynb +++ b/docs/tutorials/tutorial.ipynb @@ -14,6 +14,7 @@ "- [Type validation](#Type-validation)\n", " - [Built-in and custom types](#Built-in-and-custom-types)\n", " - [Methods](#Methods)\n", + " - [Dataclasses](#Dataclasses)\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", @@ -24,6 +25,7 @@ " - [Nested containers](#Nested-containers)\n", " - [`collections.abc.Callable`](#collections.abc.Callable)\n", " - [`typing.Literal`](#typing.Literal)\n", + " - [*args and **kwargs](#*args-and-**kwargs)\n", "- [Signature validation](#Signature-validation)\n", "- [Coerce](#Coerce)\n", " - [Type checkers](#Type-checkers)\n", @@ -47,13 +49,15 @@ "source": [ "## Introduction\n", "\n", - "Valimp uses type annotations (hints) to easily validate, parse and coerce inputs to public functions and methods.\n", + "Valimp uses type annotations (hints) to easily validate, parse and coerce inputs to public functions, methods and dataclasses.\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." + " - provide for user-defined parsing and custom validation.\n", + "\n", + "The same functionality is available to a 'type-annotated' `dataclasses.dataclass` by adding the `valimp.parse_cls` decorator above the dataclass decorator." ] }, { @@ -212,6 +216,7 @@ "metadata": {}, "outputs": [], "source": [ + "# invalid inputs...\n", "sc.public_method(0, kw_a=None)" ] }, @@ -236,12 +241,88 @@ "```" ] }, + { + "cell_type": "markdown", + "id": "330c9181-de60-43e2-aebf-3f29550486bb", + "metadata": {}, + "source": [ + "### Dataclasses\n", + "\n", + "And the same functionality is available to dataclasses by using the `@parse_cls` decorator..." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "2819abf2-a4a3-4125-8802-5f1f81700ed6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'a': 'a string', 'b': 4}" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from valimp import parse_cls\n", + "import dataclasses\n", + "\n", + "@parse_cls\n", + "@dataclasses.dataclass\n", + "class ADataclass:\n", + " \n", + " a: str\n", + " b: MyCustomType\n", + "\n", + "# valid inputs...\n", + "data = ADataclass(\"a string\", b=MyCustomType(4))\n", + "dataclasses.asdict(data)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8021e92d-e697-4826-8027-31d0f23a77ff", + "metadata": {}, + "outputs": [], + "source": [ + "# invalid inputs...\n", + "ADataclass([\"I'm\", \"a\", \"list\"], b=4.0)" + ] + }, + { + "cell_type": "markdown", + "id": "df896d3f-bcc2-447a-b5b7-07bc7fa73bef", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[7], line 2\n", + " 1 # invalid inputs...\n", + "----> 2 ADataclass([\"I'm\", \"a\", \"list\"], b=4.0)\n", + "\n", + "InputsError: The following inputs to '__init__' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes type although received '[\"I'm\", 'a', 'list']' of type .\n", + "\n", + "b\n", + "\tTakes type although received '4.0' 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." + "**NB** Henceforth all examples in this tutorial will be defined as functions, although everything works in the same way for methods and dataclasses." ] }, { @@ -258,7 +339,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 8, "id": "ffbcfe86-686a-4bc4-b285-e57a202b8dd7", "metadata": {}, "outputs": [], @@ -306,7 +387,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 10, "id": "9ce5f4a7-656e-44ff-9854-38cf687be2fd", "metadata": {}, "outputs": [], @@ -337,7 +418,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[8], line 1\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", @@ -370,7 +451,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 12, "id": "ccc170e7-22fb-4d40-bc04-653848249f69", "metadata": {}, "outputs": [], @@ -390,7 +471,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 13, "id": "22cd31c0-abdf-4051-8914-bd1c1b66ea02", "metadata": {}, "outputs": [], @@ -429,7 +510,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[11], line 1\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", @@ -465,7 +546,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 15, "id": "3631a73a-0bd5-4780-b0d8-6d6ceab98ac8", "metadata": {}, "outputs": [], @@ -485,7 +566,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 16, "id": "f6849283-06e9-41de-830a-93211c58d165", "metadata": {}, "outputs": [], @@ -524,7 +605,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[14], line 1\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", @@ -561,7 +642,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 18, "id": "efc80de9-e69c-4518-a394-c50e84d9ac23", "metadata": {}, "outputs": [], @@ -582,7 +663,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 19, "id": "b79ccb44-173c-46ca-ad3b-dc81322637c0", "metadata": {}, "outputs": [], @@ -627,7 +708,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[17], line 1\n", + "Cell In[20], line 1\n", "----> 1 pf(\n", "\n", "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", @@ -667,7 +748,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 21, "id": "6433ad69-214b-489c-8dd3-b02c8cf4156f", "metadata": {}, "outputs": [], @@ -713,7 +794,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[19], line 1\n", + "Cell In[22], line 1\n", "----> 1 pf(\n", "\n", "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", @@ -742,7 +823,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 23, "id": "b38c3307-6fbe-4fb8-ba75-16a1955cc2a8", "metadata": {}, "outputs": [], @@ -787,7 +868,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[21], line 1\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", @@ -815,7 +896,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 25, "id": "c5159fb4-bb68-445b-ba45-07509458d305", "metadata": {}, "outputs": [], @@ -869,7 +950,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[23], line 1\n", + "Cell In[26], line 1\n", "----> 1 pf(\n", "\n", "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", @@ -911,7 +992,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[24], line 1\n", + "Cell In[27], line 1\n", "----> 1 pf(\n", "\n", "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", @@ -931,7 +1012,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 28, "id": "ed78b4bd-4196-4268-b19f-5afd02b89826", "metadata": {}, "outputs": [], @@ -945,7 +1026,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 29, "id": "6edc0fb2-75b7-4d7d-b7c2-12ff6cada321", "metadata": {}, "outputs": [ @@ -955,7 +1036,7 @@ "([{0, 0.1}, {1, 1.1, 'one'}, 'not a set'], 'not a list')" ] }, - "execution_count": 26, + "execution_count": 29, "metadata": {}, "output_type": "execute_result" } @@ -982,7 +1063,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 30, "id": "f09f1647-4059-4e31-8513-3d1aa81892f2", "metadata": {}, "outputs": [ @@ -992,7 +1073,7 @@ "(\"I'm a tuple\", 1, 2.0)" ] }, - "execution_count": 27, + "execution_count": 30, "metadata": {}, "output_type": "execute_result" } @@ -1023,7 +1104,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 31, "id": "44232c3f-b399-492b-a66c-1ab4ce983cc2", "metadata": {}, "outputs": [], @@ -1072,7 +1153,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[29], line 1\n", + "Cell In[32], 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", @@ -1099,7 +1180,7 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 33, "id": "f9041474-ec0a-4488-8e80-ddcbbcbe6069", "metadata": {}, "outputs": [], @@ -1153,7 +1234,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[31], line 1\n", + "Cell In[34], line 1\n", "----> 1 pf(\n", "\n", "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", @@ -1182,7 +1263,7 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": 35, "id": "c6162215-5749-4be5-a8d2-683ac53748f0", "metadata": {}, "outputs": [], @@ -1211,7 +1292,7 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": 36, "id": "12a4aca1-005f-469c-b895-e8473ac50e86", "metadata": {}, "outputs": [ @@ -1221,7 +1302,7 @@ "'spam'" ] }, - "execution_count": 33, + "execution_count": 36, "metadata": {}, "output_type": "execute_result" } @@ -1242,7 +1323,7 @@ }, { "cell_type": "code", - "execution_count": 34, + "execution_count": 37, "id": "bb10d385-cf0e-412f-ae74-2a98aa6aea95", "metadata": {}, "outputs": [], @@ -1276,7 +1357,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[35], line 1\n", + "Cell In[38], 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", @@ -1286,6 +1367,194 @@ "```" ] }, + { + "cell_type": "markdown", + "id": "73da11d1-f9af-43b2-9e82-850b4ee77965", + "metadata": {}, + "source": [ + "### *args and **kwargs\n", + "\n", + "Valimp supports packing arguments to collect excess received positional and keyword arguments." + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "id": "50470b59-5421-4036-8c93-d80624bac11d", + "metadata": {}, + "outputs": [], + "source": [ + "@parse\n", + "def pf(\n", + " a: int,\n", + " *args,\n", + " kw_a: bool,\n", + " **kwargs\n", + "):\n", + " return a, args, kw_a, kwargs" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "a56eb1f1-14d7-41d7-8b40-23c54f73b88a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(3, (4, 5.0, 'six'), False, {'kw_extra0': 'extra0', 'kw_extra1': [1, 1]})" + ] + }, + "execution_count": 40, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pf(3, 4, 5.0, \"six\", kw_a=False, kw_extra0=\"extra0\", kw_extra1=[1, 1])" + ] + }, + { + "cell_type": "markdown", + "id": "d95486f8-2f58-4682-9b8a-41f8f2d96cc5", + "metadata": {}, + "source": [ + "Both *args and **kwargs can be validated against a type annotation. **NB** the type annotation should describe each item contained within the container, not the container object itself." + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "id": "e9dead85-ba39-425f-9858-9565230e2a1b", + "metadata": {}, + "outputs": [], + "source": [ + "@parse\n", + "def pf(\n", + " a: int,\n", + " *args: Union[int, float],\n", + " kw_a: bool,\n", + " **kwargs: bool,\n", + "):\n", + " return a, args, kw_a, kwargs" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "id": "6880feb1-5e76-430a-9bee-24ad32ff85bb", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(3, (4, 5.0), False, {'kw_b': True, 'kw_c': False})" + ] + }, + "execution_count": 42, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# valid inputs\n", + "pf(3, 4, 5.0, kw_a=False, kw_b=True, kw_c=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1f620337-a8d6-439c-bf23-3e1ccdb72208", + "metadata": {}, + "outputs": [], + "source": [ + "# invalid inputs\n", + "pf(3, 4, 5.0, \"six\", kw_a=False, kw_extra0=True, kw_extra1=2)" + ] + }, + { + "cell_type": "markdown", + "id": "ce1d479b-6e89-423b-84ea-29cd5663c9f3", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[43], line 2\n", + " 1 # invalid inputs\n", + "----> 2 pf(3, 4, 5.0, \"six\", kw_a=False, kw_extra0=True, kw_extra1=2)\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "_args2\n", + "\tTakes input that conforms with <(, )> although received 'six' of type .\n", + "\n", + "kw_extra1\n", + "\tTakes type although received '2' of type .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "fdc8fdd5-d8d4-4cc4-bbad-02fb9b1ee62f", + "metadata": {}, + "source": [ + "NB that in error message any invalid excess position arguments are named as the packing argument prefixed with a '_' and suffixed with the 0-indexed position of the argument within the container ('_args2' above).\n", + "\n", + "The packing arguments can be named unconventionally..." + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "id": "1eca3fca-eef8-425d-998d-d0deef4fc3ff", + "metadata": {}, + "outputs": [], + "source": [ + "@parse\n", + "def pf(\n", + " a: int,\n", + " *excess_pos_args: Union[int, float],\n", + " kw_a: bool,\n", + " **excess_other: bool,\n", + "):\n", + " return a, args, kw_a, kwargs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8a5f7da2-83ee-4a0c-a6b0-b030a7e84ce1", + "metadata": {}, + "outputs": [], + "source": [ + "# invalid inputs\n", + "pf(3, 4, 5.0, \"six\", kw_a=False, kw_extra0=True, kw_extra1=2)" + ] + }, + { + "cell_type": "markdown", + "id": "416367d0-1d58-4b68-938f-4d356a89bac4", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[45], line 2\n", + " 1 # invalid inputs\n", + "----> 2 pf(3, 4, 5.0, \"six\", kw_a=False, kw_extra0=True, kw_extra1=2)\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "_excess_pos_args2\n", + "\tTakes input that conforms with <(, )> although received 'six' of type .\n", + "\n", + "kw_extra1\n", + "\tTakes type although received '2' of type .\n", + "```" + ] + }, { "cell_type": "markdown", "id": "ef3aaca2-0619-4bf4-a05a-4047b941e373", @@ -1307,7 +1576,7 @@ }, { "cell_type": "code", - "execution_count": 36, + "execution_count": 46, "id": "eff9ce77-2c87-40ca-81c9-4faa54be0997", "metadata": {}, "outputs": [], @@ -1340,7 +1609,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[37], line 1\n", + "Cell In[47], 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", @@ -1379,7 +1648,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[38], line 1\n", + "Cell In[48], line 1\n", "----> 1 pf(3, kw_a=3)\n", "\n", "InputsError: Inputs to 'pf' do not conform with the function signature:\n", @@ -1404,7 +1673,7 @@ }, { "cell_type": "code", - "execution_count": 39, + "execution_count": 49, "id": "aa10cb66-5db5-425a-ba4e-fad8e7e8d3eb", "metadata": {}, "outputs": [], @@ -1418,29 +1687,35 @@ " 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", + " *args: 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}" + " return {\"a\":a, \"b\":b, \"c\":c, \"d\":d, \"e\":e, \"args\": args}" ] }, { "cell_type": "code", - "execution_count": 40, + "execution_count": 50, "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}" + "{'a': 0.0,\n", + " 'b': 1.1,\n", + " 'c': 2.0,\n", + " 'd': 3.3,\n", + " 'e': None,\n", + " 'args': (1.0, 2.0, 3.3, None)}" ] }, - "execution_count": 40, + "execution_count": 50, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "pf(0, 1.1, '2', '3.3', None) " + "pf(0, 1.1, '2', '3.3', None, 1, 2.0, \"3.3\", None) " ] }, { @@ -1448,7 +1723,7 @@ "id": "eda48990-ad81-4cd0-94cf-6ec2d0db6928", "metadata": {}, "source": [ - "Note that Valimp will not try to coerce a `None` input.\n", + "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", @@ -1465,18 +1740,13 @@ ] }, { + "attachments": {}, "cell_type": "markdown", - "id": "1d69c27a-69e8-4f6d-93de-fb72d1ca9e7d", - "metadata": {}, - "source": [ - "## Parsing" - ] - }, - { - "cell_type": "markdown", - "id": "c0190863-4a3f-4bee-86ad-a605cc86ef48", + "id": "093dbece-5d81-4760-b248-2c0e68b40417", "metadata": {}, "source": [ + "## Parsing\n", + "\n", "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", @@ -1513,7 +1783,7 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": 51, "id": "872b9027-3aff-4901-859b-f8b1163b5a54", "metadata": {}, "outputs": [ @@ -1523,7 +1793,7 @@ "{'a': 'input_a', 'b': 'input_b_suffix'}" ] }, - "execution_count": 41, + "execution_count": 51, "metadata": {}, "output_type": "execute_result" } @@ -1554,17 +1824,21 @@ }, { "cell_type": "code", - "execution_count": 42, + "execution_count": 52, "id": "3639b3c6-eb1f-493c-a62c-47b36e460fcb", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{'a': 'input_a', 'b': 'input_b_b', 'c': 'input_c_input_a'}" + "{'a': 'input_a',\n", + " 'b': 'input_b_b',\n", + " 'c': 'input_c_input_a',\n", + " 'kw_a': \"I'm a kwarg_kw_a\",\n", + " 'kwargs': {'kw_xtra': \"I'm kw_xtra_kw_xtra\"}}" ] }, - "execution_count": 42, + "execution_count": 52, "metadata": {}, "output_type": "execute_result" } @@ -1584,11 +1858,14 @@ "def pf(\n", " a: str,\n", " b: Annotated[str, my_parser], # passes a reusable Parser\n", - " c: Annotated[str, Parser(concat_earlier_input)], \n", + " c: Annotated[str, Parser(concat_earlier_input)],\n", + " *,\n", + " kw_a: Annotated[str, my_parser],\n", + " **kwargs: Annotated[str, my_parser],\n", ") -> dict[str, str]:\n", - " return {\"a\":a, \"b\":b, \"c\":c}\n", + " return {\"a\":a, \"b\":b, \"c\":c, \"kw_a\":kw_a, \"kwargs\":kwargs}\n", "\n", - "pf(\"input_a\", \"input_b\", \"input_c\")" + "pf(\"input_a\", \"input_b\", \"input_c\", kw_a=\"I'm a kwarg\", kw_xtra=\"I'm kw_xtra\")" ] }, { @@ -1619,7 +1896,7 @@ "```\n", "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", - "Cell In[43], line 1\n", + "Cell In[53], 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", @@ -1643,7 +1920,7 @@ }, { "cell_type": "code", - "execution_count": 44, + "execution_count": 54, "id": "aa1c9d23-908c-4b07-a5b1-59bc043c6d7c", "metadata": {}, "outputs": [], @@ -1725,7 +2002,7 @@ "```\n", "---------------------------------------------------------------------------\n", "ValueError Traceback (most recent call last)\n", - "Cell In[46], line 1\n", + "Cell In[56], 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", @@ -1746,7 +2023,7 @@ }, { "cell_type": "code", - "execution_count": 47, + "execution_count": 57, "id": "bf0921b4-a961-450e-b719-f7bdea866c66", "metadata": {}, "outputs": [], @@ -1764,7 +2041,7 @@ }, { "cell_type": "code", - "execution_count": 48, + "execution_count": 58, "id": "2e85bfd6-05c9-48c1-ada4-82f684476311", "metadata": {}, "outputs": [ @@ -1774,7 +2051,7 @@ "(10, 10)" ] }, - "execution_count": 48, + "execution_count": 58, "metadata": {}, "output_type": "execute_result" } @@ -1796,7 +2073,7 @@ }, { "cell_type": "code", - "execution_count": 49, + "execution_count": 59, "id": "5e8d4eba-bcf8-4c11-978a-49dcba926250", "metadata": {}, "outputs": [], @@ -1813,7 +2090,7 @@ }, { "cell_type": "code", - "execution_count": 50, + "execution_count": 60, "id": "4c50f083-b166-4a62-bb13-5ceec8ddca81", "metadata": {}, "outputs": [ @@ -1823,7 +2100,7 @@ "8" ] }, - "execution_count": 50, + "execution_count": 60, "metadata": {}, "output_type": "execute_result" } @@ -1834,7 +2111,7 @@ }, { "cell_type": "code", - "execution_count": 51, + "execution_count": 61, "id": "05f9433c-64af-48b3-bb70-80cbcec9c03d", "metadata": {}, "outputs": [], @@ -1852,7 +2129,7 @@ }, { "cell_type": "code", - "execution_count": 52, + "execution_count": 62, "id": "7f00520f-0c4f-48e1-8f65-fd4cde8c2202", "metadata": {}, "outputs": [], @@ -1885,7 +2162,7 @@ "```\n", "---------------------------------------------------------------------------\n", "TypeError Traceback (most recent call last)\n", - "Cell In[53], line 1\n", + "Cell In[63], line 1\n", "----> 1 pf(None)\n", "\n", "File ~\\valimp\\src\\valimp\\valimp.py:931, in parse..wrapped_f(*args, **kwargs)\n", @@ -1931,7 +2208,7 @@ }, { "cell_type": "code", - "execution_count": 54, + "execution_count": 64, "id": "0171f026-b3d0-434c-bd19-118a829c07a3", "metadata": {}, "outputs": [], @@ -1961,7 +2238,7 @@ }, { "cell_type": "code", - "execution_count": 55, + "execution_count": 65, "id": "b5fc576a-1c65-467c-bbb2-a67b412d3297", "metadata": {}, "outputs": [ @@ -1971,7 +2248,7 @@ "(2.2, None)" ] }, - "execution_count": 55, + "execution_count": 65, "metadata": {}, "output_type": "execute_result" } @@ -1982,7 +2259,7 @@ }, { "cell_type": "code", - "execution_count": 56, + "execution_count": 66, "id": "e05c6d24-60a5-4ec1-9e1b-b98f9f8ee094", "metadata": {}, "outputs": [ @@ -1992,7 +2269,7 @@ "(2.2, 3.3)" ] }, - "execution_count": 56, + "execution_count": 66, "metadata": {}, "output_type": "execute_result" } @@ -2003,7 +2280,7 @@ }, { "cell_type": "code", - "execution_count": 57, + "execution_count": 67, "id": "78cc5f4e-ac44-42bb-ada0-106dd023803f", "metadata": {}, "outputs": [ @@ -2013,7 +2290,7 @@ "(2.2, 3.0)" ] }, - "execution_count": 57, + "execution_count": 67, "metadata": {}, "output_type": "execute_result" } @@ -2040,7 +2317,7 @@ "```\n", "---------------------------------------------------------------------------\n", "ValueError Traceback (most recent call last)\n", - "Cell In[58], line 1\n", + "Cell In[68], 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", @@ -2050,7 +2327,7 @@ " 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", + "Cell In[64], 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", diff --git a/requirements_dev.txt b/requirements_dev.txt index f9ec7c8..3fd0219 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,9 +4,9 @@ # # pip-compile --extra=dev --output-file=requirements_dev.txt pyproject.toml # -astroid==3.0.1 +astroid==3.0.3 # via pylint -black==23.11.0 +black==24.2.0 # via valimp (pyproject.toml) build==1.0.3 # via pip-tools @@ -22,33 +22,33 @@ colorama==0.4.6 # click # pylint # pytest -dill==0.3.7 +dill==0.3.8 # via pylint -distlib==0.3.7 +distlib==0.3.8 # via virtualenv exceptiongroup==1.2.0 # via pytest filelock==3.13.1 # via virtualenv -flake8==6.1.0 +flake8==7.0.0 # via # flake8-docstrings # valimp (pyproject.toml) flake8-docstrings==1.7.0 # via valimp (pyproject.toml) -identify==2.5.32 +identify==2.5.34 # via pre-commit -importlib-metadata==6.8.0 +importlib-metadata==7.0.1 # via build iniconfig==2.0.0 # via pytest -isort==5.12.0 +isort==5.13.2 # via pylint mccabe==0.7.0 # via # flake8 # pylint -mypy==1.7.1 +mypy==1.8.0 # via valimp (pyproject.toml) mypy-extensions==1.0.0 # via @@ -61,30 +61,32 @@ packaging==23.2 # black # build # pytest -pathspec==0.11.2 +pathspec==0.12.1 # via black -pip-tools==7.3.0 +pip-tools==7.4.0 # via valimp (pyproject.toml) -platformdirs==4.0.0 +platformdirs==4.2.0 # via # black # pylint # virtualenv -pluggy==1.3.0 +pluggy==1.4.0 # via pytest -pre-commit==3.5.0 +pre-commit==3.6.1 # via valimp (pyproject.toml) pycodestyle==2.11.1 # via flake8 pydocstyle==6.3.0 # via flake8-docstrings -pyflakes==3.1.0 +pyflakes==3.2.0 # via flake8 -pylint==3.0.2 +pylint==3.0.3 # via valimp (pyproject.toml) pyproject-hooks==1.0.0 - # via build -pytest==7.4.3 + # via + # build + # pip-tools +pytest==8.0.1 # via valimp (pyproject.toml) pyyaml==6.0.1 # via pre-commit @@ -101,15 +103,15 @@ tomli==2.0.1 # pytest tomlkit==0.12.3 # via pylint -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via # astroid # black # mypy # pylint -virtualenv==20.24.7 +virtualenv==20.25.0 # via pre-commit -wheel==0.41.3 +wheel==0.42.0 # via pip-tools zipp==3.17.0 # via importlib-metadata diff --git a/requirements_tests.txt b/requirements_tests.txt index 2954925..514a3ee 100644 --- a/requirements_tests.txt +++ b/requirements_tests.txt @@ -4,7 +4,7 @@ # # pip-compile --extra=tests --output-file=requirements_tests.txt pyproject.toml # -black==23.11.0 +black==24.2.0 # via valimp (pyproject.toml) click==8.1.7 # via black @@ -14,7 +14,7 @@ colorama==0.4.6 # pytest exceptiongroup==1.2.0 # via pytest -flake8==6.1.0 +flake8==7.0.0 # via # flake8-docstrings # valimp (pyproject.toml) @@ -30,19 +30,19 @@ packaging==23.2 # via # black # pytest -pathspec==0.11.2 +pathspec==0.12.1 # via black -platformdirs==4.0.0 +platformdirs==4.2.0 # via black -pluggy==1.3.0 +pluggy==1.4.0 # via pytest pycodestyle==2.11.1 # via flake8 pydocstyle==6.3.0 # via flake8-docstrings -pyflakes==3.1.0 +pyflakes==3.2.0 # via flake8 -pytest==7.4.3 +pytest==8.0.1 # via valimp (pyproject.toml) snowballstemmer==2.2.0 # via pydocstyle @@ -50,5 +50,5 @@ tomli==2.0.1 # via # black # pytest -typing-extensions==4.8.0 +typing-extensions==4.9.0 # via black diff --git a/src/valimp/valimp.py b/src/valimp/valimp.py index b5cac63..aa5102d 100644 --- a/src/valimp/valimp.py +++ b/src/valimp/valimp.py @@ -7,17 +7,23 @@ - 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. +The `parse_cls`decorator provides the same functionality for inputs to +dataclasses. + +Valimp does NOT currently support: + - Positional-only arguments. Any '/' in the signature (to define + positional-only arguments) will be ignored. Consequently valimp DOES + allow intended positional-only arguments to be 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: +Versions of the following examples are included to the README with +explanatory comments and inputs. + +Example usage of `@parse` with a public function: + from valimp import parse, Parser, Coerce from typing import Annotated, Union, Optional, Any @@ -39,14 +45,45 @@ def public_function( Coerce(str), Parser(lambda name, obj, _: obj + f"_{name}") ], - *, + *args: Annotated[ + Union[int, float, str], # int | float | str + Coerce(int) + ], g: Optional[str], # str | None h: Annotated[ Optional[float], # float | None Parser(lambda _, obj, params: params["b"] if obj is None else obj) ] = None, + **kwargs: bool, ) -> dict[str, Any]: - return {"a":a, "b":b, "c":c, "d":d, "e":e, "f":f, "g":g, "h":h} + return { + "a":a, + "b":b, + "c":c, + "d":d, + "e":e, + "f":f, + "args":args, + "g":g, + "h":h, + "kwargs":kwargs, + } + +Example usage of `@parse_cls` with a dataclass: + + from valimp import parse_cls + import dataclasses + + @parse_cls + @dataclasses.dataclass + class ADataclass: + + a: str + b: Annotated[ + Union[str, int], + Coerce(str), + Parser(lambda name, obj, params: obj + f" {name} {params['a']}") + ] Type validation --------------- @@ -642,7 +679,8 @@ def validate_against_signature( kwargs: dict[str, Any], req_args: list[str], req_kwargs: list[str], - all_arg_names: list[str], + excess_args: tuple[Any, ...], + excess_kwargs: list[str], ) -> list[TypeError]: """Validate inputs against arguments expected by signature. @@ -666,9 +704,13 @@ def validate_against_signature( req_kwargs List of names of required keyword-only arguments. - all_arg_names - List of all possible argument names (positional and - keyword only). + excess_args + Any objects received positionally that are not accommodated the + signature. + + excess_kwargs + Names of any received kwargs that are not accommodated by the + signature. Returns ------- @@ -689,27 +731,24 @@ def validate_against_signature( ) # 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]] + if excess_args: + obj_0 = excess_args[0] msg_end = f"\t'{obj_0}' of type {type(obj_0)}." - for a in extra_args[1:]: - obj = args_as_kwargs[a] + for obj in excess_args[1:]: 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}" + f"Received {len(excess_args)} excess positional" + f" argument{'s' if len(excess_args) > 1 else ''} as:\n{msg_end}" ) ) - extra_kwargs = [a for a in kwargs if a not in all_arg_names] - if extra_kwargs: + if excess_kwargs: errors.append( TypeError( f"Got unexpected keyword" - f" argument{'s' if len(extra_kwargs) > 1 else ''}" - f": {args_name_inset(extra_kwargs)}." + f" argument{'s' if len(excess_kwargs) > 1 else ''}" + f": {args_name_inset(excess_kwargs)}." ) ) @@ -886,22 +925,55 @@ def parse(f) -> collections.abc.Callable: all_param_names = spec.args + ( spec.kwonlyargs if spec.kwonlyargs is not None else [] ) + name_extra_args = "_" + spec.varargs if spec.varargs is not None else "_a5f12_3adz" @functools.wraps(f) def wrapped_f(*args, **kwargs) -> Any: + hints_ = hints.copy() 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 + + # handle extra args + extra_args = args[len(spec.args) :] + if spec.varargs is None: # no provision for extra args, e.g. no *args in sig + excess_args = extra_args + extra_args = tuple() + else: # extra args provided for, e.g. with *args + excess_args = tuple() + # add a hint for each extra arg + hint = hints.get(spec.varargs, False) + for i, obj in enumerate(extra_args): + key = name_extra_args + str(i) + args_as_kwargs[key] = obj + if hint: + hints_[key] = hint + if hint: + del hints_[spec.varargs] + + extra_kwargs = [a for a in kwargs if a not in all_param_names] + if spec.varkw is None: # no provision for extra kwargs, e.g. no **kwargs in sig + excess_kwargs = extra_kwargs + for name in excess_kwargs: + del kwargs[name] + extra_kwargs = [] + else: # extra kwargs provided for, e.g. with **kwargs + excess_kwargs = [] + # add a hint for each extra kwarg + if hint := hints.get(spec.varkw, False): + for name in extra_kwargs: + hints_[name] = hint + del hints_[spec.varkw] sig_errors = validate_against_signature( - args_as_kwargs, kwargs, req_args, req_kwargs, all_param_names + args_as_kwargs, kwargs, req_args, req_kwargs, excess_args, excess_kwargs ) - 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 + params_as_kwargs = { # remove arguments not provided for in signature + k: v + for k, v in (args_as_kwargs | kwargs).items() + if (k in all_param_names + extra_kwargs) or k.startswith(name_extra_args) } - ann_errors = validate_against_hints(params_as_kwargs, hints) + + ann_errors = validate_against_hints(params_as_kwargs, hints_) if sig_errors or ann_errors: raise InputsError(f.__name__, sig_errors, ann_errors) @@ -915,12 +987,16 @@ def wrapped_f(*args, **kwargs) -> Any: args_as_kwargs | not_received_args | kwargs | not_received_kwargs ) - new_as_kwargs = {} + new_extra_args = [] + new_kwargs = {} for name, obj in all_as_kwargs.items(): - if name not in hints: - new_as_kwargs[name] = obj + if name not in hints_: + if name.startswith(name_extra_args): + new_extra_args.append(obj) + else: + new_kwargs[name] = obj continue - hint = hints[name] + hint = hints_[name] if is_annotated(hint): meta = hint.__metadata__ for data in meta: @@ -931,10 +1007,43 @@ def wrapped_f(*args, **kwargs) -> Any: if isinstance(data, Parser): if obj is None and not data.parse_none: continue - obj = data.function(name, obj, new_as_kwargs.copy()) + obj = data.function(name, obj, new_kwargs.copy()) - new_as_kwargs[name] = obj + if name.startswith(name_extra_args): + new_extra_args.append(obj) + else: + new_kwargs[name] = obj - return f(**new_as_kwargs) + new_args = [] + for arg_name in spec.args: + new_args.append(new_kwargs[arg_name]) + del new_kwargs[arg_name] + + return f(*new_args, *new_extra_args, **new_kwargs) return wrapped_f + + +def parse_cls(cls): + """Decorate a class to parse the constructor's arguments. + + Can be used to verify input to a `dataclasses.dataclass`. In the + following example the a and b parameters will be parsed in the same + manner as if the __init__ method were decorated with @parse: + + from dataclasses import dataclass + from typing import Union + from valimp import parse_cls, Coerce + + @parse_cls + @dataclass + class ADataCls: + + a: str + b: Annotated[Union[str, int], Coerce(str)] + + NB The @parse_cls decorator must be placed above the @dataclass + decorator. + """ + cls.__init__ = parse(cls.__init__) + return cls diff --git a/tests/test_valimp.py b/tests/test_valimp.py index b0130c3..ef63d25 100644 --- a/tests/test_valimp.py +++ b/tests/test_valimp.py @@ -1,6 +1,7 @@ """Tests for market_prices.input module.""" from collections import abc +import dataclasses import inspect import re import sys @@ -273,6 +274,192 @@ def func( yield A() +@pytest.fixture +def datacls() -> abc.Iterator[typing.Type]: + """Dataclass with type annotations. + + Constructor's signature as 'func' fixture. + """ + + @m.parse_cls + @dataclasses.dataclass + class DataCls: + """A decorated dataclass.""" + + 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 + dd: Any = 4 + ee: Optional[dict[str, Any]] = None + + # until 3.10, set default values for 'should be' required kwargs + kwonly_req_a: bool = True + kwonly_req_b: typing.Annotated[Optional[bool], "meta"] = True + # NOTE can change to kwarg only when minimum Python version is raised to 3.10 + # _: dataclasses.KW_ONLY + # kwonly_req_a: bool + # kwonly_req_b: typing.Annotated[Optional[bool], "meta"] + + kwonly_opt: typing.Annotated[Optional[bool], "meta"] = None + kwonly_opt_b: typing.Annotated[Any, "meta"] = "kwonly_opt_b" + + yield DataCls + + +@pytest.fixture +def f_with_packed() -> abc.Iterator[abc.Callable]: + """As `f` with *args and **kwargs. + + Fixture not intended to directly test *args and **kwargs but rather to + ensure behaviour their enclusion does not change behaviour from `f` + save for as would be expected by containers to scoop up excess args. + """ + + @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, + dd: Any = 4, + ee: Optional[dict[str, Any]] = None, + *args, + kwonly_req_a: bool, + kwonly_req_b: typing.Annotated[Optional[bool], "meta"], + kwonly_opt: typing.Annotated[Optional[bool], "meta"] = None, + kwonly_opt_b: typing.Annotated[Any, "meta"] = "kwonly_opt_b", + **kwargs, + ) -> 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, + dd=dd, + ee=ee, + args=args, + kwonly_req_a=kwonly_req_a, + kwonly_req_b=kwonly_req_b, + kwonly_opt=kwonly_opt, + kwonly_opt_b=kwonly_opt_b, + kwargs=kwargs, + ) + + yield func + + @pytest.fixture def dflt_values() -> abc.Iterator[dict[str, Any]]: """Default values of optional arguments (positional and keyword-only).""" @@ -374,6 +561,12 @@ def valid_args_opt_as_kwargs() -> abc.Iterator[tuple[dict[str, Any], dict[str, A yield inputs, expected_rtrns +@pytest.fixture +def valid_args_extra() -> abc.Iterator[tuple[int, str, int, int]]: + """Valid args for extra positional arguments.""" + yield (3, "three", 3, 0) + + @pytest.fixture def valid_args_req(valid_args_req_as_kwargs) -> abc.Iterator[list[Any]]: """Valid values for required positional arguments.""" @@ -422,6 +615,12 @@ def valid_kwargs_opt() -> abc.Iterator[tuple[dict[str, Any], dict[str, Any]]]: yield inputs, expected_rtrns +@pytest.fixture +def valid_kwargs_extra() -> abc.Iterator[dict[str, Any], dict[str, Any]]: + """Valid kwargs for extra positional arguments.""" + yield {"kw_xtra0": 4, "kw_xtra1": "four_one", "kw_xtra2": 4.2} + + @pytest.fixture def valid_kwargs( valid_kwargs_req, valid_kwargs_opt @@ -443,18 +642,23 @@ def valid_args_all( yield inputs, expected_rtrns -def assertion(rtrn: Any, expected: Any): - if rtrn is None: +def assertion(rtrn: Any, name: str, expected: Any): + v = getattr(rtrn, name) if dataclasses.is_dataclass(rtrn) else rtrn[name] + if v is None: assert expected is None else: - assert rtrn == expected + assert v == expected def test_general_valid( f, inst, + datacls, + f_with_packed, valid_args_req_as_kwargs, + valid_args_extra, valid_kwargs_req, + valid_kwargs_extra, dflt_values, valid_args_req, valid_args_opt_as_kwargs, @@ -464,21 +668,26 @@ def test_general_valid( """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 + 0 pos req args as kwargs, passing no optional args and verifying defaults. + 1 pos req args positionally, passing no optional args and verifying defaults. + 2 pos req args positionally, pos opt args positionally, pos extra args if applic, + all keyword-only, extra kwargs if applic + 3 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( + for func in (f, inst.func, datacls, f_with_packed): + rtrn0 = func(**valid_args_req_as_kwargs[0], **valid_kwargs_req[0]) + rtrn1 = func(*valid_args_req, **valid_kwargs_req[0]) + extra_args = valid_args_extra if func is f_with_packed else tuple() + extra_kwargs = valid_kwargs_extra if func is f_with_packed else {} + rtrn2 = func( *valid_args_req, *valid_args_opt, + *extra_args, **valid_kwargs_req[0], **valid_kwargs_opt[0], + **extra_kwargs, ) - rtrns3 = func( + rtrn3 = func( *valid_args_req, **valid_args_opt_as_kwargs[0], **valid_kwargs_req[0], @@ -486,30 +695,30 @@ def test_general_valid( ) 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) + assertion(rtrn0, k, v) + assertion(rtrn1, k, v) + assertion(rtrn2, k, v) + assertion(rtrn3, 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) + assertion(rtrn0, k, v) + assertion(rtrn1, k, v) + assertion(rtrn2, k, v) + assertion(rtrn3, k, v) # as default values for k, v in dflt_values.items(): - assertion(rtrns0[k], v) - assertion(rtrns1[k], v) + assertion(rtrn0, k, v) + assertion(rtrn1, 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) + assertion(rtrn2, k, v) + assertion(rtrn3, k, v) for k, v in valid_kwargs_opt[1].items(): - assertion(rtrns2[k], v) - assertion(rtrns3[k], v) + assertion(rtrn2, k, v) + assertion(rtrn3, k, v) def test_union_optional_valid(f, valid_args_all): @@ -532,8 +741,8 @@ def test_union_optional_valid(f, valid_args_all): expected_rtrns |= chgs rtrns = f(**inputs) - for k, v in rtrns.items(): - assertion(v, expected_rtrns[k]) + for k in rtrns: + assertion(rtrns, k, expected_rtrns[k]) chgs_1 = dict( w=None, @@ -545,8 +754,8 @@ def test_union_optional_valid(f, valid_args_all): expected_rtrns |= chgs_1 rtrns = f(**inputs) - for k, v in rtrns.items(): - assertion(v, expected_rtrns[k]) + for k in rtrns: + assertion(rtrns, k, expected_rtrns[k]) chgs_2 = dict( y=None, @@ -556,8 +765,8 @@ def test_union_optional_valid(f, valid_args_all): expected_rtrns |= chgs_2 rtrns = f(**inputs) - for k, v in rtrns.items(): - assertion(v, expected_rtrns[k]) + for k in rtrns: + assertion(rtrns, k, expected_rtrns[k]) def test_tuple_valid(): @@ -596,8 +805,8 @@ def f( assert rtrn["f"] == (0, 1, 2, {3}) -INVALID_MSG = re.escape( - """The following inputs to 'func' do not conform with the corresponding type annotation: +INVALID_MSG = "The following inputs to '(func|__init__)' do " + re.escape( + """not conform with the corresponding type annotation: h Takes type although received '3' of type . @@ -655,36 +864,48 @@ def f( ) -def test_invalid_types(f, inst, valid_args): +def test_invalid_types(f, inst, f_with_packed, datacls, valid_args): regex = re.compile("^" + INVALID_MSG + "$") - for func in (f, inst.func): + for func in (f, inst.func, datacls, f_with_packed): + # include valid extra args if function provides for them, to verify + # that their inclusion doesn't change errors or error message. + extra_args = ["x_arg0", 1, 2.0] if func is f_with_packed else [] # valid + extra_kwargs = ( + {"x_kwarg0": "x_kwarg0", "x_kwarg1": 11, "x_kwarg2": 22} + if func is f_with_packed + else {} + ) # valid 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", + 3, # h + "three", # i + "not in literal", # j + {"not": "a list"}, # k + ["not a dict"], # l + ["not a mapping"], # m + ["not a tuple"], # n + ("foo", 1, {"spam", "bar"}), # valid # o + ["not a set"], # p + {"not": "a sequence"}, # q + ["foo", "bar"], # valid # r + "not callable", # s + lambda x: x, # valid # t + lambda x: x, # valid # u + lambda x: x, # valid # v + ["list not in union"], # w + {"dict": "not in annotated union"}, # x + {"dict": "not in annotated union"}, # y + {"dict": "not in annotated union"}, # z + 6, # aa + "not an int", # bb + "not a bool", # cc + 4, # valid # dd + None, # valid # ee + *extra_args, kwonly_req_a="not a bool", kwonly_req_b=None, # valid + *extra_kwargs, ) @@ -1602,3 +1823,108 @@ def f( # 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} + + +def test_unpacked(): + """Tests for functions that provide for packing extra arguments. + + Tests verify typing, coercion and parsing of packing arguments, e.g. + *args and **kwargs. + + Note: that defining and passing kwargs and args has no effect on other + functionality is covered by use of the f_with_packed fixture within the + `test_general_valid` and `test_invalid_types` tests. + """ + + @m.parse + def f_untyped( + a: str, + b: int = 3, + *args, + kw_a: str, + kw_b: bool = False, + **kwargs, + ): + return a, b, args, kw_a, kw_b, kwargs + + pos_args = ["a", 1] + args_extra = ("pass", ["what", "ever"], 4.4) + kwargs = {"kw_a": "kw_a"} + kwargs_extra = {"kw_c": "extra kw_c", "kw_d": "extra kw_d"} + expected = tuple( + pos_args + [args_extra] + list(kwargs.values()) + [False, kwargs_extra] + ) + assert f_untyped(*pos_args, *args_extra, **kwargs, **kwargs_extra) == expected + + @m.parse + def f_typed( + a: str, + b: int = 3, + *args: Union[str, int, float], + kw_a: str, + kw_b: bool = False, + **kwargs: dict[str, bool], + ): + return a, b, args, kw_a, kw_b, kwargs + + # verify valid inputs + args_extra = ("3.4", 3, 3.2) + kwargs_extra = { + "kw_extra0": { + "0kw_extra_1": True, + "0kw_extra_2": False, + "0kw_extra_3": True, + }, + "kw_extra1": { + "1kw_extra_1": False, + "1kw_extra_2": True, + "1kw_extra_3": False, + }, + } + expected = tuple( + pos_args + [args_extra] + list(kwargs.values()) + [False, kwargs_extra] + ) + assert f_typed(*pos_args, *args_extra, **kwargs, **kwargs_extra) == expected + + # verify raises with invalid inputs + args_extra_invalid = ("3.4", [3], 3.2) + kwargs_extra_invalid = { + "kw_extra0": { + "0kw_extra_1": True, + "0kw_extra_2": False, + "0kw_extra_3": 1, + }, + "kw_extra1": "not a dict", + } + match = re.escape( + "The following inputs to 'f_typed' do not conform with the corresponding type annotation:\n\n_args1\n\tTakes input that conforms with <(, , )> although received '[3]' of type .\n\nkw_extra0\n\tTakes type with values that conform to the second argument of , although the received dictionary contains value '1' of type .\n\nkw_extra1\n\tTakes type although received 'not a dict' of type ." + ) + with pytest.raises(m.InputsError, match=match): + f_typed(*pos_args, *args_extra_invalid, **kwargs, **kwargs_extra_invalid) + + @m.parse + def f_annotated( + a: str, + b: int = 3, + *args: Annotated[ + Union[str, int, float], + m.Coerce(int), + m.Parser(lambda name, obj, params: obj + params["b"]), + ], + kw_a: str, + kw_b: bool = False, + **kwargs: Annotated[Union[str, int, float], m.Coerce(float)], + ): + return a, b, args, kw_a, kw_b, kwargs + + args_extra = [3, "4", 5.4] + args_extra_expected = (4, 5, 6) + kwargs_extra = dict(kw_extra0=7, kw_extra1="7.2", kw_extra2=7.4) + kwargs_extra_expected = dict(kw_extra0=7.0, kw_extra1=7.2, kw_extra2=7.4) + expected = tuple( + pos_args + + [args_extra_expected] + + list(kwargs.values()) + + [False, kwargs_extra_expected] + ) + assert f_annotated(*pos_args, *args_extra, **kwargs, **kwargs_extra) == expected