diff --git a/pixi.lock b/pixi.lock index e287ed2ae..8d18100d7 100644 --- a/pixi.lock +++ b/pixi.lock @@ -455,6 +455,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/cb/2a/6735b2edff23d045441b5917f35c01a4c139a186604847ca19bbd63e81e2/hypothesis-6.170.14-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl @@ -504,6 +505,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl @@ -649,6 +651,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/b2/1a/b46a2959b41ff9409af8b43fd63b07c6ed51958db0e9d314db9ee6012f5a/hypothesis-6.170.14-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl - pypi: https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl @@ -698,6 +701,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl - pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl @@ -797,6 +801,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/7c/5b/cdd2c612277b7ac7ec8c0c9bc41812c43dc7b2d5f2b0897e15fdf5a1f915/fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6e/ac/687277e019d68b96d2c33b979ad8a955b5747e279891f8c335bbf58cd9bc/hypothesis-6.170.14-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl @@ -846,6 +851,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl @@ -985,6 +991,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/cb/2a/6735b2edff23d045441b5917f35c01a4c139a186604847ca19bbd63e81e2/hypothesis-6.170.14-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/08/2a/5628a99d04acb2d2f2e749cdf4ea571d2575e898df0528a090948018b726/ipython-9.5.0-py3-none-any.whl @@ -1028,6 +1035,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl @@ -1169,6 +1177,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/b2/1a/b46a2959b41ff9409af8b43fd63b07c6ed51958db0e9d314db9ee6012f5a/hypothesis-6.170.14-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/aa/62893d6a591d337aa59dcc4c6f6c842f1fe20cd72c8c5c1f980255243252/ipython-9.7.0-py3-none-any.whl @@ -1212,6 +1221,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl @@ -1307,6 +1317,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6e/ac/687277e019d68b96d2c33b979ad8a955b5747e279891f8c335bbf58cd9bc/hypothesis-6.170.14-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/c5/d5e07995077e48220269c28a221e168c91123ad5ceee44d548f54a057fc0/ipython-9.6.0-py3-none-any.whl @@ -1350,6 +1361,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl @@ -1504,6 +1516,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/94/5c/092682ae7ca1aadd44aa36b0bc35c48c9ed55f9ffebb5794980b1e2ae74b/greenlet-3.5.6-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/42/42/31e66ce21aa6ea030ace8874269e5a169b0c69d8a3043042e315bd64c6ad/hypothesis-6.168.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl @@ -1546,6 +1559,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/9c/15fb18bd708949dc953fe120be8d3c7a1b1b140c2592a9c81ce7961e9ecf/s3fs-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/93/9d26b109f5472017bf6e7a1b9dac12ac5f6165ba37a297834d0690c42cf3/sqlalchemy-2.0.54-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl @@ -1693,6 +1707,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/1d/05/dc0d54e90af2f192724936799cee990687792ff54a4638d3ae0e0a0145c0/greenlet-3.5.6-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/53/1b/8257699b8456241b8348fe0071c29912aeeaf5d16ef97a45e9c1d3170ca6/hypothesis-6.168.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl @@ -1735,6 +1750,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/9c/15fb18bd708949dc953fe120be8d3c7a1b1b140c2592a9c81ce7961e9ecf/s3fs-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/1f/80122de187c0f80cb47addce97609160004a8605010afdd815e608fcf003/sqlalchemy-2.0.54-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl @@ -1834,6 +1850,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a1/60/90ccc9e18d831480920dc0f1d33a9af142e796d67dbe6a760e93d0122587/hypothesis-6.168.5-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl @@ -1876,6 +1893,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/9c/15fb18bd708949dc953fe120be8d3c7a1b1b140c2592a9c81ce7961e9ecf/s3fs-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/2f/7a427d7e10bc5f285d41fa1cadf46031ffc6c84f35a9d6e19737587a4eb0/sqlalchemy-2.0.54-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl @@ -2028,6 +2046,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f8/41/573468979e9579eb6df937e6a96c1a944dfe2765c73624156571e3cc4b34/hypothesis-6.170.14-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/1e/65b59cf518c106aa755e7f7da3099027738687a862ec785060702a481320/ipython-9.17.1-py3-none-any.whl @@ -2071,6 +2090,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/9c/15fb18bd708949dc953fe120be8d3c7a1b1b140c2592a9c81ce7961e9ecf/s3fs-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/02/953686f44448b92cc628245687a242799b6eb11ef30ad2bc7adacd51986d/sqlalchemy-2.1.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl @@ -2213,6 +2233,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/c3/252db9362dab21e3c3351abb89e715fa1ef598ebaf30abb9c9b1dd3b3aab/hypothesis-6.170.14-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/1e/65b59cf518c106aa755e7f7da3099027738687a862ec785060702a481320/ipython-9.17.1-py3-none-any.whl @@ -2256,6 +2277,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/9c/15fb18bd708949dc953fe120be8d3c7a1b1b140c2592a9c81ce7961e9ecf/s3fs-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7e/24/4630a4009ea08a0769d5ff6517c7fc978f6a63eba32e08c44b98c284d7e4/sqlalchemy-2.1.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl @@ -2353,6 +2375,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c7/dc/17bc4ba00a48976944918bb5d7207519ed206fdf98af0d0319e5ba33afd2/hypothesis-6.170.14-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/1e/65b59cf518c106aa755e7f7da3099027738687a862ec785060702a481320/ipython-9.17.1-py3-none-any.whl @@ -2396,6 +2419,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/9c/15fb18bd708949dc953fe120be8d3c7a1b1b140c2592a9c81ce7961e9ecf/s3fs-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/43/b0/6675a01f4e6215e0a809d28a800953294ab31370fe8c4bb3eb9e28c0b5a6/sqlalchemy-2.1.4-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl @@ -2543,6 +2567,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f5/fd/363472210e3ed2a356b7bc6ca8ca405c0dae6531ad43c8f76ca20a946884/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/1e/65b59cf518c106aa755e7f7da3099027738687a862ec785060702a481320/ipython-9.17.1-py3-none-any.whl @@ -2586,6 +2611,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/9c/15fb18bd708949dc953fe120be8d3c7a1b1b140c2592a9c81ce7961e9ecf/s3fs-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/70/81/5cadd72b0c26b6ee7c1e6950cb9f0cfc383246a842314a1b2a87f455db25/sqlalchemy-2.1.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl @@ -2725,6 +2751,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6c/5c/eea759905d4b7d4d9a750b9b61a419120742143288c48c7d0ba633efc5f1/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/1e/65b59cf518c106aa755e7f7da3099027738687a862ec785060702a481320/ipython-9.17.1-py3-none-any.whl @@ -2768,6 +2795,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/9c/15fb18bd708949dc953fe120be8d3c7a1b1b140c2592a9c81ce7961e9ecf/s3fs-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/35/74db254005ecb384533973b157ba1fc3fe5bc41a5bc6e0500ab8369c49e6/sqlalchemy-2.1.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl @@ -2863,6 +2891,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/df/f0/be3b05a4c57bbb91b0048c37a5f3a278accb1ec51e853bb87037379c3f93/hypothesis-6.170.14-cp315-abi3.abi3t-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/1e/65b59cf518c106aa755e7f7da3099027738687a862ec785060702a481320/ipython-9.17.1-py3-none-any.whl @@ -2906,6 +2935,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/9c/15fb18bd708949dc953fe120be8d3c7a1b1b140c2592a9c81ce7961e9ecf/s3fs-2026.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2b/8e/a082a165b473dae45d2f2f79be15f5c405ac579830c64253efbf04695177/sqlalchemy-2.1.4-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl @@ -4495,8 +4525,8 @@ packages: requires_python: '>=3.8' - pypi: ./ name: datajoint - version: 2.3.5.dev28+ge3d2a553e.d20261024 - sha256: 9ac2b52fcb44ab2d1c54ec75a4c390fc2a8d53b0480a2490d897c2dc22f593a3 + version: 2.3.5.dev12+g6f0775e36.d20261102 + sha256: 211415846e9ea24f08a4605bae113a08877e68da49bbc501178e0dcfb2309bee requires_dist: - deepdiff - deepdiff<9 ; python_full_version >= '3.15' @@ -4513,6 +4543,7 @@ packages: - pyarrow>=14.0.0 ; extra == 'arrow' - adlfs>=2023.1.0 ; extra == 'azure' - codespell ; extra == 'dev' + - hypothesis ; extra == 'dev' - polars>=0.20.0 ; extra == 'dev' - pre-commit ; extra == 'dev' - pyarrow>=14.0.0 ; extra == 'dev' @@ -4524,6 +4555,7 @@ packages: - psycopg2-binary>=2.9.0 ; extra == 'postgres' - s3fs>=2023.1.0 ; extra == 's3' - faker ; extra == 'test' + - hypothesis ; extra == 'test' - ipython ; extra == 'test' - matplotlib ; extra == 'test' - polars>=0.20.0 ; extra == 'test' @@ -6581,6 +6613,513 @@ packages: purls: [] size: 17616 timestamp: 1771539622983 +- pypi: https://files.pythonhosted.org/packages/42/42/31e66ce21aa6ea030ace8874269e5a169b0c69d8a3043042e315bd64c6ad/hypothesis-6.168.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: hypothesis + version: 6.168.5 + sha256: 30208c44364b6fe1f70c74b45f3f1f8a173a749d876294a80fe88c9cf16ab6d0 + requires_dist: + - exceptiongroup>=1.0.0 ; python_full_version < '3.11' + - sortedcontainers>=2.1.0,<3.0.0 + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.111 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.30 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.21.6 ; extra == 'all' + - pandas>=1.1 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - hypothesis-crosshair>=0.0.30 ; extra == 'crosshair' + - crosshair-tool>=0.0.111 ; extra == 'crosshair' + - python-dateutil>=1.4 ; extra == 'dateutil' + - django>=5.2 ; extra == 'django' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - black>=20.8b0 ; extra == 'ghostwriter' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.21.6 ; extra == 'numpy' + - pandas>=1.1 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - pytz>=2014.1 ; extra == 'pytz' + - redis>=3.0.0 ; extra == 'redis' + - watchdog>=4.0.0 ; extra == 'watchdog' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/53/1b/8257699b8456241b8348fe0071c29912aeeaf5d16ef97a45e9c1d3170ca6/hypothesis-6.168.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + name: hypothesis + version: 6.168.5 + sha256: 54f40be9b9c6b7b058ff56b0b18a91ff4cfa57a7c7756043eabaa094a0a162c9 + requires_dist: + - exceptiongroup>=1.0.0 ; python_full_version < '3.11' + - sortedcontainers>=2.1.0,<3.0.0 + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.111 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.30 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.21.6 ; extra == 'all' + - pandas>=1.1 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - hypothesis-crosshair>=0.0.30 ; extra == 'crosshair' + - crosshair-tool>=0.0.111 ; extra == 'crosshair' + - python-dateutil>=1.4 ; extra == 'dateutil' + - django>=5.2 ; extra == 'django' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - black>=20.8b0 ; extra == 'ghostwriter' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.21.6 ; extra == 'numpy' + - pandas>=1.1 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - pytz>=2014.1 ; extra == 'pytz' + - redis>=3.0.0 ; extra == 'redis' + - watchdog>=4.0.0 ; extra == 'watchdog' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/a1/60/90ccc9e18d831480920dc0f1d33a9af142e796d67dbe6a760e93d0122587/hypothesis-6.168.5-cp310-cp310-macosx_11_0_arm64.whl + name: hypothesis + version: 6.168.5 + sha256: 658563b8f2782a0577a4d8d195e31f29b18f3f3b61ba58c4dcbd8e6ac502d14d + requires_dist: + - exceptiongroup>=1.0.0 ; python_full_version < '3.11' + - sortedcontainers>=2.1.0,<3.0.0 + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.111 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.30 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.21.6 ; extra == 'all' + - pandas>=1.1 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - hypothesis-crosshair>=0.0.30 ; extra == 'crosshair' + - crosshair-tool>=0.0.111 ; extra == 'crosshair' + - python-dateutil>=1.4 ; extra == 'dateutil' + - django>=5.2 ; extra == 'django' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - black>=20.8b0 ; extra == 'ghostwriter' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.21.6 ; extra == 'numpy' + - pandas>=1.1 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - pytz>=2014.1 ; extra == 'pytz' + - redis>=3.0.0 ; extra == 'redis' + - watchdog>=4.0.0 ; extra == 'watchdog' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/1c/c3/252db9362dab21e3c3351abb89e715fa1ef598ebaf30abb9c9b1dd3b3aab/hypothesis-6.170.14-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl + name: hypothesis + version: 6.170.14 + sha256: 67d17e8b91d3913e7aa2b1179957df4e0634caea3868b5ba4fe2bf1945334134 + requires_dist: + - sortedcontainers>=2.1.0,<3.0.0 + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.112 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.31 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.23.2 ; extra == 'all' + - pandas>=1.5 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - hypothesis-crosshair>=0.0.31 ; extra == 'crosshair' + - crosshair-tool>=0.0.112 ; extra == 'crosshair' + - python-dateutil>=1.4 ; extra == 'dateutil' + - django>=5.2 ; extra == 'django' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - black>=20.8b0 ; extra == 'ghostwriter' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.23.2 ; extra == 'numpy' + - pandas>=1.5 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - pytz>=2014.1 ; extra == 'pytz' + - redis>=3.0.0 ; extra == 'redis' + - watchdog>=4.0.0 ; extra == 'watchdog' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/6c/5c/eea759905d4b7d4d9a750b9b61a419120742143288c48c7d0ba633efc5f1/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl + name: hypothesis + version: 6.170.14 + sha256: 20df34cca1265c1683ccdcab5872cf0a023f40235a3092a96d4bd81e4fc57986 + requires_dist: + - sortedcontainers>=2.1.0,<3.0.0 + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.112 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.31 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.23.2 ; extra == 'all' + - pandas>=1.5 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - hypothesis-crosshair>=0.0.31 ; extra == 'crosshair' + - crosshair-tool>=0.0.112 ; extra == 'crosshair' + - python-dateutil>=1.4 ; extra == 'dateutil' + - django>=5.2 ; extra == 'django' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - black>=20.8b0 ; extra == 'ghostwriter' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.23.2 ; extra == 'numpy' + - pandas>=1.5 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - pytz>=2014.1 ; extra == 'pytz' + - redis>=3.0.0 ; extra == 'redis' + - watchdog>=4.0.0 ; extra == 'watchdog' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/6e/ac/687277e019d68b96d2c33b979ad8a955b5747e279891f8c335bbf58cd9bc/hypothesis-6.170.14-cp313-cp313-macosx_11_0_arm64.whl + name: hypothesis + version: 6.170.14 + sha256: e7ae9ff3220659c4721acb58d34e72227a0af7747fc903ae177dfdb0323796b4 + requires_dist: + - sortedcontainers>=2.1.0,<3.0.0 + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.112 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.31 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.23.2 ; extra == 'all' + - pandas>=1.5 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - hypothesis-crosshair>=0.0.31 ; extra == 'crosshair' + - crosshair-tool>=0.0.112 ; extra == 'crosshair' + - python-dateutil>=1.4 ; extra == 'dateutil' + - django>=5.2 ; extra == 'django' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - black>=20.8b0 ; extra == 'ghostwriter' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.23.2 ; extra == 'numpy' + - pandas>=1.5 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - pytz>=2014.1 ; extra == 'pytz' + - redis>=3.0.0 ; extra == 'redis' + - watchdog>=4.0.0 ; extra == 'watchdog' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/b2/1a/b46a2959b41ff9409af8b43fd63b07c6ed51958db0e9d314db9ee6012f5a/hypothesis-6.170.14-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl + name: hypothesis + version: 6.170.14 + sha256: 857932ff6bba183af0236f659b042023ab8b4ad58725b9024bcaba941c8cd5d0 + requires_dist: + - sortedcontainers>=2.1.0,<3.0.0 + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.112 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.31 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.23.2 ; extra == 'all' + - pandas>=1.5 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - hypothesis-crosshair>=0.0.31 ; extra == 'crosshair' + - crosshair-tool>=0.0.112 ; extra == 'crosshair' + - python-dateutil>=1.4 ; extra == 'dateutil' + - django>=5.2 ; extra == 'django' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - black>=20.8b0 ; extra == 'ghostwriter' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.23.2 ; extra == 'numpy' + - pandas>=1.5 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - pytz>=2014.1 ; extra == 'pytz' + - redis>=3.0.0 ; extra == 'redis' + - watchdog>=4.0.0 ; extra == 'watchdog' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/c7/dc/17bc4ba00a48976944918bb5d7207519ed206fdf98af0d0319e5ba33afd2/hypothesis-6.170.14-cp314-cp314-macosx_11_0_arm64.whl + name: hypothesis + version: 6.170.14 + sha256: dcf6303c65e73acf134b7854cbdfec8f2108cfdc84806fa180de04d7946aab0c + requires_dist: + - sortedcontainers>=2.1.0,<3.0.0 + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.112 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.31 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.23.2 ; extra == 'all' + - pandas>=1.5 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - hypothesis-crosshair>=0.0.31 ; extra == 'crosshair' + - crosshair-tool>=0.0.112 ; extra == 'crosshair' + - python-dateutil>=1.4 ; extra == 'dateutil' + - django>=5.2 ; extra == 'django' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - black>=20.8b0 ; extra == 'ghostwriter' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.23.2 ; extra == 'numpy' + - pandas>=1.5 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - pytz>=2014.1 ; extra == 'pytz' + - redis>=3.0.0 ; extra == 'redis' + - watchdog>=4.0.0 ; extra == 'watchdog' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/cb/2a/6735b2edff23d045441b5917f35c01a4c139a186604847ca19bbd63e81e2/hypothesis-6.170.14-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: hypothesis + version: 6.170.14 + sha256: 8126e8208acadfc3c92d0aedb8011cf724fb59e5523ceaca3f4612dcc409e632 + requires_dist: + - sortedcontainers>=2.1.0,<3.0.0 + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.112 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.31 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.23.2 ; extra == 'all' + - pandas>=1.5 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - hypothesis-crosshair>=0.0.31 ; extra == 'crosshair' + - crosshair-tool>=0.0.112 ; extra == 'crosshair' + - python-dateutil>=1.4 ; extra == 'dateutil' + - django>=5.2 ; extra == 'django' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - black>=20.8b0 ; extra == 'ghostwriter' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.23.2 ; extra == 'numpy' + - pandas>=1.5 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - pytz>=2014.1 ; extra == 'pytz' + - redis>=3.0.0 ; extra == 'redis' + - watchdog>=4.0.0 ; extra == 'watchdog' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/df/f0/be3b05a4c57bbb91b0048c37a5f3a278accb1ec51e853bb87037379c3f93/hypothesis-6.170.14-cp315-abi3.abi3t-macosx_11_0_arm64.whl + name: hypothesis + version: 6.170.14 + sha256: 5a613867bd2675101dda8fda380c3ce6be47842bed5640e1ba7b6b6fd0ffe8a3 + requires_dist: + - sortedcontainers>=2.1.0,<3.0.0 + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.112 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.31 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.23.2 ; extra == 'all' + - pandas>=1.5 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - hypothesis-crosshair>=0.0.31 ; extra == 'crosshair' + - crosshair-tool>=0.0.112 ; extra == 'crosshair' + - python-dateutil>=1.4 ; extra == 'dateutil' + - django>=5.2 ; extra == 'django' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - black>=20.8b0 ; extra == 'ghostwriter' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.23.2 ; extra == 'numpy' + - pandas>=1.5 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - pytz>=2014.1 ; extra == 'pytz' + - redis>=3.0.0 ; extra == 'redis' + - watchdog>=4.0.0 ; extra == 'watchdog' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/f5/fd/363472210e3ed2a356b7bc6ca8ca405c0dae6531ad43c8f76ca20a946884/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: hypothesis + version: 6.170.14 + sha256: 1a7aa883cd30c6c0c7259f34c1e3f37617c0d52fa6718e93c9926cc49b8047d6 + requires_dist: + - sortedcontainers>=2.1.0,<3.0.0 + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.112 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.31 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.23.2 ; extra == 'all' + - pandas>=1.5 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - hypothesis-crosshair>=0.0.31 ; extra == 'crosshair' + - crosshair-tool>=0.0.112 ; extra == 'crosshair' + - python-dateutil>=1.4 ; extra == 'dateutil' + - django>=5.2 ; extra == 'django' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - black>=20.8b0 ; extra == 'ghostwriter' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.23.2 ; extra == 'numpy' + - pandas>=1.5 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - pytz>=2014.1 ; extra == 'pytz' + - redis>=3.0.0 ; extra == 'redis' + - watchdog>=4.0.0 ; extra == 'watchdog' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/f8/41/573468979e9579eb6df937e6a96c1a944dfe2765c73624156571e3cc4b34/hypothesis-6.170.14-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + name: hypothesis + version: 6.170.14 + sha256: 96f9f8a684c88ad2280129b28bc609adb6b31cd63b1b83b397b86d552391e947 + requires_dist: + - sortedcontainers>=2.1.0,<3.0.0 + - black>=20.8b0 ; extra == 'all' + - click>=7.0 ; extra == 'all' + - crosshair-tool>=0.0.112 ; extra == 'all' + - django>=5.2 ; extra == 'all' + - dpcontracts>=0.4 ; extra == 'all' + - hypothesis-crosshair>=0.0.31 ; extra == 'all' + - lark>=0.10.1 ; extra == 'all' + - libcst>=0.3.16 ; extra == 'all' + - numpy>=1.23.2 ; extra == 'all' + - pandas>=1.5 ; extra == 'all' + - pytest>=4.6 ; extra == 'all' + - python-dateutil>=1.4 ; extra == 'all' + - pytz>=2014.1 ; extra == 'all' + - redis>=3.0.0 ; extra == 'all' + - rich>=9.0.0 ; extra == 'all' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'all') or (sys_platform == 'win32' and extra == 'all') + - watchdog>=4.0.0 ; extra == 'all' + - click>=7.0 ; extra == 'cli' + - black>=20.8b0 ; extra == 'cli' + - rich>=9.0.0 ; extra == 'cli' + - libcst>=0.3.16 ; extra == 'codemods' + - hypothesis-crosshair>=0.0.31 ; extra == 'crosshair' + - crosshair-tool>=0.0.112 ; extra == 'crosshair' + - python-dateutil>=1.4 ; extra == 'dateutil' + - django>=5.2 ; extra == 'django' + - dpcontracts>=0.4 ; extra == 'dpcontracts' + - black>=20.8b0 ; extra == 'ghostwriter' + - lark>=0.10.1 ; extra == 'lark' + - numpy>=1.23.2 ; extra == 'numpy' + - pandas>=1.5 ; extra == 'pandas' + - pytest>=4.6 ; extra == 'pytest' + - pytz>=2014.1 ; extra == 'pytz' + - redis>=3.0.0 ; extra == 'redis' + - watchdog>=4.0.0 ; extra == 'watchdog' + - tzdata>=2026.5 ; (sys_platform == 'emscripten' and extra == 'zoneinfo') or (sys_platform == 'win32' and extra == 'zoneinfo') + requires_python: '>=3.11' - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda sha256: 71e750d509f5fa3421087ba88ef9a7b9be11c53174af3aa4d06aff4c18b38e8e md5: 8b189310083baabfb622af68fd9d3ae3 @@ -13527,6 +14066,10 @@ packages: version: 1.17.0 sha256: 4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' +- pypi: https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl + name: sortedcontainers + version: 2.4.0 + sha256: a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 - pypi: https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl name: sqlalchemy version: 2.0.48 diff --git a/pyproject.toml b/pyproject.toml index 47c1019e7..d49d3ff0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ datajoint = "datajoint.cli:cli" test = [ "pytest", "pytest-cov", + "hypothesis", "requests", "faker", "matplotlib", @@ -112,6 +113,7 @@ viz = ["matplotlib", "ipython"] test = [ "pytest", "pytest-cov", + "hypothesis", "requests", "faker", "matplotlib", @@ -129,6 +131,7 @@ dev = [ # including test "pytest", "pytest-cov", + "hypothesis", "polars>=0.20.0", "pyarrow>=14.0.0", ] diff --git a/tests/conftest.py b/tests/conftest.py index 6173f6623..816e855e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,11 +19,14 @@ import logging import os from os import remove +from pathlib import Path from typing import Dict, List import certifi import pytest import urllib3 +from hypothesis import settings +from hypothesis.database import DirectoryBasedExampleDatabase import datajoint as dj from datajoint.errors import DataJointError @@ -35,6 +38,26 @@ logger = logging.getLogger(__name__) +# ============================================================================= +# Hypothesis Profiles +# ============================================================================= +# Select a profile with HYPOTHESIS_PROFILE=dev|ci|thorough. Without it, the +# profile is "ci" when the CI environment variable is set (GitHub Actions sets +# it) and "dev" otherwise. The example counts are for the model-level +# properties (tests/unit/test_expand_spec.py); property tests that run against +# a database set their own, smaller max_examples. +# dev fast local runs +# ci 500 examples, the same examples on every run +# thorough 2000 random examples; failing examples are saved under +# .hypothesis/ (gitignored) and replayed first on the next run. +# Run it before each Diagram.expand merge and before a release tag. +_HYPOTHESIS_DB = DirectoryBasedExampleDatabase(str(Path(__file__).resolve().parent.parent / ".hypothesis" / "examples")) +settings.register_profile("dev", max_examples=50, deadline=None, database=_HYPOTHESIS_DB) +settings.register_profile("ci", max_examples=500, derandomize=True, deadline=None, database=None, print_blob=True) +settings.register_profile("thorough", max_examples=2000, deadline=None, database=_HYPOTHESIS_DB) +settings.load_profile(os.environ.get("HYPOTHESIS_PROFILE") or ("ci" if os.environ.get("CI") else "dev")) + + # ============================================================================= # Pytest Hooks # ============================================================================= diff --git a/tests/unit/expand_model.py b/tests/unit/expand_model.py new file mode 100644 index 000000000..5c94862aa --- /dev/null +++ b/tests/unit/expand_model.py @@ -0,0 +1,923 @@ +""" +Executable reference model of ``Diagram.expand``. + +This module is an executable copy of the traversal specification (the +"Traversal specification" section of ``diagram.md``). It holds rows in plain +Python dictionaries, needs no database, and defines the row sets that the +engine must produce: + +- ``D(S)``: the least superset of the seed rows ``S`` closed under ``down``. + This is ``expand(seed, "down")`` and ``cascade(seed, part_integrity="cascade")``. +- ``D1(S)``: the closure under ``child`` alone, which ``part_integrity="ignore"`` + deletes and ``"enforce"`` deletes when it is lift-closed. +- ``U(S)``: the least superset closed under ``up``. This is ``expand(seed, "up")`` + and ``trace(seed)``. +- ``both(S) = U(D(S))``, which is ``expand(seed, "both")``. + +Each operation is a plain breadth-first search over row identifiers +``(table, pk_tuple)``. ``test_expand_spec.py`` checks the model against the +theorems of the specification, and later test layers check the engine against +the model. + +The module also models parts of the specification's "Computation" section, so +that its claims can be checked against the definitions: the table-level reach +(the node set of an expanded diagram), the one pass over the group quotient with +its iterating groups, and the rules under which R1 may copy a condition or +relabel materialized keys. + +Model +----- +- A schema is a list of tables in declaration order and a list of foreign keys. + An FK maps child attributes onto the parent's primary key (``attr_map``). + Parallel FKs between the same two tables are separate edges. +- A2: an FK with any NULL column references nothing, as in SQL's MATCH SIMPLE. + FKs can share columns, so partial NULLs occur. +- A1, owned parts: a part is owned by its master if an FK in its primary key + references the master or an owned part of the same master, renames allowed. + The chain maps the part's owning attributes onto the master's key. A master + and its owned parts form its group; a part that is not owned is a plain table. +- A database is ``{table: {pk_tuple: row_dict}}``, where ``pk_tuple`` lists the + primary-key values in the order of ``TableSpec.pk``. +""" + +from __future__ import annotations + +from collections import defaultdict, deque +from dataclasses import dataclass, field +from typing import Callable, Iterable, Mapping + +import networkx as nx + +from datajoint.dependencies import Dependencies + +#: A row identifier: ``(table name, primary-key tuple)``. +Row = tuple +#: A database: ``{table: {pk_tuple: row_dict}}``. +DB = dict + + +# ============================================================================= +# Schema +# ============================================================================= + + +@dataclass(frozen=True) +class TableSpec: + """ + One table of a model schema. + + Parameters + ---------- + name : str + Table name. A part's name is ``__``, as in DataJoint, so + that ``datajoint.dependencies.extract_master`` recognizes it. + pk : tuple[str, ...] + Primary-key attributes, in order. + secondary : tuple[str, ...], optional + Secondary attributes. + master : str, optional + Name of the master table if this table is a part. + """ + + name: str + pk: tuple[str, ...] + secondary: tuple[str, ...] = () + master: str | None = None + + @property + def heading(self) -> tuple[str, ...]: + """All attributes: the primary key, then the secondary attributes.""" + return self.pk + self.secondary + + +@dataclass(frozen=True) +class FK: + """ + One foreign key, an edge ``parent -> child`` of the dependency graph. + + Parameters + ---------- + child : str + The referencing table. + parent : str + The referenced table. + attr_map : dict[str, str] + Maps each child attribute of the FK to the parent primary-key attribute + it references. An unrenamed FK maps every attribute to itself. + nullable : bool, optional + Whether the FK was declared ``-> [nullable]``. The attributes it + introduces may then hold NULL. + """ + + child: str + parent: str + attr_map: Mapping[str, str] = field(hash=False) + nullable: bool = False + + @property + def renamed(self) -> bool: + """True if any child attribute has a different name in the parent.""" + return any(c != p for c, p in self.attr_map.items()) + + +class Schema: + """ + Tables in declaration order and the foreign keys between them. + + Parameters + ---------- + tables : iterable of TableSpec + Tables in declaration order. + fks : iterable of FK + Foreign keys. Each child's FKs are listed in declaration order. + + Notes + ----- + Three facts are derived from the declarations: + + - **Nullable attributes.** A secondary attribute is nullable when the first + FK that includes it is nullable, as in ``declare.py``, where an FK adds + only the attributes not declared yet. Primary-key attributes and + attributes outside every FK are never NULL. + - **Owned parts and groups** (A1). A part is owned if an FK in its primary + key (every attribute of the FK in the primary key, as ``Dependencies`` + marks it ``primary``) references the master or an owned part of the same + master, renamed or not. Each master that has parts forms a group with its + owned parts; ``group[t]`` names the master of table ``t`` for the master + and for its owned parts. A part that is not owned is in no group. + - **Owning attributes.** ``owning[t]`` maps each attribute of the master's + key to the attribute of ``t`` that the owning chain maps onto it: the + identity for the master. If a part has several owning references, an + unrenamed ``-> master`` wins, and otherwise the first in its definition. + """ + + def __init__(self, tables: Iterable[TableSpec], fks: Iterable[FK]) -> None: + self.tables = tuple(tables) + self.fks = tuple(fks) + self._by_name = {t.name: t for t in self.tables} + if len(self._by_name) != len(self.tables): + raise ValueError("table names must be unique") + self._validate() + self.nullable = {t.name: self._nullable_attributes(t) for t in self.tables} + self.owned_parts = {} + self.group = {} + self.owning = {} + for t in self.tables: + parts = [p for p in self.tables if p.master == t.name] + if parts: + owning = self._find_owned(t, parts) + self.owned_parts[t.name] = tuple(p.name for p in parts if p.name in owning) + self.owning.update(owning) + self.group.update({name: t.name for name in owning}) + + def __repr__(self) -> str: + lines = ["Schema("] + for t in self.tables: + master = f", master={t.master!r}" if t.master else "" + lines.append(f" TableSpec({t.name!r}, pk={t.pk!r}, secondary={t.secondary!r}{master}),") + for fk in self.fks: + null = ", nullable=True" if fk.nullable else "" + lines.append(f" FK({fk.child!r}, {fk.parent!r}, {dict(fk.attr_map)!r}{null}),") + return "\n".join(lines) + "\n)" + + def table(self, name: str) -> TableSpec: + """Return the table named ``name``.""" + return self._by_name[name] + + def fks_of(self, child: str) -> list[FK]: + """Return the FKs declared in ``child``, in declaration order.""" + return [fk for fk in self.fks if fk.child == child] + + def is_primary(self, fk: FK) -> bool: + """True if every attribute of the FK is in the child's primary key (as in ``Dependencies``).""" + return set(fk.attr_map) <= set(self.table(fk.child).pk) + + def owning_attributes(self, table: str) -> tuple[str, ...] | None: + """Attributes of ``table`` that hold its master's key, in the master's key order; None if in no group.""" + master = self.group.get(table) + if master is None: + return None + return tuple(self.owning[table][a] for a in self.table(master).pk) + + def intra_group_fks(self, master: str) -> list[FK]: + """FKs between two different tables of the group of ``master``.""" + return [ + fk + for fk in self.fks + if fk.child != fk.parent and self.group.get(fk.child) == master and self.group.get(fk.parent) == master + ] + + def _validate(self) -> None: + declared = set() + for t in self.tables: + if not t.pk: + raise ValueError(f"{t.name} has no primary key") + if len(set(t.heading)) != len(t.heading): + raise ValueError(f"{t.name} repeats an attribute") + if t.master is not None: + if t.master not in declared or self.table(t.master).master is not None: + raise ValueError(f"{t.name}: master {t.master} must be a table declared earlier, not a part") + if not t.name.startswith(t.master + "__"): + raise ValueError(f"part {t.name} must be named {t.master}__") + declared.add(t.name) + for fk in self.fks: + child, parent = self.table(fk.child), self.table(fk.parent) + if not set(fk.attr_map) <= set(child.heading): + raise ValueError(f"{fk}: attributes missing from {child.name}") + if sorted(fk.attr_map.values()) != sorted(parent.pk): + raise ValueError(f"{fk}: must map onto the whole primary key of {parent.name}") + + def _nullable_attributes(self, table: TableSpec) -> frozenset[str]: + seen, nullable = set(), set() + for fk in self.fks_of(table.name): + for attr in fk.attr_map: + if attr not in seen: + seen.add(attr) + if fk.nullable and attr not in table.pk: + nullable.add(attr) + return frozenset(nullable) + + def _find_owned(self, master: TableSpec, parts: list[TableSpec]) -> dict[str, dict[str, str]]: + # Parts are taken in declaration order, so an owning reference to another + # part is decided after that part (an FK references a table declared earlier). + owning = {master.name: {a: a for a in master.pk}} + for part in parts: + chains = [fk for fk in self.fks_of(part.name) if fk.parent in owning and self.is_primary(fk)] + if not chains: + continue + unrenamed = [fk for fk in chains if fk.parent == master.name and not fk.renamed] + fk = (unrenamed or chains)[0] + by_parent_attr = {p: c for c, p in fk.attr_map.items()} + owning[part.name] = {a: by_parent_attr[owning[fk.parent][a]] for a in master.pk} + return owning + + +# ============================================================================= +# Databases and references +# ============================================================================= + + +def make_db(schema: Schema, data: Mapping[str, Iterable[tuple]]) -> DB: + """ + Build a database from rows written as tuples in heading order. + + Parameters + ---------- + schema : Schema + The schema. + data : dict[str, iterable of tuple] + For each table, its rows as tuples of the primary-key values followed by + the secondary values. Tables left out are empty. + + Returns + ------- + DB + ``{table: {pk_tuple: row_dict}}``. + """ + db = {t.name: {} for t in schema.tables} + for name, rows in data.items(): + table = schema.table(name) + for values in rows: + if len(values) != len(table.heading): + raise ValueError(f"{name}: expected {table.heading}, got {values}") + row = dict(zip(table.heading, values)) + key = tuple(row[a] for a in table.pk) + if key in db[name]: + raise ValueError(f"{name}: duplicate primary key {key}") + db[name][key] = row + return db + + +def all_rows(db: DB) -> frozenset[Row]: + """Return the identifiers of every row in ``db``.""" + return frozenset((t, k) for t, rows in db.items() for k in rows) + + +def fk_value(schema: Schema, fk: FK, row: Mapping) -> tuple | None: + """ + Return the parent primary key that ``row`` names through ``fk``. + + Returns None if any FK attribute is NULL (A2, MATCH SIMPLE). + """ + by_parent_attr = {p: c for c, p in fk.attr_map.items()} + value = tuple(row[by_parent_attr[a]] for a in schema.table(fk.parent).pk) + return None if any(v is None for v in value) else value + + +def ref_e(schema: Schema, db: DB, fk: FK, row: Mapping) -> Row | None: + """ + Return the parent row that ``row`` references through ``fk``. + + Returns None if the FK value has a NULL (A2) or names no existing row. + """ + value = fk_value(schema, fk, row) + if value is None or value not in db.get(fk.parent, {}): + return None + return (fk.parent, value) + + +def master_of(schema: Schema, table: str, row: Mapping) -> Row | None: + """ + ``master(r)``: identifier of the master row of a row in a group. + + The master row is named by the row's owning attributes, which lie in the + primary key and so are never NULL. Returns None for a row in no group. The + identifier is returned whether or not the master row exists. + """ + attrs = schema.owning_attributes(table) + if attrs is None: + return None + return (schema.group[table], tuple(row[a] for a in attrs)) + + +def apply_delete(db: DB, rows: Iterable[Row]) -> DB: + """Return a copy of ``db`` without ``rows``.""" + gone = set(rows) + return {t: {k: r for k, r in table.items() if (t, k) not in gone} for t, table in db.items()} + + +def subdatabase(db: DB, rows: Iterable[Row]) -> DB: + """Return the database that holds only ``rows`` (an export of them).""" + keep = set(rows) + return {t: {k: r for k, r in table.items() if (t, k) in keep} for t, table in db.items()} + + +# ============================================================================= +# Integrity checkers +# ============================================================================= + + +def ri_ok(schema: Schema, db: DB) -> bool: + """ + P1, referential integrity: every non-NULL FK value names an existing parent row. + + An FK value with any NULL attribute names nothing (A2) and is not checked. + """ + for fk in schema.fks: + parent_rows = db.get(fk.parent, {}) + for row in db.get(fk.child, {}).values(): + value = fk_value(schema, fk, row) + if value is not None and value not in parent_rows: + return False + return True + + +def group_whole(schema: Schema, db: DB) -> bool: + """ + The static half of P2: no part row exists without its master row. + + This is a property of one database state. P2 for a delete is stronger: the + delete must remove each master-part instance whole or not at all, which + ``RowGraph.group_closed`` checks on the deleted rows. Removing one part row + of an instance leaves a database that still passes this check, yet breaks + P2. For owned parts the check also follows from ``ri_ok``, since each part + row references its master along its owning chain. + """ + for table, master in schema.group.items(): + if table == master: + continue + for row in db.get(table, {}).values(): + m = master_of(schema, table, row) + if m[1] not in db.get(master, {}): + return False + return True + + +def quotient(schema: Schema) -> nx.DiGraph: + """ + The group quotient Q: the FK graph with each master-part group contracted to one node. + + FKs between two tables of the same group are left out. A part that is not + owned is in no group and stays its own node. A self-referencing FK is a + self-loop, which counts as a cycle. + """ + node = {t.name: schema.group.get(t.name, t.name) for t in schema.tables} + q = nx.DiGraph() + q.add_nodes_from(set(node.values())) + for fk in schema.fks: + u, v = node[fk.parent], node[fk.child] + if u != v or fk.parent == fk.child: + q.add_edge(u, v) + return q + + +def q_acyclic(schema: Schema) -> bool: + """True if the group quotient Q is acyclic.""" + return nx.is_directed_acyclic_graph(quotient(schema)) + + +def table_reach(schema: Schema, tables: Iterable[str], direction: str = "down", group_step: bool = True) -> frozenset: + """ + The node set of an expanded diagram: the table-level reach of ``tables``. + + The tables reached by following FKs in the direction of travel, together + with the master and all owned parts of every group that has a reached + member. ``group_step=False`` leaves the group step out (``D1``). For + ``"both"``, it is the upward reach of the downward reach. + """ + if direction == "both": + return table_reach(schema, table_reach(schema, tables, "down", group_step), "up", group_step) + if direction not in ("down", "up"): + raise ValueError(f"direction must be 'down', 'up' or 'both', not {direction!r}") + seen = set(tables) + stack = list(seen) + while stack: + t = stack.pop() + if direction == "down": + nxt = {fk.child for fk in schema.fks if fk.parent == t} + else: + nxt = {fk.parent for fk in schema.fks if fk.child == t} + if group_step and t in schema.group: + master = schema.group[t] + nxt |= {master, *schema.owned_parts[master]} + for n in nxt - seen: + seen.add(n) + stack.append(n) + return frozenset(seen) + + +# ============================================================================= +# Row relations and the operations +# ============================================================================= + + +class PartIntegrityError(Exception): + """``part_integrity="enforce"`` refuses a delete that would split an instance.""" + + +class RowGraph: + """ + The row relations of one database and the closures over them. + + Parameters + ---------- + schema : Schema + The schema. + db : DB + The database. + + Notes + ----- + - ``child(r) = {c : ref_e(c) = r}`` and ``parent(r) = {ref_e(r)}``, over + every FK edge e. + - ``inst(r)`` is the master-part instance of a row in a group: the master + row and every owned part row whose owning attributes hold the same master + key. For a row in no group, ``inst(r) = {r}``. + - ``down = child ∪ inst`` and ``up = parent ∪ inst``, so ``up`` is the + converse of ``down``. + """ + + def __init__(self, schema: Schema, db: DB) -> None: + self.schema = schema + self.db = db + self.rows = all_rows(db) + self._parents = {r: set() for r in self.rows} + self._children = {r: set() for r in self.rows} + for fk in schema.fks: + for key, row in db.get(fk.child, {}).items(): + ref = ref_e(schema, db, fk, row) + if ref is not None: + self._parents[(fk.child, key)].add(ref) + self._children[ref].add((fk.child, key)) + # Instances are keyed by the master row's identifier, which exists in a + # consistent database; the key works even when it does not. + instances = defaultdict(set) + self._master = {} + for table, rows in db.items(): + for key, row in rows.items(): + m = master_of(schema, table, row) + if m is not None: + self._master[(table, key)] = m + instances[m].add((table, key)) + self._inst = {r: frozenset(instances[m]) for r, m in self._master.items()} + + def _check(self, rows: Iterable[Row]) -> set[Row]: + rows = set(rows) + unknown = rows - self.rows + if unknown: + raise ValueError(f"rows not in the database: {sorted(unknown)}") + return rows + + # --- relations ----------------------------------------------------------- + + def child(self, r: Row) -> frozenset[Row]: + """Rows that reference ``r`` through some FK.""" + return frozenset(self._children[r]) + + def parent(self, r: Row) -> frozenset[Row]: + """Rows that ``r`` references through some FK.""" + return frozenset(self._parents[r]) + + def inst(self, r: Row) -> frozenset[Row]: + """The master-part instance of ``r``; ``{r}`` for a row in no group.""" + return self._inst.get(r, frozenset({r})) + + def master(self, r: Row) -> Row | None: + """``master(r)`` for a row in a group, else None.""" + return self._master.get(r) + + def down(self, r: Row) -> frozenset[Row]: + """``down = child ∪ inst``.""" + return self.child(r) | self.inst(r) + + def up(self, r: Row) -> frozenset[Row]: + """``up = parent ∪ inst``.""" + return self.parent(r) | self.inst(r) + + # --- operations ---------------------------------------------------------- + + def closure(self, seed: Iterable[Row], step: Callable[[Row], Iterable[Row]]) -> frozenset[Row]: + """The least superset of ``seed`` closed under ``step``, by breadth-first search.""" + seen = self._check(seed) + queue = deque(seen) + while queue: + for s in step(queue.popleft()): + if s not in seen: + seen.add(s) + queue.append(s) + return frozenset(seen) + + def D(self, seed: Iterable[Row]) -> frozenset[Row]: + """``expand(seed, "down")``: the closure under ``down``.""" + return self.closure(seed, self.down) + + def D1(self, seed: Iterable[Row]) -> frozenset[Row]: + """The closure under ``child`` alone, with no group rule.""" + return self.closure(seed, self.child) + + def U(self, seed: Iterable[Row]) -> frozenset[Row]: + """``expand(seed, "up")``: the closure under ``up``.""" + return self.closure(seed, self.up) + + def both(self, seed: Iterable[Row]) -> frozenset[Row]: + """``expand(seed, "both") = U(D(seed))``.""" + return self.U(self.D(seed)) + + def delete(self, seed: Iterable[Row], part_integrity: str = "enforce") -> frozenset[Row]: + """ + The rows ``delete(part_integrity=...)`` removes. + + ``"cascade"`` deletes D(S) and ``"ignore"`` deletes D1(S). ``"enforce"`` + deletes D1(S) if it is lift-closed, and otherwise raises + ``PartIntegrityError`` before deleting anything, naming a part row and + its master. + """ + if part_integrity == "cascade": + return self.D(seed) + if part_integrity not in ("ignore", "enforce"): + raise ValueError(f"part_integrity must be 'enforce', 'ignore' or 'cascade', not {part_integrity!r}") + rows = self.D1(seed) + if part_integrity == "enforce": + for r in sorted(rows): + m = self._master.get(r) + if m is not None and m != r and m not in rows: + raise PartIntegrityError(f"{r} would be deleted without its master {m}") + return rows + + # --- group predicates ---------------------------------------------------- + + def lift_closed(self, rows: Iterable[Row]) -> bool: + """True if every part row in ``rows`` that belongs to a group has its master row in ``rows``.""" + rows = set(rows) + return all(self._master[r] in rows for r in rows if r in self._master and self._master[r] != r) + + def group_closed(self, rows: Iterable[Row]) -> bool: + """ + True if ``rows`` takes every master-part instance it touches whole. + + This is P2 for an operation: a delete or an export of ``rows`` keeps + each instance whole if and only if this holds. + """ + rows = set(rows) + return all(self.inst(r) <= rows for r in rows) + + +# ============================================================================= +# The one pass over the group quotient +# ============================================================================= + + +def preserves_instance(schema: Schema, fk: FK) -> bool: + """ + True if an FK inside a group maps the referencing part's owning attributes + onto the referenced table's owning attributes, so it stays in the instance. + + A group iterates if and only if some FK inside it does not. + """ + master = schema.group[fk.child] + child_owning, parent_owning = schema.owning[fk.child], schema.owning[fk.parent] + return all(fk.attr_map.get(child_owning[a]) == parent_owning[a] for a in schema.table(master).pk) + + +def iterates(schema: Schema, master: str) -> bool: + """Whether the group of ``master`` iterates: some FK inside it does not preserve the instance.""" + return not all(preserves_instance(schema, fk) for fk in schema.intra_group_fks(master)) + + +def one_pass( + schema: Schema, db: DB, seed: Iterable[Row], direction: str = "down", iterate: bool = True +) -> tuple[frozenset[Row], dict]: + """ + The engine's computation, evaluated on rows: one visit per node of Q. + + The nodes of Q are visited in topological order for ``"down"`` and in + reverse order for ``"up"``. A plain table takes its seed rows and the rows + that R1 reaches from its neighbors on the incoming side. A group takes the + master keys of its seed rows and of the rows R1 reaches in any of its tables + (the lift, through the owning attributes), then all rows of those instances + (the re-expansion). A group that iterates repeats the group step, following + the FKs inside the group, until no new master key appears. + + Parameters + ---------- + schema, db : Schema, DB + The schema, whose Q must be acyclic, and the database. + seed : iterable of Row + The seed rows. + direction : {"down", "up"} + The direction of travel. + iterate : bool + Whether the groups that ``iterates`` selects repeat the group step. With + False, every group takes one step. + + Returns + ------- + rows : frozenset + The rows reached. They equal D(seed) or U(seed) when the computation is exact. + rounds : dict + For each group, the master keys after each group step. + """ + q = quotient(schema) + if not nx.is_directed_acyclic_graph(q): + raise ValueError("the group quotient has a cycle") + graph = RowGraph(schema, db) + seed = graph._check(seed) + order = list(nx.topological_sort(q)) + if direction == "up": + order.reverse() + node_of = {t.name: schema.group.get(t.name, t.name) for t in schema.tables} + members = defaultdict(list) + for t in schema.tables: + members[node_of[t.name]].append(t.name) + + def rows_of(table): + return {(table, k) for k in db[table]} + + def ref(fk, r): + return ref_e(schema, db, fk, db[r[0]][r[1]]) + + def crossing(fk, reached): + # R1 across one FK: children of reached parents (down) or parents of reached children (up). + if direction == "down": + return {c for c in rows_of(fk.child) if ref(fk, c) in reached} + return {p for c in reached if c[0] == fk.child for p in [ref(fk, c)] if p is not None} + + reached, rounds = set(), {} + for node in order: + mem = members[node] + incoming = {r for r in seed if r[0] in mem} + for fk in schema.fks: + inside = fk.child if direction == "down" else fk.parent + outside = fk.parent if direction == "down" else fk.child + if inside in mem and node_of[outside] != node: + incoming |= crossing(fk, reached) + if node not in schema.owned_parts: + reached |= incoming + continue + inner = schema.intra_group_fks(node) + repeat = iterate and iterates(schema, node) + keys = {graph.master(r) for r in incoming} + history = [set(keys)] + while True: + instance = {r for t in mem for r in rows_of(t) if graph.master(r) in keys} + if not repeat: + break + step = set().union(*(crossing(fk, instance) for fk in inner)) + grown = keys | {graph.master(r) for r in step} + if grown == keys: + break + keys = grown + history.append(set(keys)) + rounds[node] = history + reached |= instance + return frozenset(reached), rounds + + +# ============================================================================= +# Conditions: R1 copies and relabeled keys +# ============================================================================= + + +@dataclass(frozen=True) +class Relation: + """ + A query expression used as a condition: rows over ``heading``. + + Restricting a table by it keeps the rows whose values on the shared + attributes appear in it; with no shared attribute, it keeps every row if it + has any rows. + """ + + heading: tuple[str, ...] + rows: frozenset = frozenset() + + +@dataclass(frozen=True) +class Not: + """ + The complement of a condition: ``table - cond``, or ``table & dj.Not(cond)``. + + The complement of a relation follows SQL's ``NOT IN``: a NULL on either side + makes it unknown, so the row is not kept. With several shared attributes, + any NULL counts as unknown, which is exact for one shared attribute. + """ + + cond: object + + +def condition_attributes(cond) -> frozenset[str]: + """The attributes a condition names: a dict's keys, a relation's heading.""" + if isinstance(cond, dict): + return frozenset(cond) + if isinstance(cond, (list, tuple)): + return frozenset().union(*(condition_attributes(c) for c in cond)) if cond else frozenset() + if isinstance(cond, Relation): + return frozenset(cond.heading) + if isinstance(cond, Not): + return condition_attributes(cond.cond) + raise TypeError(f"unsupported condition {cond!r}") + + +def satisfies(row: Mapping, heading: tuple[str, ...], cond) -> bool: + """ + Whether ``row`` of a table with ``heading`` passes the restriction by ``cond``. + + As in DataJoint: a dict compares the keys that are attributes of the table + and ignores the others, and None matches only NULL; a list is the OR of its + elements, so the empty list matches nothing. + """ + if isinstance(cond, dict): + return all(row[k] == v for k, v in cond.items() if k in heading) + if isinstance(cond, (list, tuple)): + return any(satisfies(row, heading, c) for c in cond) + if isinstance(cond, Relation): + shared = [i for i, a in enumerate(cond.heading) if a in heading] + if not shared: + return bool(cond.rows) + value = tuple(row[cond.heading[i]] for i in shared) + return None not in value and value in {tuple(r[i] for i in shared) for r in cond.rows} + if isinstance(cond, Not): + inner = cond.cond + if isinstance(inner, Relation): + shared = [i for i, a in enumerate(inner.heading) if a in heading] + if shared and inner.rows: + value = [row[inner.heading[i]] for i in shared] + if None in value or any(r[i] is None for r in inner.rows for i in shared): + return False # x NOT IN (..., NULL) is never true + return not satisfies(row, heading, inner) + raise TypeError(f"unsupported condition {cond!r}") + + +def restrict(schema: Schema, db: DB, table: str, cond) -> frozenset[Row]: + """The rows of ``table`` that pass the restriction by ``cond``.""" + heading = schema.table(table).heading + return frozenset((table, k) for k, row in db[table].items() if satisfies(row, heading, cond)) + + +def r1_down(schema: Schema, db: DB, fk: FK, parent_rows: Iterable[Row]) -> frozenset[Row]: + """R1 downward across ``fk``: the child rows that reference one of ``parent_rows``.""" + parents = set(parent_rows) + return frozenset((fk.child, k) for k, row in db[fk.child].items() if ref_e(schema, db, fk, row) in parents) + + +def r1_up(schema: Schema, db: DB, fk: FK, child_rows: Iterable[Row]) -> frozenset[Row]: + """R1 upward across ``fk``: the parent rows that one of ``child_rows`` references.""" + refs = {ref_e(schema, db, fk, db[fk.child][k]) for t, k in child_rows if t == fk.child} + return frozenset(refs - {None}) + + +def copy_allowed(schema: Schema, fk: FK, cond) -> bool: + """ + R1's rule for copying a parent's condition to the child unchanged. + + The attributes the condition names are among the FK's attributes, the FK is + not renamed, and none of its columns is nullable. + """ + return ( + condition_attributes(cond) <= set(fk.attr_map) and not fk.renamed and not set(fk.attr_map) & schema.nullable[fk.child] + ) + + +def relabel_down(fk: FK, keys: list[dict]) -> list[dict]: + """Relabel parent keys for the child: keep the keys the FK carries, renamed through ``attr_map``.""" + by_parent_attr = {p: c for c, p in fk.attr_map.items()} + return [{by_parent_attr[a]: v for a, v in key.items() if a in by_parent_attr} for key in keys] + + +def relabel_up(fk: FK, keys: list[dict]) -> list[dict]: + """Relabel child keys for the parent: keep the keys the FK carries, renamed through ``attr_map``.""" + return [{fk.attr_map[c]: v for c, v in key.items() if c in fk.attr_map} for key in keys] + + +def relabel_down_allowed(schema: Schema, fk: FK, keys: list[dict]) -> bool: + """ + The Seeds rule for relabeling parent keys downward. + + Every key names, among the parent's attributes, only attributes the FK + carries; and if the FK has a nullable column, every key names all of the + FK's attributes, none of them NULL. + """ + parent = schema.table(fk.parent) + carried = set(fk.attr_map.values()) + if any(set(key) & set(parent.heading) - carried for key in keys): + return False + if not set(fk.attr_map) & schema.nullable[fk.child]: + return True + return all(carried <= set(key) and all(key[a] is not None for a in carried) for key in keys) + + +# ============================================================================= +# self.upstream for a row not inserted yet +# ============================================================================= + + +def relabel(schema: Schema, fk: FK, key: Mapping) -> tuple | None: + """ + ``relabel_e(key)``: the parent primary key named by a child's primary-key values. + + ``fk`` must be a primary FK of the child, so ``key`` holds every one of its + attributes. Returns None if a value is NULL. + """ + return fk_value(schema, fk, key) + + +def virtual_upstream(schema: Schema, db: DB, table: str, key: Mapping) -> frozenset[Row]: + """ + ``self.upstream`` for a row of ``table`` with primary key ``key``, inserted or not. + + ``make()`` runs before its row is inserted, and ``U(∅) = ∅``, so the upstream + of the row is built from its primary FKs instead: the union, over each FK e + whose attributes all lie in the primary key, of + ``U(rows of P_e matching relabel_e(key))``. Secondary FKs are unknown before + the insert and are left out. + + Parameters + ---------- + schema : Schema + The schema. + db : DB + The database. + table : str + The table that ``make()`` fills. + key : dict + The primary-key values of the row. + + Returns + ------- + frozenset + The upstream rows. The row itself is not included. + """ + graph = RowGraph(schema, db) + upstream = frozenset() + for fk in schema.fks_of(table): + if schema.is_primary(fk): + value = relabel(schema, fk, key) + matching = {(fk.parent, value)} if value is not None and value in db.get(fk.parent, {}) else set() + upstream |= graph.U(matching) + return upstream + + +# ============================================================================= +# The dependency graph, built the way Dependencies.load builds it +# ============================================================================= + + +def full_table_name(name: str, quote: str = "`", database: str = "model") -> str: + """Full table name as ``Dependencies`` stores it, e.g. ```model`.`t0__p1```.""" + return f"{quote}{database}{quote}.{quote}{name}{quote}" + + +def dependency_graph(schema: Schema, quote: str = "`", database: str = "model") -> Dependencies: + """ + Build the ``Dependencies`` graph that ``Dependencies.load`` would build for ``schema``. + + Nodes are full table names with a ``primary_key`` attribute. Each FK is one + edge ``parent -> child`` keyed by the tuple of its child attributes, with the + edge attributes ``primary``, ``attr_map``, ``aliased`` and ``multi``. + ``quote`` is the backtick for MySQL or the double quote for PostgreSQL. + """ + graph = Dependencies() # no connection: nodes and edges are added by hand + for t in schema.tables: + graph.add_node(full_table_name(t.name, quote, database), primary_key=set(t.pk)) + for fk in schema.fks: + child_pk = set(schema.table(fk.child).pk) + graph.add_edge( + full_table_name(fk.parent, quote, database), + full_table_name(fk.child, quote, database), + key=tuple(fk.attr_map), + primary=set(fk.attr_map) <= child_pk, + attr_map=dict(fk.attr_map), + aliased=fk.renamed, + multi=set(fk.attr_map) != child_pk, + ) + return graph diff --git a/tests/unit/test_expand_shapes_model.py b/tests/unit/test_expand_shapes_model.py new file mode 100644 index 000000000..910bef785 --- /dev/null +++ b/tests/unit/test_expand_shapes_model.py @@ -0,0 +1,754 @@ +""" +The Traversal Shape Catalog, checked against the reference model. + +Each test mirrors one shape of ``traversal-shapes.md`` (datajoint-docs, +``src/reference/specs/``) under the same ID: the same schema, rows and seeds, +and the rows the catalog states, copied from it by hand. The node set of each +result (every table the catalog lists, including those marked *none*) is +checked as the table-level reach. Where the catalog's "Why" makes a claim the +model can express (a copy or a relabeling that would select other rows, the +delete modes, the rounds of an iterating group), that claim is checked too. + +A seed is written as the rows its condition selects, by hand. Where the model +can evaluate the condition (a dict, a list of dicts, a query expression, a +``Not``), the test also checks that it selects those rows. + +Left to the integration suite, because they need a database: + +- K3's string seeds (with and without a subquery) and the ``AndList`` with a + string; the seed kinds the model can express are checked here; +- K4's ``dj.Top`` evaluation; the expansion of the two rows it keeps is checked + here; +- K6's warning for a dict that names no attribute; +- X1, the unloaded downstream schema; +- X2's refusal at declaration; the cycle in Q that it refuses is checked here. + +Table names are DataJoint's: ``session__trial`` is ``Session.Trial``. +""" + +import networkx as nx +import pytest + +from .expand_model import ( + FK, + Not, + PartIntegrityError, + Relation, + RowGraph, + Schema, + TableSpec, + apply_delete, + copy_allowed, + dependency_graph, + group_whole, + iterates, + make_db, + one_pass, + preserves_instance, + q_acyclic, + r1_down, + relabel_down_allowed, + relabel_up, + restrict, + ri_ok, + subdatabase, + table_reach, +) + + +def rows(spec: dict) -> frozenset: + """Row identifiers from ``{table: [key, ...]}``; a bare value is a one-attribute key.""" + return frozenset((t, k if isinstance(k, tuple) else (k,)) for t, keys in spec.items() for k in keys) + + +class Shape: + """One shape: its schema and rows, and the operations on them.""" + + def __init__(self, tables, fks, data): + self.schema = Schema(tables, fks) + self.db = make_db(self.schema, data) + assert ri_ok(self.schema, self.db) and group_whole(self.schema, self.db), "the shape must be consistent" + self.g = RowGraph(self.schema, self.db) + + def seed(self, table, cond, selected): + """The seed rows, written by hand, after checking that ``table & cond`` selects them.""" + seed = rows({table: selected}) + if cond is not None: + assert restrict(self.schema, self.db, table, cond) == seed + return seed + + def check(self, table, seed, direction, expected): + """``expand(seed, direction)`` returns ``expected``: its rows, and a node for every table it lists.""" + op = {"down": self.g.D, "up": self.g.U, "both": self.g.both}[direction] + assert op(seed) == rows(expected) + assert table_reach(self.schema, {table}, direction) == set(expected) + + def fk(self, child, parent, *attrs): + """The FK from ``child`` to ``parent``, chosen by its child attributes when there are several.""" + (fk,) = [ + f + for f in self.schema.fks + if f.child == child and f.parent == parent and (not attrs or set(attrs) == set(f.attr_map)) + ] + return fk + + +# ============================================================================= +# Foreign-key shapes +# ============================================================================= + + +def test_S1_identity_fk_in_primary_key(): + """traversal-shapes.md, S1. Identity foreign key in the primary key.""" + s = Shape( + [TableSpec("subject", ("subject_id",)), TableSpec("session", ("subject_id", "session_id"))], + [FK("session", "subject", {"subject_id": "subject_id"})], + {"subject": [(1,), (2,)], "session": [(1, 1), (1, 2), (2, 1)]}, + ) + seed = s.seed("subject", {"subject_id": 1}, [1]) + s.check("subject", seed, "down", {"subject": [1], "session": [(1, 1), (1, 2)]}) + seed = s.seed("session", {"session_id": 1}, [(1, 1), (2, 1)]) + s.check("session", seed, "up", {"session": [(1, 1), (2, 1)], "subject": [1, 2]}) + # Why: the condition subject_id = 1 is copied to Session unchanged. + fk, cond = s.fk("session", "subject"), {"subject_id": 1} + assert copy_allowed(s.schema, fk, cond) + assert restrict(s.schema, s.db, "session", cond) == r1_down(s.schema, s.db, fk, rows({"subject": [1]})) + + +def test_S2_secondary_fk(): + """traversal-shapes.md, S2. Secondary foreign key.""" + s = Shape( + [TableSpec("#rig", ("rig_id",)), TableSpec("session", ("session_id",), ("rig_id",))], + [FK("session", "#rig", {"rig_id": "rig_id"})], + {"#rig": [(1,), (2,), (3,)], "session": [(1, 1), (2, 1), (3, 2)]}, + ) + seed = s.seed("#rig", {"rig_id": 1}, [1]) + s.check("#rig", seed, "down", {"#rig": [1], "session": [1, 2]}) + seed = s.seed("session", {"session_id": 3}, [3]) + s.check("session", seed, "up", {"session": [3], "#rig": [2]}) + # Why: downward, rig_id = 1 names only the FK's own non-null attribute, so it is copied. + fk = s.fk("session", "#rig") + assert copy_allowed(s.schema, fk, {"rig_id": 1}) + # Why: relabeling the dict upward would drop session_id and select every rig. + relabeled = relabel_up(fk, [{"session_id": 3}]) + assert relabeled == [{}] + assert restrict(s.schema, s.db, "#rig", relabeled) == rows({"#rig": [1, 2, 3]}) + + +def test_S3_nullable_fk_sharing_a_primary_key_column(): + """traversal-shapes.md, S3. Nullable foreign key sharing a primary-key column.""" + s = Shape( + [ + TableSpec("subject", ("subject_id",)), + TableSpec("surgery", ("subject_id", "surgery_id")), + TableSpec("session", ("subject_id", "session_id"), ("surgery_id",)), + ], + [ + FK("surgery", "subject", {"subject_id": "subject_id"}), + FK("session", "subject", {"subject_id": "subject_id"}), + FK("session", "surgery", {"subject_id": "subject_id", "surgery_id": "surgery_id"}, nullable=True), + ], + { + "subject": [(1,), (2,)], + "surgery": [(1, 1), (1, 2), (2, 1)], + "session": [(1, 1, 1), (1, 2, None), (1, 3, 2), (2, 1, 1)], + }, + ) + assert s.schema.nullable["session"] == {"surgery_id"} # only surgery_id can be NULL + seed = s.seed("surgery", {"subject_id": 1}, [(1, 1), (1, 2)]) + s.check("surgery", seed, "down", {"surgery": [(1, 1), (1, 2)], "session": [(1, 1), (1, 3)]}) + seed = s.seed("session", {"subject_id": 1, "session_id": 2}, [(1, 2)]) + s.check("session", seed, "up", {"session": [(1, 2)], "subject": [1], "surgery": []}) + # Why: the FK has a nullable column, so subject_id = 1 is not copied; a copy + # would also delete session (1, 2) (#1590). Relabeling the keys is refused too. + fk, cond = s.fk("session", "surgery"), {"subject_id": 1} + assert not copy_allowed(s.schema, fk, cond) + assert ("session", (1, 2)) in restrict(s.schema, s.db, "session", cond) + assert not relabel_down_allowed(s.schema, fk, [cond]) + + +def test_S4_renamed_fk(): + """traversal-shapes.md, S4. Renamed foreign key, with an unrelated attribute named neuron_id.""" + s = Shape( + [TableSpec("neuron", ("neuron_id",)), TableSpec("input", ("source_id", "input_id"), ("neuron_id",))], + [FK("input", "neuron", {"source_id": "neuron_id"})], + {"neuron": [(1,), (2,), (3,)], "input": [(1, 1, 2), (1, 2, 3), (2, 1, 1), (3, 1, 1)]}, + ) + seed = s.seed("neuron", {"neuron_id": 1}, [1]) + s.check("neuron", seed, "down", {"neuron": [1], "input": [(1, 1), (1, 2)]}) + seed = s.seed("input", {"source_id": 2, "input_id": 1}, [(2, 1)]) + s.check("input", seed, "up", {"input": [(2, 1)], "neuron": [2]}) + # Why: copying neuron_id = 1 to Input would match the unrelated label. + cond = {"neuron_id": 1} + assert not copy_allowed(s.schema, s.fk("input", "neuron"), cond) + assert restrict(s.schema, s.db, "input", cond) == rows({"input": [(2, 1), (3, 1)]}) + + +def test_S5_parallel_fks(): + """traversal-shapes.md, S5. Parallel foreign keys.""" + s = Shape( + [TableSpec("neuron", ("neuron_id",)), TableSpec("synapse", ("neuron_id", "target_id"))], + [FK("synapse", "neuron", {"neuron_id": "neuron_id"}), FK("synapse", "neuron", {"target_id": "neuron_id"})], + {"neuron": [(1,), (2,), (3,)], "synapse": [(1, 2), (2, 3), (3, 1)]}, + ) + seed = s.seed("neuron", {"neuron_id": 1}, [1]) + s.check("neuron", seed, "down", {"neuron": [1], "synapse": [(1, 2), (3, 1)]}) + s.check("neuron", seed, "both", {"neuron": [1, 2, 3], "synapse": [(1, 2), (3, 1)]}) + up_seed = s.seed("synapse", {"neuron_id": 1, "target_id": 2}, [(1, 2)]) + s.check("synapse", up_seed, "up", {"synapse": [(1, 2)], "neuron": [1, 2]}) + # Why: combining the two FKs with AND selects only synapses from neuron 1 to itself: none. + per_fk = [r1_down(s.schema, s.db, fk, seed) for fk in s.schema.fks] + assert per_fk[0] & per_fk[1] == set() + # Why (and diagram.md, "Why both is U(D(S))"): D(S) ∪ U(S) leaves out neurons 2 and 3, + # so it carries synapse (3, 1) without neuron 3. + union = s.g.D(seed) | s.g.U(seed) + assert union == rows({"neuron": [1], "synapse": [(1, 2), (3, 1)]}) + assert not ri_ok(s.schema, subdatabase(s.db, union)) + + +def test_S6_diamond_with_a_shared_key(): + """traversal-shapes.md, S6. Diamond with a shared key.""" + s = Shape( + [ + TableSpec("subject", ("subject_id",)), + TableSpec("session", ("subject_id", "session_id")), + TableSpec("implant", ("subject_id", "implant_id")), + TableSpec("recording", ("subject_id", "session_id", "implant_id")), + ], + [ + FK("session", "subject", {"subject_id": "subject_id"}), + FK("implant", "subject", {"subject_id": "subject_id"}), + FK("recording", "session", {"subject_id": "subject_id", "session_id": "session_id"}), + FK("recording", "implant", {"subject_id": "subject_id", "implant_id": "implant_id"}), + ], + { + "subject": [(1,), (2,)], + "session": [(1, 1), (1, 2), (2, 1)], + "implant": [(1, 1), (1, 2), (2, 1)], + "recording": [(1, 1, 1), (1, 2, 2), (2, 1, 1)], + }, + ) + seed = s.seed("subject", {"subject_id": 1}, [1]) + s.check( + "subject", + seed, + "down", + { + "subject": [1], + "session": [(1, 1), (1, 2)], + "implant": [(1, 1), (1, 2)], + "recording": [(1, 1, 1), (1, 2, 2)], + }, + ) + seed = s.seed("implant", {"subject_id": 1, "implant_id": 1}, [(1, 1)]) + s.check("implant", seed, "down", {"implant": [(1, 1)], "recording": [(1, 1, 1)]}) + s.check( + "implant", + seed, + "both", + {"implant": [(1, 1)], "recording": [(1, 1, 1)], "subject": [1], "session": [(1, 1)]}, + ) + seed = s.seed("recording", {"subject_id": 1, "session_id": 2, "implant_id": 2}, [(1, 2, 2)]) + s.check( + "recording", + seed, + "up", + {"recording": [(1, 2, 2)], "session": [(1, 2)], "implant": [(1, 2)], "subject": [1]}, + ) + + +def test_S7_diamond_reached_through_a_secondary_fk(): + """traversal-shapes.md, S7. Diamond reached through a secondary foreign key.""" + s = Shape( + [ + TableSpec("lab", ("lab_id",)), + TableSpec("rig", ("rig_id",), ("lab_id",)), + TableSpec("session", ("lab_id", "session_id"), ("rig_id",)), + ], + [ + FK("rig", "lab", {"lab_id": "lab_id"}), + FK("session", "lab", {"lab_id": "lab_id"}), + FK("session", "rig", {"rig_id": "rig_id"}), + ], + {"lab": [(1,), (2,)], "rig": [(10, 1), (20, 2)], "session": [(1, 1, 20), (2, 1, 10), (2, 2, 20)]}, + ) + seed = s.seed("lab", {"lab_id": 1}, [1]) + s.check("lab", seed, "down", {"lab": [1], "rig": [10], "session": [(1, 1), (2, 1)]}) + seed = s.seed("session", {"lab_id": 2, "session_id": 1}, [(2, 1)]) + s.check("session", seed, "up", {"session": [(2, 1)], "rig": [10], "lab": [1, 2]}) + seed = s.seed("rig", {"rig_id": 10}, [10]) + s.check("rig", seed, "both", {"rig": [10], "session": [(2, 1)], "lab": [1, 2]}) + # Why: Rig's condition lab_id = 1 names an attribute that Session's FK to Rig does not + # carry, so it is not copied. A copy would miss session (2, 1), and the delete would + # then break its FK to rig 10. + cond = {"lab_id": 1} + assert not copy_allowed(s.schema, s.fk("session", "rig"), cond) + copied = restrict(s.schema, s.db, "session", cond) + assert copied == rows({"session": [(1, 1)]}) + assert not ri_ok(s.schema, apply_delete(s.db, rows({"lab": [1], "rig": [10]}) | copied)) + + +# ============================================================================= +# Group shapes +# ============================================================================= + + +def test_G1_part_to_master_lift(): + """traversal-shapes.md, G1. Part-to-master lift, and the delete modes.""" + s = Shape( + [ + TableSpec("session", ("session_id",)), + TableSpec("session__trial", ("session_id", "trial_id"), master="session"), + TableSpec("review", ("session_id", "review_id")), + ], + [ + FK("session__trial", "session", {"session_id": "session_id"}), + FK("review", "session", {"session_id": "session_id"}), + ], + { + "session": [(1,), (2,), (3,)], + "session__trial": [(1, 1), (1, 2), (2, 1), (3, 1)], + "review": [(1, 1), (2, 1)], + }, + ) + seed = s.seed("session__trial", {"trial_id": 2}, [(1, 2)]) + down = {"session__trial": [(1, 1), (1, 2)], "session": [1], "review": [(1, 1)]} + s.check("session__trial", seed, "down", down) + s.check("session__trial", seed, "up", {"session__trial": [(1, 1), (1, 2)], "session": [1]}) + # Why: the lift is Session & (Session.Trial & {"trial_id": 2}), only session 1. The + # old upward copy of trial_id = 2 named no attribute of Session and selected every session. + trials = Relation(("session_id", "trial_id"), frozenset({(1, 2)})) + assert restrict(s.schema, s.db, "session", trials) == rows({"session": [1]}) + assert restrict(s.schema, s.db, "session", {"trial_id": 2}) == rows({"session": [1, 2, 3]}) + # The delete modes. + assert s.g.delete(seed, "cascade") == rows(down) + assert s.g.delete(seed, "ignore") == rows({"session__trial": [(1, 2)]}) + with pytest.raises(PartIntegrityError, match="session__trial"): + s.g.delete(seed, "enforce") + + +def test_G2_part_of_part_with_two_chains(): + """traversal-shapes.md, G2. Part-of-part with two chains.""" + s = Shape( + [ + TableSpec("ext", ("ext_id",)), + TableSpec("master", ("master_id",)), + TableSpec("master__q", ("master_id", "q_id"), ("ext_id",), master="master"), + TableSpec("master__p", ("master_id", "p_id"), ("q_id", "ext_id"), master="master"), + ], + [ + FK("master__q", "master", {"master_id": "master_id"}), + FK("master__q", "ext", {"ext_id": "ext_id"}), + FK("master__p", "master", {"master_id": "master_id"}), + FK("master__p", "master__q", {"master_id": "master_id", "q_id": "q_id"}), + FK("master__p", "ext", {"ext_id": "ext_id"}), + ], + { + "ext": [(1,), (2,)], + "master": [(1,), (2,), (3,)], + "master__q": [(1, 10, 1), (2, 20, 2), (3, 30, 2)], + "master__p": [(1, 100, 10, 2), (2, 200, 20, 1), (3, 300, 30, 2)], + }, + ) + seed = s.seed("ext", {"ext_id": 1}, [1]) + s.check( + "ext", + seed, + "down", + {"ext": [1], "master__q": [(1, 10), (2, 20)], "master__p": [(1, 100), (2, 200)], "master": [1, 2]}, + ) + seed = s.seed("master__p", {"master_id": 1, "p_id": 100}, [(1, 100)]) + s.check( + "master__p", + seed, + "up", + {"master__p": [(1, 100)], "master": [1], "master__q": [(1, 10)], "ext": [1, 2]}, + ) + # Why: the lift is one step on master_id, which every part row carries; the + # references inside the group map owning attributes onto owning attributes, + # so the group does not iterate. + assert s.schema.owning_attributes("master__p") == ("master_id",) + assert not iterates(s.schema, "master") + + +def test_G3_second_renamed_fk_to_its_own_master(): + """traversal-shapes.md, G3. A second, renamed foreign key to its own master.""" + s = Shape( + [ + TableSpec("session", ("session_id",)), + TableSpec("session__link", ("session_id", "link_id"), ("other_session",), master="session"), + ], + [ + FK("session__link", "session", {"session_id": "session_id"}), + FK("session__link", "session", {"other_session": "session_id"}), + ], + {"session": [(1,), (2,), (3,)], "session__link": [(1, 1, 2), (3, 1, 1)]}, + ) + seed = s.seed("session__link", {"session_id": 1}, [(1, 1)]) + s.check("session__link", seed, "down", {"session__link": [(1, 1), (3, 1)], "session": [1, 3]}) + s.check("session__link", seed, "up", {"session__link": [(1, 1)], "session": [1, 2]}) + # Why: Link is owned through -> master, so its owning attribute is session_id. + # The second FK maps other_session, not an owning attribute, onto the master's + # key, so the group iterates. One pass alone stops at session 1. + assert s.schema.owning_attributes("session__link") == ("session_id",) + assert not preserves_instance(s.schema, s.fk("session__link", "session", "other_session")) + assert iterates(s.schema, "session") + assert one_pass(s.schema, s.db, seed, "down", iterate=False)[0] == rows({"session__link": [(1, 1)], "session": [1]}) + assert one_pass(s.schema, s.db, seed, "down")[0] == s.g.D(seed) + # With part_integrity="enforce", deleting session 1 raises: link (3, 1) would go without session 3. + with pytest.raises(PartIntegrityError, match=r"session__link.*\(3, 1\)"): + s.g.delete(rows({"session": [1]}), "enforce") + + +def test_G4_renamed_sibling_fk_chained_across_three_instances(): + """traversal-shapes.md, G4. Renamed sibling foreign key, chained across three instances.""" + s = Shape( + [ + TableSpec("run", ("run_id",)), + TableSpec("run__step", ("run_id", "step_id"), master="run"), + TableSpec("run__carry", ("run_id", "carry_id"), ("prev_run", "prev_step"), master="run"), + ], + [ + FK("run__step", "run", {"run_id": "run_id"}), + FK("run__carry", "run", {"run_id": "run_id"}), + FK("run__carry", "run__step", {"prev_run": "run_id", "prev_step": "step_id"}), + ], + { + "run": [(1,), (2,), (3,), (4,)], + "run__step": [(1, 1), (2, 1), (3, 1), (4, 1)], + "run__carry": [(2, 1, 1, 1), (3, 1, 2, 1)], + }, + ) + run1 = s.seed("run", {"run_id": 1}, [1]) + s.check( + "run", + run1, + "down", + {"run": [1, 2, 3], "run__step": [(1, 1), (2, 1), (3, 1)], "run__carry": [(2, 1), (3, 1)]}, + ) + run2 = s.seed("run", {"run_id": 2}, [2]) + s.check("run", run2, "down", {"run": [2, 3], "run__step": [(2, 1), (3, 1)], "run__carry": [(2, 1), (3, 1)]}) + s.check("run", run2, "up", {"run": [1, 2], "run__step": [(1, 1), (2, 1)], "run__carry": [(2, 1)]}) + s.check( + "run", + run2, + "both", + {"run": [1, 2, 3], "run__step": [(1, 1), (2, 1), (3, 1)], "run__carry": [(2, 1), (3, 1)]}, + ) + # Why: one pass over the group would stop at run 1. Carry's reference to Step maps + # prev_run, not Carry's owning attribute run_id, onto Step's owning attribute, so + # the group step repeats: the master keys grow as {1}, {1, 2}, {1, 2, 3}, and a + # fourth round adds nothing. Upward the chain runs the other way, from run 2 back to run 1. + assert not preserves_instance(s.schema, s.fk("run__carry", "run__step")) + assert one_pass(s.schema, s.db, run1, "down", iterate=False)[0] == rows({"run": [1], "run__step": [(1, 1)]}) + found, rounds = one_pass(s.schema, s.db, run1, "down") + assert found == s.g.D(run1) + assert rounds["run"] == [rows({"run": [1]}), rows({"run": [1, 2]}), rows({"run": [1, 2, 3]})] + found, rounds = one_pass(s.schema, s.db, run2, "up") + assert found == s.g.U(run2) + assert rounds["run"] == [rows({"run": [2]}), rows({"run": [1, 2]})] + + +def test_G5_merge_table(): + """traversal-shapes.md, G5. Merge table.""" + s = Shape( + [ + TableSpec("ephys_source", ("ephys_id",)), + TableSpec("imaging_source", ("imaging_id",)), + TableSpec("recording", ("recording_id",)), + TableSpec("recording__ephys", ("recording_id",), ("ephys_id",), master="recording"), + TableSpec("recording__imaging", ("recording_id",), ("imaging_id",), master="recording"), + TableSpec("analysis", ("recording_id",)), + ], + [ + FK("recording__ephys", "recording", {"recording_id": "recording_id"}), + FK("recording__ephys", "ephys_source", {"ephys_id": "ephys_id"}), + FK("recording__imaging", "recording", {"recording_id": "recording_id"}), + FK("recording__imaging", "imaging_source", {"imaging_id": "imaging_id"}), + FK("analysis", "recording", {"recording_id": "recording_id"}), + ], + { + "ephys_source": [(1,), (2,)], + "imaging_source": [(1,)], + "recording": [(1,), (2,), (3,)], + "recording__ephys": [(1, 1), (2, 2)], + "recording__imaging": [(3, 1)], + "analysis": [(1,), (2,), (3,)], + }, + ) + seed = s.seed("analysis", {"recording_id": 1}, [1]) + s.check( + "analysis", + seed, + "up", + { + "analysis": [1], + "recording": [1], + "recording__ephys": [1], + "recording__imaging": [], + "ephys_source": [1], + "imaging_source": [], + }, + ) + seed = s.seed("ephys_source", {"ephys_id": 2}, [2]) + s.check( + "ephys_source", + seed, + "down", + {"ephys_source": [2], "recording__ephys": [2], "recording": [2], "recording__imaging": [], "analysis": [2]}, + ) + # With part_integrity="enforce" that delete raises: the part row would go without its master. + with pytest.raises(PartIntegrityError, match="recording__ephys"): + s.g.delete(seed, "enforce") + + +def test_G6_part_its_master_does_not_own(): + """traversal-shapes.md, G6. A part its master does not own, traversed as a plain table.""" + tables = [TableSpec("probe", ("probe_id",)), TableSpec("probe__channel", ("channel_id",), ("probe_id",), master="probe")] + data = {"probe": [(1,), (2,)], "probe__channel": [(1, 1), (2, 1), (3, 2)]} + s = Shape(tables, [FK("probe__channel", "probe", {"probe_id": "probe_id"})], data) + assert s.schema.owned_parts == {"probe": ()} + seed = s.seed("probe__channel", {"channel_id": 1}, [1]) + s.check("probe__channel", seed, "down", {"probe__channel": [1]}) + s.check("probe__channel", seed, "up", {"probe__channel": [1], "probe": [1]}) + seed = s.seed("probe", {"probe_id": 1}, [1]) + s.check("probe", seed, "down", {"probe": [1], "probe__channel": [1, 2]}) + # A nullable reference, -> [nullable] master, has the same effect. + nullable = Schema(tables, [FK("probe__channel", "probe", {"probe_id": "probe_id"}, nullable=True)]) + assert nullable.owned_parts == {"probe": ()} + # Moving the reference into the primary key, as -> master or renamed as + # -> master.proj(host_id="probe_id"), would make the part owned. + in_key = Schema( + [TableSpec("probe", ("probe_id",)), TableSpec("probe__channel", ("probe_id", "channel_id"), master="probe")], + [FK("probe__channel", "probe", {"probe_id": "probe_id"})], + ) + renamed = Schema( + [TableSpec("probe", ("probe_id",)), TableSpec("probe__channel", ("host_id", "channel_id"), master="probe")], + [FK("probe__channel", "probe", {"host_id": "probe_id"})], + ) + assert in_key.owned_parts == renamed.owned_parts == {"probe": ("probe__channel",)} + assert renamed.owning_attributes("probe__channel") == ("host_id",) + + +def test_G7_owned_part_of_part_with_renamed_attributes(): + """traversal-shapes.md, G7. Owned part-of-part with renamed attributes (#1429, Case 1).""" + s = Shape( + [ + TableSpec("master", ("master_id",)), + TableSpec("master__part_a", ("master_id", "part_a_id"), master="master"), + TableSpec("master__part_b", ("src_master", "src_part", "part_b_id"), master="master"), + TableSpec("report", ("master_id", "report_id")), + ], + [ + FK("master__part_a", "master", {"master_id": "master_id"}), + FK("master__part_b", "master__part_a", {"src_master": "master_id", "src_part": "part_a_id"}), + FK("report", "master", {"master_id": "master_id"}), + ], + { + "master": [(1,), (2,), (3,)], + "master__part_a": [(1, 10), (1, 11), (2, 20)], + "master__part_b": [(1, 10, 100), (1, 11, 110), (2, 20, 200)], + "report": [(1, 1), (2, 1)], + }, + ) + # PartB has no master_id; it is owned through PartA, and its owning attribute is src_master. + assert s.schema.owned_parts == {"master": ("master__part_a", "master__part_b")} + assert s.schema.owning_attributes("master__part_b") == ("src_master",) + seed = s.seed("master__part_b", {"part_b_id": 100}, [(1, 10, 100)]) + s.check( + "master__part_b", + seed, + "down", + { + "master__part_b": [(1, 10, 100), (1, 11, 110)], + "master": [1], + "master__part_a": [(1, 10), (1, 11)], + "report": [(1, 1)], + }, + ) + s.check( + "master__part_b", + seed, + "up", + {"master__part_b": [(1, 10, 100), (1, 11, 110)], "master": [1], "master__part_a": [(1, 10), (1, 11)]}, + ) + # Why: the lift renames the owning attribute onto the master's key, + # Master & R(PartB).proj(master_id="src_master"), and selects master 1 in one step. + lifted = Relation(("master_id",), frozenset({(s.db["master__part_b"][k]["src_master"],) for _, k in seed})) + assert restrict(s.schema, s.db, "master", lifted) == rows({"master": [1]}) + # PartB's reference to PartA maps src_master onto PartA's owning attribute, so the group does not iterate. + assert not iterates(s.schema, "master") + assert one_pass(s.schema, s.db, seed, "down", iterate=False)[0] == s.g.D(seed) + # With part_integrity="enforce", deleting the seed raises: part B row (1, 10, 100) would go without master 1. + with pytest.raises(PartIntegrityError, match=r"master__part_b.*\(1, 10, 100\)"): + s.g.delete(seed, "enforce") + + +# ============================================================================= +# Seed shapes: K1 to K6 share one schema +# ============================================================================= + + +def k_shape(extra_flags=()): + return Shape( + [ + TableSpec("subject", ("subject_id",), ("species",)), + TableSpec("session", ("subject_id", "session_id"), ("quality",)), + TableSpec("trial", ("subject_id", "session_id", "trial_id")), + TableSpec("flag", ("flag_id",), ("subject_id",)), + ], + [ + FK("session", "subject", {"subject_id": "subject_id"}), + FK("trial", "session", {"subject_id": "subject_id", "session_id": "session_id"}), + FK("flag", "subject", {"subject_id": "subject_id"}, nullable=True), + ], + { + "subject": [(1, "mouse"), (2, "rat"), (3, "mouse"), (4, "rat")], + "session": [(1, 1, 0), (1, 2, 1), (2, 1, 1), (2, 2, 0), (3, 1, 1)], + "trial": [(1, 1, 1), (1, 2, 1), (2, 1, 1), (2, 2, 1), (3, 1, 1)], + "flag": [(1, 1), (2, 3), *extra_flags], + }, + ) + + +SESSION = Relation(("subject_id", "session_id", "quality")) +#: ``Session & "quality = 0"``, written out: the sessions of quality 0. +QUALITY_0 = Relation(SESSION.heading, frozenset({(1, 1, 0), (2, 2, 0)})) +ALL_SESSIONS = [(1, 1), (1, 2), (2, 1), (2, 2), (3, 1)] +ALL_TRIALS = [(1, 1, 1), (1, 2, 1), (2, 1, 1), (2, 2, 1), (3, 1, 1)] + + +def test_K1_seed_restricted_by_a_descendant(): + """traversal-shapes.md, K1. Seed restricted by a descendant.""" + s = k_shape() + seed = s.seed("subject", QUALITY_0, [1, 2]) # Subject & (Session & "quality = 0") + s.check( + "subject", + seed, + "down", + { + "subject": [1, 2], + "session": [(1, 1), (1, 2), (2, 1), (2, 2)], + "trial": [(1, 1, 1), (1, 2, 1), (2, 1, 1), (2, 2, 1)], + "flag": [1], + }, + ) + s.check("subject", seed, "up", {"subject": [1, 2]}) + # diagram.md, R1: copied to Session, the seed's condition would select only the + # sessions of quality 0; and the seed Subject - (Session & "quality = 0") would + # select every session of another quality, including sessions of subjects that are + # not deleted. + fk = s.fk("session", "subject") + assert not copy_allowed(s.schema, fk, QUALITY_0) + assert restrict(s.schema, s.db, "session", QUALITY_0) == rows({"session": [(1, 1), (2, 2)]}) + others = s.seed("subject", Not(QUALITY_0), [3, 4]) + assert s.g.D(others) == rows({"subject": [3, 4], "session": [(3, 1)], "trial": [(3, 1, 1)], "flag": [2]}) + assert restrict(s.schema, s.db, "session", Not(QUALITY_0)) == rows({"session": [(1, 2), (2, 1), (3, 1)]}) + + +def test_K2_self_referencing_seed(): + """traversal-shapes.md, K2. Self-referencing seed: its condition reads its own table.""" + s = k_shape() + # Session & (dj.U("subject_id") & (Session & "quality = 0")): the subjects with a session of quality 0. + subjects_with_q0 = Relation(("subject_id",), frozenset({(1,), (2,)})) + seed = s.seed("session", subjects_with_q0, [(1, 1), (1, 2), (2, 1), (2, 2)]) + s.check( + "session", + seed, + "down", + {"session": [(1, 1), (1, 2), (2, 1), (2, 2)], "trial": [(1, 1, 1), (1, 2, 1), (2, 1, 1), (2, 2, 1)]}, + ) + s.check("session", seed, "up", {"session": [(1, 1), (1, 2), (2, 1), (2, 2)], "subject": [1, 2]}) + + +def test_K3_seed_kinds_give_the_same_rows(): + """ + traversal-shapes.md, K3. Seed kinds give the same rows. + + The kinds the model can evaluate are checked to select sessions (2, 1) and + (2, 2); the string kinds need a database. + """ + s = k_shape() + sessions_of_2 = [(2, 1), (2, 2)] + rats = Relation(("subject_id", "species"), frozenset({(2, "rat"), (4, "rat")})) + flags = Relation(("flag_id", "subject_id"), frozenset({(1, 1), (2, 3)})) + for cond in ( + {"subject_id": 2}, + [{"subject_id": 2, "session_id": 1}, {"subject_id": 2, "session_id": 2}], + rats, # Session & (Subject & {"species": "rat"}) + Not(flags), # Session & dj.Not(Flag), or Session - Flag + ): + seed = s.seed("session", cond, sessions_of_2) + s.check("session", seed, "down", {"session": [(2, 1), (2, 2)], "trial": [(2, 1, 1), (2, 2, 1)]}) + s.check("session", seed, "up", {"session": [(2, 1), (2, 2)], "subject": [2]}) + # The NOT IN NULL trap: after inserting Flag (3; NULL), Session & dj.Not(Flag) selects nothing. + s = k_shape(extra_flags=[(3, None)]) + flags = Relation(("flag_id", "subject_id"), frozenset({(1, 1), (2, 3), (3, None)})) + seed = s.seed("session", Not(flags), []) + s.check("session", seed, "down", {"session": [], "trial": []}) + s.check("session", seed, "up", {"session": [], "subject": []}) + + +def test_K4_top_seed(): + """traversal-shapes.md, K4. ``Session & "quality = 1" & dj.Top(2)`` keeps sessions (1, 2) and (2, 1).""" + s = k_shape() + seed = s.seed("session", None, [(1, 2), (2, 1)]) + s.check("session", seed, "down", {"session": [(1, 2), (2, 1)], "trial": [(1, 2, 1), (2, 1, 1)]}) + s.check("session", seed, "up", {"session": [(1, 2), (2, 1)], "subject": [1, 2]}) + + +def test_K5_empty_seeds(): + """traversal-shapes.md, K5. Empty seeds: nothing in, nothing out, and the nodes are present.""" + s = k_shape() + for cond in ([], {"subject_id": 4}, None): # None stands for Session & False + seed = s.seed("session", cond, []) + s.check("session", seed, "down", {"session": [], "trial": []}) + s.check("session", seed, "up", {"session": [], "subject": []}) + + +def test_K6_unrestricted_seed(): + """traversal-shapes.md, K6. Unrestricted seed; a dict naming no attribute of Session means all rows.""" + s = k_shape() + for cond in ({}, {"species": "rat"}): + seed = s.seed("session", cond, ALL_SESSIONS) + s.check("session", seed, "down", {"session": ALL_SESSIONS, "trial": ALL_TRIALS}) + s.check("session", seed, "up", {"session": ALL_SESSIONS, "subject": [1, 2, 3]}) + + +# ============================================================================= +# Graph shapes +# ============================================================================= + + +def test_X2_group_cycle(): + """ + traversal-shapes.md, X2. The cycle in Q that declaring Session.Note would close. + + The refusal itself happens at declaration and is tested with a database. + """ + tables = [ + TableSpec("session", ("session_id",)), + TableSpec("session__trial", ("session_id", "trial_id"), master="session"), + TableSpec("review", ("session_id",)), + ] + fks = [ + FK("session__trial", "session", {"session_id": "session_id"}), + FK("review", "session", {"session_id": "session_id"}), + ] + before = Schema(tables, fks) + assert q_acyclic(before) + dependency_graph(before).topo_sort() + # Session.Note: -> master, then -> Review below the line. Review's key is session_id, + # which Note already holds, so the FK adds no attribute. + after = Schema( + [*tables, TableSpec("session__note", ("session_id",), master="session")], + [ + *fks, + FK("session__note", "session", {"session_id": "session_id"}), + FK("session__note", "review", {"session_id": "session_id"}), + ], + ) + assert after.owned_parts == {"session": ("session__trial", "session__note")} # Note is owned: -> master in its key + assert not q_acyclic(after) # Session -> Review -> Session.Note, which belongs to Session's group + with pytest.raises(nx.NetworkXUnfeasible): + dependency_graph(after).topo_sort() diff --git a/tests/unit/test_expand_spec.py b/tests/unit/test_expand_spec.py new file mode 100644 index 000000000..e1cd31bf1 --- /dev/null +++ b/tests/unit/test_expand_spec.py @@ -0,0 +1,978 @@ +""" +Tests of the ``Diagram.expand`` specification, without a database. + +The traversal specification (the "Traversal specification" section of +``diagram.md`` and the shape catalog ``traversal-shapes.md`` in datajoint-docs) +is tested in three layers that need no database server: + +- ``expand_model.py`` is the reference model: an executable copy of the + definitions. It holds rows in Python dictionaries and computes ``D``, ``D1``, + ``U`` and ``both`` as plain closures over row identifiers. +- This module tests the model against the specification's derived results + T1-T8, on random schemas and databases drawn by Hypothesis, and checks the + specification's computation rules (the one pass over the group quotient, the + groups that iterate, R1's copy rule, relabeling) against the definitions. +- ``test_expand_shapes_model.py`` mirrors the shape catalog one to one: each + shape's schema, rows, seeds and the rows the catalog states, by hand. + +Later layers run the engine against the same model on MySQL and PostgreSQL. + +Running +------- +``pytest tests/unit/test_expand_spec.py tests/unit/test_expand_shapes_model.py``. +``HYPOTHESIS_PROFILE`` selects the number of examples (``tests/conftest.py``): + +- ``dev`` (the default outside CI): 50 examples per property, for quick runs; +- ``ci`` (the default when ``CI`` is set): 500 examples, the same on every run; +- ``thorough``: 2000 random examples; failing examples are saved under the + gitignored ``.hypothesis/`` directory and replayed first on the next run. + +The derived results +------------------- +- T1 closure: D, U and D1 are extensive, monotone and idempotent. +- T2 additivity: ``X(A ∪ B) = X(A) ∪ X(B)`` for D, U, D1 and both. +- T3 duality: ``y ∈ D({x})`` if and only if ``x ∈ U({y})``. +- T4 delete safety: removing D(S) leaves a consistent database consistent, and + D(S) is the least such superset of S. +- T5 read boundary: U(S) is consistent on its own and is the least such superset. +- T6 both: U(D(S)) contains D(S), is up-closed and group-closed, and is the least + such superset; ``both`` is extensive and monotone but not idempotent. +- T7 enforce: D1(S) is lift-closed if and only if D1(S) = D(S). +- T8 ordering: ``datajoint.dependencies.topo_sort`` succeeds if and only if the + group quotient Q is acyclic, and its order is valid. + +Drawn schemas may contain group cycles (Q cyclic), which the declaration check +refuses (#1619). The row-level properties are stated for acyclic Q and discard +the other schemas with ``assume``; T8 tests both cases. A claim the engine does +not meet yet is kept as a strict ``xfail`` with a minimal example. +""" + +from __future__ import annotations + +import itertools +from dataclasses import dataclass, field + +import networkx as nx +import pytest +from hypothesis import assume, find, given, settings +from hypothesis import strategies as st + +from datajoint.dependencies import extract_master +from datajoint.errors import DataJointError + +from .expand_model import ( + DB, + FK, + Not, + PartIntegrityError, + Relation, + RowGraph, + Schema, + TableSpec, + all_rows, + apply_delete, + copy_allowed, + dependency_graph, + fk_value, + full_table_name, + group_whole, + iterates, + make_db, + one_pass, + preserves_instance, + q_acyclic, + r1_down, + r1_up, + relabel_down, + relabel_down_allowed, + relabel_up, + restrict, + ri_ok, + subdatabase, + virtual_upstream, +) + +#: Attribute values are drawn from this many small integers, so that keys collide. +DOMAIN = 3 +#: Tier prefixes of DataJoint table names: manual, lookup, imported, computed. +TIER_PREFIXES = ("", "#", "_", "__") + + +# ============================================================================= +# Generators +# ============================================================================= + + +class _TableBuilder: + """Collects the attributes and FKs of one table while it is drawn.""" + + def __init__(self, name: str, master: str | None = None) -> None: + self.name = name + self.master = master + self.pk: list[str] = [] + self.secondary: list[str] = [] + self.fks: list[FK] = [] + + def has(self, attr: str) -> bool: + return attr in self.pk or attr in self.secondary + + def add_fk(self, parent: TableSpec, primary: bool, renames: dict, nullable: bool = False) -> None: + # An FK adds only the attributes not declared yet, as in declare.py, so + # unrenamed attributes shared with an earlier FK are reused. + attr_map = {} + for attr in parent.pk: + name = renames.get(attr, attr) + attr_map[name] = attr + if not self.has(name): + (self.pk if primary else self.secondary).append(name) + if any(f.parent == parent.name and dict(f.attr_map) == attr_map for f in self.fks): + return # an identical FK adds nothing + self.fks.append(FK(self.name, parent.name, attr_map, nullable=nullable and not primary)) + + def spec(self) -> TableSpec: + return TableSpec(self.name, tuple(self.pk), tuple(self.secondary), self.master) + + +def _draw_renames(draw, parent: TableSpec, fresh: itertools.count, keep: tuple = ()) -> dict: + """Rename none or some of the parent's key attributes, never those in ``keep``.""" + candidates = [a for a in parent.pk if a not in keep] + if not candidates or not draw(st.booleans()): + return {} + renamed = draw(st.sets(st.sampled_from(candidates), min_size=1)) + return {a: f"{a}_r{next(fresh)}" for a in sorted(renamed)} + + +def _descendants(fks: list[FK], names: set[str]) -> set[str]: + """Tables that reference any of ``names``, directly or through other tables.""" + found, frontier = set(), set(names) + while frontier: + frontier = {fk.child for fk in fks if fk.parent in frontier} - found - names + found |= frontier + return found + + +def _draw_fks(draw, tables, fks, fresh, n, solid, group=None) -> list: + """ + Draw ``n`` FKs from the table being built to tables declared earlier. + + A uniform choice of parent rarely makes the shapes that matter, so the + parent is drawn from one of several pools: every table; tables whose key + shares some but not all of its attributes with this table's non-null + attributes ``solid`` (diamonds and, for a nullable FK, partial NULLs); and, + for a part, its own group (renamed references to the master or a sibling), + the parts of other groups, and the tables downstream of its group (group + cycles). + """ + drawn = [] + solid, known = set(solid), set(solid) + for _ in range(n): + kind = draw(st.sampled_from(["primary", "secondary", "nullable"])) + sharing = [t for t in tables if set(t.pk) & solid and not set(t.pk) <= known] + pools = [tables, sharing] + if kind == "nullable": + pools.append(sharing) # twice as likely, to make partial NULLs + if group is not None: + master = group[0] + pools.append([t for t in tables if t.name in group]) + pools.append([t for t in tables if t.master and t.master != master]) + downstream = _descendants(fks, set(group)) + pools.append([t for t in tables if t.name in downstream]) + parent = draw(st.sampled_from(draw(st.sampled_from([p for p in pools if p])))) + renames = _draw_renames(draw, parent, fresh) + attrs = {renames.get(a, a) for a in parent.pk} + if kind != "nullable": + solid |= attrs - known + known |= attrs + drawn.append((parent, kind == "primary", renames, kind == "nullable")) + return drawn + + +def _finish(draw, builder: _TableBuilder, tables: list[TableSpec], fks: list, own_pk: str, force_own: bool) -> TableSpec: + # Primary FKs first, then the table's own key attribute, then secondary FKs, + # as a definition lists them above and below the --- line. + for parent, primary, renames, nullable in fks: + if primary: + builder.add_fk(parent, True, renames) + # Without its own attribute, a table's key is its parents' (one-to-one, as in S1). + if force_own or not builder.pk or draw(st.sampled_from([True, True, False])): + builder.pk.append(own_pk) + for parent, primary, renames, nullable in fks: + if not primary: + builder.add_fk(parent, False, renames, nullable) + # Sometimes add a namesake: an attribute that shares a name with another + # table's key attribute but belongs to no FK. + namesakes = sorted({a for t in tables for a in t.pk if not builder.has(a)}) + if namesakes and draw(st.booleans()): + builder.secondary.append(draw(st.sampled_from(namesakes))) + return builder.spec() + + +@st.composite +def schemas(draw, max_tables: int = 7, owned_only: bool = False) -> Schema: + """ + Draw a schema: tables in declaration order, so the FK graph is a DAG. + + - Each step declares a plain table, a master, or a part of a master declared + earlier, so parts can be added late, after other tables. + - A master has 0-3 parts. A part is owned through ``-> master`` in its + primary key, a renamed one, or a primary-key FK to an owned sibling with + or without renames (#1429). Unless ``owned_only``, a part may also be not + owned: it references the master only below the line, or through a + nullable FK, or through a part that is not owned, or not at all. + - Other FKs are primary or secondary, nullable, renamed and parallel, to any + table declared earlier. Diamonds that share attributes, part -> part across + groups, references inside a group that link instances, and group cycles + all occur. + - Some tables get a namesake attribute that belongs to no FK. + """ + fresh = itertools.count() + tables: list[TableSpec] = [] + fks: list[FK] = [] + parts_of: dict[str, list[str]] = {} + for i in range(draw(st.integers(min(2, max_tables), max_tables))): + by_name = {t.name: t for t in tables} + open_masters = [m for m, parts in parts_of.items() if len(parts) < 3] + kind = draw(st.sampled_from(["plain", "master"] + (["part", "part"] if open_masters else []))) + if kind != "part": + builder = _TableBuilder(f"{draw(st.sampled_from(TIER_PREFIXES))}t{i}") + extra = _draw_fks(draw, tables, fks, fresh, draw(st.integers(0, 3)) if tables else 0, set()) + spec = _finish(draw, builder, tables, extra, own_pk=f"k{i}", force_own=False) + if kind == "master": + parts_of[spec.name] = [] + else: + master = draw(st.sampled_from(open_masters)) + mspec = by_name[master] + builder = _TableBuilder(f"{master}__p{len(parts_of[master])}", master=master) + owned = Schema(tables, fks).owned_parts.get(master, ()) + not_owned = [p for p in parts_of[master] if p not in owned] + owner_modes = ["direct", "renamed"] + (["sibling"] if owned else []) + ways_not_owned = ["secondary", "nullable", "missing"] + (["through a part not owned"] if not_owned else []) + mode = draw(st.sampled_from(owner_modes + ([] if owned_only else ["not owned"]))) + if mode == "not owned": + mode = draw(st.sampled_from(ways_not_owned)) + below_line = [] # a reference to the master below the line + if mode == "direct": + builder.add_fk(mspec, True, {}) + elif mode == "renamed": + renamed = draw(st.sets(st.sampled_from(mspec.pk), min_size=1)) + builder.add_fk(mspec, True, {a: f"{a}_r{next(fresh)}" for a in sorted(renamed)}) + elif mode == "sibling": + sibling = by_name[draw(st.sampled_from(owned))] + builder.add_fk(sibling, True, _draw_renames(draw, sibling, fresh)) + elif mode == "through a part not owned": + other = by_name[draw(st.sampled_from(not_owned))] + builder.add_fk(other, True, _draw_renames(draw, other, fresh)) + elif mode in ("secondary", "nullable"): + below_line = [(mspec, False, {}, mode == "nullable")] + solid = set(builder.pk) | {a for o in below_line if not o[3] for a in o[0].pk} + group = [master] + parts_of[master] + extra = _draw_fks(draw, tables, fks, fresh, draw(st.integers(0, 3)), solid, group) + # With the master referenced below the line, the part needs a key attribute of its own. + spec = _finish(draw, builder, tables, below_line + extra, own_pk=f"k{i}", force_own=bool(below_line)) + parts_of[master].append(spec.name) + tables.append(spec) + fks.extend(builder.fks) + schema = Schema(tables, fks) + if owned_only: + assert all(t.name in schema.group for t in schema.tables if t.master), schema + return schema + + +def _draw_row(draw, schema: Schema, db: DB, table: TableSpec) -> dict | None: + """Draw one row whose FK values name existing parent rows or are NULL; None if no row fits.""" + row = {} + nullable = schema.nullable[table.name] + for fk in schema.fks_of(table.name): + pairs = list(fk.attr_map.items()) + if any(row.get(c, 0) is None for c, _ in pairs): + continue # an earlier FK set a shared attribute to NULL, so this FK references nothing + matches = [p for p in db[fk.parent].values() if all(c not in row or row[c] == p[a] for c, a in pairs)] + can_be_null = any(c in nullable and c not in row for c, _ in pairs) + options = len(matches) + can_be_null + if not options: + return None + choice = draw(st.integers(0, options - 1)) + if choice < len(matches): + row.update({c: matches[choice][a] for c, a in pairs}) + else: + # NULL in the attributes this FK introduced; shared key attributes keep values (partial NULL). + row.update({c: None for c, _ in pairs if c in nullable and c not in row}) + for attr in table.heading: + if attr not in row: + row[attr] = draw(st.integers(0, DOMAIN - 1)) + return row + + +@st.composite +def databases(draw, schema: Schema, max_rows: int = 4) -> DB: + """Draw a consistent database: tables filled in declaration order over small integer domains.""" + db = {} + for table in schema.tables: + rows = {} + for _ in range(draw(st.integers(1, max_rows))): + row = _draw_row(draw, schema, db, table) + if row is not None: + rows.setdefault(tuple(row[a] for a in table.pk), row) + db[table.name] = rows + return db + + +@dataclass +class Case: + """A drawn schema and database, with the row graph built once.""" + + schema: Schema + db: DB + graph: RowGraph = field(repr=False) + + def __repr__(self) -> str: + rows = {t: list(r.values()) for t, r in self.db.items() if r} + return f"Case(\nschema={self.schema!r},\ndb={rows!r})" + + +@st.composite +def cases(draw, max_tables: int = 7, max_rows: int = 4, acyclic: bool = True, max_total: int | None = None) -> Case: + """Draw a schema and a database. With ``acyclic``, schemas whose Q has a cycle are discarded.""" + schema = draw(schemas(max_tables)) + if acyclic: + assume(q_acyclic(schema)) + db = draw(databases(schema, max_rows)) + if max_total is not None: + assume(len(all_rows(db)) <= max_total) + return Case(schema, db, RowGraph(schema, db)) + + +def seeds(rows) -> st.SearchStrategy: + """Draw a set of seed rows from ``rows``.""" + rows = sorted(rows) + return st.frozensets(st.sampled_from(rows)) if rows else st.just(frozenset()) + + +#: Tiny databases for the brute-force minimality checks (at most 2^8 supersets). +tiny_cases = cases(max_tables=4, max_rows=2, max_total=8) + + +def supersets(rows, seed): + """Every subset of ``rows`` that contains ``seed``.""" + rest = sorted(set(rows) - set(seed)) + for k in range(len(rest) + 1): + for extra in itertools.combinations(rest, k): + yield frozenset(seed) | frozenset(extra) + + +# ============================================================================= +# The generators and the model +# ============================================================================= + + +@given(case=cases(acyclic=False)) +def test_generated_databases_are_consistent(case): + """Every drawn database satisfies P1 and the static half of P2.""" + assert ri_ok(case.schema, case.db) + assert group_whole(case.schema, case.db) + + +@given(case=cases(acyclic=False)) +def test_up_is_the_converse_of_down(case): + """``s ∈ down(r)`` if and only if ``r ∈ up(s)``.""" + g = case.graph + for r in g.rows: + for s in g.down(r): + assert r in g.up(s) + for s in g.up(r): + assert r in g.down(s) + + +def _has_partial_null(case: Case) -> bool: + for fk in case.schema.fks: + for row in case.db[fk.child].values(): + values = [row[c] for c in fk.attr_map] + if None in values and any(v is not None for v in values): + return True + return False + + +def _crosses_instances(case: Case) -> bool: + # A part row references a row of its own group in another master instance. + g = case.graph + for r in g.rows: + for p in g.parent(r): + mr, mp = g.master(r), g.master(p) + if mr and mp and mr[0] == mp[0] and mr != mp: + return True + return False + + +def _owned(s: Schema): + return [t for t in s.tables if t.master and t.name in s.group] + + +def _leaves_instance(s: Schema, fk: FK) -> bool: + master = s.group.get(fk.child) + return master is not None and fk.child != master and fk in s.intra_group_fks(master) and not preserves_instance(s, fk) + + +SCHEMA_FEATURES = { + "group cycle": lambda s: not q_acyclic(s), + "part its master does not own": lambda s: any(t.master and t.name not in s.group for t in s.tables), + "part owned through a sibling": lambda s: any( + not any(fk.parent == t.master and s.is_primary(fk) for fk in s.fks_of(t.name)) for t in _owned(s) + ), + "renamed owning attributes": lambda s: any(s.owning_attributes(t.name) != s.table(t.master).pk for t in _owned(s)), + "FK to the own master that leaves the instance": lambda s: any( + _leaves_instance(s, fk) and fk.parent == s.group[fk.child] for fk in s.fks + ), + "FK to a sibling that leaves the instance": lambda s: any( + _leaves_instance(s, fk) and fk.parent != s.group[fk.child] for fk in s.fks + ), + "part -> part across groups": lambda s: any( + s.table(fk.child).master and s.table(fk.parent).master and s.table(fk.child).master != s.table(fk.parent).master + for fk in s.fks + ), + "parallel FKs": lambda s: any(a.child == b.child and a.parent == b.parent for a, b in itertools.combinations(s.fks, 2)), + "diamond sharing an attribute": lambda s: any( + a.child == b.child and a.parent != b.parent and set(a.attr_map) & set(b.attr_map) + for a, b in itertools.combinations(s.fks, 2) + ), + "nullable FK": lambda s: any(fk.nullable for fk in s.fks), + "namesake attribute": lambda s: any( + a in u.pk + for t in s.tables + for a in t.secondary + if not any(a in fk.attr_map for fk in s.fks_of(t.name)) + for u in s.tables + if u is not t + ), +} + +DATA_FEATURES = { + "partial NULL": _has_partial_null, + "reference across master instances": _crosses_instances, +} + +_FIND_SETTINGS = settings(max_examples=5000, database=None, derandomize=True, deadline=None) + + +@pytest.mark.parametrize("feature", sorted(SCHEMA_FEATURES)) +def test_generator_draws_schema_feature(feature): + """The schema generator reaches every shape that the properties rely on.""" + find(schemas(), SCHEMA_FEATURES[feature], settings=_FIND_SETTINGS) + + +@pytest.mark.parametrize("feature", sorted(DATA_FEATURES)) +def test_generator_draws_data_feature(feature): + """The data generator reaches partial NULLs and references across master instances.""" + find(cases(acyclic=False), DATA_FEATURES[feature], settings=_FIND_SETTINGS) + + +# ============================================================================= +# T1-T3 +# ============================================================================= + + +@given(case=cases(), data=st.data()) +def test_t1_closure(case, data): + """T1: D, U and D1 are extensive, monotone and idempotent.""" + g = case.graph + b = data.draw(seeds(g.rows), label="B") + a = data.draw(seeds(b), label="A ⊆ B") + for op in (g.D, g.U, g.D1): + assert a <= op(a) + assert op(a) <= op(b) + assert op(op(a)) == op(a) + + +@given(case=cases(), data=st.data()) +def test_t2_additivity(case, data): + """T2: X(A ∪ B) = X(A) ∪ X(B) for X in D, U, D1 and both.""" + g = case.graph + a = data.draw(seeds(g.rows), label="A") + b = data.draw(seeds(g.rows), label="B") + for op in (g.D, g.U, g.D1, g.both): + assert op(a | b) == op(a) | op(b) + + +@given(case=cases()) +def test_t3_duality(case): + """T3: y ∈ D({x}) if and only if x ∈ U({y}).""" + g = case.graph + up = {y: g.U({y}) for y in g.rows} + for x in g.rows: + down = g.D({x}) + for y in g.rows: + assert (y in down) == (x in up[y]), (x, y) + + +# ============================================================================= +# T4-T5: delete safety and the read boundary +# ============================================================================= + + +@given(case=cases(), data=st.data()) +def test_t4_delete_safety(case, data): + """T4: removing D(S) leaves the database consistent and takes every master-part instance whole.""" + g = case.graph + s = data.draw(seeds(g.rows), label="S") + d = g.D(s) + after = apply_delete(case.db, d) + assert ri_ok(case.schema, after) + assert group_whole(case.schema, after) + assert g.group_closed(d) + + +@given(case=tiny_cases, data=st.data()) +def test_t4_minimality(case, data): + """T4: D(S) is the least superset of S whose removal is safe.""" + g = case.graph + s = data.draw(seeds(g.rows), label="S") + safe = [x for x in supersets(g.rows, s) if ri_ok(case.schema, apply_delete(case.db, x)) and g.group_closed(x)] + d = g.D(s) + assert d in safe + assert all(d <= x for x in safe) + + +@given(case=cases(), data=st.data()) +def test_t5_read_boundary(case, data): + """T5: U(S) is consistent on its own and holds every master-part instance it touches whole.""" + g = case.graph + s = data.draw(seeds(g.rows), label="S") + u = g.U(s) + export = subdatabase(case.db, u) + assert ri_ok(case.schema, export) + assert group_whole(case.schema, export) + assert g.group_closed(u) + + +def _self_contained(case, rows) -> bool: + return ri_ok(case.schema, subdatabase(case.db, rows)) and case.graph.group_closed(rows) + + +@given(case=tiny_cases, data=st.data()) +def test_t5_minimality(case, data): + """T5: U(S) is the least superset of S that is consistent on its own.""" + g = case.graph + s = data.draw(seeds(g.rows), label="S") + consistent = [x for x in supersets(g.rows, s) if _self_contained(case, x)] + u = g.U(s) + assert u in consistent + assert all(u <= x for x in consistent) + + +# ============================================================================= +# T6-T7: both and enforce +# ============================================================================= + + +@given(case=cases(), data=st.data()) +def test_t6_both(case, data): + """T6: U(D(S)) contains D(S), is up-closed and group-closed; both is extensive and monotone.""" + g = case.graph + big = data.draw(seeds(g.rows), label="B") + s = data.draw(seeds(big), label="S ⊆ B") + b = g.both(s) + assert g.D(s) <= b + assert g.U(b) == b + assert g.group_closed(b) + assert b <= g.both(b) # extensive, though not idempotent (test_t6_both_is_not_idempotent) + assert b <= g.both(big) + + +@given(case=tiny_cases, data=st.data()) +def test_t6_minimality(case, data): + """T6: U(D(S)) is the least superset of D(S) that is consistent on its own.""" + g = case.graph + d = g.D(data.draw(seeds(g.rows), label="S")) + consistent = [x for x in supersets(g.rows, d) if _self_contained(case, x)] + assert g.U(d) in consistent + assert all(g.U(d) <= x for x in consistent) + + +def test_t6_both_is_not_idempotent(): + """T6: A -> B and A -> C with S = {b} give both(S) = {a, b}, then both(both(S)) = {a, b, c}.""" + schema = Schema( + [TableSpec("a", ("a",)), TableSpec("b", ("b",), ("a",)), TableSpec("c", ("c",), ("a",))], + [FK("b", "a", {"a": "a"}), FK("c", "a", {"a": "a"})], + ) + db = make_db(schema, {"a": [(1,)], "b": [(1, 1)], "c": [(1, 1)]}) + g = RowGraph(schema, db) + a, b, c = ("a", (1,)), ("b", (1,)), ("c", (1,)) + assert g.both({b}) == {a, b} + assert g.both(g.both({b})) == {a, b, c} + + +@given(case=cases(), data=st.data()) +def test_t7_enforce(case, data): + """T7: D1(S) is lift-closed if and only if D1(S) = D(S); enforce deletes D1(S) or raises.""" + g = case.graph + s = data.draw(seeds(g.rows), label="S") + d1, d = g.D1(s), g.D(s) + assert d1 <= d + assert g.lift_closed(d1) == (d1 == d) + if d1 == d: + assert g.delete(s, "enforce") == d + else: + with pytest.raises(PartIntegrityError): + g.delete(s, "enforce") + + +# ============================================================================= +# T8: topo_sort and the group quotient +# ============================================================================= + + +def assert_valid_order(schema: Schema, order: list[str], quote: str = "`") -> None: + """Every parent precedes its children, and each master is followed at once by its owned parts.""" + name = {t.name: full_table_name(t.name, quote) for t in schema.tables} + assert sorted(order) == sorted(name.values()) + pos = {n: i for i, n in enumerate(order)} + for fk in schema.fks: + assert pos[name[fk.parent]] < pos[name[fk.child]], fk + for master, parts in schema.owned_parts.items(): + block = order[pos[name[master]] : pos[name[master]] + 1 + len(parts)] + assert set(block) == {name[master]} | {name[p] for p in parts}, (master, order) + + +@pytest.mark.parametrize("quote", ["`", '"']) +@given(schema=schemas(owned_only=True)) +def test_t8_topo_sort(quote, schema): + """ + T8: topo_sort succeeds if and only if Q is acyclic, and its order is valid. + + The graph is a ``Dependencies`` object filled the way ``Dependencies.load`` + fills it, and sorted by ``datajoint.dependencies.topo_sort``. Both MySQL and + PostgreSQL quoting are used, since ``extract_master`` recognizes parts by name. + Every part is owned here: ``topo_sort`` still groups a part with its master + by name, owned or not (see the two examples below). + """ + graph = dependency_graph(schema, quote) + if q_acyclic(schema): + assert_valid_order(schema, graph.topo_sort(), quote) + else: + # The declaration check (#1619) refuses these schemas; topo_sort raises on them. + with pytest.raises((nx.NetworkXUnfeasible, DataJointError)): + graph.topo_sort() + + +@given(schema=schemas()) +def test_model_part_names_follow_datajoint(schema): + """``extract_master`` finds the master of every part in the model, and of no other table.""" + for quote in ("`", '"'): + for t in schema.tables: + expected = full_table_name(t.master, quote) if t.master else None + assert extract_master(full_table_name(t.name, quote)) == expected + + +TOPO_SORT_GROUPS_BY_NAME = ( + "engine vs docs: datajoint.dependencies.topo_sort groups every part with its master by name; " + "diagram.md (A1, T8) makes a part its master does not own a plain table in topo_sort (#1619)" +) + + +@pytest.mark.xfail(strict=True, reason=TOPO_SORT_GROUPS_BY_NAME) +def test_t8_part_not_owned_q_acyclic(): + """ + Q is acyclic, so topo_sort must succeed. + + M; X references M; part P of M does not reference M, and references X. P is + not owned, so it is a plain table and Q is ``M -> X -> P``. Today + ``topo_sort`` treats P as a part and adds the edge ``X -> M``, a cycle. + """ + schema = Schema( + [TableSpec("m", ("m",)), TableSpec("x", ("x",), ("m",)), TableSpec("m__p", ("p",), ("x",), master="m")], + [FK("x", "m", {"m": "m"}), FK("m__p", "x", {"x": "x"})], + ) + assert schema.owned_parts == {"m": ()} + assert q_acyclic(schema) + assert_valid_order(schema, dependency_graph(schema).topo_sort()) + + +@pytest.mark.xfail(strict=True, reason=TOPO_SORT_GROUPS_BY_NAME) +def test_t8_part_not_owned_q_cyclic(): + """ + Q has a cycle, so topo_sort must raise. + + M; part P1 references M only through a nullable FK, so it is not owned; part + P2 is owned and references P1. Q is ``{M, P2} -> P1 -> {M, P2}``. Today + ``topo_sort`` sees P1 as a part of M by name, adds no edge, and succeeds. + The declaration check refuses P2's reference, since P1 depends on M. + """ + schema = Schema( + [ + TableSpec("m", ("m",)), + TableSpec("m__p1", ("p1",), ("m",), master="m"), + TableSpec("m__p2", ("m", "p2"), ("p1",), master="m"), + ], + [FK("m__p1", "m", {"m": "m"}, nullable=True), FK("m__p2", "m", {"m": "m"}), FK("m__p2", "m__p1", {"p1": "p1"})], + ) + assert schema.owned_parts == {"m": ("m__p2",)} + assert not q_acyclic(schema) + with pytest.raises((nx.NetworkXUnfeasible, DataJointError)): + dependency_graph(schema).topo_sort() + + +# ============================================================================= +# The one pass over Q, and the groups that iterate (diagram.md, Computation) +# ============================================================================= +# ``one_pass`` evaluates the engine's computation on rows: one visit per node of +# Q, the lift through the owning attributes, and the group step repeated for a +# group that iterates. It must give D(S) and U(S). + + +@pytest.mark.parametrize("direction", ["down", "up"]) +@given(case=cases(), data=st.data()) +def test_one_pass_equals_the_closure(direction, case, data): + """One pass over Q, iterating the groups that iterate, gives D(S) and U(S).""" + s = data.draw(seeds(case.graph.rows), label="S") + found, _ = one_pass(case.schema, case.db, s, direction) + assert found == (case.graph.D(s) if direction == "down" else case.graph.U(s)) + + +@pytest.mark.parametrize("direction", ["down", "up"]) +@given(case=cases(), data=st.data()) +def test_one_step_suffices_when_no_group_iterates(direction, case, data): + """When every FK inside every group maps owning attributes onto owning attributes, one step per group suffices.""" + assume(not any(iterates(case.schema, m) for m in case.schema.owned_parts)) + s = data.draw(seeds(case.graph.rows), label="S") + found, _ = one_pass(case.schema, case.db, s, direction, iterate=False) + assert found == (case.graph.D(s) if direction == "down" else case.graph.U(s)) + + +@given(schema=schemas()) +def test_every_fk_inside_a_group_carries_a_master_key(schema): + """An FK references the whole primary key, which holds the referenced table's owning attributes.""" + for master in schema.owned_parts: + for fk in schema.intra_group_fks(master): + assert set(schema.owning[fk.parent].values()) <= set(fk.attr_map.values()) + + +# ============================================================================= +# R1: copying a condition (diagram.md, R1, the edge rule) +# ============================================================================= + + +VALUES = st.sampled_from([0, 1, 2, None]) + + +def key_dicts(names: list[str]) -> st.SearchStrategy: + """A dict over some of ``names``, with values that may be NULL.""" + return st.lists(st.sampled_from(names), unique=True, max_size=3).flatmap( + lambda keys: st.fixed_dictionaries({k: VALUES for k in keys}) + ) + + +def relations(names: list[str]) -> st.SearchStrategy: + """A query expression used as a condition: a few rows over some of ``names``.""" + + def over(heading): + rows = st.frozensets(st.tuples(*[VALUES] * len(heading)), max_size=4) if heading else st.just(frozenset({()})) + return rows.map(lambda r: Relation(tuple(heading), r)) + + return st.lists(st.sampled_from(names), unique=True, max_size=3).flatmap(over) + + +def conditions(names: list[str]) -> st.SearchStrategy: + """A dict, a list of dicts, a query expression (Relation) or a Not, over ``names``.""" + return st.one_of( + key_dicts(names), + st.lists(key_dicts(names), max_size=2), + relations(names), + relations(names).map(Not), + key_dicts(names).map(Not), + ) + + +def _edge_and_names(case: Case, data, copyable: bool): + fks = [fk for fk in case.schema.fks if fk.child != fk.parent] + if copyable: + fks = [fk for fk in fks if not fk.renamed and not set(fk.attr_map) & case.schema.nullable[fk.child]] + assume(fks) + fk = data.draw(st.sampled_from(fks), label="fk") + parent, child = case.schema.table(fk.parent), case.schema.table(fk.child) + # The FK's own attributes, three times as likely as the others. + names = list(fk.attr_map) * 3 + sorted(set(parent.heading) | set(child.heading)) + ["unrelated"] + return fk, names + + +@given(case=cases(), data=st.data()) +def test_r1_copy_rule(case, data): + """ + A condition copied under R1's rule selects the same child rows as the restriction. + + The rule: the attributes the condition names (every key of a dict, the whole + heading of a query expression) are among the FK's attributes, the FK is not + renamed, and none of its columns is nullable. + """ + fk, names = _edge_and_names(case, data, copyable=True) + cond = data.draw(conditions(names), label="condition") + assume(copy_allowed(case.schema, fk, cond)) + parents = restrict(case.schema, case.db, fk.parent, cond) + assert restrict(case.schema, case.db, fk.child, cond) == r1_down(case.schema, case.db, fk, parents) + + +# ============================================================================= +# Relabeling materialized keys (diagram.md, Seeds) +# ============================================================================= + + +@given(case=cases(), data=st.data()) +def test_relabel_down_rule(case, data): + """ + Parent keys relabeled under the Seeds rule select the same child rows as R1. + + The rule: every key names, among the parent's attributes, only attributes + the FK carries; and if the FK has a nullable column, every key names all of + the FK's attributes, none of them NULL. + """ + fk, names = _edge_and_names(case, data, copyable=False) + keys = data.draw(st.lists(key_dicts(names), max_size=3), label="keys") + assume(relabel_down_allowed(case.schema, fk, keys)) + parents = restrict(case.schema, case.db, fk.parent, keys) + relabeled = relabel_down(fk, keys) + assert restrict(case.schema, case.db, fk.child, relabeled) == r1_down(case.schema, case.db, fk, parents) + + +@given(case=cases(), data=st.data()) +def test_relabel_up_for_keys_read_from_the_child(case, data): + """Keys read from child rows that contain all the FK's columns, relabeled, select the referenced parents.""" + fk, _ = _edge_and_names(case, data, copyable=False) + child = case.schema.table(fk.child) + picked = data.draw(st.frozensets(st.sampled_from(sorted(case.db[fk.child])) if case.db[fk.child] else st.nothing())) + extra = data.draw(st.sets(st.sampled_from(child.heading)), label="other attributes") + attrs = set(fk.attr_map) | extra + keys = [{a: case.db[fk.child][k][a] for a in attrs} for k in picked] + relabeled = relabel_up(fk, keys) + referenced = r1_up(case.schema, case.db, fk, {(fk.child, k) for k in picked}) + assert restrict(case.schema, case.db, fk.parent, relabeled) == referenced + + +def test_relabel_rule_refuses_a_key_on_a_secondary_attribute(): + """diagram.md, Seeds: ``Session & {"quality": 0}`` relabels to ``{}`` across Trial's FK and would select every trial.""" + schema = Schema( + [ + TableSpec("subject", ("subject_id",), ("species",)), + TableSpec("session", ("subject_id", "session_id"), ("quality",)), + TableSpec("trial", ("subject_id", "session_id", "trial_id")), + ], + [ + FK("session", "subject", {"subject_id": "subject_id"}), + FK("trial", "session", {"subject_id": "subject_id", "session_id": "session_id"}), + ], + ) + db = make_db( + schema, + { + "subject": [(1, "mouse"), (2, "rat")], + "session": [(1, 1, 0), (1, 2, 1), (2, 2, 0)], + "trial": [(1, 1, 1), (1, 2, 1), (2, 2, 1)], + }, + ) + fk, keys = schema.fks_of("trial")[0], [{"quality": 0}] + assert not relabel_down_allowed(schema, fk, keys) + assert relabel_down(fk, keys) == [{}] + assert restrict(schema, db, "trial", [{}]) == {("trial", k) for k in db["trial"]} # every trial + assert r1_down(schema, db, fk, restrict(schema, db, "session", keys)) == {("trial", (1, 1, 1)), ("trial", (2, 2, 1))} + + +def test_relabel_rule_refuses_a_null_key_value(): + """ + diagram.md, Seeds, on S3's schema: ``Surgery & {"subject_id": 1, "surgery_id": None}`` + selects no surgery but, relabeled, would select session (1, 2; NULL). + """ + schema = Schema( + [ + TableSpec("subject", ("subject_id",)), + TableSpec("surgery", ("subject_id", "surgery_id")), + TableSpec("session", ("subject_id", "session_id"), ("surgery_id",)), + ], + [ + FK("surgery", "subject", {"subject_id": "subject_id"}), + FK("session", "subject", {"subject_id": "subject_id"}), + FK("session", "surgery", {"subject_id": "subject_id", "surgery_id": "surgery_id"}, nullable=True), + ], + ) + db = make_db(schema, {"subject": [(1,)], "surgery": [(1, 1)], "session": [(1, 1, 1), (1, 2, None)]}) + fk, keys = schema.fks_of("session")[1], [{"subject_id": 1, "surgery_id": None}] + assert not relabel_down_allowed(schema, fk, keys) + assert restrict(schema, db, "surgery", keys) == set() + assert restrict(schema, db, "session", relabel_down(fk, keys)) == {("session", (1, 2))} + + +# ============================================================================= +# self.upstream for a row not inserted yet +# ============================================================================= + + +def _rows_outside_parts(case: Case): + # make() fills a plain table or a master (with its parts), never a part on its own. + return sorted(r for r in case.graph.rows if case.schema.table(r[0]).master is None) + + +@given(case=cases(), data=st.data()) +def test_virtual_upstream_does_not_need_the_row(case, data): + """ + ``self.upstream`` is the same before and after the row is inserted. + + Before the insert, the row, its parts and its descendants are absent: + ``db - D({r})``. Its upstream is built from its primary FKs alone. + """ + rows = _rows_outside_parts(case) + assume(rows) + r = data.draw(st.sampled_from(rows), label="r") + table = case.schema.table(r[0]) + key = dict(zip(table.pk, r[1])) + before = apply_delete(case.db, case.graph.D({r})) + upstream = virtual_upstream(case.schema, before, table.name, key) + assert upstream == virtual_upstream(case.schema, case.db, table.name, key) + assert r not in upstream + + +@given(case=cases(), data=st.data()) +def test_virtual_upstream_matches_trace_of_the_inserted_row(case, data): + """ + Once the row exists, ``U({r})`` contains ``self.upstream``. They are equal, + apart from r itself, when r is in no group and all its FKs are primary. + """ + rows = _rows_outside_parts(case) + assume(rows) + r = data.draw(st.sampled_from(rows), label="r") + schema, g = case.schema, case.graph + table = schema.table(r[0]) + upstream = virtual_upstream(schema, case.db, table.name, dict(zip(table.pk, r[1]))) + traced = g.U({r}) + assert upstream <= traced + if table.name not in schema.group and all(schema.is_primary(fk) for fk in schema.fks_of(table.name)): + assert upstream == traced - {r} + + +def test_virtual_upstream_of_a_row_not_inserted(): + """U(∅) = ∅, so the upstream of a row not inserted yet comes from its primary FKs.""" + schema = Schema( + [ + TableSpec("subject", ("subject_id",)), + TableSpec("rig", ("rig_id",)), + TableSpec("session", ("subject_id", "session_id"), ("rig_id",)), + TableSpec("analysis", ("subject_id", "session_id")), + ], + [ + FK("session", "subject", {"subject_id": "subject_id"}), + FK("session", "rig", {"rig_id": "rig_id"}), + FK("analysis", "session", {"subject_id": "subject_id", "session_id": "session_id"}), + ], + ) + db = make_db(schema, {"subject": [(1,), (2,)], "rig": [(7,)], "session": [(1, 1, 7), (2, 1, 7)]}) + assert RowGraph(schema, db).U(set()) == set() + assert virtual_upstream(schema, db, "analysis", {"subject_id": 1, "session_id": 1}) == { + ("session", (1, 1)), + ("subject", (1,)), + ("rig", (7,)), # reached through session's secondary FK, which is known + } + # A key with no matching parent row has an empty upstream. + assert virtual_upstream(schema, db, "analysis", {"subject_id": 1, "session_id": 9}) == set() + + +def test_fk_value_follows_match_simple(): + """A2: an FK value with any NULL names nothing, even when its other attributes match a row.""" + fk = FK("child", "parent", {"a": "a", "b": "b"}, nullable=True) + schema = Schema([TableSpec("parent", ("a", "b")), TableSpec("child", ("a", "c"), ("b",))], [fk]) + assert fk_value(schema, fk, {"a": 1, "b": None, "c": 1}) is None + assert fk_value(schema, fk, {"a": 1, "b": 2, "c": 1}) == (1, 2)