diff --git a/.ai-instructions b/.ai-instructions new file mode 160000 index 0000000..609ac4a --- /dev/null +++ b/.ai-instructions @@ -0,0 +1 @@ +Subproject commit 609ac4a8d9262f93594f36ea382d30cd94ea07a4 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c7524f4..b4b44bb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@v6 - uses: prefix-dev/setup-pixi@v0.9.4 with: - pixi-version: v0.65.0 + pixi-version: v0.68.1 cache: true cache-write: ${{ github.event_name == 'push' && github.ref_name == 'main' }} frozen: true @@ -47,7 +47,7 @@ jobs: run: pixi run -e ${{ matrix.environment }} tests-with-cov - name: Upload coverage report. if: runner.os == 'Linux' && matrix.environment == 'py314' - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} run-ty: @@ -57,7 +57,7 @@ jobs: - uses: actions/checkout@v6 - uses: prefix-dev/setup-pixi@v0.9.4 with: - pixi-version: v0.65.0 + pixi-version: v0.68.1 cache: true cache-write: ${{ github.event_name == 'push' && github.ref_name == 'main' }} frozen: true diff --git a/.gitignore b/.gitignore index 0a1de5b..455f666 100644 --- a/.gitignore +++ b/.gitignore @@ -14,21 +14,20 @@ MANIFEST sdist/ wheels/ -# Documentation -docs/_build/ - # IDE .idea/ .vscode/ -# Jupyter +# Jupyter / Jupyter Book .ipynb_checkpoints/ +_build # macOS .DS_Store # pixi .pixi/ +node_modules/ # Python __pycache__/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..799092e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".ai-instructions"] + path = .ai-instructions + url = https://github.com/OpenSourceEconomics/ai-instructions.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7b8dd2d..cc3dc0b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: - id: check-hooks-apply - id: check-useless-excludes - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.18.1 + rev: v2.21.2 hooks: - id: pyproject-fmt - repo: https://github.com/lyz-code/yamlfix @@ -47,11 +47,11 @@ repos: hooks: - id: yamllint - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.37.0 + rev: 0.37.2 hooks: - id: check-github-workflows - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.6 + rev: v0.15.12 hooks: - id: ruff-check args: diff --git a/AGENTS.md b/AGENTS.md index 494f504..7eaa990 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -@https://raw.githubusercontent.com/OpenSourceEconomics/ai-instructions/make-submodule/profiles/tier-a.md +@.ai-instructions/profiles/tier-a.md # dags diff --git a/CHANGES.md b/CHANGES.md index d00951b..65bd473 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,13 @@ This is a record of all past dags releases and what went into them in reverse chronological order. We follow [semantic versioning](https://semver.org/) and all releases are available on [conda-forge](https://anaconda.org/conda-forge/dags). +## Unreleased + +## 0.6.0 + +- :gh:`82` Make dags wrappers play nicely with runtime type checkers + (:ghuser:`hmgaudecker`). + ## 0.5.1 - :gh:`79` Use AGENTS.md, update hooks and rules (:ghuser:`hmgaudecker`). diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/pixi.lock b/pixi.lock index fd229db..7712c2d 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1408,6 +1408,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h41580af_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -1565,6 +1566,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zeromq-4.3.5-h27d9b8f_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -1723,6 +1725,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h4818236_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -1882,6 +1885,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h507cc87_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -2055,6 +2059,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h41580af_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -2215,6 +2220,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zeromq-4.3.5-h27d9b8f_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -2376,6 +2382,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h4818236_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -2538,6 +2545,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h507cc87_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -2709,6 +2717,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h41580af_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -2870,6 +2879,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zeromq-4.3.5-h27d9b8f_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -3032,6 +3042,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h4818236_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -3195,6 +3206,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h507cc87_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -3366,6 +3378,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zeromq-4.3.5-h41580af_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -3377,7 +3390,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/44/bb509c3d2c0b5a87e7a5af1d5917a402a32ff026f777a6d7cb6990746cbb/tabcompleter-1.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/be/30/822bbcb92d55b65989aa7ed06d9585f28ade9c9447369194ed4b0fb3b5b9/ty-0.0.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/18/a1/a977c0e07e9f88db9c67f90c6342a4dc4422c8091fa07bf26521870687c5/ty-0.0.35-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f2/b7/eedcba86c567832699eb242709e8951c0df2b6658beb5f931e954292bcda/types_networkx-3.6.1.20260303-py3-none-any.whl - pypi: ./ osx-64: @@ -3529,6 +3542,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zeromq-4.3.5-h27d9b8f_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -3540,7 +3554,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/44/bb509c3d2c0b5a87e7a5af1d5917a402a32ff026f777a6d7cb6990746cbb/tabcompleter-1.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/72/62/0047b0bd19afeefbc7286f20a5f78a2aa39f92b4d89853f0d7185ab89edc/ty-0.0.21-py3-none-macosx_10_12_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/62/df/7e5b6f83d85b4d2e5b72b5dceb388f440acc10679417bd46f829b9200fab/ty-0.0.35-py3-none-macosx_10_12_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f2/b7/eedcba86c567832699eb242709e8951c0df2b6658beb5f931e954292bcda/types_networkx-3.6.1.20260303-py3-none-any.whl - pypi: ./ osx-arm64: @@ -3693,6 +3707,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h4818236_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -3704,7 +3719,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/44/bb509c3d2c0b5a87e7a5af1d5917a402a32ff026f777a6d7cb6990746cbb/tabcompleter-1.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a2/20/0b93a9e91aaed23155780258cdfdb4726ef68b6985378ac069bc427291a0/ty-0.0.21-py3-none-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/59/94/72d7263aca055cde427f0ebcf08d6a74e5a5fee1d1e7fdd553696089cecb/ty-0.0.35-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f2/b7/eedcba86c567832699eb242709e8951c0df2b6658beb5f931e954292bcda/types_networkx-3.6.1.20260303-py3-none-any.whl - pypi: ./ win-64: @@ -3858,6 +3873,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h507cc87_10.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda + - pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/f5/ee39c6e92acc742c052f137b47c210cd0a1b72dcd3f98495528bb4d27761/flatten_dict-0.4.2-py2.py3-none-any.whl @@ -3870,7 +3886,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/44/bb509c3d2c0b5a87e7a5af1d5917a402a32ff026f777a6d7cb6990746cbb/tabcompleter-1.4.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/8d/f8/1104808b875c26c640e536945753a78562d606bef4e241d9dbf3d92477f6/ty-0.0.21-py3-none-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/3b/36/1c7f8632bfec1c321f01581d4c940a3617b24bd3e8b37c8a7363d33fbfc4/ty-0.0.35-py3-none-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/f2/b7/eedcba86c567832699eb242709e8951c0df2b6658beb5f931e954292bcda/types_networkx-3.6.1.20260303-py3-none-any.whl - pypi: ./ packages: @@ -4479,6 +4495,111 @@ packages: - pkg:pypi/backports-zstd?source=hash-mapping size: 240406 timestamp: 1767045016907 +- pypi: https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl + name: beartype + version: 0.22.9 + sha256: d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2 + requires_dist: + - autoapi>=0.9.0 ; extra == 'dev' + - celery ; extra == 'dev' + - click ; extra == 'dev' + - coverage>=5.5 ; extra == 'dev' + - docutils>=0.22.0 ; extra == 'dev' + - equinox ; python_full_version < '3.15' and sys_platform == 'linux' and extra == 'dev' + - fastmcp ; python_full_version < '3.14' and extra == 'dev' + - jax[cpu] ; python_full_version < '3.15' and sys_platform == 'linux' and extra == 'dev' + - jaxtyping ; sys_platform == 'linux' and extra == 'dev' + - langchain ; python_full_version < '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and extra == 'dev' + - mypy>=0.800 ; platform_python_implementation != 'PyPy' and extra == 'dev' + - nuitka>=1.2.6 ; python_full_version < '3.14' and sys_platform == 'linux' and extra == 'dev' + - numba ; python_full_version < '3.14' and extra == 'dev' + - numpy ; python_full_version < '3.15' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and extra == 'dev' + - pandera>=0.26.0 ; python_full_version < '3.14' and extra == 'dev' + - poetry ; extra == 'dev' + - polars ; python_full_version < '3.14' and extra == 'dev' + - pydata-sphinx-theme<=0.7.2 ; extra == 'dev' + - pygments ; extra == 'dev' + - pyinstaller ; extra == 'dev' + - pyright>=1.1.370 ; extra == 'dev' + - pytest>=6.2.0 ; extra == 'dev' + - redis ; extra == 'dev' + - rich-click ; extra == 'dev' + - setuptools ; extra == 'dev' + - sphinx ; extra == 'dev' + - sphinx>=4.2.0,<6.0.0 ; extra == 'dev' + - sphinxext-opengraph>=0.7.5 ; extra == 'dev' + - sqlalchemy ; extra == 'dev' + - torch ; python_full_version < '3.14' and sys_platform == 'linux' and extra == 'dev' + - tox>=3.20.1 ; extra == 'dev' + - typer ; extra == 'dev' + - typing-extensions>=3.10.0.0 ; extra == 'dev' + - xarray ; python_full_version < '3.15' and extra == 'dev' + - mkdocs-material[imaging]>=9.6.0 ; extra == 'doc-ghp' + - mkdocstrings-python-xref>=1.16.0 ; extra == 'doc-ghp' + - mkdocstrings-python>=1.16.0 ; extra == 'doc-ghp' + - autoapi>=0.9.0 ; extra == 'doc-rtd' + - pydata-sphinx-theme<=0.7.2 ; extra == 'doc-rtd' + - setuptools ; extra == 'doc-rtd' + - sphinx>=4.2.0,<6.0.0 ; extra == 'doc-rtd' + - sphinxext-opengraph>=0.7.5 ; extra == 'doc-rtd' + - celery ; extra == 'test' + - click ; extra == 'test' + - coverage>=5.5 ; extra == 'test' + - docutils>=0.22.0 ; extra == 'test' + - equinox ; python_full_version < '3.15' and sys_platform == 'linux' and extra == 'test' + - fastmcp ; python_full_version < '3.14' and extra == 'test' + - jax[cpu] ; python_full_version < '3.15' and sys_platform == 'linux' and extra == 'test' + - jaxtyping ; sys_platform == 'linux' and extra == 'test' + - langchain ; python_full_version < '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and extra == 'test' + - mypy>=0.800 ; platform_python_implementation != 'PyPy' and extra == 'test' + - nuitka>=1.2.6 ; python_full_version < '3.14' and sys_platform == 'linux' and extra == 'test' + - numba ; python_full_version < '3.14' and extra == 'test' + - numpy ; python_full_version < '3.15' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and extra == 'test' + - pandera>=0.26.0 ; python_full_version < '3.14' and extra == 'test' + - poetry ; extra == 'test' + - polars ; python_full_version < '3.14' and extra == 'test' + - pygments ; extra == 'test' + - pyinstaller ; extra == 'test' + - pyright>=1.1.370 ; extra == 'test' + - pytest>=6.2.0 ; extra == 'test' + - redis ; extra == 'test' + - rich-click ; extra == 'test' + - sphinx ; extra == 'test' + - sqlalchemy ; extra == 'test' + - torch ; python_full_version < '3.14' and sys_platform == 'linux' and extra == 'test' + - tox>=3.20.1 ; extra == 'test' + - typer ; extra == 'test' + - typing-extensions>=3.10.0.0 ; extra == 'test' + - xarray ; python_full_version < '3.15' and extra == 'test' + - celery ; extra == 'test-tox' + - click ; extra == 'test-tox' + - docutils>=0.22.0 ; extra == 'test-tox' + - equinox ; python_full_version < '3.15' and sys_platform == 'linux' and extra == 'test-tox' + - fastmcp ; python_full_version < '3.14' and extra == 'test-tox' + - jax[cpu] ; python_full_version < '3.15' and sys_platform == 'linux' and extra == 'test-tox' + - jaxtyping ; sys_platform == 'linux' and extra == 'test-tox' + - langchain ; python_full_version < '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and extra == 'test-tox' + - mypy>=0.800 ; platform_python_implementation != 'PyPy' and extra == 'test-tox' + - nuitka>=1.2.6 ; python_full_version < '3.14' and sys_platform == 'linux' and extra == 'test-tox' + - numba ; python_full_version < '3.14' and extra == 'test-tox' + - numpy ; python_full_version < '3.15' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and extra == 'test-tox' + - pandera>=0.26.0 ; python_full_version < '3.14' and extra == 'test-tox' + - poetry ; extra == 'test-tox' + - polars ; python_full_version < '3.14' and extra == 'test-tox' + - pygments ; extra == 'test-tox' + - pyinstaller ; extra == 'test-tox' + - pyright>=1.1.370 ; extra == 'test-tox' + - pytest>=6.2.0 ; extra == 'test-tox' + - redis ; extra == 'test-tox' + - rich-click ; extra == 'test-tox' + - sphinx ; extra == 'test-tox' + - sqlalchemy ; extra == 'test-tox' + - torch ; python_full_version < '3.14' and sys_platform == 'linux' and extra == 'test-tox' + - typer ; extra == 'test-tox' + - typing-extensions>=3.10.0.0 ; extra == 'test-tox' + - xarray ; python_full_version < '3.15' and extra == 'test-tox' + - coverage>=5.5 ; extra == 'test-tox-coverage' + requires_python: '>=3.10' - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.14.3-pyha770c72_0.conda sha256: bf1e71c3c0a5b024e44ff928225a0874fc3c3356ec1a0b6fe719108e6d1288f6 md5: 5267bef8efea4127aacd1f4e1f149b6e @@ -5406,8 +5527,8 @@ packages: timestamp: 1770674447292 - pypi: ./ name: dags - version: 0.5.1.dev2+g7139b172b.d20260313 - sha256: 52b55361ac76033cc242b862f833794c809774101807db5b0117b5df4b8d0a36 + version: 0.5.2.dev1+g5bf08844d.d20260514 + sha256: b40091445b8648168fe3d024948eb02431918c6bba1a1d62403fd1a3d042733b requires_dist: - flatten-dict - networkx>=3.6 @@ -11412,25 +11533,25 @@ packages: - pkg:pypi/traitlets?source=hash-mapping size: 110051 timestamp: 1733367480074 -- pypi: https://files.pythonhosted.org/packages/72/62/0047b0bd19afeefbc7286f20a5f78a2aa39f92b4d89853f0d7185ab89edc/ty-0.0.21-py3-none-macosx_10_12_x86_64.whl +- pypi: https://files.pythonhosted.org/packages/18/a1/a977c0e07e9f88db9c67f90c6342a4dc4422c8091fa07bf26521870687c5/ty-0.0.35-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: ty - version: 0.0.21 - sha256: 7e9613994610431ab8625025bd2880dbcb77c5c9fabdd21134cda12d840a529d + version: 0.0.35 + sha256: eb44bb742d52c309dcaa6598bcf4d82eb4bf1241b9e4940461e522e30093fe8b requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/8d/f8/1104808b875c26c640e536945753a78562d606bef4e241d9dbf3d92477f6/ty-0.0.21-py3-none-win_amd64.whl +- pypi: https://files.pythonhosted.org/packages/3b/36/1c7f8632bfec1c321f01581d4c940a3617b24bd3e8b37c8a7363d33fbfc4/ty-0.0.35-py3-none-win_amd64.whl name: ty - version: 0.0.21 - sha256: a709d576e5bea84b745d43058d8b9cd4f27f74a0b24acb4b0cbb7d3d41e0d050 + version: 0.0.35 + sha256: 6a0a6d259f6f2f8f2f954c6f013d4e0b5eba68af6b353bf19a47d59ec254a3d5 requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/a2/20/0b93a9e91aaed23155780258cdfdb4726ef68b6985378ac069bc427291a0/ty-0.0.21-py3-none-macosx_11_0_arm64.whl +- pypi: https://files.pythonhosted.org/packages/59/94/72d7263aca055cde427f0ebcf08d6a74e5a5fee1d1e7fdd553696089cecb/ty-0.0.35-py3-none-macosx_11_0_arm64.whl name: ty - version: 0.0.21 - sha256: 56d3b198b64dd0a19b2b66e257deaed2ecea568e722ae5352f3c6fb62027f89d + version: 0.0.35 + sha256: 2cb0877419ab0c8708b6925cb0c2800b263842bd3c425113f200538772f3a0cc requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/be/30/822bbcb92d55b65989aa7ed06d9585f28ade9c9447369194ed4b0fb3b5b9/ty-0.0.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl +- pypi: https://files.pythonhosted.org/packages/62/df/7e5b6f83d85b4d2e5b72b5dceb388f440acc10679417bd46f829b9200fab/ty-0.0.35-py3-none-macosx_10_12_x86_64.whl name: ty - version: 0.0.21 - sha256: 210e7568c9f886c4d01308d751949ee714ad7ad9d7d928d2ba90d329dd880367 + version: 0.0.35 + sha256: 709dbb7af4fcadb1196863c00b8791bbbbcc9dacbe15a0ff17f0af82b35d415b requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/f2/b7/eedcba86c567832699eb242709e8951c0df2b6658beb5f931e954292bcda/types_networkx-3.6.1.20260303-py3-none-any.whl name: types-networkx diff --git a/pyproject.toml b/pyproject.toml index 4798736..0f83cbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,53 @@ build.targets.wheel.sources = [ "src" ] metadata.allow-direct-references = true version.source = "vcs" +[tool.pixi.dependencies] +jupyterlab = "*" +prek = "*" +python = ">=3.11,<3.15" +[tool.pixi.environments] +docs = [ "docs", "py314" ] +py311 = [ "py311", "tests" ] +py312 = [ "py312", "tests" ] +py313 = [ "py313", "tests" ] +py314 = [ "py314", "tests", "type-checking" ] +[tool.pixi.feature.docs.dependencies] +jupyter-book = ">=2.0" +mystmd = "*" +numpy = "*" +[tool.pixi.feature.docs.tasks] +build-docs = { cmd = "jupyter book build --html --execute", cwd = "docs" } +view-docs = { cmd = "jupyter book start --execute", cwd = "docs" } +[tool.pixi.feature.py311.dependencies] +python = "~=3.11.0" +[tool.pixi.feature.py312.dependencies] +python = "~=3.12.0" +[tool.pixi.feature.py313.dependencies] +python = "~=3.13.0" +[tool.pixi.feature.py314.dependencies] +python = "~=3.14.0" +[tool.pixi.feature.tests.dependencies] +numpy = "*" +[tool.pixi.feature.tests.pypi-dependencies] +beartype = "*" +pytest = "*" +pytest-cov = "*" +pytest-xdist = "*" +[tool.pixi.feature.tests.tasks] +tests = "pytest" +tests-with-cov = "pytest --cov-report=xml --cov=./" +[tool.pixi.feature.type-checking.pypi-dependencies] +ty = "*" +types-networkx = "*" +[tool.pixi.feature.type-checking.tasks] +ty = "ty check" +[tool.pixi.pypi-dependencies] +dags = { path = ".", editable = true } +pdbp = "*" +[tool.pixi.workspace] +channels = [ "conda-forge" ] +platforms = [ "linux-64", "osx-64", "osx-arm64", "win-64" ] + [tool.ruff] fix = true unsafe-fixes = false @@ -138,76 +185,24 @@ expand_tables = [ "tool.pixi.workspace", ] -[tool.pytest] -ini_options.addopts = [ "--pdbcls=pdbp:Pdb" ] -ini_options.filterwarnings = [] -ini_options.norecursedirs = [ "docs" ] - [tool.ty] rules.ambiguous-protocol-member = "error" rules.deprecated = "error" rules.division-by-zero = "error" rules.ignore-comment-unknown-rule = "error" -rules.invalid-argument-type = "error" +rules.ineffective-final = "error" +rules.invalid-enum-member-annotation = "error" rules.invalid-ignore-comment = "error" -rules.invalid-return-type = "error" -rules.possibly-missing-attribute = "error" -rules.possibly-missing-implicit-call = "error" -rules.possibly-missing-import = "error" -rules.possibly-unresolved-reference = "error" +rules.invalid-legacy-positional-parameter = "error" rules.redundant-cast = "error" -rules.undefined-reveal = "error" -rules.unresolved-global = "error" -rules.unsupported-base = "error" +rules.unused-awaitable = "error" rules.unused-ignore-comment = "error" -rules.useless-overload-body = "error" src.exclude = [ "docs/**/*.ipynb" ] -[tool.pixi.dependencies] -jupyterlab = "*" -prek = "*" -python = ">=3.11,<3.15" -[tool.pixi.environments] -docs = [ "docs", "py314" ] -py311 = [ "py311", "tests" ] -py312 = [ "py312", "tests" ] -py313 = [ "py313", "tests" ] -py314 = [ "py314", "tests", "type-checking" ] -[tool.pixi.feature.docs.dependencies] -jupyter-book = ">=2.0" -mystmd = "*" -numpy = "*" -[tool.pixi.feature.docs.tasks] -build-docs = { cmd = "jupyter book build --html --execute", cwd = "docs" } -view-docs = { cmd = "jupyter book start --execute", cwd = "docs" } -[tool.pixi.feature.py311.dependencies] -python = "~=3.11.0" -[tool.pixi.feature.py312.dependencies] -python = "~=3.12.0" -[tool.pixi.feature.py313.dependencies] -python = "~=3.13.0" -[tool.pixi.feature.py314.dependencies] -python = "~=3.14.0" -[tool.pixi.feature.tests.dependencies] -numpy = "*" -[tool.pixi.feature.tests.pypi-dependencies] -pytest = "*" -pytest-cov = "*" -pytest-xdist = "*" -[tool.pixi.feature.tests.tasks] -tests = "pytest" -tests-with-cov = "pytest --cov-report=xml --cov=./" -[tool.pixi.feature.type-checking.pypi-dependencies] -ty = "*" -types-networkx = "*" -[tool.pixi.feature.type-checking.tasks] -ty = "ty check" -[tool.pixi.pypi-dependencies] -dags = { path = ".", editable = true } -pdbp = "*" -[tool.pixi.workspace] -channels = [ "conda-forge" ] -platforms = [ "linux-64", "osx-64", "osx-arm64", "win-64" ] +[tool.pytest] +ini_options.addopts = [ "--pdbcls=pdbp:Pdb" ] +ini_options.filterwarnings = [] +ini_options.norecursedirs = [ "docs" ] [tool.yamlfix] line_length = 88 diff --git a/src/dags/annotations.py b/src/dags/annotations.py index 98bcdbe..c1cc977 100644 --- a/src/dags/annotations.py +++ b/src/dags/annotations.py @@ -137,23 +137,37 @@ def _get_annotations_from_signature( ) -> dict[str, Any]: """Extract annotations from the function signature. - This is a fallback for when inspect.get_annotations returns incorrect results, - such as in Python 3.14's args/kwargs annotation mismatch case. + This is a fallback for when inspect.get_annotations returns incorrect results: + the Python 3.14 args/kwargs annotation mismatch, and dags wrappers + (`with_signature`, `rename_arguments`, the `*_output` converters), which + advertise the `*args, **kwargs` forwarder shape on `__annotations__` and + keep the user-described view on `__signature__`. """ sig = inspect.signature(func) annotations: dict[str, Any] = {} for param_name, param in sig.parameters.items(): if param.annotation != inspect.Parameter.empty: - annotations[param_name] = ( - param.annotation - if eval_str or isinstance(param.annotation, str) - else _get_str_repr(param.annotation) + annotations[param_name] = _normalise_annotation( + param.annotation, eval_str=eval_str ) if sig.return_annotation != inspect.Signature.empty: - annotations["return"] = ( - sig.return_annotation - if eval_str or isinstance(sig.return_annotation, str) - else _get_str_repr(sig.return_annotation) + annotations["return"] = _normalise_annotation( + sig.return_annotation, eval_str=eval_str ) return annotations + + +def _normalise_annotation(annotation: Any, *, eval_str: bool) -> Any: + """Normalise a single signature annotation for `get_annotations`. + + With `eval_str=True` the annotation is returned untouched. With + `eval_str=False` the caller wants strings: bare `type` objects are + reduced to their `__name__`, but strings and the structured return + annotations produced by the `*_output` converters (a `tuple` / `list` + / `dict` of type strings) are passed through as-is — stringifying + those would lose their structure. + """ + if eval_str or isinstance(annotation, (str, tuple, list, dict)): + return annotation + return _get_str_repr(annotation) diff --git a/src/dags/output.py b/src/dags/output.py index 8cb3c2c..0785f8b 100644 --- a/src/dags/output.py +++ b/src/dags/output.py @@ -3,9 +3,11 @@ import functools import inspect from collections.abc import Callable, Sequence -from typing import Any, Unpack, overload +from typing import Any, Unpack, cast, overload +from dags.annotations import get_annotations from dags.exceptions import DagsError +from dags.signature import forwarder_annotations from dags.typing import MixedTupleType, P, T @@ -16,15 +18,24 @@ def _apply_return_annotation( ) -> None: """Apply a new return annotation to a wrapper function. - Updates both __signature__ and __annotations__ on the wrapper. + The user-described view (parameters from `func`, plus the new + `return_annotation`) is written to the wrapper's `__signature__`. The + wrapper's `__annotations__` advertises the `*args, **kwargs` forwarder + shape — the wrapper accepts anything at the Python level and only + `func` expects the typed arguments. See `forwarder_annotations` for + the rationale; `dags.get_annotations` recovers the user view from + `__signature__`. + + `func` is itself typically a dags wrapper (e.g. the `with_signature` + output of `concatenate_functions`), so its return annotation lives on + `__signature__`, not `__annotations__` — read it via + `dags.get_annotations`. """ signature = inspect.signature(func) - annotations = inspect.get_annotations(func) - if "return" in annotations: + if "return" in get_annotations(func): signature = signature.replace(return_annotation=return_annotation) - annotations["return"] = return_annotation wrapper.__signature__ = signature # ty: ignore[unresolved-attribute] - wrapper.__annotations__ = annotations + wrapper.__annotations__ = forwarder_annotations() def single_output( @@ -40,9 +51,13 @@ def wrapper_single_output(*args: P.args, **kwargs: P.kwargs) -> T: return raw[0] if set_annotations: - annotations = inspect.get_annotations(func) + annotations = get_annotations(func) if "return" in annotations: - tuple_of_types: tuple[str, ...] = annotations["return"] + # `func` is a tuple-returning concatenated function; its return + # annotation (recovered from `__signature__`) is a tuple of type + # strings, which `get_annotations`' `dict[str, str]` type cannot + # express. + tuple_of_types = cast("tuple[str, ...]", annotations["return"]) _apply_return_annotation(wrapper_single_output, func, tuple_of_types[0]) return wrapper_single_output @@ -88,9 +103,9 @@ def wrapper_dict_output(*args: P.args, **kwargs: P.kwargs) -> dict[str, T]: return dict(zip(keys, raw, strict=True)) if set_annotations: - annotations = inspect.get_annotations(func) + annotations = get_annotations(func) if "return" in annotations: - tuple_of_types: tuple[str, ...] = annotations["return"] + tuple_of_types = cast("tuple[str, ...]", annotations["return"]) return_annotation = dict(zip(keys, tuple_of_types, strict=True)) _apply_return_annotation(wrapper_dict_output, func, return_annotation) @@ -112,9 +127,9 @@ def wrapper_list_output(*args: P.args, **kwargs: P.kwargs) -> list[T]: return list(raw) if set_annotations: - annotations = inspect.get_annotations(func) + annotations = get_annotations(func) if "return" in annotations: - tuple_of_types: tuple[str, ...] = annotations["return"] + tuple_of_types = cast("tuple[str, ...]", annotations["return"]) _apply_return_annotation(wrapper_list_output, func, list(tuple_of_types)) return wrapper_list_output diff --git a/src/dags/signature.py b/src/dags/signature.py index 1f46ac6..7bea8e6 100644 --- a/src/dags/signature.py +++ b/src/dags/signature.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Mapping, Sequence from typing import Any, overload -from dags.annotations import get_annotations, get_free_arguments +from dags.annotations import get_free_arguments from dags.exceptions import DagsError, InvalidFunctionArgumentsError from dags.typing import P, R @@ -52,21 +52,6 @@ def _create_signature( ) -def _create_annotations( - args_types: dict[str, str] | dict[str, type[inspect._empty]], - kwargs_types: dict[str, str] | dict[str, type[inspect._empty]], - return_annotation: Any, -) -> ( - dict[str, str] - | dict[str, str | type[inspect._empty]] - | dict[str, type[inspect._empty]] -): - annotations = args_types | kwargs_types - if return_annotation is not inspect.Parameter.empty: - annotations["return"] = return_annotation - return annotations - - @overload def with_signature( func: Callable[P, R], @@ -98,6 +83,16 @@ def with_signature( ) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]: """Add a signature to a function of type `f(*args, **kwargs)` (decorator). + The user-described view (parameter names, kinds, and any type strings + passed via `args` / `kwargs`) is written to the wrapper's + `__signature__`. The wrapper's `__annotations__` advertises the + `*args, **kwargs` forwarder shape instead — the wrapper genuinely + accepts anything at the Python level, and only the wrapped function + expects the user-typed arguments. Runtime type checkers (beartype, + typeguard) read `__annotations__` and therefore treat the wrapper as + permissive; `dags.get_annotations` recovers the user view from + `__signature__` via its built-in args/kwargs-mismatch fallback. + Caveats: The created signature only contains the names of arguments and whether they are keyword-only. There is no way of setting default values or type hints. @@ -123,7 +118,6 @@ def decorator_with_signature(func: Callable[P, R]) -> Callable[P, R]: _args = _map_names_to_types(args) _kwargs = _map_names_to_types(kwargs) signature = _create_signature(_args, _kwargs, return_annotation) - annotations = _create_annotations(_args, _kwargs, return_annotation) valid_kwargs: set[str] = set(_kwargs) | set(_args) funcname: str = getattr(func, "__name__", "function") @@ -143,7 +137,7 @@ def wrapper_with_signature(*args: P.args, **kwargs: P.kwargs) -> R: return func(*args, **kwargs) wrapper_with_signature.__signature__ = signature # ty: ignore[unresolved-attribute] - wrapper_with_signature.__annotations__ = annotations + wrapper_with_signature.__annotations__ = forwarder_annotations() return wrapper_with_signature if func is not None: @@ -151,6 +145,28 @@ def wrapper_with_signature(*args: P.args, **kwargs: P.kwargs) -> R: return decorator_with_signature +def forwarder_annotations() -> dict[str, Any]: + """Build `__annotations__` advertising a `*args, **kwargs` forwarder. + + Every dags wrapper (`with_signature`, `rename_arguments`, the + `*_output` converters) is a `def wrapper(*args, **kwargs)` forwarder: + it accepts anything at the Python level and delegates to the wrapped + function. Its `__annotations__` reflects that — `{"args": object, + "kwargs": object}` — so runtime type checkers reading + `__annotations__` (beartype, typeguard, `typing.get_type_hints`) see a + permissive forwarder and do not enforce the wrapped function's + per-parameter annotations against the wrapper's actual arguments. + + The user-described view (parameter names, types, return annotation) + lives on the wrapper's `__signature__` instead, and + `dags.get_annotations` recovers it from there via its + args/kwargs-mismatch fallback. No return annotation is written so + there are no string forward refs to resolve against the wrapper's + `__module__`, where the referenced names may not be importable. + """ + return {"args": object, "kwargs": object} + + def _fail_if_too_many_positional_arguments( present_args: tuple[Any, ...], argnames: list[str], funcname: str ) -> None: @@ -213,11 +229,20 @@ def rename_arguments( ) -> Callable[[Callable[P, R]], Callable[..., R]]: ... -def rename_arguments( # noqa: C901 - func: Callable[P, R] | None = None, *, mapper: Mapping[str, str] | None = None +def rename_arguments( + func: Callable[P, R] | None = None, + *, + mapper: Mapping[str, str] | None = None, ) -> Callable[..., R] | Callable[[Callable[P, R]], Callable[..., R]]: """Rename positional and keyword arguments of func. + The renamed user-described view is written to the wrapper's + `__signature__`. The wrapper's `__annotations__` advertises the + `*args, **kwargs` forwarder shape — runtime type checkers see a + permissive forwarder, and `dags.get_annotations` recovers the renamed + view from `__signature__` via its args/kwargs-mismatch fallback. See + `forwarder_annotations` for the rationale. + Args: func (callable): The function of which the arguments are renamed. mapper (dict): Dict of strings where keys are old names and values are new @@ -236,10 +261,8 @@ def decorator_rename_arguments(func: Callable[P, R]) -> Callable[..., R]: for name, param in old_signature.parameters.items() if name in free_arguments } - old_annotations = get_annotations(func) parameters: list[inspect.Parameter] = [] - annotations: dict[str, str] = {} # mapper is assumed not to be None when renaming is desired. for name, param in old_parameters.items(): if mapper is not None and name in mapper: @@ -247,14 +270,6 @@ def decorator_rename_arguments(func: Callable[P, R]) -> Callable[..., R]: else: parameters.append(param) - # annotations do not contain information on partialled arguments, and therefore - # do not exactly align with the parameters. - for name, annotation in old_annotations.items(): - if mapper is not None and name in mapper: - annotations[mapper[name]] = annotation - else: - annotations[name] = annotation - signature = inspect.Signature( parameters=parameters, return_annotation=old_signature.return_annotation ) @@ -274,7 +289,7 @@ def wrapper_rename_arguments(*args: P.args, **kwargs: P.kwargs) -> R: return func(*args, **internal_kwargs) wrapper_rename_arguments.__signature__ = signature # ty: ignore[unresolved-attribute] - wrapper_rename_arguments.__annotations__ = annotations + wrapper_rename_arguments.__annotations__ = forwarder_annotations() return wrapper_rename_arguments diff --git a/src/dags/tree/tree_utils.py b/src/dags/tree/tree_utils.py index f0f5cb5..c748f84 100644 --- a/src/dags/tree/tree_utils.py +++ b/src/dags/tree/tree_utils.py @@ -3,6 +3,8 @@ import re import flatten_dict as fd +import flatten_dict.reducers +import flatten_dict.splitters from dags.tree.typing import FlatQNameDict, FlatTreePathDict, NestedStructureDict @@ -11,8 +13,8 @@ _python_identifier: str = r"[a-zA-Z_\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF][a-zA-Z0-9_\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]*" # noqa: E501 # Reducers and splitters to flatten/unflatten dicts with qualified names as keys -_qualified_name_reducer = fd.reducers.make_reducer(delimiter=QNAME_DELIMITER) # ty: ignore[possibly-missing-attribute] -_qualified_name_splitter = fd.splitters.make_splitter(delimiter=QNAME_DELIMITER) # ty: ignore[possibly-missing-attribute] +_qualified_name_reducer = fd.reducers.make_reducer(delimiter=QNAME_DELIMITER) +_qualified_name_splitter = fd.splitters.make_splitter(delimiter=QNAME_DELIMITER) def qname_from_tree_path(tree_path: tuple[str, ...]) -> str: diff --git a/tests/test_annotations.py b/tests/test_annotations.py index 1462354..5af191e 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -2,7 +2,6 @@ from __future__ import annotations import functools -import inspect from typing import Literal import numpy as np @@ -67,7 +66,7 @@ def test_concatenate_functions_without_input( "list": [], "dict": {}, } - assert inspect.get_annotations(concatenated, eval_str=True) == { + assert get_annotations(concatenated, eval_str=True) == { "return": expected_type[return_type], } diff --git a/tests/test_dag.py b/tests/test_dag.py index bb0adac..e72ab10 100644 --- a/tests/test_dag.py +++ b/tests/test_dag.py @@ -110,7 +110,7 @@ def test_concatenate_functions_no_target_annotations( "leisure_weight": "float", "return": ("float", "int", "float"), } - assert inspect.get_annotations(concatenated_no_target) == expected_annotations + assert get_annotations(concatenated_no_target) == expected_annotations def test_concatenate_functions_single_target_results( @@ -133,7 +133,7 @@ def test_concatenate_functions_single_target_annotations( "leisure_weight": "float", "return": "float", } - assert inspect.get_annotations(concatenated_utility_target) == expected_annotations + assert get_annotations(concatenated_utility_target) == expected_annotations @pytest.mark.parametrize("return_type", ["tuple", "list", "dict"]) @@ -184,7 +184,7 @@ def test_concatenate_functions_multi_target_annotations( "leisure_weight": "float", "return": return_annotations[return_type], } - assert inspect.get_annotations(concatenated) == expected_annotation + assert get_annotations(concatenated) == expected_annotation def test_get_ancestors_many_ancestors() -> None: @@ -252,7 +252,7 @@ def g(f: float, d: int) -> float: ) assert concatenated(c=True, d=4) == 1 + 2 + float(True) + 4 - assert inspect.get_annotations(concatenated) == { + assert get_annotations(concatenated) == { "c": "bool", "d": "int", "return": "float", @@ -344,7 +344,7 @@ def f(a): set_annotations=True, ) - assert inspect.get_annotations(concatenated) == { + assert get_annotations(concatenated) == { "a": "no_annotation_found", "return": ("no_annotation_found",), } @@ -373,7 +373,7 @@ def f2() -> bool: ) assert aggregated() is False - assert inspect.get_annotations(aggregated)["return"] == "bool" + assert get_annotations(aggregated)["return"] == "bool" def test_aggregator_return_type_inferred_from_targets() -> None: @@ -394,7 +394,7 @@ def f2() -> bool: assert aggregated() is False # Return type should be inferred from targets (both bool) - assert inspect.get_annotations(aggregated)["return"] == "bool" + assert get_annotations(aggregated)["return"] == "bool" def test_aggregator_return_type_inferred_from_typed_aggregator() -> None: @@ -418,7 +418,7 @@ def typed_and(a: bool, b: bool) -> bool: assert aggregated() is False # Return type should be inferred from aggregator - assert inspect.get_annotations(aggregated)["return"] == "bool" + assert get_annotations(aggregated)["return"] == "bool" def test_aggregator_return_type_warns_when_cannot_infer() -> None: @@ -462,7 +462,7 @@ def typed_add(a: float, b: float) -> float: assert aggregated() == 3.0 # Return type should be inferred from aggregator since target types differ - assert inspect.get_annotations(aggregated)["return"] == "float" + assert get_annotations(aggregated)["return"] == "float" def test_aggregator_return_type_ignored_when_set_annotations_false() -> None: @@ -484,8 +484,8 @@ def f2() -> bool: ) assert aggregated() is False - # No return annotation should be set - assert "return" not in inspect.get_annotations(aggregated) + # No return annotation should be set on the wrapper's signature. + assert inspect.signature(aggregated).return_annotation is inspect.Signature.empty def test_aggregator_no_inference_when_set_annotations_false() -> None: @@ -512,7 +512,7 @@ def f2(): ) assert aggregated() is False - assert "return" not in inspect.get_annotations(aggregated) + assert inspect.signature(aggregated).return_annotation is inspect.Signature.empty def test_concatenate_functions_invalid_return_type_raises() -> None: @@ -520,7 +520,7 @@ def test_concatenate_functions_invalid_return_type_raises() -> None: concatenate_functions( functions=[_leisure, _consumption], targets=["_leisure", "_consumption"], - return_type="set", # type: ignore[arg-type] + return_type="set", # ty: ignore[invalid-argument-type] ) @@ -545,7 +545,7 @@ def test_concatenate_functions_non_string_targets() -> None: with pytest.raises(DagsError, match="Targets must be strings"): concatenate_functions( functions={"f": lambda: 1}, - targets=[1], # type: ignore[list-item] + targets=[1], # ty: ignore[invalid-argument-type] ) diff --git a/tests/test_process_output.py b/tests/test_process_output.py index 0b335bd..0a2a65e 100644 --- a/tests/test_process_output.py +++ b/tests/test_process_output.py @@ -31,7 +31,13 @@ def test_single_output(f: Callable[..., Any]) -> None: def test_single_output_annotations(f: Callable[..., Any]) -> None: g = single_output(f, set_annotations=True) - assert inspect.get_annotations(g) == {"foo": "bool", "return": "int"} + # The wrapper advertises the `*args, **kwargs` forwarder shape on + # `__annotations__`; the user view (params + transformed return) lives + # on `__signature__`. + assert g.__annotations__ == {"args": object, "kwargs": object} + sig = inspect.signature(g) + assert sig.parameters["foo"].annotation == "bool" + assert sig.return_annotation == "int" def test_dict_output(f: Callable[..., Any]) -> None: @@ -41,10 +47,10 @@ def test_dict_output(f: Callable[..., Any]) -> None: def test_dict_output_annotations(f: Callable[..., Any]) -> None: g = dict_output(f, keys=["a", "b"], set_annotations=True) - assert inspect.get_annotations(g) == { - "foo": "bool", - "return": {"a": "int", "b": "float"}, - } + assert g.__annotations__ == {"args": object, "kwargs": object} + sig = inspect.signature(g) + assert sig.parameters["foo"].annotation == "bool" + assert sig.return_annotation == {"a": "int", "b": "float"} def test_list_output(f: Callable[..., Any]) -> None: @@ -54,7 +60,10 @@ def test_list_output(f: Callable[..., Any]) -> None: def test_list_output_annotations(f: Callable[..., Any]) -> None: g = list_output(f, set_annotations=True) - assert inspect.get_annotations(g) == {"foo": "bool", "return": ["int", "float"]} + assert g.__annotations__ == {"args": object, "kwargs": object} + sig = inspect.signature(g) + assert sig.parameters["foo"].annotation == "bool" + assert sig.return_annotation == ["int", "float"] def test_aggregated_output_decorator(f: Callable[..., Any]) -> None: diff --git a/tests/test_runtime_type_checkers.py b/tests/test_runtime_type_checkers.py new file mode 100644 index 0000000..3a3388b --- /dev/null +++ b/tests/test_runtime_type_checkers.py @@ -0,0 +1,152 @@ +"""Runtime type checkers must treat dags wrappers as permissive forwarders. + +Every dags wrapper (`with_signature`, `rename_arguments`, the `*_output` +converters) is a `def wrapper(*args, **kwargs)` forwarder: it accepts anything at +the Python level and delegates to the wrapped function. Its `__annotations__` +advertises that forwarder shape (`{"args": object, "kwargs": object}`) while the +user-described view (parameter names, types, return annotation) lives on +`__signature__`. + +This mirrors how dags is consumed downstream: pylcm installs beartype's import +claw on subpackages that build DAGs with dags wrappers. If a dags wrapper ever +advertised the user view on `__annotations__` again, beartype would enforce the +wrapped function's per-parameter types against the wrapper's actual arguments and +reject calls the wrapper legitimately forwards (e.g. JAX tracers passed where the +user annotation says `float`). These tests guard that invariant: beartype- +decorating a dags wrapper and calling it with arguments that violate the *user* +signature must not raise. + +Do not add "from __future__ import annotations" here -- the test relies on the +wrappers' `__annotations__` being the actual forwarder objects, not strings. +""" + +from collections.abc import Callable +from functools import partial +from typing import Any + +import pytest +from beartype import BeartypeConf, BeartypeStrategy, beartype + +from dags.dag import concatenate_functions +from dags.output import aggregated_output, dict_output, list_output, single_output +from dags.signature import rename_arguments, with_signature + +# Representative of pylcm's perimeter setup: full O(n) container validation and +# the PEP-484 numeric tower. The forwarder-shape invariant is conf-independent, +# but exercising a realistic conf keeps the test honest against the way dags is +# actually consumed downstream. +_CONF = BeartypeConf(strategy=BeartypeStrategy.On, is_pep484_tower=True) + + +def _with_signature_wrapper() -> Callable[..., Any]: + @with_signature(args={"a": "int"}, kwargs={"b": "float"}) + def f(*args: Any, **kwargs: Any) -> tuple[Any, Any]: + return (args[0], kwargs["b"]) + + return f + + +def _rename_arguments_wrapper() -> Callable[..., Any]: + def base(x: int, *, y: float) -> tuple[int, float]: + return (x, y) + + return rename_arguments(base, mapper={"x": "a", "y": "b"}) + + +def _output_wrapper( + converter: Callable[..., Callable[..., Any]], +) -> Callable[..., Any]: + def f(*args: Any, **kwargs: Any) -> tuple[Any, Any]: + return (args[0], kwargs["b"]) + + # A tuple return annotation, set directly because Python has no tuple syntax + # in annotation position. This is the shape the `*_output` converters consume + # from `concatenate_functions`. + f.__annotations__ = {"a": "int", "b": "float", "return": ("int", "float")} + return converter(f, set_annotations=True) + + +_WRAPPER_FACTORIES: dict[str, Callable[[], Callable[..., Any]]] = { + "with_signature": _with_signature_wrapper, + "rename_arguments": _rename_arguments_wrapper, + "single_output": partial(_output_wrapper, single_output), + "list_output": partial(_output_wrapper, list_output), + "dict_output": partial(_output_wrapper, partial(dict_output, keys=["a", "b"])), + "aggregated_output": partial( + _output_wrapper, + partial( + aggregated_output, + aggregator=lambda x, y: x + y, + return_annotation="int", + ), + ), +} + + +@pytest.mark.parametrize( + "factory", _WRAPPER_FACTORIES.values(), ids=list(_WRAPPER_FACTORIES) +) +def test_beartype_does_not_enforce_user_signature_on_wrapper( + factory: Callable[[], Callable[..., Any]], +) -> None: + """Beartype must treat a dags wrapper as a permissive `*args, **kwargs` forwarder. + + The user signature types `a` as `int` and `b` as `float`. Calling the + beartype-decorated wrapper with a `str` for both violates that user view -- + but the wrapper genuinely forwards `*args, **kwargs`, so beartype (reading the + forwarder `__annotations__`) must not raise. + """ + wrapper = factory() + checked = beartype(conf=_CONF)(wrapper) + checked("not an int", b="not a float") + + +def _root(x: int) -> int: + return x * 2 + + +def _derived(_root: int, y: float) -> float: + return _root + y + + +# The four ways `concatenate_functions` assembles the returned function: a bare +# `with_signature` wrapper (`return_type="tuple"`), a `*_output` converter over +# one (`list`/`dict`), or `aggregated_output` over one. `aggregator_return_type` +# is passed so the aggregator case does not emit a `DagsWarning` under +# `set_annotations=True`; it is harmlessly ignored under `set_annotations=False`. +_DAG_VARIANTS: dict[str, dict[str, Any]] = { + "tuple": {"return_type": "tuple"}, + "list": {"return_type": "list"}, + "dict": {"return_type": "dict"}, + "aggregator": { + "aggregator": lambda a, b: a + b, + "aggregator_return_type": "float", + }, +} + + +@pytest.mark.parametrize( + "set_annotations", [False, True], ids=["no_annotations", "set_annotations"] +) +@pytest.mark.parametrize("dag_kwargs", _DAG_VARIANTS.values(), ids=list(_DAG_VARIANTS)) +def test_beartype_does_not_enforce_user_signature_on_concatenated_function( + dag_kwargs: dict[str, Any], + set_annotations: bool, +) -> None: + """The genuine `concatenate_functions` output must read as a forwarder too. + + This exercises the real assembly path -- a `with_signature` wrapper, possibly + nested inside a `*_output` converter whose `_apply_return_annotation` calls + `get_annotations` on it -- across every `return_type` and both + `set_annotations` values. The DAG's root params are `x: int` and `y: float`; + `x=1.5` is a `float`, valid arithmetic but a violation of the `int` user + annotation. With the forwarder shape on `__annotations__`, beartype must not + raise. + """ + concatenated = concatenate_functions( + functions=[_root, _derived], + set_annotations=set_annotations, + **dag_kwargs, + ) + checked = beartype(conf=_CONF)(concatenated) + checked(x=1.5, y=2.0) diff --git a/tests/test_signature.py b/tests/test_signature.py index 2822cf9..a78aba5 100644 --- a/tests/test_signature.py +++ b/tests/test_signature.py @@ -6,6 +6,7 @@ import pytest +from dags.annotations import get_annotations from dags.exceptions import DagsError, InvalidFunctionArgumentsError from dags.signature import _create_signature, rename_arguments, with_signature @@ -38,8 +39,8 @@ def example_signature_annotated() -> inspect.Signature: def test_create_signature(example_signature: inspect.Signature) -> None: created = _create_signature( - args_types={"a": inspect.Parameter.empty, "b": inspect.Parameter.empty}, # ty: ignore[invalid-argument-type] - kwargs_types={"c": inspect.Parameter.empty}, # ty: ignore[invalid-argument-type] + args_types={"a": inspect.Parameter.empty, "b": inspect.Parameter.empty}, + kwargs_types={"c": inspect.Parameter.empty}, ) assert created == example_signature @@ -124,6 +125,101 @@ def f(*args, **kwargs): assert f(x=3) == 3 +def test_with_signature_advertises_forwarder_shape_on_annotations() -> None: + """`with_signature` advertises the `*args, **kwargs` forwarder on `__annotations__`. + + The wrapper is a generic `*args, **kwargs` forwarder; its + `__annotations__` says so (`{"args": object, "kwargs": object}`). + Runtime type checkers reading `__annotations__` (beartype, typeguard, + `typing.get_type_hints`) therefore treat the wrapper as permissive and + do not enforce the wrapped function's per-parameter annotations. + """ + + @with_signature(args={"a": "int"}, kwargs={"b": "float"}, return_annotation="float") + def f(*args, **kwargs): + return sum(args) + sum(kwargs.values()) + + assert f.__annotations__ == {"args": object, "kwargs": object} + assert "a" not in f.__annotations__ + assert "b" not in f.__annotations__ + assert "return" not in f.__annotations__ + + +def test_with_signature_keeps_user_view_on_signature() -> None: + """`with_signature` keeps the user-described view on `__signature__`. + + Introspection tools using `inspect.signature` see parameter names, + kinds, and the type strings passed via `args` / `kwargs`, even though + `__annotations__` only carries the forwarder shape. + """ + + @with_signature(args={"a": "int"}, kwargs={"b": "float"}, return_annotation="float") + def f(*args, **kwargs): + return sum(args) + sum(kwargs.values()) + + sig = inspect.signature(f) + assert sig.parameters["a"].annotation == "int" + assert sig.parameters["b"].annotation == "float" + assert sig.return_annotation == "float" + + +def test_with_signature_get_annotations_recovers_user_view() -> None: + """`dags.get_annotations` recovers the user view from a `with_signature` wrapper. + + The args/kwargs-mismatch fallback recognises the forwarder shape on + `__annotations__` and reads the user-described view off `__signature__` + instead, so dags' own machinery (DAG resolution, signature tooling) + keeps working on wrappers that advertise the forwarder shape. + """ + + @with_signature(args={"a": "int"}, kwargs={"b": "float"}, return_annotation="float") + def f(*args, **kwargs): + return sum(args) + sum(kwargs.values()) + + recovered = get_annotations(f) + assert recovered["a"] == "int" + assert recovered["b"] == "float" + assert recovered["return"] == "float" + + +def test_rename_arguments_advertises_forwarder_shape_on_annotations() -> None: + """`rename_arguments` advertises the forwarder shape on `__annotations__`. + + Same semantics as `with_signature`: the wrapper is a forwarder, so its + `__annotations__` is the forwarder shape and the renamed user view + lives on `__signature__`. + """ + + def f(a: int, b: float) -> float: + return a + b + + renamed = rename_arguments(f, mapper={"a": "x"}) + + assert renamed.__annotations__ == {"args": object, "kwargs": object} + assert "x" not in renamed.__annotations__ + sig = inspect.signature(renamed) + assert "x" in sig.parameters + assert "b" in sig.parameters + assert sig.return_annotation == "float" + + +def test_rename_arguments_get_annotations_recovers_user_view() -> None: + """`dags.get_annotations` recovers the renamed view from the wrapper.""" + + def f(a: int, b: float) -> float: + return a + b + + renamed = rename_arguments(f, mapper={"a": "x"}) + + recovered = get_annotations(renamed) + assert recovered["x"] == "int" + assert recovered["b"] == "float" + assert recovered["return"] == "float" + sig = inspect.signature(renamed) + assert "x" in sig.parameters + assert "b" in sig.parameters + + def test_with_signature_decorator_too_many_positional_arguments() -> None: @with_signature(args=["a", "b"], kwargs=["c"]) def f(*args, **kwargs): @@ -175,7 +271,11 @@ def test_rename_arguments_decorator_annotated() -> None: def f(d: int, e: float, *, f: bool) -> float: return d + e + f - assert inspect.get_annotations(f) == { + # `__annotations__` carries the forwarder shape; the renamed user view + # is recovered via `dags.get_annotations` (which falls back to + # `__signature__`). + assert f.__annotations__ == {"args": object, "kwargs": object} + assert get_annotations(f) == { "a": "int", "b": "float", "c": "bool", @@ -200,7 +300,10 @@ def f(d: int, e: float, *, f: bool) -> float: g = rename_arguments(f, mapper={"e": "b", "d": "a", "f": "c"}) - assert inspect.get_annotations(g) == { + # `__annotations__` carries the forwarder shape; the renamed user view + # is recovered via `dags.get_annotations`. + assert g.__annotations__ == {"args": object, "kwargs": object} + assert get_annotations(g) == { "a": "int", "b": "float", "c": "bool", @@ -243,7 +346,7 @@ def f(*args, **kwargs): def test_with_signature_invalid_args_type_int() -> None: with pytest.raises(DagsError, match="Invalid type for arg"): - @with_signature(args=42) # type: ignore[arg-type] + @with_signature(args=42) # ty: ignore[invalid-argument-type] def f(*args, **kwargs): pass