diff --git a/pixi.lock b/pixi.lock index 596718ef1e6..bfd34efff8c 100644 --- a/pixi.lock +++ b/pixi.lock @@ -51,8 +51,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -76,9 +74,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.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/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl @@ -136,8 +132,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl @@ -161,9 +155,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.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/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl @@ -219,8 +211,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl @@ -244,9 +234,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.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/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl @@ -312,6 +300,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/24/e6b7a8fe8b9e336d684779a88027b261374417f2be7c5a0fcdb40f0c8cc5/deltalake-0.25.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -337,8 +326,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-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/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/7c/f87c5db042c4f7b4476346fc0d22f1025a777fee08eebde3720d3b9c4eb5/jwcrypto-1.5.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl @@ -372,6 +359,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/60/423a63fb190a0483d049786a121bd3dfd7d93bb5ff1bb5b5cd13e5df99a7/pytest_benchmark-3.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/b2/bdc663a5647ce2034f7e8420122af340df87c01ba97745fc753b8c917acb/pytest_env-1.1.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/a1/2f2c1c2353350d66c4d110d283e422e4943eb5ad10effa9357ba66f7b5b9/pytest_lazy_fixture-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/30/43/8deecb4c123bbc16d25666f1a6d241109c97aeb2e50806b952661c8e4b95/pytest_mock-1.10.4-py2.py3-none-any.whl @@ -383,11 +371,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/2e/2e/dfbd2c9b3edf6a5a8cd9e66090221046839b488ea27824970426bf06b242/python_keycloak-4.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.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/fc/97/e4a2eb5a8ec5cd3c2a0615a2f15f0afca89ac039229599b9ed0c0ed28e5e/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/9e/82a390ecc85f066ff80affa01d195f744e3de60ad4d695b8de31c9a66da3/sqlglot-30.12.0-py3-none-any.whl @@ -443,6 +429,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dc/0e/f372bb290cef68c67331cd649b94d62220183ddc1b5bf3a9351ea6e9c8ec/deltalake-0.25.5-cp39-abi3-macosx_10_12_x86_64.whl @@ -468,8 +455,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-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/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/7c/f87c5db042c4f7b4476346fc0d22f1025a777fee08eebde3720d3b9c4eb5/jwcrypto-1.5.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl @@ -503,6 +488,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/60/423a63fb190a0483d049786a121bd3dfd7d93bb5ff1bb5b5cd13e5df99a7/pytest_benchmark-3.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/b2/bdc663a5647ce2034f7e8420122af340df87c01ba97745fc753b8c917acb/pytest_env-1.1.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/a1/2f2c1c2353350d66c4d110d283e422e4943eb5ad10effa9357ba66f7b5b9/pytest_lazy_fixture-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/30/43/8deecb4c123bbc16d25666f1a6d241109c97aeb2e50806b952661c8e4b95/pytest_mock-1.10.4-py2.py3-none-any.whl @@ -514,11 +500,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/2e/2e/dfbd2c9b3edf6a5a8cd9e66090221046839b488ea27824970426bf06b242/python_keycloak-4.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.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/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/9e/82a390ecc85f066ff80affa01d195f744e3de60ad4d695b8de31c9a66da3/sqlglot-30.12.0-py3-none-any.whl @@ -573,6 +557,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/74/7a/ec22ff9d5c891b4f9ae834ef70524c92bd59d1408e9944e2652c87bc3f02/deltalake-0.25.5-cp39-abi3-macosx_11_0_arm64.whl @@ -597,8 +582,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-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/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/7c/f87c5db042c4f7b4476346fc0d22f1025a777fee08eebde3720d3b9c4eb5/jwcrypto-1.5.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl @@ -632,6 +615,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/60/423a63fb190a0483d049786a121bd3dfd7d93bb5ff1bb5b5cd13e5df99a7/pytest_benchmark-3.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/b2/bdc663a5647ce2034f7e8420122af340df87c01ba97745fc753b8c917acb/pytest_env-1.1.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/a1/2f2c1c2353350d66c4d110d283e422e4943eb5ad10effa9357ba66f7b5b9/pytest_lazy_fixture-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/30/43/8deecb4c123bbc16d25666f1a6d241109c97aeb2e50806b952661c8e4b95/pytest_mock-1.10.4-py2.py3-none-any.whl @@ -643,11 +627,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/2e/2e/dfbd2c9b3edf6a5a8cd9e66090221046839b488ea27824970426bf06b242/python_keycloak-4.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.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/71/76/b3ea1d8842e7b62c718a88d302809003d65ed82011460ca48907dde658c4/sqlalchemy-2.0.51-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/db/9e/82a390ecc85f066ff80affa01d195f744e3de60ad4d695b8de31c9a66da3/sqlglot-30.12.0-py3-none-any.whl @@ -722,6 +704,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/66/73034ad30b59f13439b75e620989dacba4c047256e358ba7c2e9ec98ea22/datasets-5.0.0-py3-none-any.whl @@ -786,6 +769,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/60/423a63fb190a0483d049786a121bd3dfd7d93bb5ff1bb5b5cd13e5df99a7/pytest_benchmark-3.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/b2/bdc663a5647ce2034f7e8420122af340df87c01ba97745fc753b8c917acb/pytest_env-1.1.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/a1/2f2c1c2353350d66c4d110d283e422e4943eb5ad10effa9357ba66f7b5b9/pytest_lazy_fixture-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/30/43/8deecb4c123bbc16d25666f1a6d241109c97aeb2e50806b952661c8e4b95/pytest_mock-1.10.4-py2.py3-none-any.whl @@ -864,6 +848,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/66/73034ad30b59f13439b75e620989dacba4c047256e358ba7c2e9ec98ea22/datasets-5.0.0-py3-none-any.whl @@ -928,6 +913,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/60/423a63fb190a0483d049786a121bd3dfd7d93bb5ff1bb5b5cd13e5df99a7/pytest_benchmark-3.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/b2/bdc663a5647ce2034f7e8420122af340df87c01ba97745fc753b8c917acb/pytest_env-1.1.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/a1/2f2c1c2353350d66c4d110d283e422e4943eb5ad10effa9357ba66f7b5b9/pytest_lazy_fixture-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/30/43/8deecb4c123bbc16d25666f1a6d241109c97aeb2e50806b952661c8e4b95/pytest_mock-1.10.4-py2.py3-none-any.whl @@ -1005,6 +991,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/66/73034ad30b59f13439b75e620989dacba4c047256e358ba7c2e9ec98ea22/datasets-5.0.0-py3-none-any.whl @@ -1068,6 +1055,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/60/423a63fb190a0483d049786a121bd3dfd7d93bb5ff1bb5b5cd13e5df99a7/pytest_benchmark-3.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/b2/bdc663a5647ce2034f7e8420122af340df87c01ba97745fc753b8c917acb/pytest_env-1.1.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/a1/2f2c1c2353350d66c4d110d283e422e4943eb5ad10effa9357ba66f7b5b9/pytest_lazy_fixture-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/30/43/8deecb4c123bbc16d25666f1a6d241109c97aeb2e50806b952661c8e4b95/pytest_mock-1.10.4-py2.py3-none-any.whl @@ -1167,6 +1155,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/7a/797ed371bde520223e49dbbe0f8762fa4cc724d0e21fc6af944bb8f82150/db_dtypes-1.7.0-py3-none-any.whl @@ -1209,8 +1198,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/7c/f87c5db042c4f7b4476346fc0d22f1025a777fee08eebde3720d3b9c4eb5/jwcrypto-1.5.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl @@ -1256,6 +1243,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/60/423a63fb190a0483d049786a121bd3dfd7d93bb5ff1bb5b5cd13e5df99a7/pytest_benchmark-3.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/b2/bdc663a5647ce2034f7e8420122af340df87c01ba97745fc753b8c917acb/pytest_env-1.1.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/a1/2f2c1c2353350d66c4d110d283e422e4943eb5ad10effa9357ba66f7b5b9/pytest_lazy_fixture-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/30/43/8deecb4c123bbc16d25666f1a6d241109c97aeb2e50806b952661c8e4b95/pytest_mock-1.10.4-py2.py3-none-any.whl @@ -1268,11 +1256,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/2e/2677f3f93dae0497e7e33b6637302e7f3744efc553f34231183e32584885/redis-7.4.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/85/dd/904873250a6554fbae40cddbf9198e3cc37a2f1319d5e1a5ce82fe269c17/s3transfer-0.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl @@ -1342,6 +1328,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/7a/797ed371bde520223e49dbbe0f8762fa4cc724d0e21fc6af944bb8f82150/db_dtypes-1.7.0-py3-none-any.whl @@ -1384,8 +1371,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/7c/f87c5db042c4f7b4476346fc0d22f1025a777fee08eebde3720d3b9c4eb5/jwcrypto-1.5.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl @@ -1431,6 +1416,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/60/423a63fb190a0483d049786a121bd3dfd7d93bb5ff1bb5b5cd13e5df99a7/pytest_benchmark-3.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/b2/bdc663a5647ce2034f7e8420122af340df87c01ba97745fc753b8c917acb/pytest_env-1.1.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/a1/2f2c1c2353350d66c4d110d283e422e4943eb5ad10effa9357ba66f7b5b9/pytest_lazy_fixture-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/30/43/8deecb4c123bbc16d25666f1a6d241109c97aeb2e50806b952661c8e4b95/pytest_mock-1.10.4-py2.py3-none-any.whl @@ -1443,11 +1429,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/4a/2e/2677f3f93dae0497e7e33b6637302e7f3744efc553f34231183e32584885/redis-7.4.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl - pypi: https://files.pythonhosted.org/packages/85/dd/904873250a6554fbae40cddbf9198e3cc37a2f1319d5e1a5ce82fe269c17/s3transfer-0.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl @@ -1516,6 +1500,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/02/17/b82b537a30be67ba178fc0b0fbda8fcbaeda188fb039d6ad8a84e89353a2/dask-2026.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/be/7a/797ed371bde520223e49dbbe0f8762fa4cc724d0e21fc6af944bb8f82150/db_dtypes-1.7.0-py3-none-any.whl @@ -1557,8 +1542,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6e/7c/f87c5db042c4f7b4476346fc0d22f1025a777fee08eebde3720d3b9c4eb5/jwcrypto-1.5.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl @@ -1604,6 +1587,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/60/423a63fb190a0483d049786a121bd3dfd7d93bb5ff1bb5b5cd13e5df99a7/pytest_benchmark-3.4.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/25/b2/bdc663a5647ce2034f7e8420122af340df87c01ba97745fc753b8c917acb/pytest_env-1.1.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2d/a1/2f2c1c2353350d66c4d110d283e422e4943eb5ad10effa9357ba66f7b5b9/pytest_lazy_fixture-0.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/30/43/8deecb4c123bbc16d25666f1a6d241109c97aeb2e50806b952661c8e4b95/pytest_mock-1.10.4-py2.py3-none-any.whl @@ -1616,11 +1600,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/4a/2e/2677f3f93dae0497e7e33b6637302e7f3744efc553f34231183e32584885/redis-7.4.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/85/dd/904873250a6554fbae40cddbf9198e3cc37a2f1319d5e1a5ce82fe269c17/s3transfer-0.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl @@ -1996,6 +1978,27 @@ packages: version: 0.4.6 sha256: 4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*' +- pypi: https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl + name: coverage + version: 7.15.2 + sha256: 9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d + requires_dist: + - tomli ; python_full_version <= '3.11' and extra == 'toml' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: coverage + version: 7.15.2 + sha256: 1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376 + requires_dist: + - tomli ; python_full_version <= '3.11' and extra == 'toml' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl + name: coverage + version: 7.15.2 + sha256: 44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846 + requires_dist: + - tomli ; python_full_version <= '3.11' and extra == 'toml' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl name: cryptography version: 49.0.0 @@ -2421,15 +2424,15 @@ packages: requires_python: '>=3.10' - pypi: ./ name: feast - version: 0.64.1.dev33+gdddcb00a4 - sha256: b96f4ccddcdc82315a4ca26d2014829d4e2ed40c7c93f2f86005e680f3418855 + version: 0.60.1.dev301+gcef6fa632 + sha256: 8ab015050c832f342b967557f0d379aec897696b511d6bd6791e6f1b39c91d79 requires_dist: + - attrs - click>=7.0.0,<9.0.0 - colorama>=0.3.9,<1 - dill~=0.3.0 - protobuf>=4.24.0 - jinja2>=2,<4 - - jsonschema - mmh3 - numpy>=2.0.0,<3 - pandas>=1.4.3,<3 @@ -2532,6 +2535,7 @@ packages: - dbt-artifacts-parser ; extra == 'dbt' - pytest>=6.0.0,<8 ; extra == 'test' - pytest-xdist>=3.8.0 ; extra == 'test' + - pytest-cov>=5.0.0 ; extra == 'test' - pytest-timeout==1.4.2 ; extra == 'test' - pytest-lazy-fixture==0.6.3 ; extra == 'test' - pytest-ordering~=0.6.0 ; extra == 'test' @@ -5555,6 +5559,18 @@ packages: - pygal ; extra == 'histogram' - pygaljs ; extra == 'histogram' requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*' +- pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl + name: pytest-cov + version: 7.1.0 + sha256: a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678 + requires_dist: + - coverage[toml]>=7.10.6 + - pluggy>=1.2 + - pytest>=7 + - process-tests ; extra == 'testing' + - pytest-xdist ; extra == 'testing' + - virtualenv ; extra == 'testing' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/25/b2/bdc663a5647ce2034f7e8420122af340df87c01ba97745fc753b8c917acb/pytest_env-1.1.3-py3-none-any.whl name: pytest-env version: 1.1.3 @@ -6716,21 +6732,6 @@ packages: version: 0.30.0 sha256: 0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - name: rpds-py - version: 2026.5.1 - sha256: 21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl - name: rpds-py - version: 2026.5.1 - sha256: 1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9 - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl - name: rpds-py - version: 2026.5.1 - sha256: 413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78 - requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/85/dd/904873250a6554fbae40cddbf9198e3cc37a2f1319d5e1a5ce82fe269c17/s3transfer-0.17.1-py3-none-any.whl name: s3transfer version: 0.17.1 diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 75f406c8e2c..cabca1490b5 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -18,6 +18,7 @@ import os import time import warnings +from dataclasses import dataclass, field from datetime import datetime, timedelta from pathlib import Path from typing import ( @@ -143,6 +144,14 @@ def _get_track_materialization(): _UNSET = object() +@dataclass +class _MaterializationDateRange: + """Per-batch start dates plus shared end date for materialization watermarks.""" + + end_date: datetime + fv_start_dates: dict = field(default_factory=dict) + + class FeatureStore: """ A FeatureStore object is used to define, create, and retrieve features. @@ -424,6 +433,163 @@ def _get_provider(self) -> Provider: # TODO: Bake self.repo_path into self.config so that we dont only have one interface to paths return self.provider + def _rollback_fv_states( + self, + feature_views: list, + previous_states: dict, + ) -> None: + """Restore feature views to their pre-materialization states.""" + for fv in feature_views: + prev = previous_states.get(fv.name) + if ( + hasattr(fv, "state") + and prev is not None + and prev != FeatureViewState.STATE_UNSPECIFIED + ): + fv.state = prev + self.registry.apply_feature_view(fv, self.project, commit=True) + + def _transition_fv_to_materializing( + self, + feature_view, + already_transitioned: list, + previous_states: dict, + ) -> None: + """ + Transition a feature view to MATERIALIZING state. + + Rolls back all already-transitioned FVs if this one can't transition. + """ + previous_state = getattr(feature_view, "state", None) + if ( + hasattr(feature_view, "state") + and feature_view.state != FeatureViewState.STATE_UNSPECIFIED + ): + if not feature_view.state.can_transition_to(FeatureViewState.MATERIALIZING): + self._rollback_fv_states(already_transitioned, previous_states) + raise ValueError( + f"FeatureView {feature_view.name} cannot transition " + f"from {feature_view.state.name} to MATERIALIZING." + ) + feature_view.state = FeatureViewState.MATERIALIZING + self.registry.apply_feature_view(feature_view, self.project, commit=True) + previous_states[feature_view.name] = previous_state + + def _submit_and_process_materialization_jobs( + self, + provider, + tasks: list, + regular_fvs: list, + previous_states: dict, + date_range: "_MaterializationDateRange", + ) -> None: + """ + Submit all tasks to the engine in one call and process the results. + + For each returned job: record watermark on success, roll back state on + error. If the engine itself raises, all states are rolled back. + """ + from feast.infra.common.materialization_job import ( + MaterializationJobStatus, + ) + + batch_start = time.monotonic() + try: + jobs = provider.batch_engine.materialize(self.registry, tasks) + except Exception: + self._rollback_fv_states(regular_fvs, previous_states) + raise + + if len(jobs) != len(regular_fvs): + self._rollback_fv_states(regular_fvs, previous_states) + raise RuntimeError( + f"Engine returned {len(jobs)} jobs for {len(regular_fvs)} tasks" + ) + + first_error = None + succeeded_fvs = [] + failed_fvs = [] + + for fv, job in zip(regular_fvs, jobs): + fv_status = job.status() + + if fv_status == MaterializationJobStatus.ERROR: + failed_fvs.append(fv) + if first_error is None and job.error(): + first_error = job.error() + else: + succeeded_fvs.append(fv) + + if failed_fvs: + self._rollback_fv_states(failed_fvs, previous_states) + + # Engines that apply watermarks themselves (e.g. SparkApplication pod) + # must not get a second apply_materialization — that duplicates intervals. + if not getattr(provider.batch_engine, "applies_materialization", False): + for fv in succeeded_fvs: + self.registry.apply_materialization( + fv, + self.project, + date_range.fv_start_dates[fv.name], + date_range.end_date, + ) + + _tracker = _get_track_materialization() + if _tracker is not None: + elapsed = time.monotonic() - batch_start + for fv in succeeded_fvs: + _tracker(fv.name, True, elapsed) + for fv in failed_fvs: + _tracker(fv.name, False, elapsed) + + if first_error: + raise first_error + + def _materialize_fvs_batch( + self, + provider, + fv_with_dates: list, + end_date: datetime, + tqdm_builder, + disable_event_timestamp: bool = False, + ) -> None: + """Batch path: collect all FVs, submit to engine in one call. + + Only used when ``provider.batch_engine.supports_batch`` is True. + """ + from feast.infra.common.materialization_job import MaterializationTask + + tasks: list = [] + regular_fvs: list = [] + previous_states: dict = {} + date_range = _MaterializationDateRange(end_date=end_date) + + for feature_view, fv_start in fv_with_dates: + self._transition_fv_to_materializing( + feature_view, regular_fvs, previous_states + ) + regular_fvs.append(feature_view) + date_range.fv_start_dates[feature_view.name] = fv_start + tasks.append( + MaterializationTask( + project=self.project, + feature_view=feature_view, + start_time=fv_start, + end_time=end_date, + tqdm_builder=tqdm_builder, + disable_event_timestamp=disable_event_timestamp, + ) + ) + + if tasks: + self._submit_and_process_materialization_jobs( + provider, + tasks, + regular_fvs, + previous_states, + date_range, + ) + @property def openlineage_emitter(self) -> Optional[Any]: """Gets the OpenLineage emitter of this feature store.""" @@ -2249,7 +2415,15 @@ def materialize_incremental( _mat_start = time.monotonic() try: - # TODO paging large loads + provider = self._get_provider() + end_date_tz = utils.make_tzaware(end_date) or _utc_now() + + def tqdm_builder(length): + return tqdm(total=length, ncols=100) + + # (feature_view, start_date) — start_date is always set before append + regular_fvs_with_dates: list[tuple[Any, datetime]] = [] + for feature_view in feature_views_to_materialize: if isinstance(feature_view, OnDemandFeatureView): if feature_view.write_to_online_store: @@ -2295,78 +2469,86 @@ def materialize_incremental( "the start date will be set to 1 year before the current time." ) start_date = _utc_now() - timedelta(weeks=52) - provider = self._get_provider() + + start_date = utils.make_tzaware(start_date) print( f"{Style.BRIGHT + Fore.GREEN}{feature_view.name}{Style.RESET_ALL}" f" from {Style.BRIGHT + Fore.GREEN}{utils.make_tzaware(start_date.replace(microsecond=0))}{Style.RESET_ALL}" f" to {Style.BRIGHT + Fore.GREEN}{utils.make_tzaware(end_date.replace(microsecond=0))}{Style.RESET_ALL}:" ) - def tqdm_builder(length): - return tqdm(total=length, ncols=100) - - start_date = utils.make_tzaware(start_date) - end_date = utils.make_tzaware(end_date) or _utc_now() - - # Transition state to MATERIALIZING before starting. - # Only enforce when the state machine is active (not STATE_UNSPECIFIED). - previous_state = getattr(feature_view, "state", None) - if ( - hasattr(feature_view, "state") - and feature_view.state != FeatureViewState.STATE_UNSPECIFIED - ): - if not feature_view.state.can_transition_to( - FeatureViewState.MATERIALIZING - ): - raise ValueError( - f"FeatureView {feature_view.name} cannot transition " - f"from {feature_view.state.name} to MATERIALIZING." - ) - feature_view.state = FeatureViewState.MATERIALIZING - self.registry.apply_feature_view( - feature_view, self.project, commit=True - ) - - fv_start = time.monotonic() - fv_success = True - try: - provider.materialize_single_feature_view( - config=self.config, - feature_view=feature_view, - start_date=start_date, - end_date=end_date, - registry=self.registry, - project=self.project, - tqdm_builder=tqdm_builder, - ) - except Exception: - fv_success = False - # Roll back state to previous value on failure. + regular_fvs_with_dates.append((feature_view, start_date)) + + # batch_engine is on PassthroughProvider (concrete); same access as + # _submit_and_process_materialization_jobs via untyped provider. + batch_engine = getattr(provider, "batch_engine", None) + if batch_engine and getattr(batch_engine, "supports_batch", False): + self._materialize_fvs_batch( + provider, + regular_fvs_with_dates, + end_date_tz, + tqdm_builder, + ) + else: + for feature_view, start_date in regular_fvs_with_dates: + # Transition state to MATERIALIZING before starting. + # Only enforce when the state machine is active (not STATE_UNSPECIFIED). + previous_state = getattr(feature_view, "state", None) if ( hasattr(feature_view, "state") - and previous_state is not None - and previous_state != FeatureViewState.STATE_UNSPECIFIED + and feature_view.state != FeatureViewState.STATE_UNSPECIFIED ): - feature_view.state = previous_state + if not feature_view.state.can_transition_to( + FeatureViewState.MATERIALIZING + ): + raise ValueError( + f"FeatureView {feature_view.name} cannot transition " + f"from {feature_view.state.name} to MATERIALIZING." + ) + feature_view.state = FeatureViewState.MATERIALIZING self.registry.apply_feature_view( feature_view, self.project, commit=True ) - raise - finally: - _tracker = _get_track_materialization() - if _tracker is not None: - _tracker( - feature_view.name, - fv_success, - time.monotonic() - fv_start, + + fv_start = time.monotonic() + fv_success = True + try: + provider.materialize_single_feature_view( + config=self.config, + feature_view=feature_view, + start_date=start_date, + end_date=end_date_tz, + registry=self.registry, + project=self.project, + tqdm_builder=tqdm_builder, ) + except Exception: + fv_success = False + # Roll back state to previous value on failure. + if ( + hasattr(feature_view, "state") + and previous_state is not None + and previous_state != FeatureViewState.STATE_UNSPECIFIED + ): + feature_view.state = previous_state + self.registry.apply_feature_view( + feature_view, self.project, commit=True + ) + raise + finally: + _tracker = _get_track_materialization() + if _tracker is not None: + _tracker( + feature_view.name, + fv_success, + time.monotonic() - fv_start, + ) - if not isinstance(feature_view, OnDemandFeatureView): self.registry.apply_materialization( feature_view, self.project, start_date, - end_date, + end_date_tz, ) materialized_fv_names = [ @@ -2459,7 +2641,15 @@ def materialize( _mat_start = time.monotonic() try: - # TODO paging large loads + provider = self._get_provider() + start_date = utils.make_tzaware(start_date) + end_date = utils.make_tzaware(end_date) + + def tqdm_builder(length): + return tqdm(total=length, ncols=100) + + regular_fvs_with_dates: list[tuple[Any, datetime]] = [] + for feature_view in feature_views_to_materialize: if isinstance(feature_view, OnDemandFeatureView): if feature_view.write_to_online_store: @@ -2473,77 +2663,86 @@ def materialize( full_feature_names=full_feature_names, ) continue - provider = self._get_provider() + print( f"{Style.BRIGHT + Fore.GREEN}{feature_view.name}{Style.RESET_ALL}:" ) - def tqdm_builder(length): - return tqdm(total=length, ncols=100) - - start_date = utils.make_tzaware(start_date) - end_date = utils.make_tzaware(end_date) - - # Transition state to MATERIALIZING before starting. - # Only enforce when the state machine is active (not STATE_UNSPECIFIED). - previous_state = getattr(feature_view, "state", None) - if ( - hasattr(feature_view, "state") - and feature_view.state != FeatureViewState.STATE_UNSPECIFIED - ): - if not feature_view.state.can_transition_to( - FeatureViewState.MATERIALIZING - ): - raise ValueError( - f"FeatureView {feature_view.name} cannot transition " - f"from {feature_view.state.name} to MATERIALIZING." - ) - feature_view.state = FeatureViewState.MATERIALIZING - self.registry.apply_feature_view( - feature_view, self.project, commit=True - ) + regular_fvs_with_dates.append((feature_view, start_date)) - fv_start = time.monotonic() - fv_success = True - try: - provider.materialize_single_feature_view( - config=self.config, - feature_view=feature_view, - start_date=start_date, - end_date=end_date, - registry=self.registry, - project=self.project, - tqdm_builder=tqdm_builder, - disable_event_timestamp=disable_event_timestamp, - ) - except Exception: - fv_success = False - # Roll back state to previous value on failure. + # batch_engine is on PassthroughProvider (concrete); same access as + # _submit_and_process_materialization_jobs via untyped provider. + batch_engine = getattr(provider, "batch_engine", None) + if batch_engine and getattr(batch_engine, "supports_batch", False): + self._materialize_fvs_batch( + provider, + regular_fvs_with_dates, + end_date, + tqdm_builder, + disable_event_timestamp=disable_event_timestamp, + ) + else: + for feature_view, fv_start in regular_fvs_with_dates: + # Transition state to MATERIALIZING before starting. + # Only enforce when the state machine is active (not STATE_UNSPECIFIED). + previous_state = getattr(feature_view, "state", None) if ( hasattr(feature_view, "state") - and previous_state is not None - and previous_state != FeatureViewState.STATE_UNSPECIFIED + and feature_view.state != FeatureViewState.STATE_UNSPECIFIED ): - feature_view.state = previous_state + if not feature_view.state.can_transition_to( + FeatureViewState.MATERIALIZING + ): + raise ValueError( + f"FeatureView {feature_view.name} cannot transition " + f"from {feature_view.state.name} to MATERIALIZING." + ) + feature_view.state = FeatureViewState.MATERIALIZING self.registry.apply_feature_view( feature_view, self.project, commit=True ) - raise - finally: - _tracker = _get_track_materialization() - if _tracker is not None: - _tracker( - feature_view.name, - fv_success, - time.monotonic() - fv_start, + + fv_start_time = time.monotonic() + fv_success = True + try: + provider.materialize_single_feature_view( + config=self.config, + feature_view=feature_view, + start_date=fv_start, + end_date=end_date, + registry=self.registry, + project=self.project, + tqdm_builder=tqdm_builder, + disable_event_timestamp=disable_event_timestamp, ) + except Exception: + fv_success = False + # Roll back state to previous value on failure. + if ( + hasattr(feature_view, "state") + and previous_state is not None + and previous_state != FeatureViewState.STATE_UNSPECIFIED + ): + feature_view.state = previous_state + self.registry.apply_feature_view( + feature_view, self.project, commit=True + ) + raise + finally: + _tracker = _get_track_materialization() + if _tracker is not None: + _tracker( + feature_view.name, + fv_success, + time.monotonic() - fv_start_time, + ) - self.registry.apply_materialization( - feature_view, - self.project, - start_date, - end_date, - ) + self.registry.apply_materialization( + feature_view, + self.project, + fv_start, + end_date, + ) materialized_fv_names = [ fv.name diff --git a/sdk/python/feast/infra/compute_engines/base.py b/sdk/python/feast/infra/compute_engines/base.py index 360cf95a044..a99907a82b2 100644 --- a/sdk/python/feast/infra/compute_engines/base.py +++ b/sdk/python/feast/infra/compute_engines/base.py @@ -85,6 +85,21 @@ def teardown_infra( """ pass + @property + def supports_batch(self) -> bool: + """Whether this engine can accept all tasks in a single materialize() call. + + When True, feature_store.py collects all FV tasks upfront and submits + them in one batch. When False (default), the standard per-FV loop + through ``provider.materialize_single_feature_view()`` is used. + """ + return False + + @property + def applies_materialization(self) -> bool: + """If True, the engine already wrote watermarks/state (e.g. driver pod).""" + return False + def materialize( self, registry: BaseRegistry, diff --git a/sdk/python/feast/infra/compute_engines/spark_application/.dockerignore b/sdk/python/feast/infra/compute_engines/spark_application/.dockerignore new file mode 100644 index 00000000000..0a4119f63bc --- /dev/null +++ b/sdk/python/feast/infra/compute_engines/spark_application/.dockerignore @@ -0,0 +1,8 @@ +feast/.git +feast/__pycache__ +feast/**/__pycache__ +feast/.mypy_cache +feast/tests +feast/.pixi +**/*.pyc +**/.pytest_cache diff --git a/sdk/python/feast/infra/compute_engines/spark_application/Dockerfile b/sdk/python/feast/infra/compute_engines/spark_application/Dockerfile new file mode 100644 index 00000000000..066aac3ad86 --- /dev/null +++ b/sdk/python/feast/infra/compute_engines/spark_application/Dockerfile @@ -0,0 +1,34 @@ +FROM apache/spark:4.0.1 + +USER root + +RUN apt-get update && apt-get install --no-install-suggests --no-install-recommends --yes git && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +ENV PYTHONPATH="/opt/spark/python:/opt/spark/python/lib/py4j-0.10.9.9-src.zip" +ENV SPARK_HOME="/opt/spark" + +WORKDIR /app + +COPY sdk/python/feast/infra/compute_engines/spark_application/main.py /opt/feast/main.py + +# Copy necessary parts of the Feast codebase +COPY sdk/python sdk/python +COPY protos protos +COPY pyproject.toml pyproject.toml +COPY README.md README.md + +# setuptools_scm needs .git to infer the version. +# https://github.com/pypa/setuptools_scm#usage-from-docker +RUN --mount=source=.git,target=.git,type=bind uv pip install --system --no-cache-dir '.[redis,grpcio,k8s]' pymysql + +# Hadoop AWS JARs for S3A filesystem support + AWS SDK v2 bundle +RUN curl -sL https://repo1.maven.org/maven2/org/apache/hadoop/hadoop-aws/3.4.1/hadoop-aws-3.4.1.jar \ + -o /opt/spark/jars/hadoop-aws-3.4.1.jar && \ + curl -sL https://repo1.maven.org/maven2/com/amazonaws/aws-java-sdk-bundle/1.12.782/aws-java-sdk-bundle-1.12.782.jar \ + -o /opt/spark/jars/aws-java-sdk-bundle-1.12.782.jar && \ + curl -sL https://repo1.maven.org/maven2/software/amazon/awssdk/bundle/2.28.4/bundle-2.28.4.jar \ + -o /opt/spark/jars/bundle-2.28.4.jar + +USER spark diff --git a/sdk/python/feast/infra/compute_engines/spark_application/__init__.py b/sdk/python/feast/infra/compute_engines/spark_application/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/infra/compute_engines/spark_application/compute.py b/sdk/python/feast/infra/compute_engines/spark_application/compute.py new file mode 100644 index 00000000000..d5fbf7493ce --- /dev/null +++ b/sdk/python/feast/infra/compute_engines/spark_application/compute.py @@ -0,0 +1,516 @@ +import logging +import time +import uuid +from typing import List, Optional, Sequence, Union + +import pyarrow as pa +import yaml +from kubernetes import client +from kubernetes import config as k8s_config +from kubernetes.client.exceptions import ApiException + +from feast import RepoConfig +from feast.batch_feature_view import BatchFeatureView +from feast.entity import Entity +from feast.feature_view import FeatureView, FeatureViewState +from feast.infra.common.materialization_job import ( + MaterializationJob, + MaterializationJobStatus, + MaterializationTask, +) +from feast.infra.common.retrieval_task import HistoricalRetrievalTask +from feast.infra.compute_engines.base import ComputeEngine +from feast.infra.offline_stores.offline_store import OfflineStore +from feast.infra.online_stores.online_store import OnlineStore +from feast.infra.registry.base_registry import BaseRegistry +from feast.on_demand_feature_view import OnDemandFeatureView +from feast.stream_feature_view import StreamFeatureView + +from .config import ( + SparkApplicationComputeEngineConfig, # noqa: F401 — required for Feast config resolution +) +from .job import ( + _MAX_RETRIES, + _RETRY_BACKOFF_BASE, + CompletedMaterializationJob, + SparkApplicationMaterializationJob, + _is_retryable, + _rbac_hint, +) + +logger = logging.getLogger(__name__) + + +class SparkApplicationComputeEngine(ComputeEngine): + def __init__( + self, + *, + repo_config: RepoConfig, + offline_store: OfflineStore, + online_store: OnlineStore, + **kwargs, + ): + super().__init__( + repo_config=repo_config, + offline_store=offline_store, + online_store=online_store, + **kwargs, + ) + self.config = repo_config.batch_engine + + _FILE_BASED_ONLINE = {"sqlite", "faiss"} + _FILE_BASED_OFFLINE = {"dask", "file", "duckdb"} + _FILE_BASED_REGISTRY = {"file"} + + online_type = getattr(repo_config.online_store, "type", "") + if online_type in _FILE_BASED_ONLINE: + raise ValueError( + f"spark_application engine cannot use '{online_type}' online store. " + f"File-based stores ({', '.join(sorted(_FILE_BASED_ONLINE))}) write " + "data inside the SparkApplication pod, which is lost when the pod " + "terminates. Use a network-accessible store: redis, postgres, etc." + ) + + offline_type = getattr(repo_config.offline_store, "type", "") + if offline_type in _FILE_BASED_OFFLINE: + raise ValueError( + f"spark_application engine cannot use '{offline_type}' offline store. " + f"File-based stores ({', '.join(sorted(_FILE_BASED_OFFLINE))}) read " + "from the local filesystem, which is inaccessible from the " + "SparkApplication pod. Use a network-accessible store: spark, " + "bigquery, snowflake, redshift, etc." + ) + + registry_type = getattr(repo_config.registry, "registry_type", "") + if registry_type in _FILE_BASED_REGISTRY: + raise ValueError( + f"spark_application engine cannot use '{registry_type}' registry. " + f"File-based registries ({', '.join(sorted(_FILE_BASED_REGISTRY))}) " + "store data on the local filesystem, which is inaccessible from the " + "SparkApplication pod. Use a network-accessible registry: sql, " + "snowflake.registry, etc." + ) + + # Defer kubeconfig load until materialize/cleanup — feast apply only + # constructs the engine and calls update() (a no-op), so it must not + # require a cluster. + self._k8s_client = None + self._core_v1 = None + self._custom_api = None + self._server_id = uuid.uuid4().hex[:8] + + def _ensure_k8s(self) -> None: + """Load kubeconfig and create API clients on first K8s use.""" + if self._custom_api is not None: + return + k8s_config.load_config() + self._k8s_client = client.ApiClient() + self._core_v1 = client.CoreV1Api(self._k8s_client) + self._custom_api = client.CustomObjectsApi(self._k8s_client) + + @property + def core_v1(self): + self._ensure_k8s() + return self._core_v1 + + @property + def custom_api(self): + self._ensure_k8s() + return self._custom_api + + @property + def supports_batch(self) -> bool: + return True + + @property + def applies_materialization(self) -> bool: + return True + + def update( + self, + project: str, + views_to_delete: Sequence[ + Union[BatchFeatureView, StreamFeatureView, FeatureView] + ], + views_to_keep: Sequence[ + Union[BatchFeatureView, StreamFeatureView, FeatureView, OnDemandFeatureView] + ], + entities_to_delete: Sequence[Entity], + entities_to_keep: Sequence[Entity], + ): + pass + + def teardown_infra( + self, + project: str, + fvs: Sequence[Union[BatchFeatureView, StreamFeatureView, FeatureView]], + entities: Sequence[Entity], + ): + pass + + def get_historical_features( + self, registry: BaseRegistry, task: HistoricalRetrievalTask + ) -> pa.Table: + raise NotImplementedError( + "SparkApplicationComputeEngine does not yet support get_historical_features(). " + "This is planned for Phase 2." + ) + + def materialize( + self, + registry: BaseRegistry, + tasks: Union[MaterializationTask, List[MaterializationTask]], + **kwargs, + ) -> List[MaterializationJob]: + """Batch all materialization tasks into a single SparkApplication. + + The pod calls apply_materialization (via gRPC → Feast server → registry) + for each FV it successfully materializes. After the pod finishes, we read + each FV's state from the registry: AVAILABLE_ONLINE = succeeded, + still MATERIALIZING = failed. + """ + if isinstance(tasks, MaterializationTask): + tasks = [tasks] + + job_id = uuid.uuid4().hex[:8] + + try: + self._create_with_retry( + lambda: self._create_configmap(job_id, tasks), + "ConfigMap", + job_id, + ) + except ApiException as e: + job = SparkApplicationMaterializationJob( + job_id, + self.config.namespace, + self.custom_api, + error=Exception( + f"ConfigMap creation failed: HTTP {e.status} {e.reason}." + f"{_rbac_hint(e.status)}" + ), + ) + return [job for _ in tasks] + + try: + cr = self._build_spark_application_cr(job_id) + self._create_with_retry( + lambda: self.custom_api.create_namespaced_custom_object( + group="sparkoperator.k8s.io", + version="v1beta2", + namespace=self.config.namespace, + plural="sparkapplications", + body=cr, + ), + "SparkApplication", + job_id, + ) + except ApiException as e: + self._cleanup(job_id) + job = SparkApplicationMaterializationJob( + job_id, + self.config.namespace, + self.custom_api, + error=Exception( + f"SparkApplication creation failed: HTTP {e.status} {e.reason}." + f"{_rbac_hint(e.status)}" + ), + ) + return [job for _ in tasks] + + job = SparkApplicationMaterializationJob( + job_id, self.config.namespace, self.custom_api + ) + try: + self._wait_for_completion(job) + return self._build_per_fv_jobs(registry, tasks, job_id, job) + finally: + self._cleanup(job_id) + + def _build_driver_repo_config(self) -> dict: + """Build feature_store.yaml for the SparkApplication driver pod. + + One rewrite: batch_engine → spark.engine. Pod uses SparkComputeEngine + with the active SparkSession (from spark-submit). Enables distributed + reads via SparkReadNode and distributed writes via mapInArrow across + executors. This is NOT recursive — SparkComputeEngine uses the local + session, it does not create CRDs. + + offline_store and registry are NOT rewritten — the pod inherits the + server's config. File-based registries are rejected at __init__(), so + the registry is always network-accessible (SQL, Snowflake, etc.) and + the pod can write apply_materialization() directly. + """ + config_dict = self.repo_config.model_dump(by_alias=True, mode="json") + + config_dict["batch_engine"] = {"type": "spark.engine"} + + return config_dict + + def _build_per_fv_jobs( + self, + registry: BaseRegistry, + tasks: List[MaterializationTask], + job_id: str, + job: SparkApplicationMaterializationJob, + ) -> List[MaterializationJob]: + """Build one independent job object per FV from registry state. + + The driver pod calls ``apply_materialization`` for each FV it + successfully materializes, setting state to ``AVAILABLE_ONLINE``. + FVs still in ``MATERIALIZING`` were not processed. + + Each returned job is an independent object so that a failed + SparkApplication does not pollute the status of succeeded FVs. + """ + if len(tasks) <= 1: + return [job for _ in tasks] + + jobs: List[MaterializationJob] = [] + for task in tasks: + fv = registry.get_feature_view(task.feature_view.name, task.project) + if getattr(fv, "state", None) == FeatureViewState.AVAILABLE_ONLINE: + jobs.append(CompletedMaterializationJob(job_id)) + else: + jobs.append( + SparkApplicationMaterializationJob( + job_id, + self.config.namespace, + self.custom_api, + error=Exception( + f"Feature view '{task.feature_view.name}' was not " + f"materialized by SparkApplication feast-sa-{job_id}" + ), + ) + ) + return jobs + + def _create_configmap(self, job_id: str, tasks: List[MaterializationTask]): + feast_config_yaml = yaml.dump( + self._build_driver_repo_config(), default_flow_style=False + ) + mat_config = { + "operation": "materialize", + "tasks": [ + { + "feature_view": task.feature_view.name, + "start_time": task.start_time.isoformat(), + "end_time": task.end_time.isoformat(), + } + for task in tasks + ], + } + if self.config.concurrency > 1: + mat_config["concurrency"] = self.config.concurrency + mat_config_yaml = yaml.dump(mat_config) + manifest = { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": f"feast-sa-{job_id}", + "namespace": self.config.namespace, + "labels": {"feast-materializer": "configmap", **self.config.labels}, + }, + "data": { + "feature_store.yaml": feast_config_yaml, + "materialization_config.yaml": mat_config_yaml, + }, + } + self.core_v1.create_namespaced_config_map( + namespace=self.config.namespace, body=manifest + ) + + def _build_spark_application_cr(self, job_id: str) -> dict: + driver_env_conf = { + "spark.kubernetes.driverEnv.FEAST_CONFIGMAP_NAME": f"feast-sa-{job_id}", + "spark.kubernetes.driverEnv.FEAST_CONFIGMAP_NAMESPACE": self.config.namespace, + } + for entry in self.config.env: + name = entry.get("name", "") + if name and "value" in entry and entry["value"] is not None: + driver_env_conf[f"spark.kubernetes.driverEnv.{name}"] = str( + entry["value"] + ) + + spec = { + "type": "Python", + "mode": "cluster", + "pythonVersion": "3", + "image": self.config.image, + "imagePullPolicy": "IfNotPresent", + "mainApplicationFile": "local:///opt/feast/main.py", + "sparkVersion": self.config.spark_version, + "sparkConf": { + "spark.scheduler.mode": "FAIR", + **(self.config.spark_conf or {}), + **driver_env_conf, + }, + "restartPolicy": { + "type": self.config.restart_policy, + "onFailureRetries": self.config.max_retries, + "onFailureRetryInterval": 30, + }, + "timeToLiveSeconds": self.config.ttl_seconds_after_finished, + "volumes": [ + { + "name": "feast-config", + "configMap": {"name": f"feast-sa-{job_id}"}, + }, + *self.config.volumes, + ], + "driver": { + "cores": self.config.driver_cores, + "memory": self.config.driver_memory, + "serviceAccount": self.config.service_account, + "volumeMounts": [ + {"name": "feast-config", "mountPath": "/var/feast/"}, + *self.config.volume_mounts, + ], + }, + "executor": { + "instances": max(self.config.executor_instances, 1), + "cores": self.config.executor_cores, + "memory": self.config.executor_memory, + }, + } + + if self.config.image_pull_secrets: + spec["imagePullSecrets"] = self.config.image_pull_secrets + if self.config.hadoop_conf: + spec["hadoopConf"] = self.config.hadoop_conf + if self.config.py_files: + spec["deps"] = {"pyFiles": self.config.py_files} + if self.config.env: + spec["driver"]["env"] = self.config.env + spec["executor"]["env"] = self.config.env + if self.config.env_from: + spec["driver"]["envFrom"] = self.config.env_from + spec["executor"]["envFrom"] = self.config.env_from + if self.config.node_selector: + spec["driver"]["nodeSelector"] = self.config.node_selector + spec["executor"]["nodeSelector"] = self.config.node_selector + if self.config.tolerations: + spec["driver"]["tolerations"] = self.config.tolerations + spec["executor"]["tolerations"] = self.config.tolerations + if self.config.volume_mounts: + spec["executor"]["volumeMounts"] = self.config.volume_mounts + + return { + "apiVersion": "sparkoperator.k8s.io/v1beta2", + "kind": "SparkApplication", + "metadata": { + "name": f"feast-sa-{job_id}", + "namespace": self.config.namespace, + "labels": { + "feast-materializer": "sparkapplication", + "feast-job-id": job_id, + "feast-server-id": self._server_id, + **self._kueue_labels(), + **self.config.labels, + }, + }, + "spec": spec, + } + + def _kueue_labels(self) -> dict: + if self.config.queue_name: + return {"kueue.x-k8s.io/queue-name": self.config.queue_name} + return {} + + def _wait_for_completion(self, job: SparkApplicationMaterializationJob): + start = time.monotonic() + deadline = start + self.config.job_timeout_seconds + while time.monotonic() < deadline: + status = job.status() + elapsed = time.monotonic() - start + logger.info( + f"SparkApplication {job.job_id()} status={status.name} elapsed={elapsed:.0f}s" + ) + if status == MaterializationJobStatus.ERROR: + logs = self._get_driver_logs(job._job_id) + if logs: + logger.error(f"Driver logs (last 50 lines):\n{logs}") + return + if status == MaterializationJobStatus.SUCCEEDED: + return + time.sleep(self.config.poll_interval_seconds) + job._error = Exception( + f"SparkApplication {job.job_id()} did not complete " + f"within {self.config.job_timeout_seconds}s" + ) + + def _get_driver_logs(self, job_id: str, tail_lines: int = 50) -> Optional[str]: + """Fetch last N lines of driver pod logs for error diagnostics.""" + try: + pods = self.core_v1.list_namespaced_pod( + namespace=self.config.namespace, + label_selector=f"spark-role=driver,sparkoperator.k8s.io/app-name=feast-sa-{job_id}", + ) + if pods.items: + return self.core_v1.read_namespaced_pod_log( + name=pods.items[0].metadata.name, + namespace=self.config.namespace, + tail_lines=tail_lines, + ) + except ApiException: + logger.warning(f"Could not retrieve driver logs for feast-sa-{job_id}") + return None + + @staticmethod + def _create_with_retry(fn, resource_kind: str, job_id: str): + """Call *fn* with exponential backoff on transient K8s API errors.""" + last_exc = None + for attempt in range(_MAX_RETRIES): + try: + return fn() + except ApiException as e: + if not _is_retryable(e): + raise + last_exc = e + wait = _RETRY_BACKOFF_BASE**attempt + logger.warning( + f"{resource_kind} feast-sa-{job_id}: create attempt " + f"{attempt + 1}/{_MAX_RETRIES} failed (HTTP {e.status}), " + f"retrying in {wait}s" + ) + time.sleep(wait) + raise last_exc # type: ignore[misc] + + def _cleanup(self, job_id: str): + """Best-effort delete of SparkApplication + ConfigMap. + + The SparkApplication CR is also garbage-collected by the Spark Operator + after ``spec.timeToLiveSeconds`` (default 1h), so a failed delete here + is not a resource leak — just delayed cleanup. The ConfigMap is ours + and not covered by operator TTL. + """ + resources = [ + ( + "SparkApplication", + lambda: self.custom_api.delete_namespaced_custom_object( + "sparkoperator.k8s.io", + "v1beta2", + self.config.namespace, + "sparkapplications", + f"feast-sa-{job_id}", + ), + ), + ( + "ConfigMap", + lambda: self.core_v1.delete_namespaced_config_map( + f"feast-sa-{job_id}", + self.config.namespace, + ), + ), + ] + for kind, fn in resources: + try: + fn() + except ApiException as e: + if e.status != 404: + logger.warning( + f"Cleanup of {kind} feast-sa-{job_id} failed " + f"(HTTP {e.status}): {e.reason}. " + f"Manual cleanup: kubectl delete {kind.lower()} " + f"feast-sa-{job_id} -n {self.config.namespace}" + ) diff --git a/sdk/python/feast/infra/compute_engines/spark_application/config.py b/sdk/python/feast/infra/compute_engines/spark_application/config.py new file mode 100644 index 00000000000..b09aedeadc3 --- /dev/null +++ b/sdk/python/feast/infra/compute_engines/spark_application/config.py @@ -0,0 +1,81 @@ +import warnings +from typing import Dict, List, Literal, Optional + +from pydantic import StrictStr, model_validator + +from feast.repo_config import FeastConfigBaseModel + + +class SparkApplicationComputeEngineConfig(FeastConfigBaseModel): + """Batch Compute Engine config for SparkApplication CRDs via Kubeflow Spark Operator.""" + + type: Literal["spark_application"] = "spark_application" + + image: StrictStr + image_pull_secrets: List[str] = [] + + namespace: StrictStr = "default" + service_account: StrictStr = "" + + driver_cores: int = 1 + driver_memory: StrictStr = "1g" + executor_instances: int = 1 + executor_cores: int = 1 + executor_memory: StrictStr = "1g" + + spark_conf: Optional[Dict[str, str]] = None + hadoop_conf: Optional[Dict[str, str]] = None + spark_version: StrictStr = "4.0.1" + staging_location: Optional[str] = None + + env: List[dict] = [] + env_from: List[dict] = [] + + queue_name: Optional[str] = None + + job_timeout_seconds: int = 3600 + poll_interval_seconds: int = 10 + ttl_seconds_after_finished: int = 3600 + restart_policy: StrictStr = "Never" + max_retries: int = 3 + + concurrency: int = 1 + + labels: Dict[str, str] = {} + + volumes: List[dict] = [] + volume_mounts: List[dict] = [] + py_files: List[str] = [] + node_selector: Optional[Dict[str, str]] = None + tolerations: List[dict] = [] + + @staticmethod + def _validate_env_entry(index: int, entry: object) -> None: + """Validate a single K8s EnvVar dict (must have name + value or valueFrom).""" + if not isinstance(entry, dict): + raise ValueError( + f"env[{index}] must be a dict, got {type(entry).__name__}: {entry}" + ) + if "name" not in entry: + raise ValueError( + f"env[{index}] is missing required 'name'. " + f"Each env entry must be a K8s EnvVar dict. Got: {entry}" + ) + if "value" not in entry and "valueFrom" not in entry: + raise ValueError( + f"env[{index}] must set 'value' or 'valueFrom' " + f"(K8s EnvVar spec). Got: {entry}" + ) + + @model_validator(mode="after") + def _validate_config(self) -> "SparkApplicationComputeEngineConfig": + if self.staging_location: + warnings.warn( + "staging_location is configured but only used for " + "get_historical_features (not yet supported by this engine). " + "It will be ignored for materialize operations.", + stacklevel=2, + ) + for i, entry in enumerate(self.env): + self._validate_env_entry(i, entry) + return self diff --git a/sdk/python/feast/infra/compute_engines/spark_application/job.py b/sdk/python/feast/infra/compute_engines/spark_application/job.py new file mode 100644 index 00000000000..ef05585b061 --- /dev/null +++ b/sdk/python/feast/infra/compute_engines/spark_application/job.py @@ -0,0 +1,164 @@ +import logging +import time +from typing import Optional + +from kubernetes import client +from kubernetes.client.exceptions import ApiException + +from feast.infra.common.materialization_job import ( + MaterializationJob, + MaterializationJobStatus, +) + +logger = logging.getLogger(__name__) + +_MAX_RETRIES = 3 +_RETRY_BACKOFF_BASE = 2 +_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} + + +def _is_retryable(exc: ApiException) -> bool: + return exc.status in _RETRYABLE_STATUS_CODES + + +def _rbac_hint(status: int) -> str: + if status in (401, 403): + return ( + " Check that the Feast server ServiceAccount has the required" + " Role/RoleBinding for sparkapplications and configmaps in this namespace." + ) + return "" + + +_STATE_MAP = { + "": MaterializationJobStatus.WAITING, + "SUBMITTED": MaterializationJobStatus.WAITING, + "RUNNING": MaterializationJobStatus.RUNNING, + "COMPLETED": MaterializationJobStatus.SUCCEEDED, + "FAILED": MaterializationJobStatus.ERROR, + "SUBMISSION_FAILED": MaterializationJobStatus.ERROR, + "PENDING_RERUN": MaterializationJobStatus.WAITING, + "INVALIDATING": MaterializationJobStatus.WAITING, + "SUCCEEDING": MaterializationJobStatus.RUNNING, + "FAILING": MaterializationJobStatus.RUNNING, + "SUSPENDING": MaterializationJobStatus.WAITING, + "SUSPENDED": MaterializationJobStatus.WAITING, + "RESUMING": MaterializationJobStatus.WAITING, + "UNKNOWN": MaterializationJobStatus.WAITING, +} +assert len(_STATE_MAP) == 14 + + +class CompletedMaterializationJob(MaterializationJob): + """Lightweight stub for a FV whose materialization already succeeded. + + Used by ``_build_per_fv_jobs`` so that each FV gets an independent job + object. Unlike ``SparkApplicationMaterializationJob``, this never polls + the K8s API — the outcome is already known from the registry state. + """ + + def __init__(self, job_id: str): + super().__init__() + self._job_id = job_id + + def status(self) -> MaterializationJobStatus: + return MaterializationJobStatus.SUCCEEDED + + def error(self) -> Optional[BaseException]: + return None + + def should_be_retried(self) -> bool: + return False + + def job_id(self) -> str: + return f"feast-sa-{self._job_id}" + + def url(self) -> Optional[str]: + return None + + +class SparkApplicationMaterializationJob(MaterializationJob): + def __init__( + self, + job_id: str, + namespace: str, + custom_api: client.CustomObjectsApi, + error: Optional[BaseException] = None, + ): + super().__init__() + self._job_id = job_id + self.namespace = namespace + self.custom_api = custom_api + self._error: Optional[BaseException] = error + + def status(self) -> MaterializationJobStatus: + if self._error is not None: + return MaterializationJobStatus.ERROR + + obj = self._get_cr_with_retry() + if obj is None: + return ( + MaterializationJobStatus.ERROR + if self._error + else MaterializationJobStatus.RUNNING + ) + + state = obj.get("status", {}).get("applicationState", {}).get("state", "") + result = _STATE_MAP.get(state, MaterializationJobStatus.WAITING) + if result == MaterializationJobStatus.ERROR: + msg = ( + obj.get("status", {}) + .get("applicationState", {}) + .get("errorMessage", f"SparkApplication failed: {state}") + ) + self._error = Exception(msg) + return result + + def _get_cr_with_retry(self) -> Optional[dict]: + """Fetch SparkApplication CR with exponential backoff on transient errors.""" + last_exc = None + for attempt in range(_MAX_RETRIES): + try: + return self.custom_api.get_namespaced_custom_object( + group="sparkoperator.k8s.io", + version="v1beta2", + namespace=self.namespace, + plural="sparkapplications", + name=f"feast-sa-{self._job_id}", + ) + except ApiException as e: + if e.status == 404: + self._error = Exception( + f"SparkApplication feast-sa-{self._job_id} not found" + ) + return None + if not _is_retryable(e): + self._error = Exception( + f"Kubernetes API error polling feast-sa-{self._job_id}: " + f"HTTP {e.status} {e.reason}.{_rbac_hint(e.status)}" + ) + return None + last_exc = e + wait = _RETRY_BACKOFF_BASE**attempt + logger.warning( + f"API poll attempt {attempt + 1}/{_MAX_RETRIES} failed " + f"(HTTP {e.status}), retrying in {wait}s" + ) + time.sleep(wait) + self._error = Exception( + f"Failed to poll SparkApplication after {_MAX_RETRIES} attempts: " + f"{last_exc.reason if last_exc else 'unknown'}" + ) + return None + + def error(self) -> Optional[BaseException]: + return self._error + + def should_be_retried(self) -> bool: + return False + + def job_id(self) -> str: + return f"feast-sa-{self._job_id}" + + def url(self) -> Optional[str]: + return None diff --git a/sdk/python/feast/infra/compute_engines/spark_application/main.py b/sdk/python/feast/infra/compute_engines/spark_application/main.py new file mode 100644 index 00000000000..cabe9d8b2cd --- /dev/null +++ b/sdk/python/feast/infra/compute_engines/spark_application/main.py @@ -0,0 +1,195 @@ +import logging +import os +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +import yaml + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("feast.spark_application.driver") + + +def _load_config_from_configmap(): + """Load feast and materialization config from a Kubernetes ConfigMap.""" + from kubernetes import client + from kubernetes import config as k8s_config + + k8s_config.load_incluster_config() + v1 = client.CoreV1Api() + cm = v1.read_namespaced_config_map( + name=os.environ["FEAST_CONFIGMAP_NAME"], + namespace=os.environ["FEAST_CONFIGMAP_NAMESPACE"], + ) + feast_config = yaml.safe_load(cm.data["feature_store.yaml"]) + mat_config = yaml.safe_load(cm.data["materialization_config.yaml"]) + return feast_config, mat_config + + +def _load_config_from_files(): + """Load feast and materialization config from mounted files.""" + with open("/var/feast/feature_store.yaml") as f: + feast_config = yaml.safe_load(f) + with open("/var/feast/materialization_config.yaml") as f: + mat_config = yaml.safe_load(f) + return feast_config, mat_config + + +def _bind_spark_session_to_thread(spark): + """Register *spark* as the active session for this OS thread. + + spark-submit creates the SparkSession on the main thread. PySpark's + ``getActiveSession()`` is thread-local, so worker threads see ``None``. + Constructing a ``SparkSession`` from the same SparkContext + JVM session + calls Java ``setActiveSession`` for *this* thread — no classmethod + monkey-patching required. + """ + from pyspark.sql import SparkSession + + SparkSession(spark.sparkContext, spark._jsparkSession) + + +def _materialize_one_fv(spark_session, config, task_info): + """Materialize a single feature view in a worker thread. + + Each thread gets its own FeatureStore instance to avoid race conditions + in Feast's usage.py call_stack (not thread-safe). + """ + from datetime import datetime, timezone + + from tqdm import tqdm + + from feast import FeatureStore, RepoConfig + + _bind_spark_session_to_thread(spark_session) + + fv_name = task_info["feature_view"] + logger.info(f"Thread started: {fv_name}") + + # Per-thread FeatureStore avoids race in feast/usage.py call_stack + thread_config = RepoConfig(**config) + thread_store = FeatureStore(config=thread_config) + fv = thread_store.get_feature_view(fv_name) + provider = thread_store.provider + + start = datetime.fromisoformat(task_info["start_time"]) + end = datetime.fromisoformat(task_info["end_time"]) + if start.tzinfo is None: + start = start.replace(tzinfo=timezone.utc) + if end.tzinfo is None: + end = end.replace(tzinfo=timezone.utc) + + t0 = time.time() + provider.materialize_single_feature_view( + config=thread_config, + feature_view=fv, + start_date=start, + end_date=end, + registry=thread_store.registry, + project=thread_store.project, + tqdm_builder=lambda length: tqdm(total=length, ncols=100), + ) + + thread_store.registry.apply_materialization(fv, thread_store.project, start, end) + logger.info(f"Applied materialization metadata for {fv_name}") + + elapsed = time.time() - t0 + return fv_name, elapsed + + +def main(): + if os.environ.get("FEAST_CONFIGMAP_NAME"): + logger.info("Loading config from ConfigMap via K8s API") + feast_config, mat_config = _load_config_from_configmap() + elif os.path.exists("/var/feast/feature_store.yaml"): + logger.info("Loading config from mounted files") + feast_config, mat_config = _load_config_from_files() + else: + raise RuntimeError( + "No config source found. Set FEAST_CONFIGMAP_NAME env var " + "or mount config at /var/feast/" + ) + + from pyspark.sql import SparkSession + + from feast import RepoConfig + + RepoConfig(**feast_config) # validate config eagerly before any Spark work + operation = mat_config["operation"] + + if operation == "materialize": + tasks = mat_config.get("tasks", []) + if not tasks: + tasks = [ + { + "feature_view": mat_config["feature_view"], + "start_time": mat_config["start_time"], + "end_time": mat_config["end_time"], + } + ] + + concurrency = mat_config.get("concurrency", 1) + total = len(tasks) + total_start = time.time() + + logger.info( + f"Starting materialization: {total} feature views, " + f"concurrency={concurrency}" + ) + + # Get the active SparkSession (created by spark-submit) + spark = SparkSession.getActiveSession() + if spark is None: + spark = SparkSession.builder.getOrCreate() + logger.info(f"SparkSession: {spark.sparkContext.applicationId}") + + succeeded, failed = 0, 0 + if concurrency <= 1: + for i, task in enumerate(tasks, 1): + fv_name = task["feature_view"] + logger.info(f"[{i}/{total}] Materializing: {fv_name}") + try: + name, elapsed = _materialize_one_fv(spark, feast_config, task) + succeeded += 1 + logger.info(f"[{i}/{total}] Completed: {name} ({elapsed:.1f}s)") + except Exception: + failed += 1 + logger.exception(f"[{i}/{total}] Failed: {fv_name}") + else: + with ThreadPoolExecutor(max_workers=concurrency) as executor: + future_to_fv = { + executor.submit( + _materialize_one_fv, spark, feast_config, task + ): task["feature_view"] + for task in tasks + } + for future in as_completed(future_to_fv): + fv_name = future_to_fv[future] + try: + name, elapsed = future.result() + succeeded += 1 + logger.info(f"Completed: {name} ({elapsed:.1f}s)") + except Exception: + failed += 1 + logger.exception(f"Failed: {fv_name}") + + total_elapsed = time.time() - total_start + level = logging.ERROR if failed > 0 else logging.INFO + logger.log( + level, + f"Materialization batch complete: " + f"{succeeded} succeeded, {failed} failed, {total} total, " + f"elapsed={total_elapsed:.1f}s", + ) + if failed > 0: + sys.exit(1) + else: + raise ValueError(f"Unknown operation: {operation}") + + +if __name__ == "__main__": + try: + main() + except Exception: + logger.exception("Driver failed") + sys.exit(1) diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index ff23907d8a2..de83a2db163 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -51,6 +51,7 @@ "spark.engine": "feast.infra.compute_engines.spark.compute.SparkComputeEngine", "ray.engine": "feast.infra.compute_engines.ray.compute.RayComputeEngine", "flink.engine": "feast.infra.compute_engines.flink.compute.FlinkComputeEngine", + "spark_application": "feast.infra.compute_engines.spark_application.compute.SparkApplicationComputeEngine", } LEGACY_ONLINE_STORE_CLASS_FOR_TYPE = { diff --git a/sdk/python/tests/unit/infra/compute_engines/test_spark_application.py b/sdk/python/tests/unit/infra/compute_engines/test_spark_application.py new file mode 100644 index 00000000000..3b080bd8993 --- /dev/null +++ b/sdk/python/tests/unit/infra/compute_engines/test_spark_application.py @@ -0,0 +1,440 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from feast.feature_view import FeatureViewState +from feast.infra.common.materialization_job import ( + MaterializationJobStatus, +) +from feast.infra.compute_engines.spark_application.config import ( + SparkApplicationComputeEngineConfig, +) +from feast.infra.compute_engines.spark_application.job import ( + _STATE_MAP, + CompletedMaterializationJob, + SparkApplicationMaterializationJob, +) + + +def _make_repo_config( + online_store_type="redis", + registry_type="sql", + registry_path="postgresql://user:pass@host:5432/feast", # pragma: allowlist secret + offline_store_type="spark", + spark_conf=None, +): + """Build a mock RepoConfig for testing.""" + config = MagicMock() + config.online_store = MagicMock() + config.online_store.type = online_store_type + config.offline_store = MagicMock() + config.offline_store.type = offline_store_type + config.registry = MagicMock() + config.registry.path = registry_path + config.registry.registry_type = registry_type + config.batch_engine = SparkApplicationComputeEngineConfig( + image="quay.io/test/feast-spark:latest", + spark_conf=spark_conf, + ) + config.model_dump = MagicMock( + return_value={ + "project": "test", + "provider": "local", + "batch_engine": {"type": "spark_application"}, + "offline_store": { + "type": offline_store_type, + "spark_conf": {"spark.existing": "value"}, + }, + "online_store": {"type": online_store_type}, + "registry": {"registry_type": registry_type, "path": registry_path}, + } + ) + return config + + +@patch("feast.infra.compute_engines.spark_application.compute.k8s_config") +@patch("feast.infra.compute_engines.spark_application.compute.client") +def _make_engine(mock_client, mock_k8s_config, **kwargs): + """Create engine with mocked K8s client. + + Pre-seed API clients so later property access does not call real + load_config() after this helper's patches have exited. + """ + from feast.infra.compute_engines.spark_application.compute import ( + SparkApplicationComputeEngine, + ) + + repo_config = _make_repo_config(**kwargs) + engine = SparkApplicationComputeEngine( + repo_config=repo_config, offline_store=None, online_store=None + ) + engine._core_v1 = MagicMock() + engine._custom_api = MagicMock() + return engine + + +# ── Test 1: Config defaults + required field ── + + +def test_config_defaults_and_required_image(): + c = SparkApplicationComputeEngineConfig(image="quay.io/test:v1") + assert c.type == "spark_application" + assert c.namespace == "default" + assert c.executor_instances == 1 + assert c.restart_policy == "Never" + assert c.max_retries == 3 + + with pytest.raises(Exception): + SparkApplicationComputeEngineConfig() # image is required + + +# ── Test 2: EC-3 rejects file-based online stores ── + + +def test_rejects_sqlite_online_store(): + with pytest.raises(ValueError, match="sqlite"): + _make_engine(online_store_type="sqlite") + + +def test_rejects_faiss_online_store(): + with pytest.raises(ValueError, match="faiss"): + _make_engine(online_store_type="faiss") + + +# ── Test 2b: rejects file-based offline stores ── + + +@pytest.mark.parametrize("store_type", ["dask", "file", "duckdb"]) +def test_rejects_file_based_offline_store(store_type): + with pytest.raises(ValueError, match=store_type): + _make_engine(offline_store_type=store_type) + + +# ── Test 3: EC-2 rejects file-based registries ── + + +def test_rejects_file_registry(): + with pytest.raises(ValueError, match="file.*registry"): + _make_engine(registry_type="file") + + +# ── Test 4: accepts network-accessible registries ── + + +def test_accepts_sql_registry(): + engine = _make_engine(registry_type="sql") + assert engine is not None + + +def test_accepts_snowflake_registry(): + engine = _make_engine(registry_type="snowflake.registry") + assert engine is not None + + +@patch("feast.infra.compute_engines.spark_application.compute.k8s_config") +@patch("feast.infra.compute_engines.spark_application.compute.client") +def test_init_does_not_load_kubeconfig(mock_client, mock_k8s_config): + """feast apply constructs the engine but never materializes — no kubeconfig needed.""" + from feast.infra.compute_engines.spark_application.compute import ( + SparkApplicationComputeEngine, + ) + + engine = SparkApplicationComputeEngine( + repo_config=_make_repo_config(), offline_store=None, online_store=None + ) + mock_k8s_config.load_config.assert_not_called() + + _ = engine.core_v1 + mock_k8s_config.load_config.assert_called_once() + assert engine.custom_api is not None + # Second access must not reload + _ = engine.custom_api + mock_k8s_config.load_config.assert_called_once() + + +# ── Test 5: _build_driver_repo_config — one rewrite (batch_engine only) ── + + +def test_build_driver_repo_config_rewrites(): + engine = _make_engine(offline_store_type="spark") + d = engine._build_driver_repo_config() + assert d["batch_engine"]["type"] == "spark.engine" + assert d["offline_store"]["type"] == "spark" # NOT rewritten — respects user intent + assert ( + d["registry"]["registry_type"] == "sql" + ) # NOT rewritten — pod uses SQL directly + + +# ── Test 6: _build_driver_repo_config — batch_engine is just type (no spark_conf copy) ── + + +def test_build_driver_repo_config_batch_engine_minimal(): + engine = _make_engine(spark_conf={"spark.new": "from_engine"}) + d = engine._build_driver_repo_config() + assert d["batch_engine"] == {"type": "spark.engine"} + assert d["offline_store"]["spark_conf"]["spark.existing"] == "value" + + +# ── Test 7: CR structure ── + + +def test_cr_structure(): + engine = _make_engine() + cr = engine._build_spark_application_cr("abcd1234") + assert cr["apiVersion"] == "sparkoperator.k8s.io/v1beta2" + assert cr["kind"] == "SparkApplication" + assert cr["spec"]["type"] == "Python" + assert cr["spec"]["mode"] == "cluster" + assert cr["spec"]["mainApplicationFile"] == "local:///opt/feast/main.py" + assert "driver" in cr["spec"] + assert "executor" in cr["spec"] + assert cr["metadata"]["name"] == "feast-sa-abcd1234" + + +# ── Test 8: CR sparkConf includes driver env passthrough ── + + +def test_cr_driver_env_passthrough(): + engine = _make_engine() + cr = engine._build_spark_application_cr("abcd1234") + spark_conf = cr["spec"]["sparkConf"] + assert ( + spark_conf["spark.kubernetes.driverEnv.FEAST_CONFIGMAP_NAME"] + == "feast-sa-abcd1234" + ) + assert ( + spark_conf["spark.kubernetes.driverEnv.FEAST_CONFIGMAP_NAMESPACE"] == "default" + ) + + +# ── Test 9: Status mapping covers all 14 states ── + + +def test_state_map_coverage(): + assert len(_STATE_MAP) == 14 + assert _STATE_MAP["COMPLETED"] == MaterializationJobStatus.SUCCEEDED + assert _STATE_MAP["FAILED"] == MaterializationJobStatus.ERROR + assert _STATE_MAP["SUBMISSION_FAILED"] == MaterializationJobStatus.ERROR + assert _STATE_MAP["RUNNING"] == MaterializationJobStatus.RUNNING + assert _STATE_MAP[""] == MaterializationJobStatus.WAITING + assert _STATE_MAP["UNKNOWN"] == MaterializationJobStatus.WAITING + + +# ── Test 10: Cleanup swallows 404 ── + + +@patch("feast.infra.compute_engines.spark_application.compute.k8s_config") +@patch("feast.infra.compute_engines.spark_application.compute.client") +def test_cleanup_swallows_404(mock_client, mock_k8s_config): + from kubernetes.client.exceptions import ApiException + + from feast.infra.compute_engines.spark_application.compute import ( + SparkApplicationComputeEngine, + ) + + repo_config = _make_repo_config() + engine = SparkApplicationComputeEngine( + repo_config=repo_config, offline_store=None, online_store=None + ) + + engine.custom_api.delete_namespaced_custom_object.side_effect = ApiException( + status=404 + ) + engine.core_v1.delete_namespaced_config_map.side_effect = ApiException(status=404) + + engine._cleanup("test-id") + + +# ── Test 11: Timeout sets error on job (does not raise) ── + + +@patch("feast.infra.compute_engines.spark_application.compute.k8s_config") +@patch("feast.infra.compute_engines.spark_application.compute.client") +@patch("feast.infra.compute_engines.spark_application.compute.time") +def test_timeout_sets_error(mock_time, mock_client, mock_k8s_config): + from feast.infra.compute_engines.spark_application.compute import ( + SparkApplicationComputeEngine, + ) + + repo_config = _make_repo_config() + repo_config.batch_engine = SparkApplicationComputeEngineConfig( + image="test", + job_timeout_seconds=1, + poll_interval_seconds=1, + ) + engine = SparkApplicationComputeEngine( + repo_config=repo_config, offline_store=None, online_store=None + ) + + # Calls: start(0), while-check(0), elapsed(0), sleep, while-check(2 > deadline=1) → exit + mock_time.monotonic.side_effect = [0, 0, 0, 2] + mock_time.sleep = MagicMock() + + mock_job = MagicMock() + mock_job.status.return_value = MaterializationJobStatus.RUNNING + mock_job._job_id = "test123" + mock_job._error = None + mock_job.job_id.return_value = "feast-sa-test123" + + engine._wait_for_completion(mock_job) + assert mock_job._error is not None + assert "did not complete" in str(mock_job._error) + + +# ── Test 12: Job naming < 63 chars ── + + +def test_job_naming_under_63_chars(): + mock_api = MagicMock() + job = SparkApplicationMaterializationJob("abcdef12", "default", mock_api) + assert len(job.job_id()) <= 63 + assert job.job_id() == "feast-sa-abcdef12" + + +# ── Test 13: _build_per_fv_jobs — all succeeded ── + + +def test_build_per_fv_jobs_all_succeeded(): + engine = _make_engine() + mock_registry = MagicMock() + + fv1 = MagicMock() + fv1.name = "fv_1" + fv1.state = FeatureViewState.AVAILABLE_ONLINE + fv2 = MagicMock() + fv2.name = "fv_2" + fv2.state = FeatureViewState.AVAILABLE_ONLINE + mock_registry.get_feature_view.side_effect = [fv1, fv2] + + task1 = MagicMock() + task1.feature_view.name = "fv_1" + task1.project = "test" + task2 = MagicMock() + task2.feature_view.name = "fv_2" + task2.project = "test" + + parent_job = SparkApplicationMaterializationJob("job1", "default", MagicMock()) + jobs = engine._build_per_fv_jobs(mock_registry, [task1, task2], "job1", parent_job) + + assert len(jobs) == 2 + assert all(isinstance(j, CompletedMaterializationJob) for j in jobs) + assert all(j.status() == MaterializationJobStatus.SUCCEEDED for j in jobs) + + +# ── Test 14: _build_per_fv_jobs — partial failure (independent jobs) ── + + +def test_build_per_fv_jobs_partial_failure(): + engine = _make_engine() + mock_registry = MagicMock() + + fv_ok = MagicMock() + fv_ok.name = "fv_ok" + fv_ok.state = FeatureViewState.AVAILABLE_ONLINE + fv_fail = MagicMock() + fv_fail.name = "fv_fail" + fv_fail.state = FeatureViewState.MATERIALIZING + mock_registry.get_feature_view.side_effect = [fv_ok, fv_fail] + + task_ok = MagicMock() + task_ok.feature_view.name = "fv_ok" + task_ok.project = "test" + task_fail = MagicMock() + task_fail.feature_view.name = "fv_fail" + task_fail.project = "test" + + parent_job = SparkApplicationMaterializationJob("job1", "default", MagicMock()) + jobs = engine._build_per_fv_jobs( + mock_registry, [task_ok, task_fail], "job1", parent_job + ) + + assert len(jobs) == 2 + assert isinstance(jobs[0], CompletedMaterializationJob) + assert jobs[0].status() == MaterializationJobStatus.SUCCEEDED + assert jobs[1].status() == MaterializationJobStatus.ERROR + assert "fv_fail" in str(jobs[1].error()) + + +# ── Test 15: _build_per_fv_jobs — single task returns parent job directly ── + + +def test_build_per_fv_jobs_single_task(): + engine = _make_engine() + mock_registry = MagicMock() + task = MagicMock() + task.feature_view.name = "fv_1" + task.project = "test" + + parent_job = SparkApplicationMaterializationJob("job1", "default", MagicMock()) + jobs = engine._build_per_fv_jobs(mock_registry, [task], "job1", parent_job) + + assert len(jobs) == 1 + assert jobs[0] is parent_job + mock_registry.get_feature_view.assert_not_called() + + +# ── Test 16: CompletedMaterializationJob is always SUCCEEDED ── + + +def test_completed_job_status(): + job = CompletedMaterializationJob("abc123") + assert job.status() == MaterializationJobStatus.SUCCEEDED + assert job.error() is None + assert job.job_id() == "feast-sa-abc123" + assert job.should_be_retried() is False + + +# ── Test 17: Env validation — requires value or valueFrom ── + + +def test_env_requires_value_or_valuefrom(): + with pytest.raises(ValueError, match="'value' or 'valueFrom'"): + SparkApplicationComputeEngineConfig( + image="test:v1", + env=[{"name": "FOO"}], + ) + + +def test_env_accepts_value(): + c = SparkApplicationComputeEngineConfig( + image="test:v1", + env=[{"name": "FOO", "value": "bar"}], + ) + assert len(c.env) == 1 + + +def test_env_accepts_valuefrom(): + c = SparkApplicationComputeEngineConfig( + image="test:v1", + env=[ + {"name": "SECRET", "valueFrom": {"secretKeyRef": {"name": "s", "key": "k"}}} + ], + ) + assert len(c.env) == 1 + + +def test_env_rejects_non_dict(): + with pytest.raises(Exception, match="dict"): + SparkApplicationComputeEngineConfig( + image="test:v1", + env=["not_a_dict"], + ) + + +# ── Test 18: Retry — 403 fails fast with RBAC hint ── + + +def test_poll_403_fails_fast_with_hint(): + from kubernetes.client.exceptions import ApiException + + mock_api = MagicMock() + mock_api.get_namespaced_custom_object.side_effect = ApiException( + status=403, reason="Forbidden" + ) + + job = SparkApplicationMaterializationJob("test1", "default", mock_api) + status = job.status() + + assert status == MaterializationJobStatus.ERROR + assert "Role/RoleBinding" in str(job.error()) + mock_api.get_namespaced_custom_object.assert_called_once()