From 54342cf2e2fe6b7d06d5883733e8fc3a6232f9c1 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:30:19 +0000 Subject: [PATCH 01/33] Bring Pydantic in to tools. --- Makefile | 2 +- gateway-api/poetry.lock | 268 +------------------------------------ gateway-api/pyproject.toml | 10 +- 3 files changed, 7 insertions(+), 273 deletions(-) diff --git a/Makefile b/Makefile index c1f094c1..a28f37d9 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ build-gateway-api: dependencies @poetry run mypy --no-namespace-packages . @echo "Packaging dependencies..." @poetry build --format=wheel - @pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_2_x86_64 --only-binary=:all: + @pip install "dist/gateway_api-0.1.0-py3-none-any.whl" --target "./target/gateway-api" --platform musllinux_1_2_x86_64 --platform musllinux_1_1_x86_64 --only-binary=:all: # Copy main file separately as it is not included within the package. @rm -rf ../infrastructure/images/gateway-api/resources/build/ @mkdir ../infrastructure/images/gateway-api/resources/build/ diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 8485f18b..b5c48dac 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -1,16 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "anyio" @@ -1844,241 +1832,10 @@ python-versions = ">=3.10" groups = ["dev"] markers = "implementation_name != \"PyPy\"" files = [ - {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, - {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] -[[package]] -name = "pycryptodome" -version = "3.23.0" -description = "Cryptographic library for Python" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -files = [ - {file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"}, - {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"}, - {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720"}, - {file = "pycryptodome-3.23.0-cp27-cp27m-win32.whl", hash = "sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4"}, - {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818"}, - {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39"}, - {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27"}, - {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843"}, - {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490"}, - {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575"}, - {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b"}, - {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a"}, - {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f"}, - {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa"}, - {file = "pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886"}, - {file = "pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2"}, - {file = "pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c"}, - {file = "pycryptodome-3.23.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56"}, - {file = "pycryptodome-3.23.0-pp27-pypy_73-win32.whl", hash = "sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6"}, - {file = "pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef"}, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, - {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.41.5" -typing-extensions = ">=4.14.1" -typing-inspection = ">=0.4.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, - {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, -] - -[package.dependencies] -typing-extensions = ">=4.14.1" - -[[package]] -name = "pydantic-settings" -version = "2.13.1" -description = "Settings management using Pydantic" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"}, - {file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"}, -] - -[package.dependencies] -pydantic = ">=2.7.0" -python-dotenv = ">=0.21.0" -typing-inspection = ">=0.4.0" - -[package.extras] -aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] -azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] -gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] -toml = ["tomli (>=2.0.1)"] -yaml = ["pyyaml (>=6.0.1)"] - [[package]] name = "pygments" version = "2.19.2" @@ -2879,27 +2636,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -[[package]] -name = "typing-inspection" -version = "0.4.2" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - [[package]] name = "tzdata" version = "2025.3" @@ -3139,4 +2881,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.14,<4.0.0" -content-hash = "db4294b6a8a14e36d46c74a63ff3ce7fb749d4130fc8a839ee4b0c69d2b0f376" +content-hash = "5947de35cbf7651c9aafbc2237a54a1fe4bed5ad4d8fa81bcbc0e745067ea99c" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index c6480559..578d3d4b 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -14,6 +14,7 @@ flask = "^3.1.3" types-flask = "^1.1.6" requests = "^2.32.5" pyjwt = "^2.12.0" +pydantic = "^2.0" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, @@ -57,16 +58,7 @@ dev = [ "types-requests (>=2.32.4.20250913,<3.0.0.0)", "types-pyyaml (>=6.0.12.20250915,<7.0.0.0)", "pytest-mock (>=3.15.1,<4.0.0)", - "pytest-nhsd-apim (>=6.0.6,<7.0.0)", ] [tool.mypy] strict = true - -[tool.pytest.ini_options] -bdd_features_base_dir = "tests/acceptance/features" -markers = [ - "remote_only: test only runs in remote environment (skipped when --env=local)", - "status_auth_headers", - "status_merged_auth_headers", -] From ba294568c223b4bbb14cb66c871422968b888695 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:05:35 +0000 Subject: [PATCH 02/33] First step towards using Pydantic. --- gateway-api/poetry.lock | 219 +++++++------ gateway-api/src/fhir/__init__.py | 4 +- gateway-api/src/fhir/bundle.py | 4 +- gateway-api/src/fhir/elements.py | 95 ++++++ gateway-api/src/fhir/patient.py | 2 +- gateway-api/src/fhir/resources.py | 179 ++++++++++ gateway-api/src/fhir/test_elements.py | 88 +++++ gateway-api/src/fhir/test_resources.py | 309 ++++++++++++++++++ gateway-api/src/gateway_api/common/error.py | 59 ++-- gateway-api/src/gateway_api/conftest.py | 17 +- gateway-api/src/gateway_api/controller.py | 13 +- gateway-api/src/gateway_api/pds/client.py | 34 +- .../src/gateway_api/pds/test_client.py | 267 ++------------- gateway-api/src/gateway_api/test_app.py | 2 +- .../src/gateway_api/test_controller.py | 52 ++- .../data/patients/alice_jones_9999999999.json | 1 + .../blank_asid_sds_result_9000000011.json | 1 + .../blank_endpoint_sds_result_9000000013.json | 1 + .../induce_provider_error_9000000012.json | 1 + .../patients/no_sds_result_9000000010.json | 1 + .../none_consumer_sds_result_9000000014.json | 1 + 21 files changed, 915 insertions(+), 435 deletions(-) create mode 100644 gateway-api/src/fhir/elements.py create mode 100644 gateway-api/src/fhir/resources.py create mode 100644 gateway-api/src/fhir/test_elements.py create mode 100644 gateway-api/src/fhir/test_resources.py diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index b5c48dac..c1b87967 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -1,4 +1,19 @@ # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +<<<<<<< HEAD +======= + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] +>>>>>>> b7ff9bc (First step towards using Pydantic.) [[package]] name = "anyio" @@ -17,6 +32,7 @@ idna = ">=2.8" [package.extras] trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] +<<<<<<< HEAD [[package]] name = "arrow" @@ -37,6 +53,8 @@ tzdata = {version = "*", markers = "python_version >= \"3.9\""} [package.extras] doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2025.2)", "simplejson (==3.*)"] +======= +>>>>>>> b7ff9bc (First step towards using Pydantic.) [[package]] name = "attrs" @@ -351,7 +369,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\""} +markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "coverage" @@ -568,18 +586,6 @@ werkzeug = ">=3.1.0" async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] -[[package]] -name = "fqdn" -version = "1.5.1" -description = "Validates fully-qualified domain names against RFC 1123, so that they are acceptable to modern bowsers" -optional = false -python-versions = ">=2.7, !=3.0, !=3.1, !=3.2, !=3.3, !=3.4, <4" -groups = ["dev"] -files = [ - {file = "fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014"}, - {file = "fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f"}, -] - [[package]] name = "gherkin-official" version = "29.0.0" @@ -778,21 +784,6 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] -[[package]] -name = "isoduration" -version = "20.11.0" -description = "Operations with ISO 8601 durations" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042"}, - {file = "isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9"}, -] - -[package.dependencies] -arrow = ">=0.15.0" - [[package]] name = "itsdangerous" version = "2.2.0" @@ -823,18 +814,6 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] -[[package]] -name = "jsonpointer" -version = "3.0.0" -description = "Identify specific nodes in a JSON document (RFC 6901)" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"}, - {file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"}, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -849,17 +828,17 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -fqdn = {version = "*", optional = true, markers = "extra == \"format\""} -idna = {version = "*", optional = true, markers = "extra == \"format\""} -isoduration = {version = "*", optional = true, markers = "extra == \"format\""} -jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format\""} -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" +<<<<<<< HEAD rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format\""} rfc3987 = {version = "*", optional = true, markers = "extra == \"format\""} rpds-py = ">=0.25.0" uri-template = {version = "*", optional = true, markers = "extra == \"format\""} webcolors = {version = ">=1.11", optional = true, markers = "extra == \"format\""} +======= +rpds-py = ">=0.25.0" +>>>>>>> b7ff9bc (First step towards using Pydantic.) [package.extras] format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] @@ -867,12 +846,17 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- [[package]] name = "jsonschema-rs" +<<<<<<< HEAD version = "0.45.0" +======= +version = "0.44.1" +>>>>>>> b7ff9bc (First step towards using Pydantic.) description = "A high-performance JSON Schema validator for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ +<<<<<<< HEAD {file = "jsonschema_rs-0.45.0-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:74f81e45ff0ce0354cb717092407faaf275bedb5564c3c32b556cb9e06df6755"}, {file = "jsonschema_rs-0.45.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a8f47574214aee3bab3cf7457819d1aead6f27673602ea533a9bb95f432e4ef3"}, {file = "jsonschema_rs-0.45.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9cf9dd9853ce52dc2d0aa94f7e672bcaf62d170f4d0754bd29416b74b0573fd"}, @@ -901,6 +885,36 @@ files = [ {file = "jsonschema_rs-0.45.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:84424e519ec4cb6c0f6cfd83b07a8932742b63319ae0d9d948f548dbb0659ae7"}, {file = "jsonschema_rs-0.45.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0c2da9d3134d0f5ad13ced36aac2a692da181aeb181d729c341388a487003815"}, {file = "jsonschema_rs-0.45.0.tar.gz", hash = "sha256:897deffee817fe0f493710221e19bc4d9fedabdba121d9f8e0aa824460d2498d"}, +======= + {file = "jsonschema_rs-0.44.1-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6f8be6467ee403e126e4e0abb68f13cfbf7199db54d5a4c0f2a1b00e1304f2e3"}, + {file = "jsonschema_rs-0.44.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:95434b4858da6feb4b3769c955b78204dbc90988941e9e848596ab93c6005d00"}, + {file = "jsonschema_rs-0.44.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0329af23e7674d88c3117b55c89a0c36e06ee359e696be16796a29c8b1c33e85"}, + {file = "jsonschema_rs-0.44.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8078c834c3cea6303796fc4925bb8646d1f68313bd54f6d3dde08c8b8eb74bc1"}, + {file = "jsonschema_rs-0.44.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:502af60c802cf149185ea01edbd31a143b09aaf06b27b6422f8b8893984b1998"}, + {file = "jsonschema_rs-0.44.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f2760c4791ecc3c7e6196cec7e7dbf191205e36dd050119cfab421e108e8508"}, + {file = "jsonschema_rs-0.44.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:16d663e6c4838e4d594bd9d10c5939a6737c171d9c8600659fe6612098863d3d"}, + {file = "jsonschema_rs-0.44.1-cp310-abi3-win32.whl", hash = "sha256:cbec5ef1a0cc327cbc829f44a9c76778881003ada99c871a14438c7e8b264e76"}, + {file = "jsonschema_rs-0.44.1-cp310-abi3-win_amd64.whl", hash = "sha256:cee075749f0479599586b4f591940418e45eae65485ed29e84763a28ec9dd40c"}, + {file = "jsonschema_rs-0.44.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:99c0c3e4a786d1e9c25dbd58cc9781f3c3d25c9fbd76310a350de55315f05948"}, + {file = "jsonschema_rs-0.44.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:516bfb8926de7d396e4bc9a1c5085870de0035e8e2324014251d091a55a03623"}, + {file = "jsonschema_rs-0.44.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225074845f6a67e8e3ac18311f87a0ab925ae5adf16466be61c7d1df01eca20a"}, + {file = "jsonschema_rs-0.44.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:782d01412e77c83bb376d31aac8afbd06b97e3594f09d1e0304ad22c2382077b"}, + {file = "jsonschema_rs-0.44.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2afe720dfa1f93235b78e812937039537b63bf4eab6ca3c9ecb7fd7ba08a865d"}, + {file = "jsonschema_rs-0.44.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:548a1f466ce5b904c9cc52eee8f887c3838377ed95f4525d0ee5896a321e89d5"}, + {file = "jsonschema_rs-0.44.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8a758e422c4ec265e64f2232409ddc5976b28e94e84a8e5565a2bce169ab72e9"}, + {file = "jsonschema_rs-0.44.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ca8ddd724b73678f5f3d3d8f948ae40fa817ad9edd5ce4e732ae26cb0f9dd300"}, + {file = "jsonschema_rs-0.44.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ff6c9868c8f2834952efa0555fd82d0ab19664ba6b17f481330c64f7af7177d"}, + {file = "jsonschema_rs-0.44.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec883313f3782f1c0ffc58ceda55136e26967198523b9cd111af782e273659a3"}, + {file = "jsonschema_rs-0.44.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f971acf2910e64f0960080db6b6c73df483318d9db992273885f596cc3a9a5d9"}, + {file = "jsonschema_rs-0.44.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c28fd54236e43f392041f06132b0e9f09dd261cb00236045078d98e3cf84"}, + {file = "jsonschema_rs-0.44.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbc59d68f38a377117b84b8109af269813a39b4b961e803876767e4fab6bac98"}, + {file = "jsonschema_rs-0.44.1-cp314-cp314t-win_amd64.whl", hash = "sha256:049203fd4876f2ec96191c0f8befabf33289988c57e4f191b5fd5974de1fb07f"}, + {file = "jsonschema_rs-0.44.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:51886a0e09161c0f5675ca2834bcd76c086034891c1e0a9a09b2ee2fd7c60bd0"}, + {file = "jsonschema_rs-0.44.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46b629a0713397b3375e2926cf3d3f9ad511681d65f7676caee8223f3b62a427"}, + {file = "jsonschema_rs-0.44.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c338c2bf3c5a4e17fccbf504aaf8a00bd1c711f992835df19de2fe55e5cf8b53"}, + {file = "jsonschema_rs-0.44.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:26c50f9bf4568874a5c6d1ca5c7e739b42529673d2d4c89a2c170800d7983fd4"}, + {file = "jsonschema_rs-0.44.1.tar.gz", hash = "sha256:49ca909cc3017990a732145b9a7c2f1a0727b2f95dba4190c05a514575b5f4bf"}, +>>>>>>> b7ff9bc (First step towards using Pydantic.) ] [package.extras] @@ -1039,6 +1053,7 @@ files = [ ] [[package]] +<<<<<<< HEAD name = "lxml" version = "5.4.0" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." @@ -1188,6 +1203,8 @@ htmlsoup = ["BeautifulSoup4"] source = ["Cython (>=3.0.11,<3.1.0)"] [[package]] +======= +>>>>>>> b7ff9bc (First step towards using Pydantic.) name = "mako" version = "1.3.10" description = "A super-fast templating language that borrows the best ideas from the existing templating languages." @@ -1832,8 +1849,8 @@ python-versions = ">=3.10" groups = ["dev"] markers = "implementation_name != \"PyPy\"" files = [ - {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, - {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] [[package]] @@ -1886,20 +1903,16 @@ test = ["coverage", "mypy", "ruff", "wheel"] [[package]] name = "pyrate-limiter" -version = "3.9.0" +version = "4.0.2" description = "Python Rate-Limiter using Leaky-Bucket Algorithm" optional = false -python-versions = "<4.0,>=3.8" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pyrate_limiter-3.9.0-py3-none-any.whl", hash = "sha256:77357840c8cf97a36d67005d4e090787043f54000c12c2b414ff65657653e378"}, - {file = "pyrate_limiter-3.9.0.tar.gz", hash = "sha256:6b882e2c77cda07a241d3730975daea4258344b39c878f1dd8849df73f70b0ce"}, + {file = "pyrate_limiter-4.0.2-py3-none-any.whl", hash = "sha256:35ec42b9bb9cfabcafab14d0c5c6523f48378c3da2949e534ce3cbdfea71eadd"}, + {file = "pyrate_limiter-4.0.2.tar.gz", hash = "sha256:b678841e2215f114ef6f98c7093755ca3b466de83cb5a881231fd6e321fa14b5"}, ] -[package.extras] -all = ["filelock (>=3.0)", "psycopg[pool] (>=3.1.18,<4.0.0)", "redis (>=5.0.0,<6.0.0)"] -docs = ["furo (>=2022.3.4,<2023.0.0)", "myst-parser (>=0.17)", "sphinx (>=4.3.0,<5.0.0)", "sphinx-autodoc-typehints (>=1.17,<2.0)", "sphinx-copybutton (>=0.5)", "sphinxcontrib-apidoc (>=0.3,<0.4)"] - [[package]] name = "pytest" version = "8.4.2" @@ -2021,6 +2034,7 @@ pytest = ">=6.2.5" dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] +<<<<<<< HEAD name = "pytest-nhsd-apim" version = "6.0.7" description = "Pytest plugin accessing NHSDigital's APIM proxies" @@ -2081,6 +2095,8 @@ files = [ six = ">=1.5" [[package]] +======= +>>>>>>> b7ff9bc (First step towards using Pydantic.) name = "python-dotenv" version = "1.2.2" description = "Read key-value pairs from a .env file and set them as environment variables" @@ -2216,33 +2232,6 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] -[[package]] -name = "rfc3339-validator" -version = "0.1.4" -description = "A pure python RFC3339 validator" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["dev"] -files = [ - {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, - {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, -] - -[package.dependencies] -six = "*" - -[[package]] -name = "rfc3987" -version = "1.3.8" -description = "Parsing and validation of URIs (RFC 3986) and IRIs (RFC 3987)" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "rfc3987-1.3.8-py2.py3-none-any.whl", hash = "sha256:10702b1e51e5658843460b189b185c0366d2cf4cff716f13111b0ea9fd2dce53"}, - {file = "rfc3987-1.3.8.tar.gz", hash = "sha256:d3c4d257a560d544e9826b38bc81db676890c79ab9d7ac92b39c7a253d5ca733"}, -] - [[package]] name = "rich" version = "14.3.3" @@ -2389,30 +2378,28 @@ files = [ [[package]] name = "schemathesis" -version = "4.10.2" +version = "4.11.0" description = "Property-based testing framework for Open API and GraphQL based apps" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "schemathesis-4.10.2-py3-none-any.whl", hash = "sha256:47a1f32a81dd237dbeb1da4374e48dd4402813c4c75fa23091a4f0986a8616be"}, - {file = "schemathesis-4.10.2.tar.gz", hash = "sha256:ad69508a9dd1a5b6fd6f4891abe86a9fc5f3f0d7a1133353359aadfd9522ac1f"}, + {file = "schemathesis-4.11.0-py3-none-any.whl", hash = "sha256:95dae021a6a28f0e6bd4fcdaa6c1a9e952da3a6ee63cf1f18e29382933b7ec9c"}, + {file = "schemathesis-4.11.0.tar.gz", hash = "sha256:0108d21d3e662bd2a2e48297d1c4db4fb939dc4b2c9c9102d1cffaf3324220f3"}, ] [package.dependencies] click = ">=8.0,<9" -colorama = ">=0.4,<1.0" harfile = ">=0.4.0,<1.0" httpx = ">=0.22.0,<1.0" hypothesis = ">=6.108.0,<7" hypothesis-graphql = ">=0.12.0,<1" hypothesis-jsonschema = ">=0.23.1,<0.24" -jsonschema = {version = ">=4.18.0,<5.0", extras = ["format"]} -jsonschema-rs = ">=0.41.0" +jsonschema = ">=4.18.0,<5.0" +jsonschema-rs = ">=0.44.0" junit-xml = ">=1.9,<2.0" -pyrate-limiter = ">=3.0,<4.0" -pytest = ">=8,<10" -pytest-subtests = ">=0.11,<0.16.0" +pyrate-limiter = ">=4.0,<5.0" +pytest = ">=9,<10" pyyaml = ">=5.1,<7.0" requests = ">=2.22,<3" rich = ">=13.9.4" @@ -2422,11 +2409,12 @@ typing-extensions = ">=4.12.2" werkzeug = ">=0.16.0,<4" [package.extras] -bench = ["pytest-codspeed (==4.2.0)"] +bench = ["pytest-codspeed (==4.3.0)", "pytest-test-groups (==1.2.1)"] cov = ["coverage-enable-subprocess", "coverage[toml] (>=5.3)"] -dev = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "coverage-enable-subprocess", "coverage[toml] (>=5.3)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "mkdocs-material", "mkdocstrings[python]", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-codspeed (==4.2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=4,<6.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] +dev = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "coverage-enable-subprocess", "coverage[toml] (>=5.3)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<4.0)", "hypothesis-openapi (>=0.3,<1)", "mkdocs-material", "mkdocstrings[python]", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-codspeed (==4.3.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-test-groups (==1.2.1)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=4,<6.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<2.0)"] docs = ["mkdocs-material", "mkdocstrings[python]"] -tests = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<3.0)", "hypothesis-openapi (>=0.2,<1) ; python_version >= \"3.10\"", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=4,<6.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<1.0)"] +profiling = ["pyinstrument (>=5.1)"] +tests = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<4.0)", "hypothesis-openapi (>=0.3,<1)", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=4,<6.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<2.0)"] [[package]] name = "setuptools" @@ -2643,6 +2631,7 @@ files = [ ] [[package]] +<<<<<<< HEAD name = "tzdata" version = "2025.3" description = "Provider of IANA time zone data" @@ -2668,6 +2657,21 @@ files = [ [package.extras] dev = ["flake8", "flake8-annotations", "flake8-bandit", "flake8-bugbear", "flake8-commas", "flake8-comprehensions", "flake8-continuation", "flake8-datetimez", "flake8-docstrings", "flake8-import-order", "flake8-literal", "flake8-modern-annotations", "flake8-noqa", "flake8-pyproject", "flake8-requirements", "flake8-typechecking-import", "flake8-use-fstring", "mypy", "pep8-naming", "types-PyYAML"] +======= +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" +>>>>>>> b7ff9bc (First step towards using Pydantic.) [[package]] name = "urllib3" @@ -2687,18 +2691,6 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] -[[package]] -name = "webcolors" -version = "25.10.0" -description = "A library for working with the color formats defined by HTML and CSS." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d"}, - {file = "webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf"}, -] - [[package]] name = "werkzeug" version = "3.1.6" @@ -2718,13 +2710,20 @@ markupsafe = ">=2.1.1" watchdog = ["watchdog (>=2.3)"] [[package]] +<<<<<<< HEAD name = "wheel" version = "0.46.3" description = "Command line tool for manipulating wheel files" +======= +name = "yarl" +version = "1.23.0" +description = "Yet another URL library" +>>>>>>> b7ff9bc (First step towards using Pydantic.) optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ +<<<<<<< HEAD {file = "wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d"}, {file = "wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803"}, ] @@ -2743,6 +2742,8 @@ optional = false python-versions = ">=3.10" groups = ["dev"] files = [ +======= +>>>>>>> b7ff9bc (First step towards using Pydantic.) {file = "yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107"}, {file = "yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d"}, {file = "yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05"}, @@ -2881,4 +2882,8 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.14,<4.0.0" +<<<<<<< HEAD content-hash = "5947de35cbf7651c9aafbc2237a54a1fe4bed5ad4d8fa81bcbc0e745067ea99c" +======= +content-hash = "f17d07bdb04fd23760e063de2205dbe09865afbfcd73d6c171751fea57d18271" +>>>>>>> b7ff9bc (First step towards using Pydantic.) diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index f7b15f5c..170c6469 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -6,7 +6,7 @@ from fhir.identifier import Identifier from fhir.operation_outcome import OperationOutcome, OperationOutcomeIssue from fhir.parameters import Parameter, Parameters -from fhir.patient import Patient +from fhir.patient import PatientTypedDict __all__ = [ "Bundle", @@ -17,6 +17,6 @@ "OperationOutcomeIssue", "Parameter", "Parameters", - "Patient", + "PatientTypedDict", "GeneralPractitioner", ] diff --git a/gateway-api/src/fhir/bundle.py b/gateway-api/src/fhir/bundle.py index 5fbc9a3b..8d46720a 100644 --- a/gateway-api/src/fhir/bundle.py +++ b/gateway-api/src/fhir/bundle.py @@ -2,12 +2,12 @@ from typing import TypedDict -from fhir.patient import Patient +from fhir.patient import PatientTypedDict class BundleEntry(TypedDict): fullUrl: str - resource: Patient + resource: PatientTypedDict class Bundle(TypedDict): diff --git a/gateway-api/src/fhir/elements.py b/gateway-api/src/fhir/elements.py new file mode 100644 index 00000000..83170b63 --- /dev/null +++ b/gateway-api/src/fhir/elements.py @@ -0,0 +1,95 @@ +import datetime +import uuid +from abc import ABC +from dataclasses import dataclass +from enum import StrEnum +from typing import Annotated, ClassVar + +from pydantic import Field, model_validator + + +@dataclass(frozen=True) +class Meta: + """ + A FHIR R4 Meta element. See https://hl7.org/fhir/R4/datatypes.html#Meta. + Attributes: + version_id: The version id of the resource. + last_updated: The last updated timestamp of the resource. + """ + + last_updated: Annotated[datetime.datetime | None, Field(alias="lastUpdated")] = None + version_id: Annotated[str | None, Field(alias="versionId")] = None + + @classmethod + def with_last_updated(cls, last_updated: datetime.datetime | None = None) -> "Meta": + """ + Create a Meta instance with the provided last_updated timestamp. + Args: + last_updated: The last updated timestamp. + Returns: + A Meta instance with the specified last_updated. + """ + return cls( + last_updated=last_updated or datetime.datetime.now(tz=datetime.timezone.utc) + ) + + +@dataclass(frozen=True) +class Identifier(ABC): + """ + A FHIR R4 Identifier element. See https://hl7.org/fhir/R4/datatypes.html#Identifier. + Attributes: + system: The namespace for the identifier value. + value: The value that is unique within the system. + """ + + _expected_system: ClassVar[str] = "__unknown__" + + value: str + system: str + + @model_validator(mode="after") + def validate_system(self) -> "Identifier": + if self.system != self._expected_system: + raise ValueError( + f"Identifier system '{self.system}' does not match expected " + f"system '{self._expected_system}'." + ) + return self + + @classmethod + def __init_subclass__(cls, expected_system: str) -> None: + cls._expected_system = expected_system + + +class UUIDIdentifier(Identifier, expected_system="https://tools.ietf.org/html/rfc4122"): + """A UUID identifier utilising the standard RFC 4122 system.""" + + def __init__(self, value: uuid.UUID | None = None): + super().__init__( + value=str(value or uuid.uuid4()), + system=self._expected_system, + ) + + +class IssueSeverity(StrEnum): + FATAL = "fatal" + ERROR = "error" + WARNING = "warning" + INFORMATION = "information" + + +class IssueCode(StrEnum): + INVALID = "invalid" + EXCEPTION = "exception" + + +@dataclass(frozen=True) +class Issue(ABC): + """ + A FHIR R4 OperationOutcome Issue element. See https://hl7.org/fhir/R4/datatypes.html#OperationOutcome. + """ + + severity: IssueSeverity + code: IssueCode + diagnostics: str | None = None diff --git a/gateway-api/src/fhir/patient.py b/gateway-api/src/fhir/patient.py index 453a6f2a..db852a1a 100644 --- a/gateway-api/src/fhir/patient.py +++ b/gateway-api/src/fhir/patient.py @@ -7,7 +7,7 @@ from fhir.identifier import Identifier -class Patient(TypedDict): +class PatientTypedDict(TypedDict): resourceType: str id: str identifier: list[Identifier] diff --git a/gateway-api/src/fhir/resources.py b/gateway-api/src/fhir/resources.py new file mode 100644 index 00000000..83a938c5 --- /dev/null +++ b/gateway-api/src/fhir/resources.py @@ -0,0 +1,179 @@ +from typing import Annotated, Any, ClassVar, Literal, Self + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + SerializeAsAny, + ValidatorFunctionWrapHandler, + field_validator, + model_validator, +) + +from .elements import Identifier, Issue, Meta, UUIDIdentifier + + +class Resource(BaseModel): + """A FHIR R4 Resource base class.""" + + # class variable to hold class mappings per resource_type + __resource_types: ClassVar[dict[str, type["Resource"]]] = {} + __expected_resource_type: ClassVar[dict[type["Resource"], str]] = {} + + meta: Annotated[Meta | None, Field(alias="meta", frozen=True)] = None + resource_type: str = Field(alias="resourceType", frozen=True) + + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + def __init_subclass__(cls, resource_type: str, **kwargs: Any) -> None: + cls.__resource_types[resource_type] = cls + cls.__expected_resource_type[cls] = resource_type + + super().__init_subclass__(**kwargs) + + def model_dump_json(self, *args: Any, **kwargs: Any) -> str: + # FHIR resources should not return empty fields + kwargs.setdefault("exclude_none", True) + return super().model_dump_json(*args, **kwargs) + + @model_validator(mode="wrap") + @classmethod + def validate_with_subtype( + cls, value: dict[str, Any], handler: ValidatorFunctionWrapHandler + ) -> Any: + """ + Provides a model validator that instantiates the correct Resource subclass + based on its defined resource_type. + """ + # If we're not currently acting on a top level Resource, and we've not been + # provided a generic dictonary object, delegate to the normal handler. + if cls != Resource or not isinstance(value, dict): + return handler(value) + + if "resourceType" not in value or value["resourceType"] is None: + raise TypeError("resourceType is required for Resource validation.") + + resource_type = value["resourceType"] + + subclass = cls.__resource_types.get(resource_type) + if subclass is None: + raise TypeError(f"Unknown resource type: {resource_type}") + + # Instantiate the subclass using the dictionary values. + return subclass.model_validate(value) + + @classmethod + def create(cls, **kwargs: Any) -> Self: + """ + Create a Resource instance with the correct resourceType. + Note any unknown arguments provided via this method will only error at runtime. + """ + return cls(resourceType=cls.__expected_resource_type[cls], **kwargs) + + @field_validator("resource_type", mode="after") + @classmethod + def _validate_resource_type(cls, value: str) -> str: + expected_resource_type = cls.__expected_resource_type[cls] + if value != expected_resource_type: + raise ValueError( + f"Resource type '{value}' does not match expected " + f"resource type '{expected_resource_type}'." + ) + return value + + +type BundleType = Literal["document", "transaction"] + + +class Bundle(Resource, resource_type="Bundle"): + """A FHIR R4 Bundle resource.""" + + bundle_type: BundleType = Field(alias="type", frozen=True) + identifier: Annotated[UUIDIdentifier | None, Field(frozen=True)] = None + entries: list["Bundle.Entry"] | None = Field(None, frozen=True, alias="entry") + + class Entry(BaseModel): + full_url: str = Field(..., alias="fullUrl", frozen=True) + resource: Annotated[SerializeAsAny[Resource], Field(frozen=True)] + + def find_resources[T: Resource](self, t: type[T]) -> list[T]: + """ + Find all resources of a given type in the bundle entries. If the bundle has no + entries, an empty list is returned. + Args: + t: The resource type to search for. + Returns: + A list of resources of the specified type. + """ + return [ + entry.resource + for entry in self.entries or [] + if isinstance(entry.resource, t) + ] + + @classmethod + def empty(cls, bundle_type: BundleType) -> "Bundle": + """Create an empty Bundle of the specified type.""" + return cls.create(type=bundle_type, entry=None) + + +class OperationOutcome(Resource, resource_type="OperationOutcome"): + """A FHIR R4 OperationOutcome resource.""" + + issue: Annotated[list[Issue], Field(frozen=True)] + + +class Reference(BaseModel): + """A FHIR R4 Reference base class.""" + + reference_type: str = Field(alias="type", frozen=True) + + def __init_subclass__( + cls, reference_type: str, **kwargs: Any + ) -> None: # TODO: Why is this necessary? + super().__init_subclass__(**kwargs) + + +class Patient(Resource, resource_type="Patient"): + """A FHIR R4 Patient resource.""" + + class PatientIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/nhs-number" + ): + """A FHIR R4 Patient Identifier utilising the NHS Number system.""" + + def __init__(self, value: str): + super().__init__(value=value, system=self._expected_system) + + @classmethod + def from_nhs_number(cls, nhs_number: str) -> "Patient.PatientIdentifier": + """Create a PatientIdentifier from an NHS number.""" + return cls(value=nhs_number) + + identifier: Annotated[list[PatientIdentifier], Field(frozen=True, min_length=1)] + + @property + def nhs_number(self) -> str: + return self.identifier[0].value + + class GeneralPractitioner(Reference, reference_type="Organization"): + class OrganizationIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/ods-organization-code" + ): + """ + A FHIR R4 Organization Identifier utilising the ODS Organization Code + system. + """ + + identifier: Annotated[OrganizationIdentifier, Field(frozen=True)] + + generalPractitioner: Annotated[ + list[GeneralPractitioner] | None, Field(frozen=True) + ] = None + + @property + def gp_ods_code(self) -> str | None: + if not self.generalPractitioner: + return None + + return self.generalPractitioner[0].identifier.value diff --git a/gateway-api/src/fhir/test_elements.py b/gateway-api/src/fhir/test_elements.py new file mode 100644 index 00000000..d5e7c4db --- /dev/null +++ b/gateway-api/src/fhir/test_elements.py @@ -0,0 +1,88 @@ +import datetime +import uuid + +import pytest +from pydantic import BaseModel + +from .elements import Identifier, Meta, UUIDIdentifier + + +class TestMeta: + def test_create(self) -> None: + meta = Meta( + version_id="1", + last_updated=datetime.datetime.fromisoformat("2023-10-01T12:00:00Z"), + ) + assert meta.version_id == "1" + assert meta.last_updated == datetime.datetime.fromisoformat( + "2023-10-01T12:00:00Z" + ) + + def test_create_without_last_updated(self) -> None: + meta = Meta(version_id="2") + + assert meta.version_id == "2" + assert meta.last_updated is None + + def test_create_without_version(self) -> None: + meta = Meta( + last_updated=datetime.datetime.fromisoformat("2023-10-01T12:00:00Z") + ) + + assert meta.version_id is None + assert meta.last_updated == datetime.datetime.fromisoformat( + "2023-10-01T12:00:00Z" + ) + + def test_with_last_updated(self) -> None: + last_updated = datetime.datetime.fromisoformat("2023-10-01T12:00:00Z") + meta = Meta.with_last_updated(last_updated) + + assert meta.last_updated == last_updated + assert meta.version_id is None + + def test_with_last_updated_defaults_to_now(self) -> None: + before_create = datetime.datetime.now(tz=datetime.timezone.utc) + meta = Meta.with_last_updated(None) + after_create = datetime.datetime.now(tz=datetime.timezone.utc) + + assert meta.last_updated is not None + assert meta.version_id is None + + assert before_create <= meta.last_updated + assert meta.last_updated <= after_create + + +class TestUUIDIdentifier: + def test_create_with_value(self) -> None: + expected_uuid = uuid.UUID("12345678-1234-5678-1234-567812345678") + identifier = UUIDIdentifier(value=expected_uuid) + + assert identifier.system == "https://tools.ietf.org/html/rfc4122" + assert identifier.value == str(expected_uuid) + + def test_create_without_value(self) -> None: + identifier = UUIDIdentifier() + + assert identifier.system == "https://tools.ietf.org/html/rfc4122" + # Validates that value is a valid UUID v4 + parsed_uuid = uuid.UUID(identifier.value) + assert parsed_uuid.version == 4 + + +class TestIdentifier: + def test_invalid_system(self) -> None: + class _TestIdentifier(Identifier, expected_system="expected-system"): + pass + + class _TestContainer(BaseModel): + identifier: _TestIdentifier + + with pytest.raises( + ValueError, + match="Identifier system 'invalid-system' does not match expected " + "system 'expected-system'.", + ): + _TestContainer.model_validate( + {"identifier": {"system": "invalid-system", "value": "some-value"}} + ) diff --git a/gateway-api/src/fhir/test_resources.py b/gateway-api/src/fhir/test_resources.py new file mode 100644 index 00000000..537d33f5 --- /dev/null +++ b/gateway-api/src/fhir/test_resources.py @@ -0,0 +1,309 @@ +import json +from typing import Any + +import pytest +from pydantic import BaseModel + +from .resources import Bundle, Patient, Resource + + +class TestResource: + class _TestContainer(BaseModel): + resource: Resource + + def test_resource_deserialisation(self) -> None: + expected_system = "https://fhir.nhs.uk/Id/nhs-number" + expected_nhs_number = "nhs_number" + example_json = json.dumps( + { + "resource": { + "resourceType": "Patient", + "identifier": [ + { + "system": expected_system, + "value": expected_nhs_number, + } + ], + } + } + ) + + created_object = self._TestContainer.model_validate_json(example_json) + assert isinstance(created_object.resource, Patient) + + created_patient = created_object.resource + assert created_patient.identifier is not None + assert created_patient.identifier[0].system == expected_system + assert created_patient.identifier[0].value == expected_nhs_number + + def test_resource_deserialisation_unknown_resource(self) -> None: + expected_resource_type = "UnknownResourceType" + example_json = json.dumps( + { + "resource": { + "resourceType": expected_resource_type, + } + } + ) + + with pytest.raises( + TypeError, + match=f"Unknown resource type: {expected_resource_type}", + ): + self._TestContainer.model_validate_json(example_json) + + @pytest.mark.parametrize( + "value", + [ + pytest.param({"resource": {}}, id="No resourceType key"), + pytest.param( + {"resource": {"resourceType": None}}, + id="resourceType is defined as None", + ), + ], + ) + def test_resource_deserialisation_without_resource_type( + self, value: dict[str, Any] + ) -> None: + example_json = json.dumps(value) + + with pytest.raises( + TypeError, + match="resourceType is required for Resource validation.", + ): + self._TestContainer.model_validate_json(example_json) + + @pytest.mark.parametrize( + ("json", "expected_error_message"), + [ + pytest.param( + json.dumps({"resourceType": "invalid", "type": "document"}), + "Value error, Resource type 'invalid' does not match expected " + "resource type 'Bundle'.", + id="Invalid resource type", + ), + pytest.param( + json.dumps({"resourceType": None, "type": "document"}), + "1 validation error for Bundle\nresourceType\n " + "Input should be a valid string", + id="Input should be a valid string", + ), + pytest.param( + json.dumps({"type": "document"}), + "1 validation error for Bundle\nresourceType\n Field required", + id="Missing resource type", + ), + ], + ) + def test_deserialise_wrong_resource_type( + self, json: str, expected_error_message: str + ) -> None: + with pytest.raises( + ValueError, + match=expected_error_message, + ): + Bundle.model_validate_json(json, strict=True) + + +class TestBundle: + def test_create(self) -> None: + """Test creating a Bundle resource.""" + expected_entry = Bundle.Entry( + fullUrl="full", + resource=Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number("nhs_number")] + ), + ) + + bundle = Bundle.create( + type="document", + entry=[expected_entry], + ) + + assert bundle.bundle_type == "document" + assert bundle.identifier is None + assert bundle.entries == [expected_entry] + + def test_create_without_entries(self) -> None: + """Test creating a Bundle resource without entries.""" + bundle = Bundle.empty("document") + + assert bundle.bundle_type == "document" + assert bundle.identifier is None + assert bundle.entries is None + + expected_resource = Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number("nhs_number")] + ) + + @pytest.mark.parametrize( + ("entries", "expected_results"), + [ + pytest.param( + [ + Bundle.Entry( + fullUrl="fullUrl", + resource=expected_resource, + ), + Bundle.Entry( + fullUrl="fullUrl", + resource=expected_resource, + ), + ], + [expected_resource, expected_resource], + id="Duplicate resources", + ), + pytest.param( + [ + Bundle.Entry( + fullUrl="fullUrl", + resource=expected_resource, + ), + ], + [expected_resource], + id="Single resource", + ), + ], + ) + def test_find_resources( + self, entries: list[Bundle.Entry], expected_results: list[Resource] + ) -> None: + bundle = Bundle.create(type="document", entry=entries) + + result = bundle.find_resources(Patient) + assert result == expected_results + + @pytest.mark.parametrize( + "bundle", + [ + pytest.param(Bundle.empty("document"), id="Bundle has no entries at all"), + pytest.param( + Bundle.create(type="document", entry=[]), + id="Bundle has an empty entries list", + ), + pytest.param( + Bundle.create( + type="document", + entry=[ + Bundle.Entry( + fullUrl="fullUrl", + resource=Bundle.empty("document"), + ), + ], + ), + id="different_resource_type", + ), + ], + ) + def test_find_resources_returns_empty_list(self, bundle: Bundle) -> None: + """ + Test that find_resources returns an empty list when no matching resources exist. + """ + result = bundle.find_resources(Patient) + assert result == [] + + +class TestPatient: + def test_create(self) -> None: + """Test creating a Patient resource.""" + nhs_number = "1234567890" + + expected_identifier = Patient.PatientIdentifier.from_nhs_number(nhs_number) + patient = Patient.create(identifier=[expected_identifier]) + + assert patient.identifier[0] == expected_identifier + + def test_create_with_general_practitioner_identifier(self) -> None: + """Test creating a Patient resource with an ODS-coded practitioner org.""" + nhs_number = "1234567890" + ods_code = "A12345" + + patient = Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number(nhs_number)], + generalPractitioner=[ + Patient.GeneralPractitioner( + type="Organization", + identifier=Patient.GeneralPractitioner.OrganizationIdentifier( + system="https://fhir.nhs.uk/Id/ods-organization-code", + value=ods_code, + ), + ) + ], + ) + + assert patient.generalPractitioner is not None + assert patient.generalPractitioner[0].reference_type == "Organization" + assert patient.generalPractitioner[0].identifier is not None + assert ( + patient.generalPractitioner[0].identifier.system + == "https://fhir.nhs.uk/Id/ods-organization-code" + ) + assert patient.generalPractitioner[0].identifier.value == ods_code + + def test_create_with_invalid_patient_identifier_system_raises_error(self) -> None: + """Test invalid patient identifier systems are rejected.""" + with pytest.raises( + ValueError, + match=( + "Identifier system 'https://example.org/invalid' does not match " + "expected system 'https://fhir.nhs.uk/Id/nhs-number'." + ), + ): + Patient.model_validate( + { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://example.org/invalid", + "value": "1234567890", + } + ], + } + ) + + def test_create_with_invalid_general_practitioner_identifier_system_raises_error( + self, + ) -> None: + """Test invalid practitioner organization identifier systems are rejected.""" + with pytest.raises( + ValueError, + match=( + "Identifier system 'https://example.org/invalid' does not match " + "expected system 'https://fhir.nhs.uk/Id/ods-organization-code'." + ), + ): + Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number("1234567890")], + generalPractitioner=[ + Patient.GeneralPractitioner( + type="Organization", + identifier=( + Patient.GeneralPractitioner.OrganizationIdentifier( + system="https://example.org/invalid", + value="A12345", + ) + ), + ) + ], + ) + + def test_model_dump_json_excludes_none_general_practitioner(self) -> None: + """Test JSON output omits optional fields when they are None.""" + patient = Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number("1234567890")] + ) + + payload = json.loads(patient.model_dump_json()) + + assert payload["resourceType"] == "Patient" + assert "generalPractitioner" not in payload + + +class TestPatientIdentifier: + def test_create_from_nhs_number(self) -> None: + """Test creating a PatientIdentifier from an NHS number.""" + nhs_number = "1234567890" + identifier = Patient.PatientIdentifier.from_nhs_number(nhs_number) + + assert identifier.system == "https://fhir.nhs.uk/Id/nhs-number" + assert identifier.value == nhs_number diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index 3915502d..f38ab64b 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -1,20 +1,11 @@ -import json import traceback from dataclasses import dataclass -from enum import StrEnum from http.client import BAD_GATEWAY, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND -from typing import TYPE_CHECKING +from fhir.elements import Issue, IssueCode, IssueSeverity +from fhir.resources import OperationOutcome from flask import Response -if TYPE_CHECKING: - from fhir.operation_outcome import OperationOutcome - - -class ErrorCode(StrEnum): - INVALID = "invalid" - EXCEPTION = "exception" - @dataclass class AbstractCDGError(Exception): @@ -24,8 +15,8 @@ class AbstractCDGError(Exception): _message: str status_code: int - error_code: ErrorCode - severity: str = "error" + error_code: IssueCode + severity: IssueSeverity = IssueSeverity.ERROR def __init__(self, **additional_details: str): """ @@ -36,18 +27,18 @@ def __init__(self, **additional_details: str): super().__init__(self) def build_response(self) -> Response: - operation_outcome: OperationOutcome = { - "resourceType": "OperationOutcome", - "issue": [ - { - "severity": self.severity, - "code": self.error_code, - "diagnostics": self.message, - } - ], - } + operation_outcome = OperationOutcome.create( + issue=[ + Issue( + severity=self.severity, + code=self.error_code, + diagnostics=self.message, + ) + ] + ) + response = Response( - response=json.dumps(operation_outcome), + response=operation_outcome.model_dump_json(), status=self.status_code, content_type="application/fhir+json", ) @@ -66,26 +57,26 @@ def __str__(self) -> str: class InvalidRequestJSONError(AbstractCDGError): _message = "Invalid JSON body sent in request" - error_code = ErrorCode.INVALID + error_code = IssueCode.INVALID status_code = BAD_REQUEST class MissingOrEmptyHeaderError(AbstractCDGError): _message = 'Missing or empty required header "{header}"' status_code = BAD_REQUEST - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class NoCurrentProviderError(AbstractCDGError): _message = "PDS patient {nhs_number} did not contain a current provider ODS code" status_code = NOT_FOUND - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class NoOrganisationFoundError(AbstractCDGError): _message = "No SDS org found for {org_type} ODS code {ods_code}" status_code = NOT_FOUND - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class NoAsidFoundError(AbstractCDGError): @@ -93,7 +84,7 @@ class NoAsidFoundError(AbstractCDGError): "SDS result for {org_type} ODS code {ods_code} did not contain a current ASID" ) status_code = NOT_FOUND - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class NoCurrentEndpointError(AbstractCDGError): @@ -102,23 +93,23 @@ class NoCurrentEndpointError(AbstractCDGError): "a current endpoint" ) status_code = NOT_FOUND - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class PdsRequestFailedError(AbstractCDGError): _message = "PDS FHIR API request failed: {error_reason}" status_code = BAD_GATEWAY - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class ProviderRequestFailedError(AbstractCDGError): _message = "Provider request failed: {error_reason}" status_code = BAD_GATEWAY - error_code = ErrorCode.EXCEPTION + error_code = IssueCode.EXCEPTION class UnexpectedError(AbstractCDGError): _message = "Internal Server Error: {traceback}" status_code = INTERNAL_SERVER_ERROR - severity = "error" - error_code = ErrorCode.EXCEPTION + severity = IssueSeverity.ERROR + error_code = IssueCode.EXCEPTION diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 8ef4c2d9..7471c85a 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -6,7 +6,7 @@ import pytest import requests -from fhir import Bundle, OperationOutcome, Patient +from fhir import Bundle, OperationOutcome, PatientTypedDict from fhir.parameters import Parameters from flask import Request from requests.structures import CaseInsensitiveDict @@ -21,10 +21,10 @@ class FakeResponse: status_code: int headers: dict[str, str] | CaseInsensitiveDict[str] - _json: dict[str, Any] | Patient | OperationOutcome | Bundle + _json: dict[str, Any] | PatientTypedDict | OperationOutcome | Bundle reason: str = "" - def json(self) -> dict[str, Any] | Patient | OperationOutcome | Bundle: + def json(self) -> dict[str, Any] | PatientTypedDict | OperationOutcome | Bundle: return self._json def raise_for_status(self) -> None: @@ -92,7 +92,10 @@ def valid_simple_response_payload() -> Bundle: "resourceType": "Patient", "id": "9999999999", "identifier": [ - {"value": "9999999999", "system": "urn:nhs:numbers"} + { + "value": "9999999999", + "system": "https://fhir.nhs.uk/Id/nhs-number", + } ], "generalPractitioner": [ { @@ -121,11 +124,13 @@ def valid_headers() -> dict[str, str]: @pytest.fixture -def happy_path_pds_response_body() -> Patient: +def happy_path_pds_response_body() -> PatientTypedDict: return { "resourceType": "Patient", "id": "9999999999", - "identifier": [{"value": "9999999999", "system": "urn:nhs:numbers"}], + "identifier": [ + {"value": "9999999999", "system": "https://fhir.nhs.uk/Id/nhs-number"} + ], "name": [ { "family": "Johnson", diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 78568d0c..80b0af37 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -2,6 +2,11 @@ Controller layer for orchestrating calls to external services """ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fhir.resources import Patient + from gateway_api.clinical_jwt import JWT, Device, Practitioner from gateway_api.common.common import FlaskResponse from gateway_api.common.error import ( @@ -11,7 +16,7 @@ NoOrganisationFoundError, ) from gateway_api.get_structured_record.request import GetStructuredRecordRequest -from gateway_api.pds import PdsClient, PdsSearchResults +from gateway_api.pds import PdsClient from gateway_api.provider import GpProviderClient from gateway_api.sds import SdsClient, SdsSearchResults @@ -141,12 +146,12 @@ def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: ignore_dates=True, ) - pds_result: PdsSearchResults = pds.search_patient_by_nhs_number(nhs_number) + patient: Patient = pds.search_patient_by_nhs_number(nhs_number) - if not pds_result.gp_ods_code: + if not patient.gp_ods_code: raise NoCurrentProviderError(nhs_number=nhs_number) - return pds_result.gp_ods_code + return patient.gp_ods_code def _get_sds_details( self, consumer_ods: str, provider_ods: str diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 773ca423..faaf3e86 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -25,7 +25,9 @@ from typing import cast import requests -from fhir import Bundle, BundleEntry, GeneralPractitioner, HumanName, Patient +from fhir import BundleEntry, GeneralPractitioner, HumanName, PatientTypedDict +from fhir.resources import Patient +from pydantic import ValidationError from gateway_api.common.error import PdsRequestFailedError from gateway_api.pds.search_results import PdsSearchResults @@ -111,7 +113,7 @@ def search_patient_by_nhs_number( request_id: str | None = None, correlation_id: str | None = None, timeout: int | None = None, - ) -> PdsSearchResults: + ) -> Patient: """ Retrieve a patient by NHS number. @@ -138,8 +140,24 @@ def search_patient_by_nhs_number( except requests.HTTPError as err: raise PdsRequestFailedError(error_reason=err.response.reason) from err - body = response.json() - return self._extract_single_search_result(body) + try: + patient = Patient.model_validate(response.json()) + except ValidationError as err: + first_error = err.errors()[0] + error_is_identifier = first_error["loc"] == ("identifier",) + no_patient_identifier = ( + "at least 1 item" in first_error["msg"] and error_is_identifier + ) + nhs_number_is_missing = "Field required" in str(err) and first_error[ + "loc" + ] == ("identifier",) + if nhs_number_is_missing or no_patient_identifier: + raise PdsRequestFailedError( + error_reason="PDS Patient resource missing NHS number" + ) from err + raise err + + return patient # --------------- internal helpers for result extraction ----------------- @@ -168,7 +186,7 @@ def _get_gp_ods_code( return None if ods_code == "None" else ods_code - def _extract_single_search_result(self, body: Patient | Bundle) -> PdsSearchResults: + def _extract_single_search_result(self, body: PatientTypedDict) -> PdsSearchResults: """ Extract a single :class:`PdsSearchResults` from a Patient response. @@ -183,7 +201,7 @@ def _extract_single_search_result(self, body: Patient | Bundle) -> PdsSearchResu # 1) Patient (GET /Patient/{id}) # 2) Bundle with Patient in entry[0].resource (search endpoints) if str(body.get("resourceType", "")) == "Patient": - patient = cast("Patient", body) + patient = body else: entries = cast("list[BundleEntry]", body.get("entry", [])) if not entries: @@ -198,6 +216,10 @@ def _extract_single_search_result(self, body: Patient | Bundle) -> PdsSearchResu # See MaxResults parameter in the PDS OpenAPI spec. entry = entries[0] patient = entry.get("resource", {}) +<<<<<<< HEAD +======= + +>>>>>>> b7ff9bc (First step towards using Pydantic.) nhs_number = str(patient.get("id", "")).strip() if not nhs_number: raise PdsRequestFailedError( diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index d3571d43..dc2676dd 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -2,26 +2,22 @@ Unit tests for :mod:`gateway_api.pds_search`. """ -from datetime import date -from typing import TYPE_CHECKING, Any from uuid import UUID, uuid4 import pytest -from fhir import Patient +from fhir import PatientTypedDict +from fhir.resources import Patient from pytest_mock import MockerFixture from gateway_api.common.error import PdsRequestFailedError from gateway_api.conftest import FakeResponse from gateway_api.pds.client import PdsClient -if TYPE_CHECKING: - from fhir import GeneralPractitioner, HumanName - def test_search_patient_by_nhs_number_happy_path( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: Patient, + happy_path_pds_response_body: PatientTypedDict, ) -> None: happy_path_response = FakeResponse( status_code=200, headers={}, _json=happy_path_pds_response_body @@ -31,17 +27,15 @@ def test_search_patient_by_nhs_number_happy_path( client = PdsClient(auth_token) result = client.search_patient_by_nhs_number("9999999999") - assert result is not None + assert isinstance(result, Patient) assert result.nhs_number == "9999999999" - assert result.family_name == "Johnson" - assert result.given_names == "Alice" assert result.gp_ods_code == "A12345" def test_search_patient_by_nhs_number_has_no_gp_returns_gp_ods_code_none( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: Patient, + happy_path_pds_response_body: PatientTypedDict, ) -> None: gp_less_response_body = happy_path_pds_response_body.copy() del gp_less_response_body["generalPractitioner"] @@ -53,17 +47,15 @@ def test_search_patient_by_nhs_number_has_no_gp_returns_gp_ods_code_none( client = PdsClient(auth_token) result = client.search_patient_by_nhs_number("9999999999") - assert result is not None + assert isinstance(result, Patient) assert result.nhs_number == "9999999999" - assert result.family_name == "Johnson" - assert result.given_names == "Alice" assert result.gp_ods_code is None def test_search_patient_by_nhs_number_sends_expected_headers( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: Patient, + happy_path_pds_response_body: PatientTypedDict, ) -> None: happy_path_response = FakeResponse( status_code=200, headers={}, _json=happy_path_pds_response_body @@ -95,7 +87,7 @@ def test_search_patient_by_nhs_number_sends_expected_headers( def test_search_patient_by_nhs_number_generates_request_id( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: Patient, + happy_path_pds_response_body: PatientTypedDict, ) -> None: happy_path_response = FakeResponse( status_code=200, headers={}, _json=happy_path_pds_response_body @@ -133,238 +125,25 @@ def test_search_patient_by_nhs_number_not_found_raises_error( pds.search_patient_by_nhs_number("9900000001") -def test_search_patient_by_nhs_number_finds_current_gp_ods_code_when_pds_returns_two( +def test_search_patient_by_nhs_number_missing_nhs_number_raises_error( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: Patient, + happy_path_pds_response_body: PatientTypedDict, ) -> None: - old_gp: GeneralPractitioner = { - "id": "1", - "type": "Organization", - "identifier": { - "value": "OLDGP", - "period": {"start": "2010-01-01", "end": "2012-01-01"}, - "system": "https://fhir.nhs.uk/Id/ods-organization-code", - }, - } - current_gp: GeneralPractitioner = { - "id": "2", - "type": "Organization", - "identifier": { - "value": "CURRGP", - "period": {"start": "2020-01-01", "end": "9999-01-01"}, - "system": "https://fhir.nhs.uk/Id/ods-organization-code", - }, - } - pds_response_body_with_two_gps = happy_path_pds_response_body.copy() - pds_response_body_with_two_gps["generalPractitioner"] = [old_gp, current_gp] - pds_response_with_two_gps = FakeResponse( - status_code=200, headers={}, _json=pds_response_body_with_two_gps - ) - mocker.patch("gateway_api.pds.client.get", return_value=pds_response_with_two_gps) - - client = PdsClient(auth_token) - - result = client.search_patient_by_nhs_number("9999999999") - assert result is not None - assert result.nhs_number == "9999999999" - assert result.family_name == "Johnson" - assert result.given_names == "Alice" - assert result.gp_ods_code == "CURRGP" - - -def test_find_current_gp_with_today_override() -> None: - """ - Verify that ``find_current_gp`` honours an explicit ``today`` value. - """ - pds = PdsClient("test-token", "A12345") - pds_ignore_dates = PdsClient("test-token", "A12345", ignore_dates=True) - - records: list[GeneralPractitioner] = [ - { - "id": "1234", - "type": "Organization", - "identifier": { - "value": "a", - "period": {"start": "2020-01-01", "end": "2020-12-31"}, - "system": "https://fhir.nhs.uk/Id/ods-organization-code", - }, - }, - { - "id": "abcd", - "type": "Organization", - "identifier": { - "value": "b", - "period": {"start": "2021-01-01", "end": "2021-12-31"}, - "system": "https://fhir.nhs.uk/Id/ods-organization-code", - }, - }, - ] - - assert pds.find_current_gp(records, today=date(2020, 6, 1)) == records[0] - assert pds.find_current_gp(records, today=date(2021, 6, 1)) == records[1] - assert pds.find_current_gp(records, today=date(2019, 6, 1)) is None - assert pds_ignore_dates.find_current_gp(records, today=date(2019, 6, 1)) is not None - - -def test_find_current_name_record_no_current_name() -> None: - """ - Verify that ``find_current_name_record`` returns ``None`` when no current name - exists. - """ - pds = PdsClient("test-token", "A12345") - pds_ignore_date = PdsClient("test-token", "A12345", ignore_dates=True) - - records: list[HumanName] = [ - { - "use": "official", - "family": "Doe", - "given": ["John"], - "period": {"start": "2000-01-01", "end": "2010-12-31"}, - }, - { - "use": "official", - "family": "Smith", - "given": ["John"], - "period": {"start": "2011-01-01", "end": "2020-12-31"}, - }, - ] - - assert pds.find_current_name_record(records) is None - assert pds_ignore_date.find_current_name_record(records) is not None - - -def test_extract_single_search_result_with_invalid_body_raises_pds_request_failed() -> ( - None -): - """ - Verify that ``PdsClient._extract_single_search_result`` raises ``PdsRequestFailed`` - when mandatory patient content is missing. + response_body_missing_nhs_number = happy_path_pds_response_body.copy() + response_body_missing_nhs_number["identifier"] = [] - This test asserts that a ``PdsRequestFailed`` is raised when: - - * The body is a bundle containing no entries (``entry`` is empty). - * The body is a patient resource with no NHS number (missing/blank ``id``). - * The body is a patient resource with an NHS number, - but the patient has no *current* name record. - """ - client = PdsClient( - auth_token="test-token", # noqa: S106 (test token hardcoded) - base_url="https://example.test/personal-demographics/FHIR/R4", + response = FakeResponse( + status_code=200, + headers={}, + _json=response_body_missing_nhs_number, ) + mocker.patch("gateway_api.pds.client.get", return_value=response) - # 1) Bundle contains no entries. - bundle_no_entries: Any = {"resourceType": "Bundle", "entry": []} - with pytest.raises(PdsRequestFailedError): - client._extract_single_search_result(bundle_no_entries) # noqa SLF001 (testing private method) - - # 2) Patient has no NHS number (Patient.id missing/blank). - patient_missing_nhs_number: Any = { - "resourceType": "Patient", - "name": [ - { - "use": "official", - "family": "Smith", - "given": ["Jane"], - "period": {"start": "1900-01-01", "end": "9999-12-31"}, - } - ], - "generalPractitioner": [], - } - with pytest.raises(PdsRequestFailedError): - client._extract_single_search_result(patient_missing_nhs_number) # noqa SLF001 (testing private method) - - # 3) Bundle entry exists with NHS number, but no current name record. - bundle_no_current_name: Any = { - "resourceType": "Bundle", - "entry": [ - { - "resource": { - "resourceType": "Patient", - "id": "9000000009", - "name": [ - { - "use": "official", - "family": "Smith", - "given": ["Jane"], - "period": {"start": "1900-01-01", "end": "1900-12-31"}, - } - ], - "generalPractitioner": [], - } - } - ], - } - - # No current name record is tolerated by PdsClient; names are returned as empty. - result = client._extract_single_search_result(bundle_no_current_name) # noqa SLF001 (testing private method) - assert result is not None - assert result.nhs_number == "9000000009" - assert result.given_names == "" - assert result.family_name == "" - - -def test_find_current_name_record_ignore_dates_returns_last_or_none() -> None: - """ - If ignore_dates=True: - * returns the last name record even if none are current - * returns None when the list is empty - """ - pds_ignore = PdsClient("test-token", "A12345", ignore_dates=True) - - records: list[HumanName] = [ - { - "use": "official", - "family": "Old", - "given": ["First"], - "period": {"start": "1900-01-01", "end": "1900-12-31"}, - }, - { - "use": "official", - "family": "Newer", - "given": ["Second"], - "period": {"start": "1901-01-01", "end": "1901-12-31"}, - }, - ] - - # Pick a date that is not covered by any record; ignore_dates should still pick last - chosen = pds_ignore.find_current_name_record(records, today=date(2026, 1, 1)) - assert chosen == records[-1] - - assert pds_ignore.find_current_name_record([]) is None - - -def test_find_current_gp_ignore_dates_returns_last_or_none() -> None: - """ - If ignore_dates=True: - * returns the last GP record even if none are current - * returns None when the list is empty - """ - pds_ignore = PdsClient("test-token", "A12345", ignore_dates=True) - - records: list[GeneralPractitioner] = [ - { - "id": "abcd", - "type": "Organization", - "identifier": { - "value": "GP-OLD", - "period": {"start": "1900-01-01", "end": "1900-12-31"}, - "system": "https://fhir.nhs.uk/Id/ods-organization-code", - }, - }, - { - "id": "1234", - "type": "Organization", - "identifier": { - "value": "GP-NEWER", - "period": {"start": "1901-01-01", "end": "1901-12-31"}, - "system": "https://fhir.nhs.uk/Id/ods-organization-code", - }, - }, - ] - - # Pick a date that is not covered by any record; ignore_dates should still pick last - chosen = pds_ignore.find_current_gp(records, today=date(2026, 1, 1)) - assert chosen == records[-1] + client = PdsClient(auth_token) - assert pds_ignore.find_current_gp([]) is None + with pytest.raises( + PdsRequestFailedError, + match="PDS FHIR API request failed: PDS Patient resource missing NHS number", + ): + client.search_patient_by_nhs_number("9999999999") diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index de7982f9..36285c3f 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -133,7 +133,7 @@ def test_get_structured_record_returns_operation_outcome_when_missing_header( get_structured_record_response_from_missing_header: Flask, expected_message: str, ) -> None: - expected_body: OperationOutcome = { + expected_body = { "resourceType": "OperationOutcome", "issue": [ { diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 257a7d95..c0851cf7 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -5,6 +5,7 @@ import pytest from fhir.bundle import Bundle from fhir.parameters import Parameters +from fhir.resources import Patient from flask import Request from pytest_mock import MockerFixture @@ -17,10 +18,28 @@ from gateway_api.conftest import FakeResponse, create_mock_request from gateway_api.controller import Controller from gateway_api.get_structured_record import GetStructuredRecordRequest -from gateway_api.pds import PdsSearchResults from gateway_api.sds import SdsSearchResults +def _create_patient(nhs_number: str, gp_ods_code: str | None) -> Patient: + general_practitioner = None + if gp_ods_code is not None: + general_practitioner = [ + Patient.GeneralPractitioner( + type="Organization", + identifier=Patient.GeneralPractitioner.OrganizationIdentifier( + system="https://fhir.nhs.uk/Id/ods-organization-code", + value=gp_ods_code, + ), + ) + ] + + return Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number(nhs_number)], + generalPractitioner=general_practitioner, + ) + + def test_controller_run_happy_path_returns_200_status_code( mock_happy_path_get_structured_record_request: Request, ) -> None: @@ -48,15 +67,9 @@ def test_get_pds_details_returns_provider_ods_code_for_happy_path( auth_token: str, ) -> None: nhs_number = "9000000009" - pds_search_result = PdsSearchResults( - given_names="Jane", - family_name="Smith", - nhs_number=nhs_number, - gp_ods_code="A12345", - ) mocker.patch( "gateway_api.pds.PdsClient.search_patient_by_nhs_number", - return_value=pds_search_result, + return_value=_create_patient(nhs_number, "A12345"), ) controller = Controller(pds_base_url="https://example.test/pds", timeout=7) @@ -70,15 +83,9 @@ def test_get_pds_details_raises_no_current_provider_when_ods_code_missing_in_pds auth_token: str, ) -> None: nhs_number = "9000000009" - pds_search_result_without_ods_code = PdsSearchResults( - given_names="Jane", - family_name="Smith", - nhs_number=nhs_number, - gp_ods_code=None, - ) mocker.patch( "gateway_api.pds.PdsClient.search_patient_by_nhs_number", - return_value=pds_search_result_without_ods_code, + return_value=_create_patient(nhs_number, None), ) controller = Controller() @@ -253,12 +260,7 @@ def mock_happy_path_get_structured_record_request( sds_results = [provider_sds_results, consumer_sds_results] mocker.patch( "gateway_api.pds.PdsClient.search_patient_by_nhs_number", - return_value=PdsSearchResults( - given_names="Jane", - family_name="Smith", - nhs_number=nhs_number, - gp_ods_code=provider_ods, - ), + return_value=_create_patient(nhs_number, provider_ods), ) mocker.patch( "gateway_api.sds.SdsClient.get_org_details", @@ -296,15 +298,9 @@ def test_controller_creates_jwt_token_with_correct_claims( provider_endpoint = "https://provider.example/ep" # Mock PDS to return provider ODS code - pds_search_result = PdsSearchResults( - given_names="Jane", - family_name="Smith", - nhs_number=nhs_number, - gp_ods_code=provider_ods, - ) mocker.patch( "gateway_api.pds.PdsClient.search_patient_by_nhs_number", - return_value=pds_search_result, + return_value=_create_patient(nhs_number, provider_ods), ) # Mock SDS to return provider and consumer details diff --git a/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json b/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json index 558a4e30..a9f9665e 100644 --- a/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json +++ b/gateway-api/stubs/stubs/data/patients/alice_jones_9999999999.json @@ -26,6 +26,7 @@ "id": "1", "type": "Organization", "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", "value": "A12345", "period": {"start": "2020-01-01", "end": "9999-12-31"} } diff --git a/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json b/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json index 58b47242..d635e8e5 100644 --- a/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json +++ b/gateway-api/stubs/stubs/data/patients/blank_asid_sds_result_9000000011.json @@ -26,6 +26,7 @@ "id": "1", "type": "Organization", "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", "value": "BlankAsidInSDS", "period": {"start": "2020-01-01"} } diff --git a/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json b/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json index 1e3645b6..3d6f18d8 100644 --- a/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json +++ b/gateway-api/stubs/stubs/data/patients/blank_endpoint_sds_result_9000000013.json @@ -26,6 +26,7 @@ "id": "1", "type": "Organization", "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", "value": "BlankEndpointInSDS", "period": {"start": "2020-01-01"} } diff --git a/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json b/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json index 94ed1c30..fd1c7214 100644 --- a/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json +++ b/gateway-api/stubs/stubs/data/patients/induce_provider_error_9000000012.json @@ -26,6 +26,7 @@ "id": "1", "type": "Organization", "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", "value": "A12345", "period": {"start": "2020-01-01"} } diff --git a/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json b/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json index f43198ba..78b61fb9 100644 --- a/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json +++ b/gateway-api/stubs/stubs/data/patients/no_sds_result_9000000010.json @@ -26,6 +26,7 @@ "id": "1", "type": "Organization", "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", "value": "DoesNotExistInSDS", "period": {"start": "2020-01-01"} } diff --git a/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json b/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json index 6834ebe6..b3168cf6 100644 --- a/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json +++ b/gateway-api/stubs/stubs/data/patients/none_consumer_sds_result_9000000014.json @@ -26,6 +26,7 @@ "id": "1", "type": "Organization", "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", "value": "BlankConsumerRequest", "period": {"start": "2020-01-01"} } From b326b515071d2889aff906688e8151cbdce2b727 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:01:47 +0000 Subject: [PATCH 03/33] Remove unused helper functions. --- gateway-api/src/gateway_api/pds/__init__.py | 4 +- gateway-api/src/gateway_api/pds/client.py | 140 +----------------- .../src/gateway_api/pds/search_results.py | 18 --- 3 files changed, 4 insertions(+), 158 deletions(-) delete mode 100644 gateway-api/src/gateway_api/pds/search_results.py diff --git a/gateway-api/src/gateway_api/pds/__init__.py b/gateway-api/src/gateway_api/pds/__init__.py index 7c687699..5dc353b6 100644 --- a/gateway-api/src/gateway_api/pds/__init__.py +++ b/gateway-api/src/gateway_api/pds/__init__.py @@ -1,9 +1,7 @@ """PDS (Personal Demographics Service) client and data structures.""" -from gateway_api.pds.client import PdsClient -from gateway_api.pds.search_results import PdsSearchResults +from .client import PdsClient __all__ = [ "PdsClient", - "PdsSearchResults", ] diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index faaf3e86..00691e23 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -21,16 +21,12 @@ import os import uuid from collections.abc import Callable -from datetime import UTC, date, datetime -from typing import cast import requests -from fhir import BundleEntry, GeneralPractitioner, HumanName, PatientTypedDict from fhir.resources import Patient from pydantic import ValidationError from gateway_api.common.error import PdsRequestFailedError -from gateway_api.pds.search_results import PdsSearchResults # TODO: Once stub servers/containers made for PDS, SDS and provider # we should remove the STUB_PDS environment variable and just @@ -55,7 +51,7 @@ class PdsClient: * :meth:`search_patient_by_nhs_number` - calls ``GET /Patient/{nhs_number}`` - This method returns a :class:`PdsSearchResults` instance when a patient can be + This method returns a :class:`Patient` instance when a patient can be extracted, otherwise ``None``. **Usage example**:: @@ -118,7 +114,7 @@ def search_patient_by_nhs_number( Retrieve a patient by NHS number. Calls ``GET /Patient/{nhs_number}``, which returns a single FHIR Patient - resource on success, then extracts a single :class:`PdsSearchResults`. + resource on success, then builds and returns a single :class:`Patient`. """ headers = self._build_headers( request_id=request_id, @@ -143,6 +139,7 @@ def search_patient_by_nhs_number( try: patient = Patient.model_validate(response.json()) except ValidationError as err: + # TODO: improve this hacky handling. first_error = err.errors()[0] error_is_identifier = first_error["loc"] == ("identifier",) no_patient_identifier = ( @@ -158,134 +155,3 @@ def search_patient_by_nhs_number( raise err return patient - - # --------------- internal helpers for result extraction ----------------- - - def _get_gp_ods_code( - self, general_practitioners: list[GeneralPractitioner] - ) -> str | None: - """ - Extract the current GP ODS code from ``Patient.generalPractitioner``. - - This function implements the business rule: - - * If the list is empty, return ``None``. - * If the list is non-empty and no record is current, return ``None``. - * If exactly one record is current, return its ``identifier.value``. - - In future this may change to return the most recent record if none is current. - """ - if len(general_practitioners) == 0: - return None - - gp = self.find_current_gp(general_practitioners) - if gp is None: - return None - - ods_code = gp["identifier"]["value"] - - return None if ods_code == "None" else ods_code - - def _extract_single_search_result(self, body: PatientTypedDict) -> PdsSearchResults: - """ - Extract a single :class:`PdsSearchResults` from a Patient response. - - This helper accepts either: - * a single FHIR Patient resource (as returned by ``GET /Patient/{id}``), or - * a FHIR Bundle containing Patient entries (as typically returned by searches). - - For Bundle inputs, the code assumes either zero matches (empty entry list) or a - single match; if multiple entries are present, the first entry is used. - """ - # Accept either: - # 1) Patient (GET /Patient/{id}) - # 2) Bundle with Patient in entry[0].resource (search endpoints) - if str(body.get("resourceType", "")) == "Patient": - patient = body - else: - entries = cast("list[BundleEntry]", body.get("entry", [])) - if not entries: - raise PdsRequestFailedError( - error_response="PDS response contains no patient entries" - ) - - # Use the first patient entry. Search by NHS number is unique. Search by - # demographics for an application is allowed to return max one entry from - # PDS. Search by a human can return more, but presumably we count as an - # application. - # See MaxResults parameter in the PDS OpenAPI spec. - entry = entries[0] - patient = entry.get("resource", {}) -<<<<<<< HEAD -======= - ->>>>>>> b7ff9bc (First step towards using Pydantic.) - nhs_number = str(patient.get("id", "")).strip() - if not nhs_number: - raise PdsRequestFailedError( - error_reason="PDS Patient resource missing NHS number" - ) - - current_name = self.find_current_name_record(patient["name"]) - - if current_name is not None: - given_names = " ".join(current_name.get("given", [])).strip() - family_name = current_name.get("family", "") - else: - given_names = "" - family_name = "" - - # Extract GP ODS code if a current GP record exists. - gp_ods_code = self._get_gp_ods_code(patient.get("generalPractitioner", [])) - - return PdsSearchResults( - given_names=given_names, - family_name=family_name, - nhs_number=nhs_number, - gp_ods_code=gp_ods_code, - ) - - def find_current_gp( - self, - general_practitioners: list[GeneralPractitioner], - today: date | None = None, - ) -> GeneralPractitioner | None: - if today is None: - today = datetime.now(UTC).date() - - if self.ignore_dates: - if len(general_practitioners) > 0: - return general_practitioners[-1] - else: - return None - - for record in general_practitioners: - period = record["identifier"]["period"] - start = date.fromisoformat(period["start"]) - # TODO: period is not required to have end - end = date.fromisoformat(period["end"]) - if start <= today <= end: - return record - - return None - - def find_current_name_record( - self, names: list[HumanName], today: date | None = None - ) -> HumanName | None: - if today is None: - today = datetime.now(UTC).date() - - if self.ignore_dates: - if len(names) > 0: - return names[-1] - else: - return None - - for name in names: - period = cast("dict[str, str]", name["period"]) - start = date.fromisoformat(period["start"]) - end = date.fromisoformat(period["end"]) - if start <= today <= end: - return name - - return None diff --git a/gateway-api/src/gateway_api/pds/search_results.py b/gateway-api/src/gateway_api/pds/search_results.py deleted file mode 100644 index 331a476d..00000000 --- a/gateway-api/src/gateway_api/pds/search_results.py +++ /dev/null @@ -1,18 +0,0 @@ -"""PDS search result data structures.""" - -from dataclasses import dataclass - - -@dataclass -class PdsSearchResults: - """ - A single extracted patient record. - - Only a small subset of the PDS Patient fields are currently required by this - gateway. More will be added in later phases. - """ - - given_names: str - family_name: str - nhs_number: str - gp_ods_code: str | None From ae3252c08e9a53e2c4107ba87a49ff76a7211e16 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:24:05 +0000 Subject: [PATCH 04/33] Use Pydantic FHIR types in SDS client. --- gateway-api/src/fhir/resources.py | 26 ++++++- .../get_structured_record/request.py | 2 + gateway-api/src/gateway_api/sds/client.py | 67 ++++++++----------- 3 files changed, 54 insertions(+), 41 deletions(-) diff --git a/gateway-api/src/fhir/resources.py b/gateway-api/src/fhir/resources.py index 83a938c5..16e9ae59 100644 --- a/gateway-api/src/fhir/resources.py +++ b/gateway-api/src/fhir/resources.py @@ -82,7 +82,7 @@ def _validate_resource_type(cls, value: str) -> str: return value -type BundleType = Literal["document", "transaction"] +type BundleType = Literal["document", "transaction", "searchset"] class Bundle(Resource, resource_type="Bundle"): @@ -117,6 +117,30 @@ def empty(cls, bundle_type: BundleType) -> "Bundle": return cls.create(type=bundle_type, entry=None) +class Device(Resource, resource_type="Device"): + """A FHIR R4 Device resource.""" + + class ASIDIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/nhsSpineASID" + ): + """A FHIR R4 ASID Identifier.""" + + class PartyKeyIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/nhsMhsPartyKey" + ): + """A FHIR R4 Party Key Identifier.""" + + identifier: Annotated[ + list[ASIDIdentifier | PartyKeyIdentifier], Field(frozen=True, min_length=1) + ] + + +class Endpoint(Resource, resource_type="Endpoint"): + """A FHIR R4 Endpoint resource.""" + + address: str | None = Field(None, frozen=True) + + class OperationOutcome(Resource, resource_type="OperationOutcome"): """A FHIR R4 OperationOutcome resource.""" diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index 7e723e7c..af63789a 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING, ClassVar from fhir import OperationOutcome, Parameters + +# TODO: may be able to remove the use of the FHIR type entirely. from fhir.operation_outcome import OperationOutcomeIssue from flask.wrappers import Request, Response from werkzeug.exceptions import BadRequest diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py index a2f7d640..6dd498d6 100644 --- a/gateway-api/src/gateway_api/sds/client.py +++ b/gateway-api/src/gateway_api/sds/client.py @@ -10,8 +10,9 @@ import os from enum import StrEnum -from typing import Any, cast +from typing import Any +from fhir.resources import Bundle, Device, Endpoint, Resource from stubs import SdsFhirApiStub from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID @@ -29,11 +30,6 @@ sds = SdsFhirApiStub() get = sds.get # type: ignore -# Recursive JSON-like structure typing used for parsed FHIR bodies. -type ResultStructureDict = dict[str, ResultStructure] -type ResultList = list[ResultStructureDict] -type ResultStructure = str | ResultStructureDict | list["ResultStructure"] - class SdsResourceType(StrEnum): """SDS FHIR resource types.""" @@ -134,12 +130,14 @@ def get_org_details( querytype=SdsResourceType.DEVICE, ) - device = self._extract_first_entry(device_bundle) + device = self._extract_first_resource(device_bundle, Device) - # TODO: Post-steel-thread handle case where no device is found for ODS code + if not device: + empty_search_results = SdsSearchResults(asid=None, endpoint=None) + return empty_search_results - asid = self._extract_identifier(device, self.ASID_SYSTEM) - party_key = self._extract_identifier(device, self.PARTYKEY_SYSTEM) + asid = self._extract_device_identifier(device, self.ASID_SYSTEM) + party_key = self._extract_device_identifier(device, self.PARTYKEY_SYSTEM) # Step 2: Get Endpoint to obtain endpoint URL endpoint_url: str | None = None @@ -154,11 +152,9 @@ def get_org_details( timeout=timeout, querytype=SdsResourceType.ENDPOINT, ) - endpoint = self._extract_first_entry(endpoint_bundle) - if endpoint: - address = endpoint.get("address") - if address: - endpoint_url = str(address).strip() + endpoint = self._extract_first_resource(endpoint_bundle, Endpoint) + if endpoint and endpoint.address: + endpoint_url = str(endpoint.address).strip() return SdsSearchResults(asid=asid, endpoint=endpoint_url) @@ -182,7 +178,7 @@ def _query_sds( correlation_id: str | None = None, timeout: int | None = 10, querytype: SdsResourceType = SdsResourceType.DEVICE, - ) -> ResultStructureDict: + ) -> Bundle: """ Query SDS /Device or /Endpoint endpoint. """ @@ -206,38 +202,29 @@ def _query_sds( # TODO: Post-steel-thread we probably want a raise_for_status() here - body = response.json() - return cast("ResultStructureDict", body) + bundle = Bundle.model_validate(response.json()) + return bundle @staticmethod - def _extract_first_entry( - bundle: ResultStructureDict, - ) -> ResultStructureDict: # TODO: Post-steel-thread this may return a None as well - """ - Extract the first resource from a Bundle. - """ - entries = cast("ResultList", bundle.get("entry", [])) - + def _extract_first_resource[T: Resource]( + bundle: Bundle, resource: type[T] + ) -> T | None: # TODO: Post-steel-thread handle case where bundle contains no entries # TODO: more carefully consider business logic for handling multiple # entries in beta - if not entries: - return {} - first_entry = entries[0] - return cast("ResultStructureDict", first_entry.get("resource", {})) - - def _extract_identifier( - self, device: ResultStructureDict, system: str - ) -> str | None: + resources = bundle.find_resources(resource) + if not resources: + return None + first_entry = resources[0] + return first_entry + + def _extract_device_identifier(self, device: Device, system: str) -> str | None: """ Extract an identifier value from a Device resource for a given system. """ - identifiers = cast("ResultList", device.get("identifier", [])) - - for identifier in identifiers: - id_system = str(identifier.get("system", "")) - if id_system == system: - return cast("str", identifier.get("value", "")) + for identifier in device.identifier: + if identifier.system == system: + return identifier.value or "" return None From 9b77b80db3d5b0c16ee40b0e11dede870a5e61e8 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:54:39 +0000 Subject: [PATCH 05/33] Use Pydantic FHIR types in tests. --- .vscode/cspell-dictionary.txt | 1 + gateway-api/src/fhir/__init__.py | 4 ++-- gateway-api/src/fhir/bundle.py | 2 +- gateway-api/src/fhir/resources.py | 7 ++++++- gateway-api/src/gateway_api/conftest.py | 16 ++++++++-------- .../get_structured_record/request.py | 4 ++-- .../get_structured_record/test_request.py | 17 ++++++++--------- gateway-api/src/gateway_api/pds/test_client.py | 12 ++++++------ .../src/gateway_api/provider/test_client.py | 13 ++++++------- gateway-api/src/gateway_api/test_app.py | 12 +++++------- gateway-api/src/gateway_api/test_controller.py | 13 ++++++------- 11 files changed, 51 insertions(+), 50 deletions(-) diff --git a/.vscode/cspell-dictionary.txt b/.vscode/cspell-dictionary.txt index 32bf0846..13b723fe 100644 --- a/.vscode/cspell-dictionary.txt +++ b/.vscode/cspell-dictionary.txt @@ -2,4 +2,5 @@ asid fhir getstructuredrecord gpconnect +searchset usefixtures diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index 170c6469..5b3e09c1 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -1,6 +1,6 @@ """FHIR data types and resources.""" -from fhir.bundle import Bundle, BundleEntry +from fhir.bundle import BundleEntry, BundleTypedDict from fhir.general_practitioner import GeneralPractitioner from fhir.human_name import HumanName from fhir.identifier import Identifier @@ -9,7 +9,7 @@ from fhir.patient import PatientTypedDict __all__ = [ - "Bundle", + "BundleTypedDict", "BundleEntry", "HumanName", "Identifier", diff --git a/gateway-api/src/fhir/bundle.py b/gateway-api/src/fhir/bundle.py index 8d46720a..c34da1f4 100644 --- a/gateway-api/src/fhir/bundle.py +++ b/gateway-api/src/fhir/bundle.py @@ -10,7 +10,7 @@ class BundleEntry(TypedDict): resource: PatientTypedDict -class Bundle(TypedDict): +class BundleTypedDict(TypedDict): resourceType: str id: str type: str diff --git a/gateway-api/src/fhir/resources.py b/gateway-api/src/fhir/resources.py index 16e9ae59..d081dbd2 100644 --- a/gateway-api/src/fhir/resources.py +++ b/gateway-api/src/fhir/resources.py @@ -36,6 +36,11 @@ def model_dump_json(self, *args: Any, **kwargs: Any) -> str: kwargs.setdefault("exclude_none", True) return super().model_dump_json(*args, **kwargs) + def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + # FHIR resources should not return empty fields + kwargs.setdefault("exclude_none", True) + return super().model_dump(*args, **kwargs) + @model_validator(mode="wrap") @classmethod def validate_with_subtype( @@ -82,7 +87,7 @@ def _validate_resource_type(cls, value: str) -> str: return value -type BundleType = Literal["document", "transaction", "searchset"] +type BundleType = Literal["document", "transaction", "searchset", "collection"] class Bundle(Resource, resource_type="Bundle"): diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 7471c85a..55593bf2 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -6,8 +6,6 @@ import pytest import requests -from fhir import Bundle, OperationOutcome, PatientTypedDict -from fhir.parameters import Parameters from flask import Request from requests.structures import CaseInsensitiveDict from werkzeug.test import EnvironBuilder @@ -21,10 +19,12 @@ class FakeResponse: status_code: int headers: dict[str, str] | CaseInsensitiveDict[str] - _json: dict[str, Any] | PatientTypedDict | OperationOutcome | Bundle + _json: dict[str, Any] reason: str = "" - def json(self) -> dict[str, Any] | PatientTypedDict | OperationOutcome | Bundle: + def json( + self, + ) -> dict[str, Any]: return self._json def raise_for_status(self) -> None: @@ -39,7 +39,7 @@ def text(self) -> str: return json.dumps(self._json) -def create_mock_request(headers: dict[str, str], body: Parameters) -> Request: +def create_mock_request(headers: dict[str, str], body: dict[str, Any]) -> Request: """Create a proper Flask Request object with headers and JSON body.""" builder = EnvironBuilder( method="POST", @@ -53,7 +53,7 @@ def create_mock_request(headers: dict[str, str], body: Parameters) -> Request: @pytest.fixture -def valid_simple_request_payload() -> Parameters: +def valid_simple_request_payload() -> dict[str, Any]: return { "resourceType": "Parameters", "parameter": [ @@ -69,7 +69,7 @@ def valid_simple_request_payload() -> Parameters: @pytest.fixture -def valid_simple_response_payload() -> Bundle: +def valid_simple_response_payload() -> dict[str, Any]: return { "resourceType": "Bundle", "id": "example-patient-bundle", @@ -124,7 +124,7 @@ def valid_headers() -> dict[str, str]: @pytest.fixture -def happy_path_pds_response_body() -> PatientTypedDict: +def happy_path_pds_response_body() -> dict[str, Any]: return { "resourceType": "Patient", "id": "9999999999", diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index af63789a..6cfcef10 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -18,7 +18,7 @@ ) if TYPE_CHECKING: - from fhir.bundle import Bundle + from fhir.bundle import BundleTypedDict class GetStructuredRecordRequest: @@ -34,7 +34,7 @@ def __init__(self, request: Request) -> None: except BadRequest as error: raise InvalidRequestJSONError() from error - self._response_body: Bundle | OperationOutcome | None = None + self._response_body: BundleTypedDict | OperationOutcome | None = None self._status_code: int | None = None self._validate_headers() diff --git a/gateway-api/src/gateway_api/get_structured_record/test_request.py b/gateway-api/src/gateway_api/get_structured_record/test_request.py index 34498655..6ebd4551 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_request.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -1,8 +1,7 @@ import json -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast import pytest -from fhir.parameters import Parameters from flask import Request from gateway_api.common.common import FlaskResponse @@ -11,11 +10,11 @@ from gateway_api.get_structured_record.request import GetStructuredRecordRequest if TYPE_CHECKING: - from fhir.bundle import Bundle + from fhir.bundle import BundleTypedDict @pytest.fixture -def mock_request_with_headers(valid_simple_request_payload: Parameters) -> Request: +def mock_request_with_headers(valid_simple_request_payload: dict[str, Any]) -> Request: headers = { "Ssp-TraceID": "test-trace-id", "ODS-from": "test-ods", @@ -58,7 +57,7 @@ def test_nhs_number_is_pulled_from_request_body( assert actual == expected def test_raises_value_error_when_ods_from_header_is_missing( - self, valid_simple_request_payload: Parameters + self, valid_simple_request_payload: dict[str, Any] ) -> None: """Test that ValueError is raised when ODS-from header is missing.""" headers = { @@ -73,7 +72,7 @@ def test_raises_value_error_when_ods_from_header_is_missing( GetStructuredRecordRequest(request=mock_request) def test_raises_value_error_when_ods_from_header_is_whitespace( - self, valid_simple_request_payload: Parameters + self, valid_simple_request_payload: dict[str, Any] ) -> None: """ Test that ValueError is raised when ODS-from header contains only whitespace. @@ -91,7 +90,7 @@ def test_raises_value_error_when_ods_from_header_is_whitespace( GetStructuredRecordRequest(request=mock_request) def test_raises_value_error_when_trace_id_header_is_missing( - self, valid_simple_request_payload: Parameters + self, valid_simple_request_payload: dict[str, Any] ) -> None: """Test that ValueError is raised when Ssp-TraceID header is missing.""" headers = { @@ -106,7 +105,7 @@ def test_raises_value_error_when_trace_id_header_is_missing( GetStructuredRecordRequest(request=mock_request) def test_raises_value_error_when_trace_id_header_is_whitespace( - self, valid_simple_request_payload: Parameters + self, valid_simple_request_payload: dict[str, Any] ) -> None: """ Test that ValueError is raised when Ssp-TraceID header contains only whitespace. @@ -132,7 +131,7 @@ def test_sets_response_body_from_valid_json_data( request_obj = GetStructuredRecordRequest(request=mock_request_with_headers) - bundle_data: Bundle = { + bundle_data: BundleTypedDict = { "resourceType": "Bundle", "id": "test-bundle", "type": "collection", diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index dc2676dd..85ea9180 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -2,10 +2,10 @@ Unit tests for :mod:`gateway_api.pds_search`. """ +from typing import Any from uuid import UUID, uuid4 import pytest -from fhir import PatientTypedDict from fhir.resources import Patient from pytest_mock import MockerFixture @@ -17,7 +17,7 @@ def test_search_patient_by_nhs_number_happy_path( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: PatientTypedDict, + happy_path_pds_response_body: dict[str, Any], ) -> None: happy_path_response = FakeResponse( status_code=200, headers={}, _json=happy_path_pds_response_body @@ -35,7 +35,7 @@ def test_search_patient_by_nhs_number_happy_path( def test_search_patient_by_nhs_number_has_no_gp_returns_gp_ods_code_none( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: PatientTypedDict, + happy_path_pds_response_body: dict[str, Any], ) -> None: gp_less_response_body = happy_path_pds_response_body.copy() del gp_less_response_body["generalPractitioner"] @@ -55,7 +55,7 @@ def test_search_patient_by_nhs_number_has_no_gp_returns_gp_ods_code_none( def test_search_patient_by_nhs_number_sends_expected_headers( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: PatientTypedDict, + happy_path_pds_response_body: dict[str, Any], ) -> None: happy_path_response = FakeResponse( status_code=200, headers={}, _json=happy_path_pds_response_body @@ -87,7 +87,7 @@ def test_search_patient_by_nhs_number_sends_expected_headers( def test_search_patient_by_nhs_number_generates_request_id( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: PatientTypedDict, + happy_path_pds_response_body: dict[str, Any], ) -> None: happy_path_response = FakeResponse( status_code=200, headers={}, _json=happy_path_pds_response_body @@ -128,7 +128,7 @@ def test_search_patient_by_nhs_number_not_found_raises_error( def test_search_patient_by_nhs_number_missing_nhs_number_raises_error( auth_token: str, mocker: MockerFixture, - happy_path_pds_response_body: PatientTypedDict, + happy_path_pds_response_body: dict[str, Any], ) -> None: response_body_missing_nhs_number = happy_path_pds_response_body.copy() response_body_missing_nhs_number["identifier"] = [] diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index 07d4e587..458bd2fc 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -10,7 +10,6 @@ from typing import Any import pytest -from fhir import Parameters from requests import Response from requests.structures import CaseInsensitiveDict from stubs.provider.stub import GpProviderStub @@ -73,7 +72,7 @@ def dummy_jwt() -> JWT: def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( mock_request_post: dict[str, Any], - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], dummy_jwt: JWT, ) -> None: """ @@ -110,7 +109,7 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( mock_request_post: dict[str, Any], - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], dummy_jwt: JWT, ) -> None: """ @@ -156,7 +155,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 def test_valid_gpprovider_access_structured_record_with_correct_body_200( mock_request_post: dict[str, Any], - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], dummy_jwt: JWT, ) -> None: """ @@ -191,7 +190,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( def test_valid_gpprovider_access_structured_record_returns_stub_response_200( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) stub: GpProviderStub, - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], dummy_jwt: JWT, ) -> None: """ @@ -227,7 +226,7 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( def test_access_structured_record_raises_external_service_error( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], dummy_jwt: JWT, ) -> None: """ @@ -257,7 +256,7 @@ def test_access_structured_record_raises_external_service_error( def test_gpprovider_client_includes_authorization_header_with_bearer_token( mock_request_post: dict[str, Any], - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], dummy_jwt: JWT, ) -> None: """ diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 36285c3f..9c296c9c 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -4,11 +4,9 @@ import os from collections.abc import Generator from copy import copy -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import pytest -from fhir.bundle import Bundle -from fhir.parameters import Parameters from flask import Flask from flask.testing import FlaskClient from pytest_mock import MockerFixture @@ -58,7 +56,7 @@ class TestGetStructuredRecord: def test_valid_get_structured_record_request_returns_expected_bundle( self, get_structured_record_response: Flask, - valid_simple_response_payload: Bundle, + valid_simple_response_payload: dict[str, Any], ) -> None: actual_bundle = get_structured_record_response.get_json() assert actual_bundle == valid_simple_response_payload @@ -182,7 +180,7 @@ def test_get_structured_record_returns_internal_server_error_when_invalid_json_s def get_structured_record_response( client: FlaskClient[Flask], valid_headers: dict[str, str], - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], ) -> Flask: response = client.post( "/patient/$gpc.getstructuredrecord", @@ -196,7 +194,7 @@ def get_structured_record_response( def get_structured_record_response_from_missing_header( client: FlaskClient[Flask], missing_headers: dict[str, str], - valid_simple_request_payload: Parameters, + valid_simple_request_payload: dict[str, Any], ) -> Flask: response = client.post( "/patient/$gpc.getstructuredrecord", @@ -225,7 +223,7 @@ def get_structured_record_response_using_invalid_json_body( def mock_positive_return_value_from_controller_run( mocker: MockerFixture, valid_headers: dict[str, str], - valid_simple_response_payload: Bundle, + valid_simple_response_payload: dict[str, Any], ) -> None: postive_response = FlaskResponse( status_code=200, diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index c0851cf7..cd721d17 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -1,10 +1,9 @@ """Unit tests for :mod:`gateway_api.controller`.""" import json +from typing import Any import pytest -from fhir.bundle import Bundle -from fhir.parameters import Parameters from fhir.resources import Patient from flask import Request from pytest_mock import MockerFixture @@ -52,7 +51,7 @@ def test_controller_run_happy_path_returns_200_status_code( def test_controller_run_happy_path_returns_returns_expected_body( mock_happy_path_get_structured_record_request: Request, - valid_simple_response_payload: Bundle, + valid_simple_response_payload: dict[str, Any], ) -> None: controller = Controller() actual_response = controller.run( @@ -245,8 +244,8 @@ def test_get_sds_details_raises_no_asid_found_when_sds_returns_empty_consumer_as @pytest.fixture def mock_happy_path_get_structured_record_request( mocker: MockerFixture, - valid_simple_request_payload: Parameters, - valid_simple_response_payload: Bundle, + valid_simple_request_payload: dict[str, Any], + valid_simple_response_payload: dict[str, Any], ) -> Request: nhs_number = "9000000009" provider_ods = "ProviderODS" @@ -286,8 +285,8 @@ def mock_happy_path_get_structured_record_request( def test_controller_creates_jwt_token_with_correct_claims( mocker: MockerFixture, - valid_simple_request_payload: Parameters, - valid_simple_response_payload: Bundle, + valid_simple_request_payload: dict[str, Any], + valid_simple_response_payload: dict[str, Any], ) -> None: """ Test that the controller creates a JWT token with the correct claims. From 8bb2ec899519996d428b84ec50ef57b5aea87c2e Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:07:32 +0000 Subject: [PATCH 06/33] Apply review comment for PR 275 from JW - move response behaviour out of request class. --- gateway-api/src/gateway_api/app.py | 13 +- gateway-api/src/gateway_api/common/error.py | 12 +- gateway-api/src/gateway_api/controller.py | 13 +- .../get_structured_record/request.py | 61 ++----- .../get_structured_record/response.py | 50 ++++++ .../get_structured_record/test_request.py | 159 +----------------- .../get_structured_record/test_response.py | 84 +++++++++ gateway-api/src/gateway_api/test_app.py | 14 +- .../src/gateway_api/test_controller.py | 22 +-- 9 files changed, 187 insertions(+), 241 deletions(-) create mode 100644 gateway-api/src/gateway_api/get_structured_record/response.py create mode 100644 gateway-api/src/gateway_api/get_structured_record/test_response.py diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 3b7cd5c5..60c99134 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -9,6 +9,7 @@ from gateway_api.get_structured_record import ( GetStructuredRecordRequest, ) +from gateway_api.get_structured_record.response import GetStructuredRecordResponse app = Flask(__name__) @@ -31,20 +32,22 @@ def get_app_port() -> int: @app.route("/patient/$gpc.getstructuredrecord", methods=["POST"]) def get_structured_record() -> Response: + response = GetStructuredRecordResponse() + response.mirror_headers(request) try: get_structured_record_request = GetStructuredRecordRequest(request) controller = Controller() - flask_response = controller.run(request=get_structured_record_request) - get_structured_record_request.set_response_from_flaskresponse(flask_response) + provider_response = controller.run(request=get_structured_record_request) + response.add_provider_response(provider_response) except AbstractCDGError as e: e.log() - return e.build_response() + response.add_error_response(e) except Exception: error = UnexpectedError(traceback=traceback.format_exc()) error.log() - return error.build_response() + response.add_error_response(error) - return get_structured_record_request.build_response() + return response.build() @app.route("/health", methods=["GET"]) diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index f38ab64b..f9104fd2 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -4,7 +4,6 @@ from fhir.elements import Issue, IssueCode, IssueSeverity from fhir.resources import OperationOutcome -from flask import Response @dataclass @@ -26,7 +25,8 @@ def __init__(self, **additional_details: str): self.additional_details = additional_details super().__init__(self) - def build_response(self) -> Response: + @property + def operation_outcome(self) -> OperationOutcome: operation_outcome = OperationOutcome.create( issue=[ Issue( @@ -36,13 +36,7 @@ def build_response(self) -> Response: ) ] ) - - response = Response( - response=operation_outcome.model_dump_json(), - status=self.status_code, - content_type="application/fhir+json", - ) - return response + return operation_outcome def log(self) -> None: print(traceback.format_exc(), flush=True) diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 80b0af37..5d7d553a 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -4,11 +4,12 @@ from typing import TYPE_CHECKING +from requests import Response + if TYPE_CHECKING: from fhir.resources import Patient from gateway_api.clinical_jwt import JWT, Device, Practitioner -from gateway_api.common.common import FlaskResponse from gateway_api.common.error import ( NoAsidFoundError, NoCurrentEndpointError, @@ -42,7 +43,7 @@ def __init__( self.timeout = timeout self.gp_provider_client = None - def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: + def run(self, request: GetStructuredRecordRequest) -> Response: """ Controller entry point @@ -73,16 +74,12 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: token=token, ) - response = self.gp_provider_client.access_structured_record( + provider_response = self.gp_provider_client.access_structured_record( trace_id=request.trace_id, body=request.request_body, ) - return FlaskResponse( - status_code=response.status_code, - data=response.text, - headers=dict(response.headers), - ) + return provider_response def get_auth_token(self) -> str: """ diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index 6cfcef10..4bae4142 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -1,15 +1,19 @@ import json +from collections.abc import Mapping from typing import TYPE_CHECKING, ClassVar -from fhir import OperationOutcome, Parameters +if TYPE_CHECKING: + from fhir import Parameters # TODO: may be able to remove the use of the FHIR type entirely. -from fhir.operation_outcome import OperationOutcomeIssue -from flask.wrappers import Request, Response +from flask.wrappers import Request +from requests.structures import CaseInsensitiveDict from werkzeug.exceptions import BadRequest -from gateway_api.common.common import FlaskResponse -from gateway_api.common.error import InvalidRequestJSONError, MissingOrEmptyHeaderError +from gateway_api.common.error import ( + InvalidRequestJSONError, + MissingOrEmptyHeaderError, +) # Access record structured interaction ID from # https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development.html#spine-interactions @@ -17,9 +21,6 @@ "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" ) -if TYPE_CHECKING: - from fhir.bundle import BundleTypedDict - class GetStructuredRecordRequest: INTERACTION_ID: ClassVar[str] = ACCESS_RECORD_STRUCTURED_INTERACTION_ID @@ -28,13 +29,12 @@ class GetStructuredRecordRequest: def __init__(self, request: Request) -> None: self._http_request = request - self._headers = request.headers + self._headers = CaseInsensitiveDict(request.headers) try: self._request_body: Parameters = request.get_json() except BadRequest as error: raise InvalidRequestJSONError() from error - self._response_body: BundleTypedDict | OperationOutcome | None = None self._status_code: int | None = None self._validate_headers() @@ -58,6 +58,10 @@ def ods_from(self) -> str: def request_body(self) -> str: return json.dumps(self._request_body) + @property + def headers(self) -> Mapping[str, str]: + return self._headers + def _validate_headers(self) -> None: trace_id = self._headers.get("Ssp-TraceID", "").strip() if not trace_id: @@ -66,40 +70,3 @@ def _validate_headers(self) -> None: ods_from = self._headers.get("ODS-from", "").strip() if not ods_from: raise MissingOrEmptyHeaderError(header="ODS-from") - - def build_response(self) -> Response: - return Response( - response=json.dumps(self._response_body), - status=self._status_code, - mimetype="application/fhir+json", - ) - - def set_negative_response(self, error: str, status_code: int = 500) -> None: - self._status_code = status_code - self._response_body = OperationOutcome( - resourceType="OperationOutcome", - issue=[ - OperationOutcomeIssue( - severity="error", - code="exception", - diagnostics=error, - ) - ], - ) - - def set_response_from_flaskresponse(self, flask_response: FlaskResponse) -> None: - if flask_response.data: - self._status_code = flask_response.status_code - try: - self._response_body = json.loads(flask_response.data) - except json.JSONDecodeError as err: - self.set_negative_response(f"Failed to decode response body: {err}") - except Exception as err: - self.set_negative_response( - f"Unexpected error decoding response body: {err}" - ) - else: - self.set_negative_response( - error="No response body received", - status_code=flask_response.status_code, - ) diff --git a/gateway-api/src/gateway_api/get_structured_record/response.py b/gateway-api/src/gateway_api/get_structured_record/response.py new file mode 100644 index 00000000..764fc52e --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/response.py @@ -0,0 +1,50 @@ +import json +from collections.abc import Mapping +from typing import ClassVar + +from flask import Request, Response +from requests import Response as HTTPResponse +from requests.structures import CaseInsensitiveDict + +from gateway_api.common.error import AbstractCDGError + +# Access record structured interaction ID from +# https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development.html#spine-interactions +ACCESS_RECORD_STRUCTURED_INTERACTION_ID = ( + "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" +) + + +class GetStructuredRecordResponse: + INTERACTION_ID: ClassVar[str] = ACCESS_RECORD_STRUCTURED_INTERACTION_ID + RESOURCE: ClassVar[str] = "patient" + FHIR_OPERATION: ClassVar[str] = "$gpc.getstructuredrecord" + + MIME_TYPE: ClassVar[str] = "application/fhir+json" + + def __init__(self) -> None: + self._response_body: str | None = None + self._headers: Mapping[str, str] | None = None + self._status_code: int | None = None + + def mirror_headers(self, request: Request) -> None: + self._headers = CaseInsensitiveDict(request.headers) + + @property + def headers(self) -> Mapping[str, str] | None: + return self._headers + + def add_provider_response(self, provider_response: HTTPResponse) -> None: + self._response_body = json.dumps(provider_response.json()) + self._status_code = provider_response.status_code + + def add_error_response(self, error: AbstractCDGError) -> None: + self._response_body = error.operation_outcome.model_dump_json() + self._status_code = error.status_code + + def build(self) -> Response: + return Response( + response=self._response_body, + status=self._status_code, + mimetype=self.MIME_TYPE, + ) diff --git a/gateway-api/src/gateway_api/get_structured_record/test_request.py b/gateway-api/src/gateway_api/get_structured_record/test_request.py index 6ebd4551..688bbf2a 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_request.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_request.py @@ -1,17 +1,14 @@ -import json -from typing import TYPE_CHECKING, Any, cast +from typing import Any import pytest from flask import Request -from gateway_api.common.common import FlaskResponse -from gateway_api.common.error import MissingOrEmptyHeaderError +from gateway_api.common.error import ( + MissingOrEmptyHeaderError, +) from gateway_api.conftest import create_mock_request from gateway_api.get_structured_record.request import GetStructuredRecordRequest -if TYPE_CHECKING: - from fhir.bundle import BundleTypedDict - @pytest.fixture def mock_request_with_headers(valid_simple_request_payload: dict[str, Any]) -> Request: @@ -121,151 +118,3 @@ def test_raises_value_error_when_trace_id_header_is_whitespace( match='Missing or empty required header "Ssp-TraceID"', ): GetStructuredRecordRequest(request=mock_request) - - -class TestSetResponseFromFlaskResponse: - def test_sets_response_body_from_valid_json_data( - self, mock_request_with_headers: Request - ) -> None: - """Test that valid JSON data is correctly parsed and set.""" - - request_obj = GetStructuredRecordRequest(request=mock_request_with_headers) - - bundle_data: BundleTypedDict = { - "resourceType": "Bundle", - "id": "test-bundle", - "type": "collection", - "timestamp": "2026-02-03T10:00:00Z", - "entry": [], - } - flask_response = FlaskResponse( - status_code=200, - data=json.dumps(bundle_data), - headers={"Content-Type": "application/fhir+json"}, - ) - - request_obj.set_response_from_flaskresponse(flask_response) - - resp = request_obj.build_response() - assert resp.status == "200 OK" - assert resp.response is not None - assert cast("list[bytes]", resp.response)[0].decode("utf-8") == json.dumps( - bundle_data - ) - - def test_handles_json_decode_error( - self, mock_request_with_headers: Request - ) -> None: - """Test that JSONDecodeError is handled and sets negative response.""" - request_obj = GetStructuredRecordRequest(request=mock_request_with_headers) - - flask_response = FlaskResponse( - status_code=200, - data="invalid json {not valid}", - headers={"Content-Type": "application/fhir+json"}, - ) - - request_obj.set_response_from_flaskresponse(flask_response) - - resp = request_obj.build_response() - assert resp.status == "500 INTERNAL SERVER ERROR" - assert resp.response is not None - response_data = json.loads( - cast("list[bytes]", resp.response)[0].decode("utf-8") - ) - assert response_data["resourceType"] == "OperationOutcome" - assert len(response_data["issue"]) == 1 - assert ( - "Failed to decode response body:" - in response_data["issue"][0]["diagnostics"] - ) - - def test_handles_unexpected_exception_during_json_decode( - self, mock_request_with_headers: Request, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Test that unexpected exceptions during JSON parsing are handled.""" - request_obj = GetStructuredRecordRequest(request=mock_request_with_headers) - - flask_response = FlaskResponse( - status_code=200, - data='{"valid": "json"}', - headers={"Content-Type": "application/fhir+json"}, - ) - - # Mock json.loads to raise an unexpected exception - original_json_loads = json.loads - - def mock_json_loads(data: str) -> None: # noqa: ARG001 - raise RuntimeError("Unexpected error during JSON parsing") - - monkeypatch.setattr(json, "loads", mock_json_loads) - - request_obj.set_response_from_flaskresponse(flask_response) - - # Restore json.loads before building response - monkeypatch.setattr(json, "loads", original_json_loads) - - resp = request_obj.build_response() - assert resp.status == "500 INTERNAL SERVER ERROR" - assert resp.response is not None - response_data = json.loads( - cast("list[bytes]", resp.response)[0].decode("utf-8") - ) - assert response_data["resourceType"] == "OperationOutcome" - assert len(response_data["issue"]) == 1 - assert ( - "Unexpected error decoding response body:" - in response_data["issue"][0]["diagnostics"] - ) - assert ( - "Unexpected error during JSON parsing" - in response_data["issue"][0]["diagnostics"] - ) - - def test_handles_empty_response_data( - self, mock_request_with_headers: Request - ) -> None: - """Test that empty/None response data is handled correctly.""" - request_obj = GetStructuredRecordRequest(request=mock_request_with_headers) - - flask_response = FlaskResponse( - status_code=404, - data=None, - headers={"Content-Type": "application/fhir+json"}, - ) - - request_obj.set_response_from_flaskresponse(flask_response) - - resp = request_obj.build_response() - assert resp.status == "404 NOT FOUND" - assert resp.response is not None - response_data = json.loads( - cast("list[bytes]", resp.response)[0].decode("utf-8") - ) - assert response_data["resourceType"] == "OperationOutcome" - assert len(response_data["issue"]) == 1 - assert response_data["issue"][0]["diagnostics"] == "No response body received" - - def test_handles_empty_string_response_data( - self, mock_request_with_headers: Request - ) -> None: - """Test that empty string response data is handled as no data.""" - request_obj = GetStructuredRecordRequest(request=mock_request_with_headers) - - flask_response = FlaskResponse( - status_code=500, - data="", - headers={"Content-Type": "application/fhir+json"}, - ) - - request_obj.set_response_from_flaskresponse(flask_response) - - resp = request_obj.build_response() - assert resp.status == "500 INTERNAL SERVER ERROR" - assert resp.response is not None - response_data = json.loads( - cast("list[bytes]", resp.response)[0].decode("utf-8") - ) - assert response_data["resourceType"] == "OperationOutcome" - assert len(response_data["issue"]) == 1 - assert response_data["issue"][0]["diagnostics"] == "No response body received" diff --git a/gateway-api/src/gateway_api/get_structured_record/test_response.py b/gateway-api/src/gateway_api/get_structured_record/test_response.py new file mode 100644 index 00000000..aeb9c985 --- /dev/null +++ b/gateway-api/src/gateway_api/get_structured_record/test_response.py @@ -0,0 +1,84 @@ +import json +from typing import Any +from unittest.mock import Mock + +from flask import request +from requests.structures import CaseInsensitiveDict + +from gateway_api.app import app +from gateway_api.common.error import UnexpectedError +from gateway_api.get_structured_record.response import GetStructuredRecordResponse + + +class TestGetStructuredRecordResponse: + def test_mirror_headers_adds_request_headers_to_response( + self, valid_simple_request_payload: dict[str, Any] + ) -> None: + additional_headers = CaseInsensitiveDict( + {"first": "a header", "second": "another header"} + ) + + with app.test_request_context( + "/patient/$gpc.getstructuredrecord", + method="POST", + data=json.dumps(valid_simple_request_payload), + headers=additional_headers, + ): + response = GetStructuredRecordResponse() + response.mirror_headers(request) + + assert response.headers is not None, ( + "Expected headers to be set, but they were None" + ) + assert response.headers == dict(request.headers), ( + "Expected response headers to match request headers, but they did not" + ) + + def test_add_provider_response_adds_provider_response_body( + self, valid_simple_response_payload: dict[str, Any] + ) -> None: + provider_response = Mock() + provider_response.status_code = 200 + provider_response.json.return_value = valid_simple_response_payload + + response = GetStructuredRecordResponse() + response.add_provider_response(provider_response) + + actual = response.build().json + assert actual == valid_simple_response_payload, ( + "Actual response body did not match actual response body." + ) + + def test_add_provider_response_adds_200_status( + self, valid_simple_response_payload: dict[str, Any] + ) -> None: + provider_response = Mock() + provider_response.status_code = 200 + provider_response.json.return_value = valid_simple_response_payload + + response = GetStructuredRecordResponse() + response.add_provider_response(provider_response) + + actual = response.build().status_code + assert actual == 200, f"Expected status code to be 200, but got {actual}" + + def test_add_error_response_adds_error_response_body(self) -> None: + error = UnexpectedError(traceback="something broke") + + response = GetStructuredRecordResponse() + response.add_error_response(error) + + expected_response_body = { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "exception", + "diagnostics": "Internal Server Error: something broke", + } + ], + } + actual_response_body = response.build().json + assert actual_response_body == expected_response_body, ( + "Actual response body did not match expected response body." + ) diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 9c296c9c..99b2bab0 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -5,6 +5,7 @@ from collections.abc import Generator from copy import copy from typing import TYPE_CHECKING, Any +from unittest.mock import Mock import pytest from flask import Flask @@ -12,7 +13,6 @@ from pytest_mock import MockerFixture from gateway_api.app import app, get_app_host, get_app_port -from gateway_api.common.common import FlaskResponse if TYPE_CHECKING: from fhir.operation_outcome import OperationOutcome @@ -225,13 +225,13 @@ def mock_positive_return_value_from_controller_run( valid_headers: dict[str, str], valid_simple_response_payload: dict[str, Any], ) -> None: - postive_response = FlaskResponse( - status_code=200, - data=json.dumps(valid_simple_response_payload), - headers=valid_headers, - ) + positive_response = Mock() + positive_response.status_code = 200 + positive_response.json.return_value = valid_simple_response_payload + positive_response.headers = valid_headers + mocker.patch( - "gateway_api.controller.Controller.run", return_value=postive_response + "gateway_api.controller.Controller.run", return_value=positive_response ) @staticmethod diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index cd721d17..08c5e917 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -1,6 +1,5 @@ """Unit tests for :mod:`gateway_api.controller`.""" -import json from typing import Any import pytest @@ -42,10 +41,11 @@ def _create_patient(nhs_number: str, gp_ods_code: str | None) -> Patient: def test_controller_run_happy_path_returns_200_status_code( mock_happy_path_get_structured_record_request: Request, ) -> None: + request = GetStructuredRecordRequest(mock_happy_path_get_structured_record_request) + controller = Controller() - actual_response = controller.run( - GetStructuredRecordRequest(mock_happy_path_get_structured_record_request) - ) + actual_response = controller.run(request) + assert actual_response.status_code == 200 @@ -53,12 +53,12 @@ def test_controller_run_happy_path_returns_returns_expected_body( mock_happy_path_get_structured_record_request: Request, valid_simple_response_payload: dict[str, Any], ) -> None: + request = GetStructuredRecordRequest(mock_happy_path_get_structured_record_request) + controller = Controller() - actual_response = controller.run( - GetStructuredRecordRequest(mock_happy_path_get_structured_record_request) - ) - assert isinstance(actual_response.data, str) - assert json.loads(actual_response.data) == valid_simple_response_payload + actual_response = controller.run(request) + + assert actual_response.json() == valid_simple_response_payload def test_get_pds_details_returns_provider_ods_code_for_happy_path( @@ -331,8 +331,10 @@ def test_controller_creates_jwt_token_with_correct_claims( body=valid_simple_request_payload, ) + get_structured_record_request = GetStructuredRecordRequest(request) + controller = Controller() - _ = controller.run(GetStructuredRecordRequest(request)) + controller.run(get_structured_record_request) # Verify that GpProviderClient was called and extract the JWT token mock_gp_provider.assert_called_once() From 5da3ba85c81fb122b1244d05e5c4f836bdfbcd57 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:45:59 +0000 Subject: [PATCH 07/33] Build Pydantic Parameter type. --- gateway-api/openapi.yaml | 2 +- gateway-api/src/fhir/__init__.py | 4 ++-- gateway-api/src/fhir/elements.py | 6 ++++++ gateway-api/src/fhir/parameters.py | 2 +- gateway-api/src/fhir/resources.py | 16 +++++++++++++++- gateway-api/src/gateway_api/controller.py | 4 ++-- .../gateway_api/get_structured_record/request.py | 15 +++++++-------- gateway-api/src/gateway_api/test_app.py | 7 ++----- gateway-api/tests/acceptance/steps/happy_path.py | 6 +++--- gateway-api/tests/conftest.py | 5 ++--- .../integration/test_get_structured_record.py | 14 +++++++------- 11 files changed, 48 insertions(+), 33 deletions(-) diff --git a/gateway-api/openapi.yaml b/gateway-api/openapi.yaml index 0a987ffc..bdadc13d 100644 --- a/gateway-api/openapi.yaml +++ b/gateway-api/openapi.yaml @@ -76,7 +76,7 @@ paths: properties: system: type: string - minLength: 1 + enum: ["https://fhir.nhs.uk/Id/nhs-number"] example: "https://fhir.nhs.uk/Id/nhs-number" value: type: string diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index 5b3e09c1..4382800c 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -5,7 +5,7 @@ from fhir.human_name import HumanName from fhir.identifier import Identifier from fhir.operation_outcome import OperationOutcome, OperationOutcomeIssue -from fhir.parameters import Parameter, Parameters +from fhir.parameters import Parameter, ParametersTypedDict from fhir.patient import PatientTypedDict __all__ = [ @@ -16,7 +16,7 @@ "OperationOutcome", "OperationOutcomeIssue", "Parameter", - "Parameters", + "ParametersTypedDict", "PatientTypedDict", "GeneralPractitioner", ] diff --git a/gateway-api/src/fhir/elements.py b/gateway-api/src/fhir/elements.py index 83170b63..5dc5a18b 100644 --- a/gateway-api/src/fhir/elements.py +++ b/gateway-api/src/fhir/elements.py @@ -72,6 +72,12 @@ def __init__(self, value: uuid.UUID | None = None): ) +class NHSNumberValueIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/nhs-number" +): + """A valueIdentifier NHS numbers - used in Parameter""" + + class IssueSeverity(StrEnum): FATAL = "fatal" ERROR = "error" diff --git a/gateway-api/src/fhir/parameters.py b/gateway-api/src/fhir/parameters.py index 30b7cce8..6297a77c 100644 --- a/gateway-api/src/fhir/parameters.py +++ b/gateway-api/src/fhir/parameters.py @@ -10,6 +10,6 @@ class Parameter(TypedDict): valueIdentifier: Identifier -class Parameters(TypedDict): +class ParametersTypedDict(TypedDict): resourceType: str parameter: list[Parameter] diff --git a/gateway-api/src/fhir/resources.py b/gateway-api/src/fhir/resources.py index d081dbd2..50c85457 100644 --- a/gateway-api/src/fhir/resources.py +++ b/gateway-api/src/fhir/resources.py @@ -1,3 +1,5 @@ +from abc import ABC +from dataclasses import dataclass from typing import Annotated, Any, ClassVar, Literal, Self from pydantic import ( @@ -10,7 +12,7 @@ model_validator, ) -from .elements import Identifier, Issue, Meta, UUIDIdentifier +from .elements import Identifier, Issue, Meta, NHSNumberValueIdentifier, UUIDIdentifier class Resource(BaseModel): @@ -152,6 +154,18 @@ class OperationOutcome(Resource, resource_type="OperationOutcome"): issue: Annotated[list[Issue], Field(frozen=True)] +class Parameters(Resource, resource_type="Parameters"): + """A FHIR R4 Parameters resource.""" + + @dataclass(frozen=True) + class Parameter(ABC): + """A FHIR R4 Parameter resource.""" + + valueIdentifier: Annotated[NHSNumberValueIdentifier, Field(frozen=True)] + + parameter: Annotated[list[Parameter], Field(frozen=True)] + + class Reference(BaseModel): """A FHIR R4 Reference base class.""" diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 5d7d553a..b673ba89 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -4,11 +4,11 @@ from typing import TYPE_CHECKING -from requests import Response - if TYPE_CHECKING: from fhir.resources import Patient +from requests import Response + from gateway_api.clinical_jwt import JWT, Device, Practitioner from gateway_api.common.error import ( NoAsidFoundError, diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index 4bae4142..57383a55 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -1,12 +1,11 @@ -import json from collections.abc import Mapping -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar -if TYPE_CHECKING: - from fhir import Parameters +from fhir.resources import Parameters # TODO: may be able to remove the use of the FHIR type entirely. from flask.wrappers import Request +from pydantic import ValidationError from requests.structures import CaseInsensitiveDict from werkzeug.exceptions import BadRequest @@ -31,8 +30,8 @@ def __init__(self, request: Request) -> None: self._http_request = request self._headers = CaseInsensitiveDict(request.headers) try: - self._request_body: Parameters = request.get_json() - except BadRequest as error: + self.parameters = Parameters.model_validate(request.get_json()) + except (BadRequest, ValidationError) as error: raise InvalidRequestJSONError() from error self._status_code: int | None = None @@ -46,7 +45,7 @@ def trace_id(self) -> str: @property def nhs_number(self) -> str: - nhs_number: str = self._request_body["parameter"][0]["valueIdentifier"]["value"] + nhs_number = self.parameters.parameter[0].valueIdentifier.value return nhs_number @property @@ -56,7 +55,7 @@ def ods_from(self) -> str: @property def request_body(self) -> str: - return json.dumps(self._request_body) + return self.parameters.model_dump_json() @property def headers(self) -> Mapping[str, str]: diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index 99b2bab0..e6f808e0 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -4,7 +4,7 @@ import os from collections.abc import Generator from copy import copy -from typing import TYPE_CHECKING, Any +from typing import Any from unittest.mock import Mock import pytest @@ -14,9 +14,6 @@ from gateway_api.app import app, get_app_host, get_app_port -if TYPE_CHECKING: - from fhir.operation_outcome import OperationOutcome - @pytest.fixture def client() -> Generator[FlaskClient[Flask]]: @@ -162,7 +159,7 @@ def test_get_structured_record_returns_content_type_fhir_json_for_invalid_json_s def test_get_structured_record_returns_internal_server_error_when_invalid_json_sent( self, get_structured_record_response_using_invalid_json_body: Flask ) -> None: - expected: OperationOutcome = { + expected = { "resourceType": "OperationOutcome", "issue": [ { diff --git a/gateway-api/tests/acceptance/steps/happy_path.py b/gateway-api/tests/acceptance/steps/happy_path.py index b4bdfbf9..2b0b0965 100644 --- a/gateway-api/tests/acceptance/steps/happy_path.py +++ b/gateway-api/tests/acceptance/steps/happy_path.py @@ -2,9 +2,9 @@ import json from datetime import timedelta +from typing import Any import requests -from fhir.parameters import Parameters from pytest_bdd import given, parsers, then, when from stubs.data.bundles import Bundles @@ -24,7 +24,7 @@ def check_api_is_running(client: Client) -> None: def send_get_request( client: Client, response_context: ResponseContext, - simple_request_payload: Parameters, + simple_request_payload: dict[str, Any], ) -> None: response_context.response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) @@ -35,7 +35,7 @@ def send_get_request( def send_to_nonexistent_endpoint( client: Client, response_context: ResponseContext, - simple_request_payload: Parameters, + simple_request_payload: dict[str, Any], ) -> None: nonexistent_endpoint = f"{client.base_url}/nonexistent" response_context.response = requests.post( diff --git a/gateway-api/tests/conftest.py b/gateway-api/tests/conftest.py index fddc7309..c624754f 100644 --- a/gateway-api/tests/conftest.py +++ b/gateway-api/tests/conftest.py @@ -2,12 +2,11 @@ import os from datetime import timedelta -from typing import Protocol, cast +from typing import Any, Protocol, cast import pytest import requests from dotenv import find_dotenv, load_dotenv -from fhir.parameters import Parameters # Load environment variables from .env file in the workspace root load_dotenv(find_dotenv(usecwd=True)) @@ -107,7 +106,7 @@ def send_health_check(self) -> requests.Response: @pytest.fixture -def simple_request_payload() -> Parameters: +def simple_request_payload() -> dict[str, Any]: return { "resourceType": "Parameters", "parameter": [ diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py index 868ecb7e..4fa4bb6d 100644 --- a/gateway-api/tests/integration/test_get_structured_record.py +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -2,9 +2,9 @@ import json from collections.abc import Callable +from typing import Any import pytest -from fhir.parameters import Parameters from requests import Response from stubs.data.bundles import Bundles @@ -15,7 +15,7 @@ class TestGetStructuredRecord: def test_happy_path_returns_200( self, client: Client, - simple_request_payload: Parameters, + simple_request_payload: dict[str, Any], ) -> None: response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) @@ -25,7 +25,7 @@ def test_happy_path_returns_200( def test_happy_path_returns_correct_message( self, client: Client, - simple_request_payload: Parameters, + simple_request_payload: dict[str, Any], ) -> None: response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) @@ -35,7 +35,7 @@ def test_happy_path_returns_correct_message( def test_happy_path_content_type( self, client: Client, - simple_request_payload: Parameters, + simple_request_payload: dict[str, Any], ) -> None: response = client.send_to_get_structured_record_endpoint( json.dumps(simple_request_payload) @@ -273,7 +273,7 @@ def response_when_sds_returns_blank_provider_asid( @pytest.fixture def response_when_sds_returns_blank_consumer_asid( - self, client: Client, simple_request_payload: Parameters + self, client: Client, simple_request_payload: dict[str, Any] ) -> Response: ods_from_for_consumer_with_blank_consumer_asid_in_sds = "BlankAsidInSDS" headers = {"Ods-From": ods_from_for_consumer_with_blank_consumer_asid_in_sds} @@ -312,7 +312,7 @@ def response_when_sds_provider_endpoint_blank( @pytest.fixture def response_when_consumer_is_none_from_sds( - self, client: Client, simple_request_payload: Parameters + self, client: Client, simple_request_payload: dict[str, Any] ) -> Response: ods_from_for_consumer_with_none_consumer_in_sds = "ConsumerWithNoneInSDS" headers = {"Ods-From": ods_from_for_consumer_with_none_consumer_in_sds} @@ -325,7 +325,7 @@ def response_when_consumer_is_none_from_sds( def get_structured_record_requester( self, client: Client, - simple_request_payload: Parameters, + simple_request_payload: dict[str, Any], ) -> Callable[[str], Response]: def requester(nhs_number: str) -> Response: simple_request_payload["parameter"][0]["valueIdentifier"]["value"] = ( From f109bfb9911daff94a540d5dac55a6b3942c8781 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:59:17 +0000 Subject: [PATCH 08/33] Remove unused TypedDict FHIR types. --- gateway-api/src/fhir/__init__.py | 19 +++--------------- gateway-api/src/fhir/bundle.py | 18 ----------------- gateway-api/src/fhir/general_practitioner.py | 21 -------------------- gateway-api/src/fhir/human_name.py | 12 ----------- gateway-api/src/fhir/identifier.py | 8 -------- gateway-api/src/fhir/operation_outcome.py | 14 ------------- gateway-api/src/fhir/parameters.py | 15 -------------- gateway-api/src/fhir/patient.py | 17 ---------------- gateway-api/src/fhir/period.py | 10 ---------- 9 files changed, 3 insertions(+), 131 deletions(-) delete mode 100644 gateway-api/src/fhir/bundle.py delete mode 100644 gateway-api/src/fhir/general_practitioner.py delete mode 100644 gateway-api/src/fhir/human_name.py delete mode 100644 gateway-api/src/fhir/identifier.py delete mode 100644 gateway-api/src/fhir/operation_outcome.py delete mode 100644 gateway-api/src/fhir/parameters.py delete mode 100644 gateway-api/src/fhir/patient.py delete mode 100644 gateway-api/src/fhir/period.py diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index 4382800c..f7438439 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -1,22 +1,9 @@ """FHIR data types and resources.""" -from fhir.bundle import BundleEntry, BundleTypedDict -from fhir.general_practitioner import GeneralPractitioner -from fhir.human_name import HumanName -from fhir.identifier import Identifier -from fhir.operation_outcome import OperationOutcome, OperationOutcomeIssue -from fhir.parameters import Parameter, ParametersTypedDict -from fhir.patient import PatientTypedDict +from .resources import OperationOutcome, Parameters, Patient __all__ = [ - "BundleTypedDict", - "BundleEntry", - "HumanName", - "Identifier", "OperationOutcome", - "OperationOutcomeIssue", - "Parameter", - "ParametersTypedDict", - "PatientTypedDict", - "GeneralPractitioner", + "Parameters", + "Patient", ] diff --git a/gateway-api/src/fhir/bundle.py b/gateway-api/src/fhir/bundle.py deleted file mode 100644 index c34da1f4..00000000 --- a/gateway-api/src/fhir/bundle.py +++ /dev/null @@ -1,18 +0,0 @@ -"""FHIR Bundle resource.""" - -from typing import TypedDict - -from fhir.patient import PatientTypedDict - - -class BundleEntry(TypedDict): - fullUrl: str - resource: PatientTypedDict - - -class BundleTypedDict(TypedDict): - resourceType: str - id: str - type: str - timestamp: str - entry: list[BundleEntry] diff --git a/gateway-api/src/fhir/general_practitioner.py b/gateway-api/src/fhir/general_practitioner.py deleted file mode 100644 index 4e20d932..00000000 --- a/gateway-api/src/fhir/general_practitioner.py +++ /dev/null @@ -1,21 +0,0 @@ -"""FHIR GeneralPractitioner type.""" - -from typing import TypedDict - -from fhir.period import Period - - -class GeneralPractitionerIdentifier(TypedDict): - """Identifier for GeneralPractitioner""" - - system: str - value: str - period: Period - - -class GeneralPractitioner(TypedDict): - """FHIR GeneralPractitioner reference.""" - - id: str - type: str - identifier: GeneralPractitionerIdentifier diff --git a/gateway-api/src/fhir/human_name.py b/gateway-api/src/fhir/human_name.py deleted file mode 100644 index 6b284c88..00000000 --- a/gateway-api/src/fhir/human_name.py +++ /dev/null @@ -1,12 +0,0 @@ -"""FHIR HumanName type.""" - -from typing import TypedDict - -from fhir.period import Period - - -class HumanName(TypedDict): - use: str - family: str - given: list[str] - period: Period diff --git a/gateway-api/src/fhir/identifier.py b/gateway-api/src/fhir/identifier.py deleted file mode 100644 index 4e59908d..00000000 --- a/gateway-api/src/fhir/identifier.py +++ /dev/null @@ -1,8 +0,0 @@ -"""FHIR Identifier type.""" - -from typing import TypedDict - - -class Identifier(TypedDict): - system: str - value: str diff --git a/gateway-api/src/fhir/operation_outcome.py b/gateway-api/src/fhir/operation_outcome.py deleted file mode 100644 index d25765f5..00000000 --- a/gateway-api/src/fhir/operation_outcome.py +++ /dev/null @@ -1,14 +0,0 @@ -"""FHIR OperationOutcome resource.""" - -from typing import TypedDict - - -class OperationOutcomeIssue(TypedDict): - severity: str - code: str - diagnostics: str - - -class OperationOutcome(TypedDict): - resourceType: str - issue: list[OperationOutcomeIssue] diff --git a/gateway-api/src/fhir/parameters.py b/gateway-api/src/fhir/parameters.py deleted file mode 100644 index 6297a77c..00000000 --- a/gateway-api/src/fhir/parameters.py +++ /dev/null @@ -1,15 +0,0 @@ -"""FHIR Parameters resource.""" - -from typing import TypedDict - -from fhir.identifier import Identifier - - -class Parameter(TypedDict): - name: str - valueIdentifier: Identifier - - -class ParametersTypedDict(TypedDict): - resourceType: str - parameter: list[Parameter] diff --git a/gateway-api/src/fhir/patient.py b/gateway-api/src/fhir/patient.py deleted file mode 100644 index db852a1a..00000000 --- a/gateway-api/src/fhir/patient.py +++ /dev/null @@ -1,17 +0,0 @@ -"""FHIR Patient resource.""" - -from typing import NotRequired, TypedDict - -from fhir.general_practitioner import GeneralPractitioner -from fhir.human_name import HumanName -from fhir.identifier import Identifier - - -class PatientTypedDict(TypedDict): - resourceType: str - id: str - identifier: list[Identifier] - name: list[HumanName] - gender: str - birthDate: str - generalPractitioner: NotRequired[list[GeneralPractitioner]] diff --git a/gateway-api/src/fhir/period.py b/gateway-api/src/fhir/period.py deleted file mode 100644 index 6ac40b4f..00000000 --- a/gateway-api/src/fhir/period.py +++ /dev/null @@ -1,10 +0,0 @@ -"""FHIR Period type.""" - -from typing import NotRequired, TypedDict - - -class Period(TypedDict, total=False): - """FHIR Period type.""" - - start: str - end: NotRequired[str] From 63ed2ea59e6bb1f8c86046fc06877363697f405f Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:43:52 +0000 Subject: [PATCH 09/33] import fhir resources from package __init__ --- gateway-api/src/fhir/__init__.py | 18 +++++++++++++++++- gateway-api/src/gateway_api/common/error.py | 3 +-- gateway-api/src/gateway_api/controller.py | 2 +- .../get_structured_record/request.py | 2 +- gateway-api/src/gateway_api/pds/client.py | 2 +- gateway-api/src/gateway_api/pds/test_client.py | 2 +- gateway-api/src/gateway_api/sds/client.py | 2 +- gateway-api/src/gateway_api/test_controller.py | 2 +- 8 files changed, 24 insertions(+), 9 deletions(-) diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index f7438439..57e6c2e4 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -1,9 +1,25 @@ """FHIR data types and resources.""" -from .resources import OperationOutcome, Parameters, Patient +from .elements import Issue, IssueCode, IssueSeverity +from .resources import ( + Bundle, + Device, + Endpoint, + OperationOutcome, + Parameters, + Patient, + Resource, +) __all__ = [ + "Bundle", + "Device", + "Endpoint", + "Issue", + "IssueCode", + "IssueSeverity", "OperationOutcome", "Parameters", "Patient", + "Resource", ] diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index f9104fd2..41cfd8df 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -2,8 +2,7 @@ from dataclasses import dataclass from http.client import BAD_GATEWAY, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND -from fhir.elements import Issue, IssueCode, IssueSeverity -from fhir.resources import OperationOutcome +from fhir import Issue, IssueCode, IssueSeverity, OperationOutcome @dataclass diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index b673ba89..e61adcf3 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from fhir.resources import Patient + from fhir import Patient from requests import Response diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index 57383a55..3fd559af 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -1,7 +1,7 @@ from collections.abc import Mapping from typing import ClassVar -from fhir.resources import Parameters +from fhir import Parameters # TODO: may be able to remove the use of the FHIR type entirely. from flask.wrappers import Request diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 00691e23..ded4fb90 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -23,7 +23,7 @@ from collections.abc import Callable import requests -from fhir.resources import Patient +from fhir import Patient from pydantic import ValidationError from gateway_api.common.error import PdsRequestFailedError diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index 85ea9180..d886369e 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -6,7 +6,7 @@ from uuid import UUID, uuid4 import pytest -from fhir.resources import Patient +from fhir import Patient from pytest_mock import MockerFixture from gateway_api.common.error import PdsRequestFailedError diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py index 6dd498d6..29b6b2f9 100644 --- a/gateway-api/src/gateway_api/sds/client.py +++ b/gateway-api/src/gateway_api/sds/client.py @@ -12,7 +12,7 @@ from enum import StrEnum from typing import Any -from fhir.resources import Bundle, Device, Endpoint, Resource +from fhir import Bundle, Device, Endpoint, Resource from stubs import SdsFhirApiStub from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index 08c5e917..facfe3b9 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from fhir.resources import Patient +from fhir import Patient from flask import Request from pytest_mock import MockerFixture From 2059e3f6f8ced683c824c98892066518482dc19e Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:29:07 +0000 Subject: [PATCH 10/33] Restructure files. --- gateway-api/src/fhir/__init__.py | 18 +- gateway-api/src/fhir/elements.py | 101 -------- gateway-api/src/fhir/elements/__init__.py | 0 gateway-api/src/fhir/elements/identifier.py | 50 ++++ gateway-api/src/fhir/elements/issue.py | 26 ++ gateway-api/src/fhir/elements/meta.py | 31 +++ gateway-api/src/fhir/elements/py.typed | 0 gateway-api/src/fhir/elements/reference.py | 15 ++ .../src/fhir/{ => elements}/test_elements.py | 3 +- gateway-api/src/fhir/resources.py | 222 ------------------ gateway-api/src/fhir/resources/__init__.py | 0 gateway-api/src/fhir/resources/bundle.py | 40 ++++ gateway-api/src/fhir/resources/device.py | 24 ++ gateway-api/src/fhir/resources/endpoint.py | 9 + .../src/fhir/resources/operation_outcome.py | 12 + gateway-api/src/fhir/resources/parameters.py | 20 ++ gateway-api/src/fhir/resources/patient.py | 52 ++++ gateway-api/src/fhir/resources/py.typed | 0 gateway-api/src/fhir/resources/resource.py | 86 +++++++ .../fhir/{ => resources}/test_resources.py | 4 +- 20 files changed, 378 insertions(+), 335 deletions(-) delete mode 100644 gateway-api/src/fhir/elements.py create mode 100644 gateway-api/src/fhir/elements/__init__.py create mode 100644 gateway-api/src/fhir/elements/identifier.py create mode 100644 gateway-api/src/fhir/elements/issue.py create mode 100644 gateway-api/src/fhir/elements/meta.py create mode 100644 gateway-api/src/fhir/elements/py.typed create mode 100644 gateway-api/src/fhir/elements/reference.py rename gateway-api/src/fhir/{ => elements}/test_elements.py (96%) delete mode 100644 gateway-api/src/fhir/resources.py create mode 100644 gateway-api/src/fhir/resources/__init__.py create mode 100644 gateway-api/src/fhir/resources/bundle.py create mode 100644 gateway-api/src/fhir/resources/device.py create mode 100644 gateway-api/src/fhir/resources/endpoint.py create mode 100644 gateway-api/src/fhir/resources/operation_outcome.py create mode 100644 gateway-api/src/fhir/resources/parameters.py create mode 100644 gateway-api/src/fhir/resources/patient.py create mode 100644 gateway-api/src/fhir/resources/py.typed create mode 100644 gateway-api/src/fhir/resources/resource.py rename gateway-api/src/fhir/{ => resources}/test_resources.py (98%) diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index 57e6c2e4..1bdbb939 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -1,15 +1,13 @@ """FHIR data types and resources.""" -from .elements import Issue, IssueCode, IssueSeverity -from .resources import ( - Bundle, - Device, - Endpoint, - OperationOutcome, - Parameters, - Patient, - Resource, -) +from .elements.issue import Issue, IssueCode, IssueSeverity +from .resources.bundle import Bundle +from .resources.device import Device +from .resources.endpoint import Endpoint +from .resources.operation_outcome import OperationOutcome +from .resources.parameters import Parameters +from .resources.patient import Patient +from .resources.resource import Resource __all__ = [ "Bundle", diff --git a/gateway-api/src/fhir/elements.py b/gateway-api/src/fhir/elements.py deleted file mode 100644 index 5dc5a18b..00000000 --- a/gateway-api/src/fhir/elements.py +++ /dev/null @@ -1,101 +0,0 @@ -import datetime -import uuid -from abc import ABC -from dataclasses import dataclass -from enum import StrEnum -from typing import Annotated, ClassVar - -from pydantic import Field, model_validator - - -@dataclass(frozen=True) -class Meta: - """ - A FHIR R4 Meta element. See https://hl7.org/fhir/R4/datatypes.html#Meta. - Attributes: - version_id: The version id of the resource. - last_updated: The last updated timestamp of the resource. - """ - - last_updated: Annotated[datetime.datetime | None, Field(alias="lastUpdated")] = None - version_id: Annotated[str | None, Field(alias="versionId")] = None - - @classmethod - def with_last_updated(cls, last_updated: datetime.datetime | None = None) -> "Meta": - """ - Create a Meta instance with the provided last_updated timestamp. - Args: - last_updated: The last updated timestamp. - Returns: - A Meta instance with the specified last_updated. - """ - return cls( - last_updated=last_updated or datetime.datetime.now(tz=datetime.timezone.utc) - ) - - -@dataclass(frozen=True) -class Identifier(ABC): - """ - A FHIR R4 Identifier element. See https://hl7.org/fhir/R4/datatypes.html#Identifier. - Attributes: - system: The namespace for the identifier value. - value: The value that is unique within the system. - """ - - _expected_system: ClassVar[str] = "__unknown__" - - value: str - system: str - - @model_validator(mode="after") - def validate_system(self) -> "Identifier": - if self.system != self._expected_system: - raise ValueError( - f"Identifier system '{self.system}' does not match expected " - f"system '{self._expected_system}'." - ) - return self - - @classmethod - def __init_subclass__(cls, expected_system: str) -> None: - cls._expected_system = expected_system - - -class UUIDIdentifier(Identifier, expected_system="https://tools.ietf.org/html/rfc4122"): - """A UUID identifier utilising the standard RFC 4122 system.""" - - def __init__(self, value: uuid.UUID | None = None): - super().__init__( - value=str(value or uuid.uuid4()), - system=self._expected_system, - ) - - -class NHSNumberValueIdentifier( - Identifier, expected_system="https://fhir.nhs.uk/Id/nhs-number" -): - """A valueIdentifier NHS numbers - used in Parameter""" - - -class IssueSeverity(StrEnum): - FATAL = "fatal" - ERROR = "error" - WARNING = "warning" - INFORMATION = "information" - - -class IssueCode(StrEnum): - INVALID = "invalid" - EXCEPTION = "exception" - - -@dataclass(frozen=True) -class Issue(ABC): - """ - A FHIR R4 OperationOutcome Issue element. See https://hl7.org/fhir/R4/datatypes.html#OperationOutcome. - """ - - severity: IssueSeverity - code: IssueCode - diagnostics: str | None = None diff --git a/gateway-api/src/fhir/elements/__init__.py b/gateway-api/src/fhir/elements/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/elements/identifier.py b/gateway-api/src/fhir/elements/identifier.py new file mode 100644 index 00000000..b5a64d23 --- /dev/null +++ b/gateway-api/src/fhir/elements/identifier.py @@ -0,0 +1,50 @@ +import uuid +from abc import ABC +from dataclasses import dataclass +from typing import ClassVar + +from pydantic import model_validator + + +@dataclass(frozen=True) +class Identifier(ABC): + """ + A FHIR R4 Identifier element. See https://hl7.org/fhir/R4/datatypes.html#Identifier. + Attributes: + system: The namespace for the identifier value. + value: The value that is unique within the system. + """ + + _expected_system: ClassVar[str] = "__unknown__" + + value: str + system: str + + @model_validator(mode="after") + def validate_system(self) -> "Identifier": + if self.system != self._expected_system: + raise ValueError( + f"Identifier system '{self.system}' does not match expected " + f"system '{self._expected_system}'." + ) + return self + + @classmethod + def __init_subclass__(cls, expected_system: str) -> None: + cls._expected_system = expected_system + + +class UUIDIdentifier(Identifier, expected_system="https://tools.ietf.org/html/rfc4122"): + """A UUID identifier utilising the standard RFC 4122 system.""" + + def __init__(self, value: uuid.UUID | None = None): + super().__init__( + value=str(value or uuid.uuid4()), + system=self._expected_system, + ) + + +class NHSNumberValueIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/nhs-number" +): + """A valueIdentifier NHS numbers - used in Parameter""" diff --git a/gateway-api/src/fhir/elements/issue.py b/gateway-api/src/fhir/elements/issue.py new file mode 100644 index 00000000..8e5d4de3 --- /dev/null +++ b/gateway-api/src/fhir/elements/issue.py @@ -0,0 +1,26 @@ +from abc import ABC +from dataclasses import dataclass +from enum import StrEnum + + +class IssueSeverity(StrEnum): + FATAL = "fatal" + ERROR = "error" + WARNING = "warning" + INFORMATION = "information" + + +class IssueCode(StrEnum): + INVALID = "invalid" + EXCEPTION = "exception" + + +@dataclass(frozen=True) +class Issue(ABC): + """ + A FHIR R4 OperationOutcome Issue element. See https://hl7.org/fhir/R4/datatypes.html#OperationOutcome. + """ + + severity: IssueSeverity + code: IssueCode + diagnostics: str | None = None diff --git a/gateway-api/src/fhir/elements/meta.py b/gateway-api/src/fhir/elements/meta.py new file mode 100644 index 00000000..8c227019 --- /dev/null +++ b/gateway-api/src/fhir/elements/meta.py @@ -0,0 +1,31 @@ +import datetime +from dataclasses import dataclass +from typing import Annotated + +from pydantic import Field + + +@dataclass(frozen=True) +class Meta: + """ + A FHIR R4 Meta element. See https://hl7.org/fhir/R4/datatypes.html#Meta. + Attributes: + version_id: The version id of the resource. + last_updated: The last updated timestamp of the resource. + """ + + last_updated: Annotated[datetime.datetime | None, Field(alias="lastUpdated")] = None + version_id: Annotated[str | None, Field(alias="versionId")] = None + + @classmethod + def with_last_updated(cls, last_updated: datetime.datetime | None = None) -> "Meta": + """ + Create a Meta instance with the provided last_updated timestamp. + Args: + last_updated: The last updated timestamp. + Returns: + A Meta instance with the specified last_updated. + """ + return cls( + last_updated=last_updated or datetime.datetime.now(tz=datetime.timezone.utc) + ) diff --git a/gateway-api/src/fhir/elements/py.typed b/gateway-api/src/fhir/elements/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/elements/reference.py b/gateway-api/src/fhir/elements/reference.py new file mode 100644 index 00000000..3550f20a --- /dev/null +++ b/gateway-api/src/fhir/elements/reference.py @@ -0,0 +1,15 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +# TODO: convert this to dataclass/ABC like the other elements? +class Reference(BaseModel): + """A FHIR R4 Reference base class.""" + + reference_type: str = Field(alias="type", frozen=True) + + def __init_subclass__( + cls, reference_type: str, **kwargs: Any + ) -> None: # TODO: Why is this necessary? + super().__init_subclass__(**kwargs) diff --git a/gateway-api/src/fhir/test_elements.py b/gateway-api/src/fhir/elements/test_elements.py similarity index 96% rename from gateway-api/src/fhir/test_elements.py rename to gateway-api/src/fhir/elements/test_elements.py index d5e7c4db..f152d88e 100644 --- a/gateway-api/src/fhir/test_elements.py +++ b/gateway-api/src/fhir/elements/test_elements.py @@ -4,7 +4,8 @@ import pytest from pydantic import BaseModel -from .elements import Identifier, Meta, UUIDIdentifier +from fhir.elements.identifier import Identifier, UUIDIdentifier +from fhir.elements.meta import Meta class TestMeta: diff --git a/gateway-api/src/fhir/resources.py b/gateway-api/src/fhir/resources.py deleted file mode 100644 index 50c85457..00000000 --- a/gateway-api/src/fhir/resources.py +++ /dev/null @@ -1,222 +0,0 @@ -from abc import ABC -from dataclasses import dataclass -from typing import Annotated, Any, ClassVar, Literal, Self - -from pydantic import ( - BaseModel, - ConfigDict, - Field, - SerializeAsAny, - ValidatorFunctionWrapHandler, - field_validator, - model_validator, -) - -from .elements import Identifier, Issue, Meta, NHSNumberValueIdentifier, UUIDIdentifier - - -class Resource(BaseModel): - """A FHIR R4 Resource base class.""" - - # class variable to hold class mappings per resource_type - __resource_types: ClassVar[dict[str, type["Resource"]]] = {} - __expected_resource_type: ClassVar[dict[type["Resource"], str]] = {} - - meta: Annotated[Meta | None, Field(alias="meta", frozen=True)] = None - resource_type: str = Field(alias="resourceType", frozen=True) - - model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) - - def __init_subclass__(cls, resource_type: str, **kwargs: Any) -> None: - cls.__resource_types[resource_type] = cls - cls.__expected_resource_type[cls] = resource_type - - super().__init_subclass__(**kwargs) - - def model_dump_json(self, *args: Any, **kwargs: Any) -> str: - # FHIR resources should not return empty fields - kwargs.setdefault("exclude_none", True) - return super().model_dump_json(*args, **kwargs) - - def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: - # FHIR resources should not return empty fields - kwargs.setdefault("exclude_none", True) - return super().model_dump(*args, **kwargs) - - @model_validator(mode="wrap") - @classmethod - def validate_with_subtype( - cls, value: dict[str, Any], handler: ValidatorFunctionWrapHandler - ) -> Any: - """ - Provides a model validator that instantiates the correct Resource subclass - based on its defined resource_type. - """ - # If we're not currently acting on a top level Resource, and we've not been - # provided a generic dictonary object, delegate to the normal handler. - if cls != Resource or not isinstance(value, dict): - return handler(value) - - if "resourceType" not in value or value["resourceType"] is None: - raise TypeError("resourceType is required for Resource validation.") - - resource_type = value["resourceType"] - - subclass = cls.__resource_types.get(resource_type) - if subclass is None: - raise TypeError(f"Unknown resource type: {resource_type}") - - # Instantiate the subclass using the dictionary values. - return subclass.model_validate(value) - - @classmethod - def create(cls, **kwargs: Any) -> Self: - """ - Create a Resource instance with the correct resourceType. - Note any unknown arguments provided via this method will only error at runtime. - """ - return cls(resourceType=cls.__expected_resource_type[cls], **kwargs) - - @field_validator("resource_type", mode="after") - @classmethod - def _validate_resource_type(cls, value: str) -> str: - expected_resource_type = cls.__expected_resource_type[cls] - if value != expected_resource_type: - raise ValueError( - f"Resource type '{value}' does not match expected " - f"resource type '{expected_resource_type}'." - ) - return value - - -type BundleType = Literal["document", "transaction", "searchset", "collection"] - - -class Bundle(Resource, resource_type="Bundle"): - """A FHIR R4 Bundle resource.""" - - bundle_type: BundleType = Field(alias="type", frozen=True) - identifier: Annotated[UUIDIdentifier | None, Field(frozen=True)] = None - entries: list["Bundle.Entry"] | None = Field(None, frozen=True, alias="entry") - - class Entry(BaseModel): - full_url: str = Field(..., alias="fullUrl", frozen=True) - resource: Annotated[SerializeAsAny[Resource], Field(frozen=True)] - - def find_resources[T: Resource](self, t: type[T]) -> list[T]: - """ - Find all resources of a given type in the bundle entries. If the bundle has no - entries, an empty list is returned. - Args: - t: The resource type to search for. - Returns: - A list of resources of the specified type. - """ - return [ - entry.resource - for entry in self.entries or [] - if isinstance(entry.resource, t) - ] - - @classmethod - def empty(cls, bundle_type: BundleType) -> "Bundle": - """Create an empty Bundle of the specified type.""" - return cls.create(type=bundle_type, entry=None) - - -class Device(Resource, resource_type="Device"): - """A FHIR R4 Device resource.""" - - class ASIDIdentifier( - Identifier, expected_system="https://fhir.nhs.uk/Id/nhsSpineASID" - ): - """A FHIR R4 ASID Identifier.""" - - class PartyKeyIdentifier( - Identifier, expected_system="https://fhir.nhs.uk/Id/nhsMhsPartyKey" - ): - """A FHIR R4 Party Key Identifier.""" - - identifier: Annotated[ - list[ASIDIdentifier | PartyKeyIdentifier], Field(frozen=True, min_length=1) - ] - - -class Endpoint(Resource, resource_type="Endpoint"): - """A FHIR R4 Endpoint resource.""" - - address: str | None = Field(None, frozen=True) - - -class OperationOutcome(Resource, resource_type="OperationOutcome"): - """A FHIR R4 OperationOutcome resource.""" - - issue: Annotated[list[Issue], Field(frozen=True)] - - -class Parameters(Resource, resource_type="Parameters"): - """A FHIR R4 Parameters resource.""" - - @dataclass(frozen=True) - class Parameter(ABC): - """A FHIR R4 Parameter resource.""" - - valueIdentifier: Annotated[NHSNumberValueIdentifier, Field(frozen=True)] - - parameter: Annotated[list[Parameter], Field(frozen=True)] - - -class Reference(BaseModel): - """A FHIR R4 Reference base class.""" - - reference_type: str = Field(alias="type", frozen=True) - - def __init_subclass__( - cls, reference_type: str, **kwargs: Any - ) -> None: # TODO: Why is this necessary? - super().__init_subclass__(**kwargs) - - -class Patient(Resource, resource_type="Patient"): - """A FHIR R4 Patient resource.""" - - class PatientIdentifier( - Identifier, expected_system="https://fhir.nhs.uk/Id/nhs-number" - ): - """A FHIR R4 Patient Identifier utilising the NHS Number system.""" - - def __init__(self, value: str): - super().__init__(value=value, system=self._expected_system) - - @classmethod - def from_nhs_number(cls, nhs_number: str) -> "Patient.PatientIdentifier": - """Create a PatientIdentifier from an NHS number.""" - return cls(value=nhs_number) - - identifier: Annotated[list[PatientIdentifier], Field(frozen=True, min_length=1)] - - @property - def nhs_number(self) -> str: - return self.identifier[0].value - - class GeneralPractitioner(Reference, reference_type="Organization"): - class OrganizationIdentifier( - Identifier, expected_system="https://fhir.nhs.uk/Id/ods-organization-code" - ): - """ - A FHIR R4 Organization Identifier utilising the ODS Organization Code - system. - """ - - identifier: Annotated[OrganizationIdentifier, Field(frozen=True)] - - generalPractitioner: Annotated[ - list[GeneralPractitioner] | None, Field(frozen=True) - ] = None - - @property - def gp_ods_code(self) -> str | None: - if not self.generalPractitioner: - return None - - return self.generalPractitioner[0].identifier.value diff --git a/gateway-api/src/fhir/resources/__init__.py b/gateway-api/src/fhir/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/resources/bundle.py b/gateway-api/src/fhir/resources/bundle.py new file mode 100644 index 00000000..a37fbc0d --- /dev/null +++ b/gateway-api/src/fhir/resources/bundle.py @@ -0,0 +1,40 @@ +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, SerializeAsAny + +from ..elements.identifier import UUIDIdentifier +from .resource import Resource + +type BundleType = Literal["document", "transaction", "searchset", "collection"] + + +class Bundle(Resource, resource_type="Bundle"): + """A FHIR R4 Bundle resource.""" + + bundle_type: BundleType = Field(alias="type", frozen=True) + identifier: Annotated[UUIDIdentifier | None, Field(frozen=True)] = None + entries: list["Bundle.Entry"] | None = Field(None, frozen=True, alias="entry") + + class Entry(BaseModel): + full_url: str = Field(..., alias="fullUrl", frozen=True) + resource: Annotated[SerializeAsAny[Resource], Field(frozen=True)] + + def find_resources[T: Resource](self, t: type[T]) -> list[T]: + """ + Find all resources of a given type in the bundle entries. If the bundle has no + entries, an empty list is returned. + Args: + t: The resource type to search for. + Returns: + A list of resources of the specified type. + """ + return [ + entry.resource + for entry in self.entries or [] + if isinstance(entry.resource, t) + ] + + @classmethod + def empty(cls, bundle_type: BundleType) -> "Bundle": + """Create an empty Bundle of the specified type.""" + return cls.create(type=bundle_type, entry=None) diff --git a/gateway-api/src/fhir/resources/device.py b/gateway-api/src/fhir/resources/device.py new file mode 100644 index 00000000..749eef7e --- /dev/null +++ b/gateway-api/src/fhir/resources/device.py @@ -0,0 +1,24 @@ +from typing import Annotated + +from pydantic import Field + +from ..elements.identifier import Identifier +from .resource import Resource + + +class Device(Resource, resource_type="Device"): + """A FHIR R4 Device resource.""" + + class ASIDIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/nhsSpineASID" + ): + """A FHIR R4 ASID Identifier.""" + + class PartyKeyIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/nhsMhsPartyKey" + ): + """A FHIR R4 Party Key Identifier.""" + + identifier: Annotated[ + list[ASIDIdentifier | PartyKeyIdentifier], Field(frozen=True, min_length=1) + ] diff --git a/gateway-api/src/fhir/resources/endpoint.py b/gateway-api/src/fhir/resources/endpoint.py new file mode 100644 index 00000000..a639b2ed --- /dev/null +++ b/gateway-api/src/fhir/resources/endpoint.py @@ -0,0 +1,9 @@ +from pydantic import Field + +from .resource import Resource + + +class Endpoint(Resource, resource_type="Endpoint"): + """A FHIR R4 Endpoint resource.""" + + address: str | None = Field(None, frozen=True) diff --git a/gateway-api/src/fhir/resources/operation_outcome.py b/gateway-api/src/fhir/resources/operation_outcome.py new file mode 100644 index 00000000..c065051d --- /dev/null +++ b/gateway-api/src/fhir/resources/operation_outcome.py @@ -0,0 +1,12 @@ +from typing import Annotated + +from pydantic import Field + +from ..elements.issue import Issue +from .resource import Resource + + +class OperationOutcome(Resource, resource_type="OperationOutcome"): + """A FHIR R4 OperationOutcome resource.""" + + issue: Annotated[list[Issue], Field(frozen=True)] diff --git a/gateway-api/src/fhir/resources/parameters.py b/gateway-api/src/fhir/resources/parameters.py new file mode 100644 index 00000000..b38f8ce4 --- /dev/null +++ b/gateway-api/src/fhir/resources/parameters.py @@ -0,0 +1,20 @@ +from abc import ABC +from dataclasses import dataclass +from typing import Annotated + +from pydantic import Field + +from ..elements.identifier import NHSNumberValueIdentifier +from .resource import Resource + + +class Parameters(Resource, resource_type="Parameters"): + """A FHIR R4 Parameters resource.""" + + @dataclass(frozen=True) + class Parameter(ABC): + """A FHIR R4 Parameter resource.""" + + valueIdentifier: Annotated[NHSNumberValueIdentifier, Field(frozen=True)] + + parameter: Annotated[list[Parameter], Field(frozen=True)] diff --git a/gateway-api/src/fhir/resources/patient.py b/gateway-api/src/fhir/resources/patient.py new file mode 100644 index 00000000..ac38f0b6 --- /dev/null +++ b/gateway-api/src/fhir/resources/patient.py @@ -0,0 +1,52 @@ +from typing import Annotated + +from pydantic import Field + +from ..elements.identifier import Identifier +from ..elements.reference import Reference +from .resource import Resource + + +class Patient(Resource, resource_type="Patient"): + """A FHIR R4 Patient resource.""" + + class PatientIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/nhs-number" + ): + """A FHIR R4 Patient Identifier utilising the NHS Number system.""" + + def __init__(self, value: str): + super().__init__(value=value, system=self._expected_system) + + @classmethod + def from_nhs_number(cls, nhs_number: str) -> "Patient.PatientIdentifier": + """Create a PatientIdentifier from an NHS number.""" + return cls(value=nhs_number) + + identifier: Annotated[list[PatientIdentifier], Field(frozen=True, min_length=1)] + + @property + def nhs_number(self) -> str: + return self.identifier[0].value + + class GeneralPractitioner(Reference, reference_type="Organization"): + class OrganizationIdentifier( + Identifier, expected_system="https://fhir.nhs.uk/Id/ods-organization-code" + ): + """ + A FHIR R4 Organization Identifier utilising the ODS Organization Code + system. + """ + + identifier: Annotated[OrganizationIdentifier, Field(frozen=True)] + + generalPractitioner: Annotated[ + list[GeneralPractitioner] | None, Field(frozen=True) + ] = None + + @property + def gp_ods_code(self) -> str | None: + if not self.generalPractitioner: + return None + + return self.generalPractitioner[0].identifier.value diff --git a/gateway-api/src/fhir/resources/py.typed b/gateway-api/src/fhir/resources/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/resources/resource.py b/gateway-api/src/fhir/resources/resource.py new file mode 100644 index 00000000..c9b44d0b --- /dev/null +++ b/gateway-api/src/fhir/resources/resource.py @@ -0,0 +1,86 @@ +from typing import Annotated, Any, ClassVar, Self + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidatorFunctionWrapHandler, + field_validator, + model_validator, +) + +from ..elements.meta import Meta + + +class Resource(BaseModel): + """A FHIR R4 Resource base class.""" + + # class variable to hold class mappings per resource_type + __resource_types: ClassVar[dict[str, type["Resource"]]] = {} + __expected_resource_type: ClassVar[dict[type["Resource"], str]] = {} + + meta: Annotated[Meta | None, Field(alias="meta", frozen=True)] = None + resource_type: str = Field(alias="resourceType", frozen=True) + + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + def __init_subclass__(cls, resource_type: str, **kwargs: Any) -> None: + cls.__resource_types[resource_type] = cls + cls.__expected_resource_type[cls] = resource_type + + super().__init_subclass__(**kwargs) + + def model_dump_json(self, *args: Any, **kwargs: Any) -> str: + # FHIR resources should not return empty fields + kwargs.setdefault("exclude_none", True) + return super().model_dump_json(*args, **kwargs) + + def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + # FHIR resources should not return empty fields + kwargs.setdefault("exclude_none", True) + return super().model_dump(*args, **kwargs) + + @model_validator(mode="wrap") + @classmethod + def validate_with_subtype( + cls, value: dict[str, Any], handler: ValidatorFunctionWrapHandler + ) -> Any: + """ + Provides a model validator that instantiates the correct Resource subclass + based on its defined resource_type. + """ + # If we're not currently acting on a top level Resource, and we've not been + # provided a generic dictonary object, delegate to the normal handler. + if cls != Resource or not isinstance(value, dict): + return handler(value) + + if "resourceType" not in value or value["resourceType"] is None: + raise TypeError("resourceType is required for Resource validation.") + + resource_type = value["resourceType"] + + subclass = cls.__resource_types.get(resource_type) + if subclass is None: + raise TypeError(f"Unknown resource type: {resource_type}") + + # Instantiate the subclass using the dictionary values. + return subclass.model_validate(value) + + @classmethod + def create(cls, **kwargs: Any) -> Self: + """ + Create a Resource instance with the correct resourceType. + Note any unknown arguments provided via this method will only error at runtime. + """ + return cls(resourceType=cls.__expected_resource_type[cls], **kwargs) + + @field_validator("resource_type", mode="after") + @classmethod + def _validate_resource_type(cls, value: str) -> str: + expected_resource_type = cls.__expected_resource_type[cls] + if value != expected_resource_type: + raise ValueError( + f"Resource type '{value}' does not match expected " + f"resource type '{expected_resource_type}'." + ) + return value diff --git a/gateway-api/src/fhir/test_resources.py b/gateway-api/src/fhir/resources/test_resources.py similarity index 98% rename from gateway-api/src/fhir/test_resources.py rename to gateway-api/src/fhir/resources/test_resources.py index 537d33f5..f0d4de4d 100644 --- a/gateway-api/src/fhir/test_resources.py +++ b/gateway-api/src/fhir/resources/test_resources.py @@ -4,7 +4,9 @@ import pytest from pydantic import BaseModel -from .resources import Bundle, Patient, Resource +from fhir.resources.bundle import Bundle +from fhir.resources.patient import Patient +from fhir.resources.resource import Resource class TestResource: From 36cf647bf93128584d99b48fd5bcc920726b1ce4 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:30:55 +0000 Subject: [PATCH 11/33] Add docs. --- gateway-api/src/fhir/README.md | 76 +++++++++++++++++++ .../config/vocabularies/words/accept.txt | 2 + 2 files changed, 78 insertions(+) create mode 100644 gateway-api/src/fhir/README.md diff --git a/gateway-api/src/fhir/README.md b/gateway-api/src/fhir/README.md new file mode 100644 index 00000000..47faf03c --- /dev/null +++ b/gateway-api/src/fhir/README.md @@ -0,0 +1,76 @@ +# FHIR Types in Gateway API + +## What is FHIR? + +FHIR (Fast Healthcare Interoperability Resources) is the HL7 standard for exchanging healthcare information as structured resources over HTTP APIs. + +Read more on the standards: [R4](https://hl7.org/fhir/R4/overview.html) and [STU3](https://hl7.org/fhir/R4/overview.html). + +In this codebase, the FHIR package provides strongly typed Python models for request validation, response parsing, and safe serialization. + +## FHIR versions in Clinical Data Sharing APIs + +Two FHIR versions are used: + +- STU3: used only for inbound Gateway API operation messages with `resourceType` Parameters (the Access Record Structured request payload). +- R4: used for all other typed resources in this module, including PDS FHIR resources such as Patient. + +Version behaviour in the current flow: + +- Inbound request body is validated as STU3 Parameters. +- Outbound provider response body is returned without transformation (mirrored payload). +- PDS, SDS, and internal typed handling use R4 resource models. + +## How Pydantic is used + +This package uses Pydantic to make FHIR payload handling explicit and safe: + +- Model validation: model_validate(...) is used to parse inbound JSON into typed models. +- Field aliasing: FHIR JSON names like `resourceType`, `fullUrl`, `lastUpdated` are mapped with `Field(alias=...)`. +- Type constraints: `Annotated`, `Literal`, and `min_length` constraints enforce schema-like rules. +- Runtime guards: validators check that `resourceType` and identifier system values match expected FHIR semantics. +- Polymorphism: the Resource base type dispatches to the correct subclass from `resourceType`. +- Serialization: `model_dump()`/`model_dump_json()` default to exclude_none=True to avoid emitting empty FHIR fields. + +Typical patterns in this code: + +- Parse JSON from API input or upstream systems into typed models. +- Access domain properties (for example, `Patient.nhs_number`) instead of raw dictionary traversal. +- Serialize models back to canonical FHIR JSON with aliases preserved. + +## Example usage + +The example below shows how to load a simple FHIR R4 Patient payload and obtain the GP ODS code. + +```python +from fhir import Patient + +payload = { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009", + } + ], + "generalPractitioner": [ + { + "type": "Organization", + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "A12345", + }, + } + ], +} + +patient = Patient.model_validate(payload) + +nhs_number = patient.nhs_number +gp_ods_code = patient.gp_ods_code + +print(nhs_number) # 9000000009 +print(gp_ods_code) # A12345 +``` + +If `generalPractitioner` is missing, `patient.gp_ods_code` returns `None`. diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 33ed410e..d06d7613 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -36,6 +36,7 @@ Octokit onboarding Podman [Pp]roxygen +[Pp]ydantic [Pp]ytest Python [Rr]epos? @@ -47,5 +48,6 @@ Terraform toolchain Trufflehog usebruno +validators? VMs [Vv]scode From 7c05a56f327b764b940ca6fe8e9e862033de7e2e Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:41:14 +0000 Subject: [PATCH 12/33] Add/restructure unit tests. --- gateway-api/src/fhir/elements/reference.py | 31 +- .../src/fhir/elements/test_elements.py | 377 ++++++++++- gateway-api/src/fhir/resources/parameters.py | 2 +- .../src/fhir/resources/test_resource.py | 153 +++++ .../src/fhir/resources/test_resources.py | 601 +++++++++++++++--- .../src/fhir/stu3/elements/__init__.py | 0 .../src/fhir/stu3/elements/test_elements.py | 216 +++++++ ruff.toml | 5 +- 8 files changed, 1242 insertions(+), 143 deletions(-) create mode 100644 gateway-api/src/fhir/resources/test_resource.py create mode 100644 gateway-api/src/fhir/stu3/elements/__init__.py create mode 100644 gateway-api/src/fhir/stu3/elements/test_elements.py diff --git a/gateway-api/src/fhir/elements/reference.py b/gateway-api/src/fhir/elements/reference.py index 3550f20a..964eaca8 100644 --- a/gateway-api/src/fhir/elements/reference.py +++ b/gateway-api/src/fhir/elements/reference.py @@ -1,15 +1,30 @@ -from typing import Any +from typing import ClassVar -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator + +from .identifier import Identifier -# TODO: convert this to dataclass/ABC like the other elements? class Reference(BaseModel): """A FHIR R4 Reference base class.""" - reference_type: str = Field(alias="type", frozen=True) + _expected_reference_type: ClassVar[str] = "__unknown__" + + identifier: Identifier + reference_type: str = Field(alias="type") + + reference: str | None = None + display: str | None = None + + def __init_subclass__(cls, reference_type: str) -> None: + cls._expected_reference_type = reference_type + super().__init_subclass__() - def __init_subclass__( - cls, reference_type: str, **kwargs: Any - ) -> None: # TODO: Why is this necessary? - super().__init_subclass__(**kwargs) + @model_validator(mode="after") + def validate_reference_type(self) -> "Reference": + if self.reference_type != self._expected_reference_type: + raise ValueError( + f"Reference type '{self.reference_type}' does not match expected " + f"type '{self._expected_reference_type}'." + ) + return self diff --git a/gateway-api/src/fhir/elements/test_elements.py b/gateway-api/src/fhir/elements/test_elements.py index f152d88e..ecc6fb1d 100644 --- a/gateway-api/src/fhir/elements/test_elements.py +++ b/gateway-api/src/fhir/elements/test_elements.py @@ -2,10 +2,21 @@ import uuid import pytest -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError +<<<<<<< HEAD:gateway-api/src/fhir/elements/test_elements.py from fhir.elements.identifier import Identifier, UUIDIdentifier from fhir.elements.meta import Meta +======= +from fhir.r4.elements.identifier import ( + Identifier, + NHSNumberValueIdentifier, + UUIDIdentifier, +) +from fhir.r4.elements.issue import Issue, IssueCode, IssueSeverity +from fhir.r4.elements.meta import Meta +from fhir.r4.elements.reference import Reference +>>>>>>> e419cd7 (Add/restructure unit tests.):gateway-api/src/fhir/r4/elements/test_elements.py class TestMeta: @@ -14,44 +25,146 @@ def test_create(self) -> None: version_id="1", last_updated=datetime.datetime.fromisoformat("2023-10-01T12:00:00Z"), ) - assert meta.version_id == "1" + assert meta.version_id == "1", "version_id should be set to '1'" assert meta.last_updated == datetime.datetime.fromisoformat( "2023-10-01T12:00:00Z" - ) + ), "last_updated should match the provided datetime" def test_create_without_last_updated(self) -> None: meta = Meta(version_id="2") - assert meta.version_id == "2" - assert meta.last_updated is None + assert meta.version_id == "2", "version_id should be set to '2'" + assert meta.last_updated is None, "last_updated should default to None" def test_create_without_version(self) -> None: meta = Meta( last_updated=datetime.datetime.fromisoformat("2023-10-01T12:00:00Z") ) - assert meta.version_id is None + assert meta.version_id is None, "version_id should default to None" assert meta.last_updated == datetime.datetime.fromisoformat( "2023-10-01T12:00:00Z" - ) + ), "last_updated should match the provided datetime" + + def test_create_with_defaults(self) -> None: + meta = Meta() + + assert meta.version_id is None, "version_id should default to None" + assert meta.last_updated is None, "last_updated should default to None" def test_with_last_updated(self) -> None: last_updated = datetime.datetime.fromisoformat("2023-10-01T12:00:00Z") meta = Meta.with_last_updated(last_updated) - assert meta.last_updated == last_updated - assert meta.version_id is None + assert meta.last_updated == last_updated, ( + "last_updated should match the provided datetime" + ) + assert meta.version_id is None, "version_id should default to None" def test_with_last_updated_defaults_to_now(self) -> None: before_create = datetime.datetime.now(tz=datetime.timezone.utc) meta = Meta.with_last_updated(None) after_create = datetime.datetime.now(tz=datetime.timezone.utc) - assert meta.last_updated is not None - assert meta.version_id is None + assert meta.last_updated is not None, "last_updated should not be None" + assert meta.version_id is None, "version_id should default to None" + + assert before_create <= meta.last_updated, ( + "last_updated should be >= the time before creation" + ) + assert meta.last_updated <= after_create, ( + "last_updated should be <= the time after creation" + ) + + def test_is_frozen(self) -> None: + meta = Meta(version_id="1") + + with pytest.raises(AttributeError): + meta.version_id = "2" # type: ignore[misc] + + +class TestIdentifierInitSubclass: + def test_subclass_sets_expected_system(self) -> None: + class _Custom(Identifier, expected_system="https://example.com"): + pass + + assert _Custom._expected_system == "https://example.com", ( + "_expected_system should be set by __init_subclass__" + ) + + def test_multiple_subclasses_have_independent_expected_system(self) -> None: + class _A(Identifier, expected_system="system-a"): + pass + + class _B(Identifier, expected_system="system-b"): + pass + + assert _A._expected_system == "system-a", ( + "_A._expected_system should be 'system-a'" + ) + assert _B._expected_system == "system-b", ( + "_B._expected_system should be 'system-b'" + ) + + def test_subclass_without_expected_system_raises(self) -> None: + with pytest.raises(TypeError): - assert before_create <= meta.last_updated - assert meta.last_updated <= after_create + class _Bad(Identifier): # type: ignore[call-arg] + pass + + +class TestIdentifierModelValidate: + def test_valid_system_passes_validation(self) -> None: + class _TestId(Identifier, expected_system="https://example.com"): + pass + + class _Container(BaseModel): + identifier: _TestId + + result = _Container.model_validate( + {"identifier": {"system": "https://example.com", "value": "abc-123"}} + ) + + assert result.identifier.system == "https://example.com", ( + "system should match the expected system" + ) + assert result.identifier.value == "abc-123", "value should be 'abc-123'" + + def test_invalid_system_fails_validation(self) -> None: + class _TestId(Identifier, expected_system="expected-system"): + pass + + class _Container(BaseModel): + identifier: _TestId + + with pytest.raises( + ValidationError, + match="Identifier system 'invalid-system' does not match expected " + "system 'expected-system'.", + ): + _Container.model_validate( + {"identifier": {"system": "invalid-system", "value": "some-value"}} + ) + + def test_missing_value_fails_validation(self) -> None: + class _TestId(Identifier, expected_system="sys"): + pass + + class _Container(BaseModel): + identifier: _TestId + + with pytest.raises(ValidationError): + _Container.model_validate({"identifier": {"system": "sys"}}) + + def test_missing_system_fails_validation(self) -> None: + class _TestId(Identifier, expected_system="sys"): + pass + + class _Container(BaseModel): + identifier: _TestId + + with pytest.raises(ValidationError): + _Container.model_validate({"identifier": {"value": "v"}}) class TestUUIDIdentifier: @@ -59,31 +172,239 @@ def test_create_with_value(self) -> None: expected_uuid = uuid.UUID("12345678-1234-5678-1234-567812345678") identifier = UUIDIdentifier(value=expected_uuid) - assert identifier.system == "https://tools.ietf.org/html/rfc4122" - assert identifier.value == str(expected_uuid) + assert identifier.system == "https://tools.ietf.org/html/rfc4122", ( + "system should be the RFC 4122 URI" + ) + assert identifier.value == str(expected_uuid), ( + "value should match the provided UUID string" + ) def test_create_without_value(self) -> None: identifier = UUIDIdentifier() - assert identifier.system == "https://tools.ietf.org/html/rfc4122" - # Validates that value is a valid UUID v4 + assert identifier.system == "https://tools.ietf.org/html/rfc4122", ( + "system should be the RFC 4122 URI" + ) parsed_uuid = uuid.UUID(identifier.value) - assert parsed_uuid.version == 4 + assert parsed_uuid.version == 4, "auto-generated value should be a UUID v4" + + def test_each_call_generates_unique_uuid(self) -> None: + a = UUIDIdentifier() + b = UUIDIdentifier() + + assert a.value != b.value, "two UUIDIdentifiers should have different values" + + def test_expected_system_class_var(self) -> None: + assert ( + UUIDIdentifier._expected_system == "https://tools.ietf.org/html/rfc4122" + ), "_expected_system should be set to RFC 4122 URI" + + +class TestNHSNumberValueIdentifier: + def test_expected_system(self) -> None: + assert NHSNumberValueIdentifier._expected_system == ( + "https://fhir.nhs.uk/Id/nhs-number" + ), "_expected_system should be the NHS number system URI" + + def test_model_validate_valid(self) -> None: + class _Container(BaseModel): + identifier: NHSNumberValueIdentifier + + result = _Container.model_validate( + { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009", + } + } + ) + + assert result.identifier.value == "9000000009", ( + "value should be the provided NHS number" + ) + assert result.identifier.system == "https://fhir.nhs.uk/Id/nhs-number", ( + "system should match NHS number URI" + ) + + def test_model_validate_wrong_system(self) -> None: + class _Container(BaseModel): + identifier: NHSNumberValueIdentifier + + with pytest.raises( + ValidationError, + match="Identifier system 'wrong' does not match expected " + "system 'https://fhir.nhs.uk/Id/nhs-number'.", + ): + _Container.model_validate( + {"identifier": {"system": "wrong", "value": "9000000009"}} + ) + + +class TestIssue: + def test_diagnostics_defaults_to_none(self) -> None: + class _ConcreteIssue(Issue): + pass + + issue = _ConcreteIssue(severity=IssueSeverity.WARNING, code=IssueCode.EXCEPTION) + + assert issue.diagnostics is None, "diagnostics should default to None" + + def test_is_frozen(self) -> None: + class _ConcreteIssue(Issue): + pass + + issue = _ConcreteIssue(severity=IssueSeverity.FATAL, code=IssueCode.EXCEPTION) + + with pytest.raises(AttributeError): + issue.severity = IssueSeverity.WARNING # type: ignore[misc] + +class TestReferenceInitSubclass: + def test_subclass_sets_expected_reference_type(self) -> None: + class _TestId(Identifier, expected_system="sys"): + pass + + class _TestRef(Reference, reference_type="Patient"): + identifier: _TestId + + assert _TestRef._expected_reference_type == "Patient", ( + "_expected_reference_type should be 'Patient'" + ) -class TestIdentifier: - def test_invalid_system(self) -> None: - class _TestIdentifier(Identifier, expected_system="expected-system"): + def test_multiple_subclasses_have_independent_reference_types(self) -> None: + class _IdA(Identifier, expected_system="sys-a"): pass - class _TestContainer(BaseModel): - identifier: _TestIdentifier + class _IdB(Identifier, expected_system="sys-b"): + pass + + class _RefA(Reference, reference_type="Patient"): + identifier: _IdA + + class _RefB(Reference, reference_type="Organization"): + identifier: _IdB + + assert _RefA._expected_reference_type == "Patient", ( + "_RefA should have reference_type 'Patient'" + ) + assert _RefB._expected_reference_type == "Organization", ( + "_RefB should have reference_type 'Organization'" + ) + + def test_subclass_without_reference_type_raises(self) -> None: + with pytest.raises(TypeError): + + class _BadRef(Reference): + pass + + +class TestReferenceModelValidate: + @pytest.fixture + def id_and_ref_classes(self) -> tuple[type[Identifier], type[Reference]]: + class _TestId(Identifier, expected_system="https://example.com/id"): + pass + + class _TestRef(Reference, reference_type="Patient"): + identifier: _TestId + + return _TestId, _TestRef + + def test_valid_reference( + self, id_and_ref_classes: tuple[type[Identifier], type[Reference]] + ) -> None: + _, ref_cls = id_and_ref_classes + + result = ref_cls.model_validate( + { + "identifier": { + "system": "https://example.com/id", + "value": "12345", + }, + "type": "Patient", + } + ) + + assert result.reference is None, "reference should default to None" + assert result.display is None, "display should default to None" + + def test_valid_reference_with_optional_fields( + self, id_and_ref_classes: tuple[type[Identifier], type[Reference]] + ) -> None: + _, ref_cls = id_and_ref_classes + + result = ref_cls.model_validate( + { + "identifier": { + "system": "https://example.com/id", + "value": "12345", + }, + "type": "Patient", + "reference": "Patient/12345", + "display": "Jane Doe", + } + ) + + assert result.reference == "Patient/12345", ( + "reference should be 'Patient/12345'" + ) + assert result.display == "Jane Doe", "display should be 'Jane Doe'" + + def test_invalid_reference_type_fails( + self, id_and_ref_classes: tuple[type[Identifier], type[Reference]] + ) -> None: + _, ref_cls = id_and_ref_classes with pytest.raises( - ValueError, - match="Identifier system 'invalid-system' does not match expected " - "system 'expected-system'.", + ValidationError, + match="Reference type 'Organization' does not match expected " + "type 'Patient'.", ): - _TestContainer.model_validate( - {"identifier": {"system": "invalid-system", "value": "some-value"}} + ref_cls.model_validate( + { + "identifier": { + "system": "https://example.com/id", + "value": "12345", + }, + "type": "Organization", + } + ) + + def test_invalid_identifier_system_fails( + self, id_and_ref_classes: tuple[type[Identifier], type[Reference]] + ) -> None: + _, ref_cls = id_and_ref_classes + + with pytest.raises( + ValidationError, + match="Identifier system 'wrong-sys' does not match expected " + "system 'https://example.com/id'.", + ): + ref_cls.model_validate( + { + "identifier": {"system": "wrong-sys", "value": "12345"}, + "type": "Patient", + } ) + + def test_missing_type_fails( + self, id_and_ref_classes: tuple[type[Identifier], type[Reference]] + ) -> None: + _, ref_cls = id_and_ref_classes + + with pytest.raises(ValidationError): + ref_cls.model_validate( + { + "identifier": { + "system": "https://example.com/id", + "value": "12345", + }, + } + ) + + def test_missing_identifier_fails( + self, id_and_ref_classes: tuple[type[Identifier], type[Reference]] + ) -> None: + _, ref_cls = id_and_ref_classes + + with pytest.raises(ValidationError): + ref_cls.model_validate({"type": "Patient"}) diff --git a/gateway-api/src/fhir/resources/parameters.py b/gateway-api/src/fhir/resources/parameters.py index b38f8ce4..ea57ab8d 100644 --- a/gateway-api/src/fhir/resources/parameters.py +++ b/gateway-api/src/fhir/resources/parameters.py @@ -17,4 +17,4 @@ class Parameter(ABC): valueIdentifier: Annotated[NHSNumberValueIdentifier, Field(frozen=True)] - parameter: Annotated[list[Parameter], Field(frozen=True)] + parameter: Annotated[list[Parameter], Field(frozen=True, min_length=1)] diff --git a/gateway-api/src/fhir/resources/test_resource.py b/gateway-api/src/fhir/resources/test_resource.py new file mode 100644 index 00000000..7884c6de --- /dev/null +++ b/gateway-api/src/fhir/resources/test_resource.py @@ -0,0 +1,153 @@ +import json +from typing import Any + +import pytest +from pydantic import BaseModel + +from fhir.r4.resources.bundle import Bundle +from fhir.r4.resources.patient import Patient +from fhir.resources.resource import Resource + + +class TestResource: + class _TestContainer(BaseModel): + resource: Resource + + def test_resource_deserialisation(self) -> None: + expected_system = "https://fhir.nhs.uk/Id/nhs-number" + expected_nhs_number = "nhs_number" + example_json = json.dumps( + { + "resource": { + "resourceType": "Patient", + "identifier": [ + { + "system": expected_system, + "value": expected_nhs_number, + } + ], + } + } + ) + + created_object = self._TestContainer.model_validate_json(example_json) + assert isinstance(created_object.resource, Patient) + + created_patient = created_object.resource + assert created_patient.identifier is not None + assert created_patient.identifier[0].system == expected_system + assert created_patient.identifier[0].value == expected_nhs_number + + def test_resource_deserialisation_unknown_resource(self) -> None: + expected_resource_type = "UnknownResourceType" + example_json = json.dumps( + { + "resource": { + "resourceType": expected_resource_type, + } + } + ) + + with pytest.raises( + TypeError, + match=f"Unknown resource type: {expected_resource_type}", + ): + self._TestContainer.model_validate_json(example_json) + + @pytest.mark.parametrize( + "value", + [ + pytest.param({"resource": {}}, id="No resourceType key"), + pytest.param( + {"resource": {"resourceType": None}}, + id="resourceType is defined as None", + ), + ], + ) + def test_resource_deserialisation_without_resource_type( + self, value: dict[str, Any] + ) -> None: + example_json = json.dumps(value) + + with pytest.raises( + TypeError, + match="resourceType is required for Resource validation.", + ): + self._TestContainer.model_validate_json(example_json) + + @pytest.mark.parametrize( + ("json", "expected_error_message"), + [ + pytest.param( + json.dumps({"resourceType": "invalid", "type": "document"}), + "Value error, Resource type 'invalid' does not match expected " + "resource type 'Bundle'.", + id="Invalid resource type", + ), + pytest.param( + json.dumps({"resourceType": None, "type": "document"}), + "1 validation error for Bundle\nresourceType\n " + "Input should be a valid string", + id="Input should be a valid string", + ), + pytest.param( + json.dumps({"type": "document"}), + "1 validation error for Bundle\nresourceType\n Field required", + id="Missing resource type", + ), + ], + ) + def test_deserialise_wrong_resource_type( + self, json: str, expected_error_message: str + ) -> None: + with pytest.raises( + ValueError, + match=expected_error_message, + ): + Bundle.model_validate_json(json, strict=True) + + +class TestResourceInitSubclass: + def test_subclass_without_resource_type_raises(self) -> None: + with pytest.raises(TypeError): + + class _Bad(Resource): + pass + + +class TestResourceCreate: + def test_create_sets_resource_type(self) -> None: + patient = Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number("1234567890")] + ) + + assert patient.resource_type == "Patient", "resource_type should be 'Patient'" + + def test_create_on_bundle(self) -> None: + bundle = Bundle.create(type="document", entry=None) + + assert bundle.resource_type == "Bundle", "resource_type should be 'Bundle'" + + +class TestResourceModelDump: + def test_model_dump_excludes_none(self) -> None: + patient = Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number("1234567890")] + ) + dumped = patient.model_dump() + + assert "generalPractitioner" not in dumped, ( + "None fields should be excluded from model_dump" + ) + assert "meta" not in dumped, "None meta should be excluded from model_dump" + + def test_model_dump_json_excludes_none(self) -> None: + patient = Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number("1234567890")] + ) + payload = json.loads(patient.model_dump_json()) + + assert "generalPractitioner" not in payload, ( + "None fields should be excluded from model_dump_json" + ) + assert "meta" not in payload, "None meta should be excluded" diff --git a/gateway-api/src/fhir/resources/test_resources.py b/gateway-api/src/fhir/resources/test_resources.py index f0d4de4d..44cecf75 100644 --- a/gateway-api/src/fhir/resources/test_resources.py +++ b/gateway-api/src/fhir/resources/test_resources.py @@ -1,112 +1,17 @@ import json -from typing import Any import pytest -from pydantic import BaseModel - -from fhir.resources.bundle import Bundle -from fhir.resources.patient import Patient +from pydantic import ValidationError + +from fhir.r4.elements.issue import Issue, IssueCode, IssueSeverity +from fhir.r4.resources.bundle import Bundle +from fhir.r4.resources.device import Device +from fhir.r4.resources.endpoint import Endpoint +from fhir.r4.resources.operation_outcome import OperationOutcome +from fhir.r4.resources.patient import Patient from fhir.resources.resource import Resource -class TestResource: - class _TestContainer(BaseModel): - resource: Resource - - def test_resource_deserialisation(self) -> None: - expected_system = "https://fhir.nhs.uk/Id/nhs-number" - expected_nhs_number = "nhs_number" - example_json = json.dumps( - { - "resource": { - "resourceType": "Patient", - "identifier": [ - { - "system": expected_system, - "value": expected_nhs_number, - } - ], - } - } - ) - - created_object = self._TestContainer.model_validate_json(example_json) - assert isinstance(created_object.resource, Patient) - - created_patient = created_object.resource - assert created_patient.identifier is not None - assert created_patient.identifier[0].system == expected_system - assert created_patient.identifier[0].value == expected_nhs_number - - def test_resource_deserialisation_unknown_resource(self) -> None: - expected_resource_type = "UnknownResourceType" - example_json = json.dumps( - { - "resource": { - "resourceType": expected_resource_type, - } - } - ) - - with pytest.raises( - TypeError, - match=f"Unknown resource type: {expected_resource_type}", - ): - self._TestContainer.model_validate_json(example_json) - - @pytest.mark.parametrize( - "value", - [ - pytest.param({"resource": {}}, id="No resourceType key"), - pytest.param( - {"resource": {"resourceType": None}}, - id="resourceType is defined as None", - ), - ], - ) - def test_resource_deserialisation_without_resource_type( - self, value: dict[str, Any] - ) -> None: - example_json = json.dumps(value) - - with pytest.raises( - TypeError, - match="resourceType is required for Resource validation.", - ): - self._TestContainer.model_validate_json(example_json) - - @pytest.mark.parametrize( - ("json", "expected_error_message"), - [ - pytest.param( - json.dumps({"resourceType": "invalid", "type": "document"}), - "Value error, Resource type 'invalid' does not match expected " - "resource type 'Bundle'.", - id="Invalid resource type", - ), - pytest.param( - json.dumps({"resourceType": None, "type": "document"}), - "1 validation error for Bundle\nresourceType\n " - "Input should be a valid string", - id="Input should be a valid string", - ), - pytest.param( - json.dumps({"type": "document"}), - "1 validation error for Bundle\nresourceType\n Field required", - id="Missing resource type", - ), - ], - ) - def test_deserialise_wrong_resource_type( - self, json: str, expected_error_message: str - ) -> None: - with pytest.raises( - ValueError, - match=expected_error_message, - ): - Bundle.model_validate_json(json, strict=True) - - class TestBundle: def test_create(self) -> None: """Test creating a Bundle resource.""" @@ -307,5 +212,491 @@ def test_create_from_nhs_number(self) -> None: nhs_number = "1234567890" identifier = Patient.PatientIdentifier.from_nhs_number(nhs_number) - assert identifier.system == "https://fhir.nhs.uk/Id/nhs-number" - assert identifier.value == nhs_number + assert identifier.system == "https://fhir.nhs.uk/Id/nhs-number", ( + "system should be the NHS number URI" + ) + assert identifier.value == nhs_number, "value should match the NHS number" + + def test_create_with_constructor(self) -> None: + identifier = Patient.PatientIdentifier(value="0000000000") + + assert identifier.system == "https://fhir.nhs.uk/Id/nhs-number", ( + "system should be populated from _expected_system" + ) + assert identifier.value == "0000000000", "value should be '0000000000'" + + def test_expected_system_class_var(self) -> None: + assert Patient.PatientIdentifier._expected_system == ( + "https://fhir.nhs.uk/Id/nhs-number" + ), "_expected_system should be the NHS number URI" + + +class TestPatientNhsNumber: + def test_nhs_number_property(self) -> None: + patient = Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number("9876543210")] + ) + + assert patient.nhs_number == "9876543210", ( + "nhs_number property should return the first identifier value" + ) + + +class TestPatientGpOdsCode: + def test_gp_ods_code_with_practitioner(self) -> None: + patient = Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number("1234567890")], + generalPractitioner=[ + Patient.GeneralPractitioner( + type="Organization", + identifier=Patient.GeneralPractitioner.OrganizationIdentifier( + system="https://fhir.nhs.uk/Id/ods-organization-code", + value="B81001", + ), + ) + ], + ) + + assert patient.gp_ods_code == "B81001", ( + "gp_ods_code should return the ODS code from the first generalPractitioner" + ) + + def test_gp_ods_code_without_practitioner(self) -> None: + patient = Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number("1234567890")] + ) + + assert patient.gp_ods_code is None, ( + "gp_ods_code should be None when generalPractitioner is absent" + ) + + def test_gp_ods_code_with_empty_practitioner_list(self) -> None: + patient = Patient.create( + identifier=[Patient.PatientIdentifier.from_nhs_number("1234567890")], + generalPractitioner=[], + ) + + assert patient.gp_ods_code is None, ( + "gp_ods_code should be None when generalPractitioner list is empty" + ) + + +class TestPatientModelValidate: + def test_valid_patient(self) -> None: + patient = Patient.model_validate( + { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "1234567890", + } + ], + } + ) + + assert patient.nhs_number == "1234567890", ( + "nhs_number should be parsed from JSON" + ) + + def test_valid_patient_with_general_practitioner(self) -> None: + patient = Patient.model_validate( + { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "1234567890", + } + ], + "generalPractitioner": [ + { + "type": "Organization", + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "A12345", + }, + } + ], + } + ) + + assert patient.gp_ods_code == "A12345", "gp_ods_code should be parsed from JSON" + + def test_missing_identifier_fails(self) -> None: + with pytest.raises(ValidationError, match="identifier"): + Patient.model_validate({"resourceType": "Patient"}) + + def test_empty_identifier_list_fails(self) -> None: + with pytest.raises(ValidationError, match="too_short"): + Patient.model_validate({"resourceType": "Patient", "identifier": []}) + + def test_invalid_gp_reference_type_fails(self) -> None: + with pytest.raises( + ValidationError, + match=( + "Reference type 'Device' does not match expected type 'Organization'." + ), + ): + Patient.model_validate( + { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "1234567890", + } + ], + "generalPractitioner": [ + { + "type": "Device", + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "A12345", + }, + } + ], + } + ) + + +class TestPatientGeneralPractitioner: + def test_expected_reference_type(self) -> None: + assert Patient.GeneralPractitioner._expected_reference_type == "Organization", ( + "_expected_reference_type should be 'Organization'" + ) + + def test_organization_identifier_expected_system(self) -> None: + assert ( + Patient.GeneralPractitioner.OrganizationIdentifier._expected_system + == "https://fhir.nhs.uk/Id/ods-organization-code" + ), "_expected_system should be the ODS organization code URI" + + +class TestDevice: + def test_create_with_asid_identifier(self) -> None: + device = Device.create( + identifier=[ + Device.ASIDIdentifier( + system="https://fhir.nhs.uk/Id/nhsSpineASID", + value="123456789012", + ) + ], + ) + + assert device.resource_type == "Device", "resource_type should be 'Device'" + assert device.identifier[0].value == "123456789012", ( + "identifier value should match" + ) + + def test_create_with_party_key_identifier(self) -> None: + device = Device.create( + identifier=[ + Device.PartyKeyIdentifier( + system="https://fhir.nhs.uk/Id/nhsMhsPartyKey", + value="P12345-000001", + ) + ], + ) + + assert device.identifier[0].system == "https://fhir.nhs.uk/Id/nhsMhsPartyKey", ( + "system should match the party key URI" + ) + + def test_create_with_mixed_identifiers(self) -> None: + device = Device.create( + identifier=[ + Device.ASIDIdentifier( + system="https://fhir.nhs.uk/Id/nhsSpineASID", + value="123", + ), + Device.PartyKeyIdentifier( + system="https://fhir.nhs.uk/Id/nhsMhsPartyKey", + value="PK-1", + ), + ], + ) + + assert len(device.identifier) == 2, "should have two identifiers" + + def test_asid_identifier_expected_system(self) -> None: + assert Device.ASIDIdentifier._expected_system == ( + "https://fhir.nhs.uk/Id/nhsSpineASID" + ), "_expected_system should be the ASID URI" + + def test_party_key_identifier_expected_system(self) -> None: + assert Device.PartyKeyIdentifier._expected_system == ( + "https://fhir.nhs.uk/Id/nhsMhsPartyKey" + ), "_expected_system should be the party key URI" + + +class TestDeviceModelValidate: + def test_valid_device(self) -> None: + device = Device.model_validate( + { + "resourceType": "Device", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsSpineASID", + "value": "123456789012", + } + ], + } + ) + + assert device.identifier[0].value == "123456789012", ( + "identifier value should be parsed" + ) + + def test_wrong_resource_type_fails(self) -> None: + with pytest.raises( + ValidationError, + match=( + "Resource type 'Patient' does not match expected resource type " + "'Device'." + ), + ): + Device.model_validate( + { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhsSpineASID", + "value": "123", + } + ], + } + ) + + def test_empty_identifier_list_fails(self) -> None: + with pytest.raises(ValidationError, match="too_short"): + Device.model_validate({"resourceType": "Device", "identifier": []}) + + def test_missing_identifier_fails(self) -> None: + with pytest.raises(ValidationError, match="identifier"): + Device.model_validate({"resourceType": "Device"}) + + def test_invalid_identifier_system_fails(self) -> None: + with pytest.raises(ValidationError, match="does not match expected system"): + Device.model_validate( + { + "resourceType": "Device", + "identifier": [{"system": "https://bad.system", "value": "123"}], + } + ) + + +class TestEndpoint: + def test_create_with_address(self) -> None: + endpoint = Endpoint.create(address="https://example.com/fhir") + + assert endpoint.resource_type == "Endpoint", ( + "resource_type should be 'Endpoint'" + ) + assert endpoint.address == "https://example.com/fhir", ( + "address should match the provided URL" + ) + + def test_create_without_address(self) -> None: + endpoint = Endpoint.create() + + assert endpoint.address is None, "address should default to None" + + +class TestEndpointModelValidate: + def test_valid_endpoint(self) -> None: + endpoint = Endpoint.model_validate( + {"resourceType": "Endpoint", "address": "https://example.com/fhir"} + ) + + assert endpoint.address == "https://example.com/fhir", ( + "address should be parsed from dict" + ) + + def test_valid_endpoint_without_address(self) -> None: + endpoint = Endpoint.model_validate({"resourceType": "Endpoint"}) + + assert endpoint.address is None, "address should default to None" + + def test_wrong_resource_type_fails(self) -> None: + with pytest.raises( + ValidationError, + match=( + "Resource type 'Bundle' does not match expected resource type " + "'Endpoint'." + ), + ): + Endpoint.model_validate({"resourceType": "Bundle"}) + + +class TestOperationOutcome: + def test_create(self) -> None: + class _TestIssue(Issue): + pass + + outcome = OperationOutcome.create( + issue=[ + _TestIssue( + severity=IssueSeverity.ERROR, + code=IssueCode.INVALID, + diagnostics="Something failed", + ) + ], + ) + + assert outcome.resource_type == "OperationOutcome", ( + "resource_type should be 'OperationOutcome'" + ) + assert len(outcome.issue) == 1, "should have one issue" + assert outcome.issue[0].severity == IssueSeverity.ERROR, ( + "issue severity should be ERROR" + ) + assert outcome.issue[0].code == IssueCode.INVALID, ( + "issue code should be INVALID" + ) + assert outcome.issue[0].diagnostics == "Something failed", ( + "diagnostics should match" + ) + + +class TestOperationOutcomeModelValidate: + def test_valid_operation_outcome(self) -> None: + outcome = OperationOutcome.model_validate( + { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "invalid", + "diagnostics": "Bad request", + } + ], + } + ) + + assert outcome.issue[0].severity == IssueSeverity.ERROR, ( + "severity should be parsed" + ) + assert outcome.issue[0].code == IssueCode.INVALID, "code should be parsed" + assert outcome.issue[0].diagnostics == "Bad request", ( + "diagnostics should be parsed" + ) + + def test_missing_issue_fails(self) -> None: + with pytest.raises(ValidationError, match="issue"): + OperationOutcome.model_validate({"resourceType": "OperationOutcome"}) + + def test_wrong_resource_type_fails(self) -> None: + with pytest.raises( + ValidationError, + match=( + "Resource type 'Patient' does not match expected resource type " + "'OperationOutcome'." + ), + ): + OperationOutcome.model_validate( + { + "resourceType": "Patient", + "issue": [{"severity": "error", "code": "invalid"}], + } + ) + + +class TestBundleModelValidate: + def test_valid_bundle(self) -> None: + bundle = Bundle.model_validate( + { + "resourceType": "Bundle", + "type": "searchset", + "entry": [ + { + "fullUrl": "https://example.com/Patient/1", + "resource": { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "1234567890", + } + ], + }, + } + ], + } + ) + + assert bundle.bundle_type == "searchset", ( + "bundle_type should be parsed from JSON" + ) + assert bundle.entries is not None, "entries should not be None" + assert len(bundle.entries) == 1, "should have one entry" + assert isinstance(bundle.entries[0].resource, Patient), ( + "entry resource should be deserialized as Patient" + ) + + def test_valid_bundle_without_entries(self) -> None: + bundle = Bundle.model_validate({"resourceType": "Bundle", "type": "collection"}) + + assert bundle.bundle_type == "collection", "bundle_type should be 'collection'" + assert bundle.entries is None, "entries should default to None" + + def test_missing_type_fails(self) -> None: + with pytest.raises(ValidationError, match="type"): + Bundle.model_validate({"resourceType": "Bundle"}) + + def test_wrong_resource_type_fails(self) -> None: + with pytest.raises( + ValidationError, + match=( + "Resource type 'Endpoint' does not match expected resource type " + "'Bundle'." + ), + ): + Bundle.model_validate({"resourceType": "Endpoint", "type": "document"}) + + def test_entry_missing_full_url_fails(self) -> None: + with pytest.raises(ValidationError, match="fullUrl"): + Bundle.model_validate( + { + "resourceType": "Bundle", + "type": "document", + "entry": [ + { + "resource": { + "resourceType": "Patient", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "123", + } + ], + } + } + ], + } + ) + + def test_entry_missing_resource_fails(self) -> None: + with pytest.raises(ValidationError, match="resource"): + Bundle.model_validate( + { + "resourceType": "Bundle", + "type": "document", + "entry": [{"fullUrl": "https://example.com"}], + } + ) + + +class TestBundleEmpty: + @pytest.mark.parametrize( + "bundle_type", + ["document", "transaction", "searchset", "collection"], + ) + def test_empty_bundle_types(self, bundle_type: str) -> None: + bundle = Bundle.empty(bundle_type) # type: ignore[arg-type] + + assert bundle.bundle_type == bundle_type, ( + f"bundle_type should be '{bundle_type}'" + ) + assert bundle.entries is None, "entries should be None for empty bundles" + assert bundle.identifier is None, "identifier should be None for empty bundles" diff --git a/gateway-api/src/fhir/stu3/elements/__init__.py b/gateway-api/src/fhir/stu3/elements/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/stu3/elements/test_elements.py b/gateway-api/src/fhir/stu3/elements/test_elements.py new file mode 100644 index 00000000..c71b2920 --- /dev/null +++ b/gateway-api/src/fhir/stu3/elements/test_elements.py @@ -0,0 +1,216 @@ +import pytest +from pydantic import ValidationError + +from fhir.r4.elements.identifier import NHSNumberValueIdentifier +from fhir.stu3.resources.parameters import Parameters + + +class TestParameters: + def test_create(self) -> None: + """Test creating a Parameters resource.""" + parameter = Parameters.Parameter( + valueIdentifier=NHSNumberValueIdentifier( + system="https://fhir.nhs.uk/Id/nhs-number", + value="9000000009", + ), + ) + + params = Parameters.create(parameter=[parameter]) + + assert params.resource_type == "Parameters", ( + "resourceType should be 'Parameters'" + ) + assert len(params.parameter) == 1, "parameter list should contain one entry" + assert params.parameter[0] == parameter, ( + "first parameter should match the provided Parameter" + ) + + def test_create_with_multiple_parameters(self) -> None: + """Test creating a Parameters resource with multiple parameters.""" + param_a = Parameters.Parameter( + valueIdentifier=NHSNumberValueIdentifier( + system="https://fhir.nhs.uk/Id/nhs-number", + value="9000000009", + ), + ) + param_b = Parameters.Parameter( + valueIdentifier=NHSNumberValueIdentifier( + system="https://fhir.nhs.uk/Id/nhs-number", + value="9000000017", + ), + ) + + params = Parameters.create(parameter=[param_a, param_b]) + + assert len(params.parameter) == 2, "parameter list should contain two entries" + assert params.parameter[0].valueIdentifier.value == "9000000009", ( + "first parameter NHS number should be '9000000009'" + ) + assert params.parameter[1].valueIdentifier.value == "9000000017", ( + "second parameter NHS number should be '9000000017'" + ) + + def test_model_validate_valid(self) -> None: + """Test model_validate with valid Parameters JSON.""" + params = Parameters.model_validate( + { + "resourceType": "Parameters", + "parameter": [ + { + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009", + }, + } + ], + } + ) + + assert params.resource_type == "Parameters", ( + "resourceType should be 'Parameters'" + ) + assert len(params.parameter) == 1, "parameter list should contain one entry" + assert params.parameter[0].valueIdentifier.value == "9000000009", ( + "valueIdentifier value should be '9000000009'" + ) + assert params.parameter[0].valueIdentifier.system == ( + "https://fhir.nhs.uk/Id/nhs-number" + ), "valueIdentifier system should be the NHS number URI" + + def test_model_validate_with_wrong_resource_type_raises_error(self) -> None: + """Test that an incorrect resourceType is rejected.""" + with pytest.raises( + ValidationError, + match=( + "Resource type 'Patient' does not match expected resource type " + "'Parameters'." + ), + ): + Parameters.model_validate( + { + "resourceType": "Patient", + "parameter": [ + { + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9000000009", + }, + } + ], + } + ) + + def test_model_validate_with_invalid_identifier_system_raises_error(self) -> None: + """Test that an invalid identifier system is rejected.""" + with pytest.raises( + ValidationError, + match=( + "Identifier system 'https://example.org/invalid' does not match " + "expected system 'https://fhir.nhs.uk/Id/nhs-number'." + ), + ): + Parameters.model_validate( + { + "resourceType": "Parameters", + "parameter": [ + { + "valueIdentifier": { + "system": "https://example.org/invalid", + "value": "9000000009", + }, + } + ], + } + ) + + def test_model_validate_missing_parameter_raises_error(self) -> None: + """Test that missing parameter field is rejected.""" + with pytest.raises(ValidationError): + Parameters.model_validate( + { + "resourceType": "Parameters", + } + ) + + def test_model_validate_empty_parameter_list(self) -> None: + """Test creating Parameters with an empty parameter list.""" + with pytest.raises(ValidationError): + Parameters.model_validate( + { + "resourceType": "Parameters", + "parameter": [], + } + ) + + def test_model_dump_json_roundtrip(self) -> None: + """Test JSON serialization roundtrip preserves data.""" + params = Parameters.create( + parameter=[ + Parameters.Parameter( + valueIdentifier=NHSNumberValueIdentifier( + system="https://fhir.nhs.uk/Id/nhs-number", + value="9000000009", + ), + ) + ], + ) + + json_str = params.model_dump_json() + + assert '"resourceType":"Parameters"' in json_str.replace(" ", ""), ( + "JSON output should contain the resourceType" + ) + assert "9000000009" in json_str, ( + "JSON output should contain the NHS number value" + ) + + def test_is_frozen(self) -> None: + """Test that Parameters fields are frozen (immutable).""" + params = Parameters.create( + parameter=[ + Parameters.Parameter( + valueIdentifier=NHSNumberValueIdentifier( + system="https://fhir.nhs.uk/Id/nhs-number", + value="9000000009", + ), + ) + ], + ) + + with pytest.raises((ValidationError, AttributeError)): + params.parameter = [] # type: ignore[misc] + + +class TestParameter: + def test_create(self) -> None: + """Test creating a Parameter element.""" + identifier = NHSNumberValueIdentifier( + system="https://fhir.nhs.uk/Id/nhs-number", + value="9000000009", + ) + parameter = Parameters.Parameter(valueIdentifier=identifier) + + assert parameter.valueIdentifier == identifier, ( + "valueIdentifier should match the provided identifier" + ) + assert parameter.valueIdentifier.value == "9000000009", ( + "valueIdentifier value should be '9000000009'" + ) + assert parameter.valueIdentifier.system == ( + "https://fhir.nhs.uk/Id/nhs-number" + ), "valueIdentifier system should be the NHS number URI" + + def test_is_frozen(self) -> None: + """Test that Parameter fields are frozen (immutable).""" + parameter = Parameters.Parameter( + valueIdentifier=NHSNumberValueIdentifier( + system="https://fhir.nhs.uk/Id/nhs-number", + value="9000000009", + ), + ) + + with pytest.raises(AttributeError): + parameter.valueIdentifier = NHSNumberValueIdentifier( # type: ignore[misc] + system="https://fhir.nhs.uk/Id/nhs-number", + value="0000000000", + ) diff --git a/ruff.toml b/ruff.toml index db28865d..6417e6d5 100644 --- a/ruff.toml +++ b/ruff.toml @@ -50,4 +50,7 @@ ignore =["COM812"] # Ignore assert rule in test files to keep test code susinct and easy to read. [lint.per-file-ignores] "**/{tests,steps}/*" = ["S101"] -"**/test_*.py" = ["S101"] +"**/test_*.py" = [ + "S101", # Allow `assert` in tests + "SLF001", # private members can be accessed in tests +] From 4b70b5340ac2cc84c3693d7f2654304b3bd3dee0 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:48:25 +0000 Subject: [PATCH 13/33] Tidy up TODO. --- gateway-api/src/gateway_api/get_structured_record/request.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index 3fd559af..d47d1a9e 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -1,9 +1,7 @@ from collections.abc import Mapping from typing import ClassVar -from fhir import Parameters - -# TODO: may be able to remove the use of the FHIR type entirely. +from fhir.stu3 import Parameters from flask.wrappers import Request from pydantic import ValidationError from requests.structures import CaseInsensitiveDict From 7814f8fce55cc9eb4dd7873f88d8de5c34a56f06 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:48:57 +0000 Subject: [PATCH 14/33] Export response class from module __init__.py --- gateway-api/src/gateway_api/app.py | 2 +- gateway-api/src/gateway_api/get_structured_record/__init__.py | 2 ++ .../src/gateway_api/get_structured_record/test_response.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/gateway-api/src/gateway_api/app.py b/gateway-api/src/gateway_api/app.py index 60c99134..1ee168b6 100644 --- a/gateway-api/src/gateway_api/app.py +++ b/gateway-api/src/gateway_api/app.py @@ -8,8 +8,8 @@ from gateway_api.controller import Controller from gateway_api.get_structured_record import ( GetStructuredRecordRequest, + GetStructuredRecordResponse, ) -from gateway_api.get_structured_record.response import GetStructuredRecordResponse app = Flask(__name__) diff --git a/gateway-api/src/gateway_api/get_structured_record/__init__.py b/gateway-api/src/gateway_api/get_structured_record/__init__.py index e665e366..2f59f851 100644 --- a/gateway-api/src/gateway_api/get_structured_record/__init__.py +++ b/gateway-api/src/gateway_api/get_structured_record/__init__.py @@ -4,8 +4,10 @@ ACCESS_RECORD_STRUCTURED_INTERACTION_ID, GetStructuredRecordRequest, ) +from gateway_api.get_structured_record.response import GetStructuredRecordResponse __all__ = [ "GetStructuredRecordRequest", + "GetStructuredRecordResponse", "ACCESS_RECORD_STRUCTURED_INTERACTION_ID", ] diff --git a/gateway-api/src/gateway_api/get_structured_record/test_response.py b/gateway-api/src/gateway_api/get_structured_record/test_response.py index aeb9c985..b17c7169 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_response.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_response.py @@ -7,7 +7,7 @@ from gateway_api.app import app from gateway_api.common.error import UnexpectedError -from gateway_api.get_structured_record.response import GetStructuredRecordResponse +from gateway_api.get_structured_record import GetStructuredRecordResponse class TestGetStructuredRecordResponse: From 8827d2107c52a94be4454f09d8b344ad5c3e5f91 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:53:04 +0000 Subject: [PATCH 15/33] Correct typing. --- gateway-api/src/fhir/stu3/elements/test_elements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway-api/src/fhir/stu3/elements/test_elements.py b/gateway-api/src/fhir/stu3/elements/test_elements.py index c71b2920..8093568b 100644 --- a/gateway-api/src/fhir/stu3/elements/test_elements.py +++ b/gateway-api/src/fhir/stu3/elements/test_elements.py @@ -178,7 +178,7 @@ def test_is_frozen(self) -> None: ) with pytest.raises((ValidationError, AttributeError)): - params.parameter = [] # type: ignore[misc] + params.parameter = [] class TestParameter: From 923f005c28089d46d1dd86f522bb9a8b9d407394 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:37:25 +0000 Subject: [PATCH 16/33] Tidying up. --- .../src/gateway_api/get_structured_record/response.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/gateway-api/src/gateway_api/get_structured_record/response.py b/gateway-api/src/gateway_api/get_structured_record/response.py index 764fc52e..1821574a 100644 --- a/gateway-api/src/gateway_api/get_structured_record/response.py +++ b/gateway-api/src/gateway_api/get_structured_record/response.py @@ -8,18 +8,8 @@ from gateway_api.common.error import AbstractCDGError -# Access record structured interaction ID from -# https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development.html#spine-interactions -ACCESS_RECORD_STRUCTURED_INTERACTION_ID = ( - "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" -) - class GetStructuredRecordResponse: - INTERACTION_ID: ClassVar[str] = ACCESS_RECORD_STRUCTURED_INTERACTION_ID - RESOURCE: ClassVar[str] = "patient" - FHIR_OPERATION: ClassVar[str] = "$gpc.getstructuredrecord" - MIME_TYPE: ClassVar[str] = "application/fhir+json" def __init__(self) -> None: From 6dc288f98135107697315a03b55d98932a2006bc Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:20:13 +0000 Subject: [PATCH 17/33] Clean up error handling. --- gateway-api/src/gateway_api/pds/client.py | 16 +++------------- gateway-api/src/gateway_api/pds/test_client.py | 2 +- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index ded4fb90..b52bc2cb 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -139,19 +139,9 @@ def search_patient_by_nhs_number( try: patient = Patient.model_validate(response.json()) except ValidationError as err: - # TODO: improve this hacky handling. first_error = err.errors()[0] - error_is_identifier = first_error["loc"] == ("identifier",) - no_patient_identifier = ( - "at least 1 item" in first_error["msg"] and error_is_identifier - ) - nhs_number_is_missing = "Field required" in str(err) and first_error[ - "loc" - ] == ("identifier",) - if nhs_number_is_missing or no_patient_identifier: - raise PdsRequestFailedError( - error_reason="PDS Patient resource missing NHS number" - ) from err - raise err + raise PdsRequestFailedError( + error_reason=str(first_error), + ) from err return patient diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index d886369e..965f33bf 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -144,6 +144,6 @@ def test_search_patient_by_nhs_number_missing_nhs_number_raises_error( with pytest.raises( PdsRequestFailedError, - match="PDS FHIR API request failed: PDS Patient resource missing NHS number", + match="PDS FHIR API request failed: {'type': 'too_short', 'loc': \('identifier", ): client.search_patient_by_nhs_number("9999999999") From 6df3060d9a06e78792163ac6b4a6956a39e12259 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:24:21 +0000 Subject: [PATCH 18/33] Rename expression to better express what is being returned. --- gateway-api/src/gateway_api/pds/test_client.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index 965f33bf..ca6945a3 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -25,11 +25,11 @@ def test_search_patient_by_nhs_number_happy_path( mocker.patch("gateway_api.pds.client.get", return_value=happy_path_response) client = PdsClient(auth_token) - result = client.search_patient_by_nhs_number("9999999999") + patient = client.search_patient_by_nhs_number("9999999999") - assert isinstance(result, Patient) - assert result.nhs_number == "9999999999" - assert result.gp_ods_code == "A12345" + assert isinstance(patient, Patient) + assert patient.nhs_number == "9999999999" + assert patient.gp_ods_code == "A12345" def test_search_patient_by_nhs_number_has_no_gp_returns_gp_ods_code_none( @@ -45,11 +45,11 @@ def test_search_patient_by_nhs_number_has_no_gp_returns_gp_ods_code_none( mocker.patch("gateway_api.pds.client.get", return_value=gp_less_response) client = PdsClient(auth_token) - result = client.search_patient_by_nhs_number("9999999999") + patient = client.search_patient_by_nhs_number("9999999999") - assert isinstance(result, Patient) - assert result.nhs_number == "9999999999" - assert result.gp_ods_code is None + assert isinstance(patient, Patient) + assert patient.nhs_number == "9999999999" + assert patient.gp_ods_code is None def test_search_patient_by_nhs_number_sends_expected_headers( From 0bbd8c4f1f93af410e95f79538a4ce20328cee27 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:07:42 +0000 Subject: [PATCH 19/33] Verify certs as aprt of contract testing. --- gateway-api/tests/contract/conftest.py | 93 ++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 gateway-api/tests/contract/conftest.py diff --git a/gateway-api/tests/contract/conftest.py b/gateway-api/tests/contract/conftest.py new file mode 100644 index 00000000..043699ca --- /dev/null +++ b/gateway-api/tests/contract/conftest.py @@ -0,0 +1,93 @@ +import os +import threading +from collections.abc import Generator +from functools import partial +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any + +import pytest +import requests + + +def get_mtls_cert() -> tuple[str, str] | None: + cert_path = os.getenv("MTLS_CERT") + key_path = os.getenv("MTLS_KEY") + if not cert_path or not key_path: + return None + return (cert_path, key_path) + + +class MtlsProxyHandler(BaseHTTPRequestHandler): + """ + A simple proxy that forwards requests to the target HTTPS URL + attaching the mTLS client certificates. + """ + + def __init__( + self, + target_base: str, + cert: tuple[str, str] | None, + *args: Any, + **kwargs: Any, + ) -> None: + self.target_base = target_base + self.cert = cert + super().__init__(*args, **kwargs) + + def do_proxy(self, method: str) -> None: + if not self.target_base: + self.send_error(500, "Target base URL not set") + return + + url = f"{self.target_base}{self.path}" + content_length_header = self.headers.get("Content-Length") + content_length = int(content_length_header) if content_length_header else 0 + body = self.rfile.read(content_length) if content_length > 0 else None + headers = {k: v for k, v in self.headers.items() if k.lower() != "host"} + + try: + response = requests.request( + method=method, + url=url, + headers=headers, + data=body, + cert=self.cert, + timeout=30, + ) + + self.send_response(response.status_code) + for k, v in response.headers.items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(response.content) + + except Exception as e: + self.send_error(500, f"Proxy Error: {str(e)}") + + def do_GET(self) -> None: + self.do_proxy("GET") + + def do_POST(self) -> None: + self.do_proxy("POST") + + def do_PUT(self) -> None: + self.do_proxy("PUT") + + +@pytest.fixture(scope="module") +def mtls_proxy(base_url: str) -> Generator[str]: + """ + Spins up a local HTTP server in a separate thread. + Returns the URL of this local proxy. + """ + + cert = get_mtls_cert() + handler_factory = partial(MtlsProxyHandler, base_url, cert) + server = HTTPServer(("localhost", 0), handler_factory) + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + + yield f"http://localhost:{server.server_port}" + + server.shutdown() From e48d647bffbd0991b79980d2f42e3c157c7340d4 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:06:46 +0000 Subject: [PATCH 20/33] reinstate nhs-engand-tools/trivy-scan version. --- .github/workflows/preview-env.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index 00c56100..ce4edc38 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -421,19 +421,20 @@ jobs: # ---------- Security scanning ---------- - name: Trivy IaC scan if: github.event.action != 'closed' - uses: nhs-england-tools/trivy-action/iac-scan@289984b2f03034233a347d6dbadecd5ca9ea9634 + uses: nhs-england-tools/trivy-action/iac-scan@3456c1657a37d500027fd782e6b08911725392da with: scan-ref: infrastructure/environments/preview artifact-name: trivy-iac-scan-${{ steps.meta.outputs.branch_name }} - name: Trivy image scan if: github.event.action != 'closed' - uses: nhs-england-tools/trivy-action/image-scan@289984b2f03034233a347d6dbadecd5ca9ea9634 + uses: nhs-england-tools/trivy-action/image-scan@3456c1657a37d500027fd782e6b08911725392da with: image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}} artifact-name: trivy-image-scan-${{ steps.meta.outputs.branch_name }} - name: Generate SBOM + uses: nhs-england-tools/trivy-action/sbom-scan@3456c1657a37d500027fd782e6b08911725392da if: github.event.action != 'closed' uses: nhs-england-tools/trivy-action/image-scan@289984b2f03034233a347d6dbadecd5ca9ea9634 with: From 7f546b59d40ffe654aad99ee1f5c8d5ca589de73 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:16:09 +0000 Subject: [PATCH 21/33] Revert "reinstate nhs-engand-tools/trivy-scan version." This reverts commit 49409de610b30f229a35738794740dcb81284665. --- .github/workflows/preview-env.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index ce4edc38..88367970 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -421,20 +421,20 @@ jobs: # ---------- Security scanning ---------- - name: Trivy IaC scan if: github.event.action != 'closed' - uses: nhs-england-tools/trivy-action/iac-scan@3456c1657a37d500027fd782e6b08911725392da + uses: nhs-england-tools/trivy-action/iac-scan@289984b2f03034233a347d6dbadecd5ca9ea9634 with: scan-ref: infrastructure/environments/preview artifact-name: trivy-iac-scan-${{ steps.meta.outputs.branch_name }} - name: Trivy image scan if: github.event.action != 'closed' - uses: nhs-england-tools/trivy-action/image-scan@3456c1657a37d500027fd782e6b08911725392da + uses: nhs-england-tools/trivy-action/image-scan@289984b2f03034233a347d6dbadecd5ca9ea9634 with: image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}} artifact-name: trivy-image-scan-${{ steps.meta.outputs.branch_name }} - name: Generate SBOM - uses: nhs-england-tools/trivy-action/sbom-scan@3456c1657a37d500027fd782e6b08911725392da + uses: nhs-england-tools/trivy-action/sbom-scan@289984b2f03034233a347d6dbadecd5ca9ea9634 if: github.event.action != 'closed' uses: nhs-england-tools/trivy-action/image-scan@289984b2f03034233a347d6dbadecd5ca9ea9634 with: From 738de94eed77e8bfd3b92293617677ef3b3a86b7 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:30:35 +0000 Subject: [PATCH 22/33] Actually mirror headers back. --- .../gateway_api/get_structured_record/response.py | 1 + .../tests/integration/test_get_structured_record.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/gateway-api/src/gateway_api/get_structured_record/response.py b/gateway-api/src/gateway_api/get_structured_record/response.py index 1821574a..39a3e9a0 100644 --- a/gateway-api/src/gateway_api/get_structured_record/response.py +++ b/gateway-api/src/gateway_api/get_structured_record/response.py @@ -37,4 +37,5 @@ def build(self) -> Response: response=self._response_body, status=self._status_code, mimetype=self.MIME_TYPE, + headers=self.headers, ) diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py index 4fa4bb6d..c7af5312 100644 --- a/gateway-api/tests/integration/test_get_structured_record.py +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -42,6 +42,18 @@ def test_happy_path_content_type( ) assert "application/fhir+json" in response.headers["Content-Type"] + def test_happy_path_response_mirrors_request_headers( + self, + client: Client, + simple_request_payload: dict[str, Any], + ) -> None: + additional_headers = {"first": "a header", "second": "another header"} + response = client.send_to_get_structured_record_endpoint( + json.dumps(simple_request_payload), headers=additional_headers + ) + for header_key, header_value in additional_headers.items(): + assert response.headers.get(header_key) == header_value + def test_empty_request_body_returns_400_status_code( self, response_from_sending_request_with_empty_body: Response ) -> None: From 25d5f98be2802d821fdc596929a5fdc7091683ad Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:03:40 +0000 Subject: [PATCH 23/33] Only mirror trace id header --- .../src/gateway_api/get_structured_record/response.py | 7 ++++++- .../tests/integration/test_get_structured_record.py | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/gateway-api/src/gateway_api/get_structured_record/response.py b/gateway-api/src/gateway_api/get_structured_record/response.py index 39a3e9a0..c1d57325 100644 --- a/gateway-api/src/gateway_api/get_structured_record/response.py +++ b/gateway-api/src/gateway_api/get_structured_record/response.py @@ -18,7 +18,12 @@ def __init__(self) -> None: self._status_code: int | None = None def mirror_headers(self, request: Request) -> None: - self._headers = CaseInsensitiveDict(request.headers) + headers_to_mirror = [ + "ssp-traceid", + ] + self._headers = CaseInsensitiveDict( + {k: v for k, v in request.headers.items() if k.lower() in headers_to_mirror} + ) @property def headers(self) -> Mapping[str, str] | None: diff --git a/gateway-api/tests/integration/test_get_structured_record.py b/gateway-api/tests/integration/test_get_structured_record.py index c7af5312..480ca4f5 100644 --- a/gateway-api/tests/integration/test_get_structured_record.py +++ b/gateway-api/tests/integration/test_get_structured_record.py @@ -47,11 +47,11 @@ def test_happy_path_response_mirrors_request_headers( client: Client, simple_request_payload: dict[str, Any], ) -> None: - additional_headers = {"first": "a header", "second": "another header"} + headers_to_be_mirrored = {"Ssp-TraceID": "a_trace_id"} response = client.send_to_get_structured_record_endpoint( - json.dumps(simple_request_payload), headers=additional_headers + json.dumps(simple_request_payload), headers=headers_to_be_mirrored ) - for header_key, header_value in additional_headers.items(): + for header_key, header_value in headers_to_be_mirrored.items(): assert response.headers.get(header_key) == header_value def test_empty_request_body_returns_400_status_code( From 3045aa6e8586ef2ef7a021a3e28012cbc25155ab Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:03:40 +0000 Subject: [PATCH 24/33] Only mirror trace id header --- .../get_structured_record/test_response.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/gateway-api/src/gateway_api/get_structured_record/test_response.py b/gateway-api/src/gateway_api/get_structured_record/test_response.py index b17c7169..2c4b1a3e 100644 --- a/gateway-api/src/gateway_api/get_structured_record/test_response.py +++ b/gateway-api/src/gateway_api/get_structured_record/test_response.py @@ -14,15 +14,13 @@ class TestGetStructuredRecordResponse: def test_mirror_headers_adds_request_headers_to_response( self, valid_simple_request_payload: dict[str, Any] ) -> None: - additional_headers = CaseInsensitiveDict( - {"first": "a header", "second": "another header"} - ) + headers_to_be_mirrored = CaseInsensitiveDict({"Ssp-TraceId": "a_trace_id"}) with app.test_request_context( "/patient/$gpc.getstructuredrecord", method="POST", data=json.dumps(valid_simple_request_payload), - headers=additional_headers, + headers=headers_to_be_mirrored, ): response = GetStructuredRecordResponse() response.mirror_headers(request) @@ -30,9 +28,10 @@ def test_mirror_headers_adds_request_headers_to_response( assert response.headers is not None, ( "Expected headers to be set, but they were None" ) - assert response.headers == dict(request.headers), ( - "Expected response headers to match request headers, but they did not" - ) + assert all( + response.headers.get(key) == value + for key, value in headers_to_be_mirrored.items() + ), "Expected response headers to match request headers, but they did not" def test_add_provider_response_adds_provider_response_body( self, valid_simple_response_payload: dict[str, Any] @@ -46,7 +45,7 @@ def test_add_provider_response_adds_provider_response_body( actual = response.build().json assert actual == valid_simple_response_payload, ( - "Actual response body did not match actual response body." + "Actual response body did not match expected response body." ) def test_add_provider_response_adds_200_status( From e5e57e94777d31fb6f5ad03ff37f6bc54d4f21c6 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:25:20 +0000 Subject: [PATCH 25/33] correct the import statement to reflect the changes to the structure. --- gateway-api/src/fhir/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway-api/src/fhir/README.md b/gateway-api/src/fhir/README.md index 47faf03c..57d6d4a1 100644 --- a/gateway-api/src/fhir/README.md +++ b/gateway-api/src/fhir/README.md @@ -43,7 +43,7 @@ Typical patterns in this code: The example below shows how to load a simple FHIR R4 Patient payload and obtain the GP ODS code. ```python -from fhir import Patient +from fhir.r4 import Patient payload = { "resourceType": "Patient", From e25123f245586c645bc1efd449681692f5ad031e Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:26:49 +0000 Subject: [PATCH 26/33] URL needs to point at correct version. --- gateway-api/src/fhir/README.md | 2 +- gateway-api/src/gateway_api/pds/test_client.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gateway-api/src/fhir/README.md b/gateway-api/src/fhir/README.md index 57d6d4a1..a234c482 100644 --- a/gateway-api/src/fhir/README.md +++ b/gateway-api/src/fhir/README.md @@ -4,7 +4,7 @@ FHIR (Fast Healthcare Interoperability Resources) is the HL7 standard for exchanging healthcare information as structured resources over HTTP APIs. -Read more on the standards: [R4](https://hl7.org/fhir/R4/overview.html) and [STU3](https://hl7.org/fhir/R4/overview.html). +Read more on the standards: [R4](https://hl7.org/fhir/R4/overview.html) and [STU3](https://hl7.org/fhir/STU3/overview.html). In this codebase, the FHIR package provides strongly typed Python models for request validation, response parsing, and safe serialization. diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index ca6945a3..e5777629 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -142,8 +142,8 @@ def test_search_patient_by_nhs_number_missing_nhs_number_raises_error( client = PdsClient(auth_token) - with pytest.raises( - PdsRequestFailedError, - match="PDS FHIR API request failed: {'type': 'too_short', 'loc': \('identifier", - ): + with pytest.raises(PdsRequestFailedError) as error: client.search_patient_by_nhs_number("9999999999") + + assert "'identifier'" in str(error.value) + assert "too_short" in str(error.value) From b1ee939d47f89dc9f673e753a2f7d0cbe9d0ba85 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:39:18 +0000 Subject: [PATCH 27/33] No need to return empty string here. --- gateway-api/src/gateway_api/sds/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py index 29b6b2f9..e30d14d0 100644 --- a/gateway-api/src/gateway_api/sds/client.py +++ b/gateway-api/src/gateway_api/sds/client.py @@ -225,6 +225,6 @@ def _extract_device_identifier(self, device: Device, system: str) -> str | None: """ for identifier in device.identifier: if identifier.system == system: - return identifier.value or "" + return identifier.value return None From 068d8af86ce23be33ea706d8a1ecff0fdd33f471 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:40:13 +0000 Subject: [PATCH 28/33] Remove unnecessary import. --- gateway-api/src/gateway_api/sds/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py index e30d14d0..ea1e9b97 100644 --- a/gateway-api/src/gateway_api/sds/client.py +++ b/gateway-api/src/gateway_api/sds/client.py @@ -12,8 +12,8 @@ from enum import StrEnum from typing import Any -from fhir import Bundle, Device, Endpoint, Resource -from stubs import SdsFhirApiStub +from fhir.r4 import Bundle, Device, Endpoint +from fhir.resources.resource import Resource from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID from gateway_api.sds.search_results import SdsSearchResults @@ -25,7 +25,7 @@ if not STUB_SDS: from requests import get else: - from stubs.sds.stub import SdsFhirApiStub + from stubs import SdsFhirApiStub sds = SdsFhirApiStub() get = sds.get # type: ignore From 6270716dd1ee1e7ed2535d4de7797d4522cd37f0 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:42:44 +0000 Subject: [PATCH 29/33] Correct doc string to accurately document behaviour. --- gateway-api/src/gateway_api/pds/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index b52bc2cb..fcec8d33 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -52,7 +52,7 @@ class PdsClient: * :meth:`search_patient_by_nhs_number` - calls ``GET /Patient/{nhs_number}`` This method returns a :class:`Patient` instance when a patient can be - extracted, otherwise ``None``. + extracted, otherwise raise `PdsRequestFailedError` with a reason for the failure. **Usage example**:: From e877d0dbf2c78566a10822ce650ebcf7be7b402b Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:33:45 +0000 Subject: [PATCH 30/33] Resolve import issues from merge coflict resolutions. --- gateway-api/poetry.lock | 1120 ++++++----------- gateway-api/src/fhir/__init__.py | 22 +- gateway-api/src/fhir/elements/meta.py | 31 - gateway-api/src/fhir/r4/__init__.py | 25 + .../src/fhir/{ => r4}/elements/__init__.py | 0 .../src/fhir/{ => r4}/elements/identifier.py | 0 .../src/fhir/{ => r4}/elements/issue.py | 0 .../src/fhir/{ => r4}/elements/py.typed | 0 .../src/fhir/{ => r4}/elements/reference.py | 0 .../fhir/{ => r4}/elements/test_elements.py | 79 +- .../py.typed => r4/resources/__init__.py} | 0 .../src/fhir/{ => r4}/resources/bundle.py | 3 +- .../src/fhir/{ => r4}/resources/device.py | 3 +- .../src/fhir/{ => r4}/resources/endpoint.py | 2 +- .../{ => r4}/resources/operation_outcome.py | 3 +- .../src/fhir/{ => r4}/resources/patient.py | 3 +- gateway-api/src/fhir/r4/resources/py.typed | 0 .../fhir/{ => r4}/resources/test_resources.py | 18 +- gateway-api/src/fhir/resources/resource.py | 28 +- .../src/fhir/resources/test_resource.py | 70 +- gateway-api/src/fhir/stu3/__init__.py | 3 + .../elements}/parameters.py | 5 +- .../src/fhir/stu3/elements/test_elements.py | 2 +- .../src/gateway_api/clinical_jwt/jwt.py | 2 +- gateway-api/src/gateway_api/common/error.py | 2 +- gateway-api/src/gateway_api/controller.py | 2 +- gateway-api/src/gateway_api/pds/client.py | 2 +- .../src/gateway_api/pds/test_client.py | 2 +- gateway-api/src/gateway_api/sds/client.py | 2 +- .../src/gateway_api/test_controller.py | 2 +- gateway-api/tests/contract/conftest.py | 93 -- 31 files changed, 566 insertions(+), 958 deletions(-) delete mode 100644 gateway-api/src/fhir/elements/meta.py create mode 100644 gateway-api/src/fhir/r4/__init__.py rename gateway-api/src/fhir/{ => r4}/elements/__init__.py (100%) rename gateway-api/src/fhir/{ => r4}/elements/identifier.py (100%) rename gateway-api/src/fhir/{ => r4}/elements/issue.py (100%) rename gateway-api/src/fhir/{ => r4}/elements/py.typed (100%) rename gateway-api/src/fhir/{ => r4}/elements/reference.py (100%) rename gateway-api/src/fhir/{ => r4}/elements/test_elements.py (78%) rename gateway-api/src/fhir/{resources/py.typed => r4/resources/__init__.py} (100%) rename gateway-api/src/fhir/{ => r4}/resources/bundle.py (97%) rename gateway-api/src/fhir/{ => r4}/resources/device.py (95%) rename gateway-api/src/fhir/{ => r4}/resources/endpoint.py (84%) rename gateway-api/src/fhir/{ => r4}/resources/operation_outcome.py (89%) rename gateway-api/src/fhir/{ => r4}/resources/patient.py (98%) create mode 100644 gateway-api/src/fhir/r4/resources/py.typed rename gateway-api/src/fhir/{ => r4}/resources/test_resources.py (98%) create mode 100644 gateway-api/src/fhir/stu3/__init__.py rename gateway-api/src/fhir/{resources => stu3/elements}/parameters.py (83%) delete mode 100644 gateway-api/tests/contract/conftest.py diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index c1b87967..8cb07e2f 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -1,6 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. -<<<<<<< HEAD -======= +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "annotated-types" @@ -13,7 +11,6 @@ files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] ->>>>>>> b7ff9bc (First step towards using Pydantic.) [[package]] name = "anyio" @@ -32,29 +29,6 @@ idna = ">=2.8" [package.extras] trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] -<<<<<<< HEAD - -[[package]] -name = "arrow" -version = "1.4.0" -description = "Better dates & times for Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205"}, - {file = "arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7"}, -] - -[package.dependencies] -python-dateutil = ">=2.7.0" -tzdata = {version = "*", markers = "python_version >= \"3.9\""} - -[package.extras] -doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] -test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2025.2)", "simplejson (==3.*)"] -======= ->>>>>>> b7ff9bc (First step towards using Pydantic.) [[package]] name = "attrs" @@ -68,21 +42,6 @@ files = [ {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, ] -[[package]] -name = "authlib" -version = "1.6.9" -description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"}, - {file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"}, -] - -[package.dependencies] -cryptography = "*" - [[package]] name = "blinker" version = "1.9.0" @@ -206,125 +165,141 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" -version = "3.4.5" +version = "3.4.6" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main", "dev"] files = [ - {file = "charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e22d1059b951e7ae7c20ef6b06afd10fb95e3c41bf3c4fbc874dba113321c193"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afca7f78067dd27c2b848f1b234623d26b87529296c6c5652168cc1954f2f3b2"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec56a2266f32bc06ed3c3e2a8f58417ce02f7e0356edc89786e52db13c593c98"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b970382e4a36bed897c19f310f31d7d13489c11b4f468ddfba42d41cddfb918"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:573ef5814c4b7c0d59a7710aa920eaaaef383bd71626aa420fba27b5cab92e8d"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:50bcbca6603c06a1dcc7b056ed45c37715fb5d2768feb3bcd37d2313c587a5b9"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1f2da5cbb9becfcd607757a169e38fb82aa5fd86fae6653dea716e7b613fe2cf"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:fc1c64934b8faf7584924143eb9db4770bbdb16659626e1a1a4d9efbcb68d947"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ae8b03427410731469c4033934cf473426faff3e04b69d2dfb64a4281a3719f8"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:b3e71afc578b98512bfe7bdb822dd6bc57d4b0093b4b6e5487c1e96ad4ace242"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:4b8551b6e6531e156db71193771c93bda78ffc4d1e6372517fe58ad3b91e4659"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:65b3c403a5b6b8034b655e7385de4f72b7b244869a22b32d4030b99a60593eca"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8ce11cd4d62d11166f2b441e30ace226c19a3899a7cf0796f668fba49a9fb123"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-win32.whl", hash = "sha256:66dee73039277eb35380d1b82cccc69cc82b13a66f9f4a18da32d573acf02b7c"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-win_amd64.whl", hash = "sha256:d29dd9c016f2078b43d0c357511e87eee5b05108f3dd603423cb389b89813969"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:259cd1ca995ad525f638e131dbcc2353a586564c038fc548a3fe450a91882139"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a28afb04baa55abf26df544e3e5c6534245d3daa5178bc4a8eeb48202060d0e"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff95a9283de8a457e6b12989de3f9f5193430f375d64297d323a615ea52cbdb3"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:708c7acde173eedd4bfa4028484426ba689d2103b28588c513b9db2cd5ecde9c"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa92ec1102eaff840ccd1021478af176a831f1bccb08e526ce844b7ddda85c22"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:5fea359734b140d0d6741189fea5478c6091b54ffc69d7ce119e0a05637d8c99"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e545b51da9f9af5c67815ca0eb40676c0f016d0b0381c86f20451e35696c5f95"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:30987f4a8ed169983f93e1be8ffeea5214a779e27ed0b059835c7afe96550ad7"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:149ec69866c3d6c2fb6f758dbc014ecb09f30b35a5ca90b6a8a2d4e54e18fdfe"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:530beedcec9b6e027e7a4b6ce26eed36678aa39e17da85e6e03d7bd9e8e9d7c9"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:14498a429321de554b140013142abe7608f9d8ccc04d7baf2ad60498374aefa2"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2820a98460c83663dd8ec015d9ddfd1e4879f12e06bb7d0500f044fb477d2770"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:aa2f963b4da26daf46231d9b9e0e2c9408a751f8f0d0f44d2de56d3caf51d294"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-win32.whl", hash = "sha256:82cc7c2ad42faec8b574351f8bc2a0c049043893853317bd9bb309f5aba6cb5a"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:92263f7eca2f4af326cd20de8d16728d2602f7cfea02e790dcde9d83c365d7cc"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-win_arm64.whl", hash = "sha256:014837af6fabf57121b6254fa8ade10dceabc3528b27b721a64bbc7b8b1d4eb4"}, - {file = "charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0"}, - {file = "charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-win32.whl", hash = "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win32.whl", hash = "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win_arm64.whl", hash = "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8"}, + {file = "charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69"}, + {file = "charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6"}, ] [[package]] @@ -373,195 +348,123 @@ markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \" [[package]] name = "coverage" -version = "7.13.4" +version = "7.13.5" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415"}, - {file = "coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9"}, - {file = "coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf"}, - {file = "coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95"}, - {file = "coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053"}, - {file = "coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9"}, - {file = "coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9"}, - {file = "coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f"}, - {file = "coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f"}, - {file = "coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459"}, - {file = "coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0"}, - {file = "coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246"}, - {file = "coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126"}, - {file = "coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d"}, - {file = "coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9"}, - {file = "coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a"}, - {file = "coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d"}, - {file = "coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd"}, - {file = "coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af"}, - {file = "coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d"}, - {file = "coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b"}, - {file = "coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9"}, - {file = "coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd"}, - {file = "coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997"}, - {file = "coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601"}, - {file = "coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0"}, - {file = "coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb"}, - {file = "coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505"}, - {file = "coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2"}, - {file = "coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056"}, - {file = "coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0"}, - {file = "coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea"}, - {file = "coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932"}, - {file = "coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b"}, - {file = "coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0"}, - {file = "coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91"}, + {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, + {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0"}, + {file = "coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58"}, + {file = "coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8"}, + {file = "coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf"}, + {file = "coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9"}, + {file = "coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c"}, + {file = "coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf"}, + {file = "coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810"}, + {file = "coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17"}, + {file = "coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85"}, + {file = "coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b"}, + {file = "coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2"}, + {file = "coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a"}, + {file = "coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819"}, + {file = "coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0"}, + {file = "coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc"}, + {file = "coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633"}, + {file = "coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a"}, + {file = "coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215"}, + {file = "coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43"}, + {file = "coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45"}, + {file = "coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61"}, + {file = "coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179"}, ] [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] -[[package]] -name = "cryptography" -version = "46.0.5" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["dev"] -files = [ - {file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"}, - {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"}, - {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"}, - {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"}, - {file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"}, - {file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"}, - {file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"}, - {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"}, - {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"}, - {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"}, - {file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"}, - {file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"}, - {file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"}, - {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"}, - {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"}, - {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"}, - {file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"}, - {file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257"}, - {file = "cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7"}, - {file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] - [[package]] name = "flask" version = "3.1.3" @@ -828,17 +731,9 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" -<<<<<<< HEAD -rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format\""} -rfc3987 = {version = "*", optional = true, markers = "extra == \"format\""} -rpds-py = ">=0.25.0" -uri-template = {version = "*", optional = true, markers = "extra == \"format\""} -webcolors = {version = ">=1.11", optional = true, markers = "extra == \"format\""} -======= rpds-py = ">=0.25.0" ->>>>>>> b7ff9bc (First step towards using Pydantic.) [package.extras] format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] @@ -846,17 +741,12 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- [[package]] name = "jsonschema-rs" -<<<<<<< HEAD version = "0.45.0" -======= -version = "0.44.1" ->>>>>>> b7ff9bc (First step towards using Pydantic.) description = "A high-performance JSON Schema validator for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ -<<<<<<< HEAD {file = "jsonschema_rs-0.45.0-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:74f81e45ff0ce0354cb717092407faaf275bedb5564c3c32b556cb9e06df6755"}, {file = "jsonschema_rs-0.45.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a8f47574214aee3bab3cf7457819d1aead6f27673602ea533a9bb95f432e4ef3"}, {file = "jsonschema_rs-0.45.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9cf9dd9853ce52dc2d0aa94f7e672bcaf62d170f4d0754bd29416b74b0573fd"}, @@ -885,36 +775,6 @@ files = [ {file = "jsonschema_rs-0.45.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:84424e519ec4cb6c0f6cfd83b07a8932742b63319ae0d9d948f548dbb0659ae7"}, {file = "jsonschema_rs-0.45.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0c2da9d3134d0f5ad13ced36aac2a692da181aeb181d729c341388a487003815"}, {file = "jsonschema_rs-0.45.0.tar.gz", hash = "sha256:897deffee817fe0f493710221e19bc4d9fedabdba121d9f8e0aa824460d2498d"}, -======= - {file = "jsonschema_rs-0.44.1-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6f8be6467ee403e126e4e0abb68f13cfbf7199db54d5a4c0f2a1b00e1304f2e3"}, - {file = "jsonschema_rs-0.44.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:95434b4858da6feb4b3769c955b78204dbc90988941e9e848596ab93c6005d00"}, - {file = "jsonschema_rs-0.44.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0329af23e7674d88c3117b55c89a0c36e06ee359e696be16796a29c8b1c33e85"}, - {file = "jsonschema_rs-0.44.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8078c834c3cea6303796fc4925bb8646d1f68313bd54f6d3dde08c8b8eb74bc1"}, - {file = "jsonschema_rs-0.44.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:502af60c802cf149185ea01edbd31a143b09aaf06b27b6422f8b8893984b1998"}, - {file = "jsonschema_rs-0.44.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f2760c4791ecc3c7e6196cec7e7dbf191205e36dd050119cfab421e108e8508"}, - {file = "jsonschema_rs-0.44.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:16d663e6c4838e4d594bd9d10c5939a6737c171d9c8600659fe6612098863d3d"}, - {file = "jsonschema_rs-0.44.1-cp310-abi3-win32.whl", hash = "sha256:cbec5ef1a0cc327cbc829f44a9c76778881003ada99c871a14438c7e8b264e76"}, - {file = "jsonschema_rs-0.44.1-cp310-abi3-win_amd64.whl", hash = "sha256:cee075749f0479599586b4f591940418e45eae65485ed29e84763a28ec9dd40c"}, - {file = "jsonschema_rs-0.44.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:99c0c3e4a786d1e9c25dbd58cc9781f3c3d25c9fbd76310a350de55315f05948"}, - {file = "jsonschema_rs-0.44.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:516bfb8926de7d396e4bc9a1c5085870de0035e8e2324014251d091a55a03623"}, - {file = "jsonschema_rs-0.44.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225074845f6a67e8e3ac18311f87a0ab925ae5adf16466be61c7d1df01eca20a"}, - {file = "jsonschema_rs-0.44.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:782d01412e77c83bb376d31aac8afbd06b97e3594f09d1e0304ad22c2382077b"}, - {file = "jsonschema_rs-0.44.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2afe720dfa1f93235b78e812937039537b63bf4eab6ca3c9ecb7fd7ba08a865d"}, - {file = "jsonschema_rs-0.44.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:548a1f466ce5b904c9cc52eee8f887c3838377ed95f4525d0ee5896a321e89d5"}, - {file = "jsonschema_rs-0.44.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8a758e422c4ec265e64f2232409ddc5976b28e94e84a8e5565a2bce169ab72e9"}, - {file = "jsonschema_rs-0.44.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ca8ddd724b73678f5f3d3d8f948ae40fa817ad9edd5ce4e732ae26cb0f9dd300"}, - {file = "jsonschema_rs-0.44.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ff6c9868c8f2834952efa0555fd82d0ab19664ba6b17f481330c64f7af7177d"}, - {file = "jsonschema_rs-0.44.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec883313f3782f1c0ffc58ceda55136e26967198523b9cd111af782e273659a3"}, - {file = "jsonschema_rs-0.44.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f971acf2910e64f0960080db6b6c73df483318d9db992273885f596cc3a9a5d9"}, - {file = "jsonschema_rs-0.44.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c28fd54236e43f392041f06132b0e9f09dd261cb00236045078d98e3cf84"}, - {file = "jsonschema_rs-0.44.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbc59d68f38a377117b84b8109af269813a39b4b961e803876767e4fab6bac98"}, - {file = "jsonschema_rs-0.44.1-cp314-cp314t-win_amd64.whl", hash = "sha256:049203fd4876f2ec96191c0f8befabf33289988c57e4f191b5fd5974de1fb07f"}, - {file = "jsonschema_rs-0.44.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:51886a0e09161c0f5675ca2834bcd76c086034891c1e0a9a09b2ee2fd7c60bd0"}, - {file = "jsonschema_rs-0.44.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46b629a0713397b3375e2926cf3d3f9ad511681d65f7676caee8223f3b62a427"}, - {file = "jsonschema_rs-0.44.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c338c2bf3c5a4e17fccbf504aaf8a00bd1c711f992835df19de2fe55e5cf8b53"}, - {file = "jsonschema_rs-0.44.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:26c50f9bf4568874a5c6d1ca5c7e739b42529673d2d4c89a2c170800d7983fd4"}, - {file = "jsonschema_rs-0.44.1.tar.gz", hash = "sha256:49ca909cc3017990a732145b9a7c2f1a0727b2f95dba4190c05a514575b5f4bf"}, ->>>>>>> b7ff9bc (First step towards using Pydantic.) ] [package.extras] @@ -1053,158 +913,6 @@ files = [ ] [[package]] -<<<<<<< HEAD -name = "lxml" -version = "5.4.0" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c"}, - {file = "lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b"}, - {file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b"}, - {file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563"}, - {file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5"}, - {file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776"}, - {file = "lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7"}, - {file = "lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250"}, - {file = "lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9"}, - {file = "lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86"}, - {file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056"}, - {file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7"}, - {file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd"}, - {file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751"}, - {file = "lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4"}, - {file = "lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539"}, - {file = "lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4"}, - {file = "lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079"}, - {file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20"}, - {file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8"}, - {file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f"}, - {file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc"}, - {file = "lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f"}, - {file = "lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2"}, - {file = "lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0"}, - {file = "lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982"}, - {file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61"}, - {file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54"}, - {file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b"}, - {file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a"}, - {file = "lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82"}, - {file = "lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f"}, - {file = "lxml-5.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7be701c24e7f843e6788353c055d806e8bd8466b52907bafe5d13ec6a6dbaecd"}, - {file = "lxml-5.4.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb54f7c6bafaa808f27166569b1511fc42701a7713858dddc08afdde9746849e"}, - {file = "lxml-5.4.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97dac543661e84a284502e0cf8a67b5c711b0ad5fb661d1bd505c02f8cf716d7"}, - {file = "lxml-5.4.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:c70e93fba207106cb16bf852e421c37bbded92acd5964390aad07cb50d60f5cf"}, - {file = "lxml-5.4.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9c886b481aefdf818ad44846145f6eaf373a20d200b5ce1a5c8e1bc2d8745410"}, - {file = "lxml-5.4.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:fa0e294046de09acd6146be0ed6727d1f42ded4ce3ea1e9a19c11b6774eea27c"}, - {file = "lxml-5.4.0-cp36-cp36m-win32.whl", hash = "sha256:61c7bbf432f09ee44b1ccaa24896d21075e533cd01477966a5ff5a71d88b2f56"}, - {file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"}, - {file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"}, - {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"}, - {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"}, - {file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"}, - {file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"}, - {file = "lxml-5.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eaf24066ad0b30917186420d51e2e3edf4b0e2ea68d8cd885b14dc8afdcf6556"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b31a3a77501d86d8ade128abb01082724c0dfd9524f542f2f07d693c9f1175f"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e108352e203c7afd0eb91d782582f00a0b16a948d204d4dec8565024fafeea5"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11a96c3b3f7551c8a8109aa65e8594e551d5a84c76bf950da33d0fb6dfafab7"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:ca755eebf0d9e62d6cb013f1261e510317a41bf4650f22963474a663fdfe02aa"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:4cd915c0fb1bed47b5e6d6edd424ac25856252f09120e3e8ba5154b6b921860e"}, - {file = "lxml-5.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:226046e386556a45ebc787871d6d2467b32c37ce76c2680f5c608e25823ffc84"}, - {file = "lxml-5.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b108134b9667bcd71236c5a02aad5ddd073e372fb5d48ea74853e009fe38acb6"}, - {file = "lxml-5.4.0-cp38-cp38-win32.whl", hash = "sha256:1320091caa89805df7dcb9e908add28166113dcd062590668514dbd510798c88"}, - {file = "lxml-5.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:073eb6dcdf1f587d9b88c8c93528b57eccda40209cf9be549d469b942b41d70b"}, - {file = "lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e"}, - {file = "lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140"}, - {file = "lxml-5.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5"}, - {file = "lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142"}, - {file = "lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6"}, - {file = "lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5f11a1526ebd0dee85e7b1e39e39a0cc0d9d03fb527f56d8457f6df48a10dc0c"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b4afaf38bf79109bb060d9016fad014a9a48fb244e11b94f74ae366a64d252"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de6f6bb8a7840c7bf216fb83eec4e2f79f7325eca8858167b68708b929ab2172"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5cca36a194a4eb4e2ed6be36923d3cffd03dcdf477515dea687185506583d4c9"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b7c86884ad23d61b025989d99bfdd92a7351de956e01c61307cb87035960bcb1"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:53d9469ab5460402c19553b56c3648746774ecd0681b1b27ea74d5d8a3ef5590"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:56dbdbab0551532bb26c19c914848d7251d73edb507c3079d6805fa8bba5b706"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14479c2ad1cb08b62bb941ba8e0e05938524ee3c3114644df905d2331c76cd57"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32697d2ea994e0db19c1df9e40275ffe84973e4232b5c274f47e7c1ec9763cdd"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:24f6df5f24fc3385f622c0c9d63fe34604893bc1a5bdbb2dbf5870f85f9a404a"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:151d6c40bc9db11e960619d2bf2ec5829f0aaffb10b41dcf6ad2ce0f3c0b2325"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4025bf2884ac4370a3243c5aa8d66d3cb9e15d3ddd0af2d796eccc5f0244390e"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987"}, - {file = "lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd"}, -] - -[package.extras] -cssselect = ["cssselect (>=0.7)"] -html-clean = ["lxml_html_clean"] -html5 = ["html5lib"] -htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=3.0.11,<3.1.0)"] - -[[package]] -======= ->>>>>>> b7ff9bc (First step towards using Pydantic.) name = "mako" version = "1.3.10" description = "A super-fast templating language that borrows the best ideas from the existing templating languages." @@ -1853,6 +1561,162 @@ files = [ {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] +[[package]] +name = "pydantic" +version = "2.12.5" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + [[package]] name = "pygments" version = "2.19.2" @@ -1874,7 +1738,7 @@ version = "2.12.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, @@ -1886,21 +1750,6 @@ dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pyt docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] -[[package]] -name = "pyotp" -version = "2.9.0" -description = "Python One Time Password Library" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612"}, - {file = "pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63"}, -] - -[package.extras] -test = ["coverage", "mypy", "ruff", "wheel"] - [[package]] name = "pyrate-limiter" version = "4.0.2" @@ -1915,20 +1764,20 @@ files = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1" -packaging = ">=20" +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" @@ -2034,69 +1883,6 @@ pytest = ">=6.2.5" dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] -<<<<<<< HEAD -name = "pytest-nhsd-apim" -version = "6.0.7" -description = "Pytest plugin accessing NHSDigital's APIM proxies" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pytest_nhsd_apim-6.0.7-py3-none-any.whl", hash = "sha256:546683475a498c1ef49e9a2f4f195be4322edde16289ef0a847cd46343e55c1b"}, - {file = "pytest_nhsd_apim-6.0.7.tar.gz", hash = "sha256:826f0434da78cf0eb59ea6cb5c3354b906916da741cc5002ede12594555a925e"}, -] - -[package.dependencies] -Authlib = ">=1.6.6,<2.0.0" -cryptography = ">=46.0.5" -lxml = ">=5.3.1,<6.0.0" -pycryptodome = ">=3.20.0,<4.0.0" -pydantic = ">=2.9.2,<3.0.0" -pydantic-settings = ">=2.2.1,<3.0.0" -PyJWT = ">=2.8.0,<3.0.0" -pyotp = ">=2.9.0,<3.0.0" -pytest = ">=8.2.0,<9.0.0" -requests = ">=2.32.0,<3.0.0" -setuptools = ">=80.0.1,<81.0.0" -toml = ">=0.10.2,<1.0.0" -typing-extensions = ">=4.12.2,<5.0.0" -urllib3 = ">=2.6.3,<3.0.0" -wheel = ">=0.46.2,<0.47.0" - -[[package]] -name = "pytest-subtests" -version = "0.15.0" -description = "unittest subTest() support and subtests fixture" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493"}, - {file = "pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c"}, -] - -[package.dependencies] -attrs = ">=19.2.0" -pytest = ">=7.4" - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["dev"] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -======= ->>>>>>> b7ff9bc (First step towards using Pydantic.) name = "python-dotenv" version = "1.2.2" description = "Read key-value pairs from a .env file and set them as environment variables" @@ -2378,14 +2164,14 @@ files = [ [[package]] name = "schemathesis" -version = "4.11.0" +version = "4.12.1" description = "Property-based testing framework for Open API and GraphQL based apps" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "schemathesis-4.11.0-py3-none-any.whl", hash = "sha256:95dae021a6a28f0e6bd4fcdaa6c1a9e952da3a6ee63cf1f18e29382933b7ec9c"}, - {file = "schemathesis-4.11.0.tar.gz", hash = "sha256:0108d21d3e662bd2a2e48297d1c4db4fb939dc4b2c9c9102d1cffaf3324220f3"}, + {file = "schemathesis-4.12.1-py3-none-any.whl", hash = "sha256:fbca23d71cf34ade35808cdc813a97c670dcfe2fe3f20c07599bc78b6b904207"}, + {file = "schemathesis-4.12.1.tar.gz", hash = "sha256:c004411b8f358c03cf68f3478d195f54e6d69790d6159fd30918b175b2938bc1"}, ] [package.dependencies] @@ -2396,7 +2182,7 @@ hypothesis = ">=6.108.0,<7" hypothesis-graphql = ">=0.12.0,<1" hypothesis-jsonschema = ">=0.23.1,<0.24" jsonschema = ">=4.18.0,<5.0" -jsonschema-rs = ">=0.44.0" +jsonschema-rs = ">=0.44.1" junit-xml = ">=1.9,<2.0" pyrate-limiter = ">=4.0,<5.0" pytest = ">=9,<10" @@ -2416,27 +2202,6 @@ docs = ["mkdocs-material", "mkdocstrings[python]"] profiling = ["pyinstrument (>=5.1)"] tests = ["aiohttp (>=3.9.1,<4.0)", "coverage (>=6)", "fastapi (>=0.86.0)", "flask (>=2.1.1,<4.0)", "hypothesis-openapi (>=0.3,<1)", "pydantic (>=1.10.2)", "pytest-asyncio (>=1.0,<2.0)", "pytest-httpserver (>=1.0,<2.0)", "pytest-mock (>=3.7.0,<4.0)", "pytest-trio (>=0.8,<1.0)", "pytest-xdist (>=3,<4.0)", "strawberry-graphql[fastapi] (>=0.109.0)", "syrupy (>=4,<6.0)", "tomli-w (>=1.2.0)", "trustme (>=0.9.0,<2.0)"] -[[package]] -name = "setuptools" -version = "80.10.2" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173"}, - {file = "setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] - [[package]] name = "six" version = "1.17.0" @@ -2511,18 +2276,6 @@ files = [ doc = ["reno", "sphinx"] test = ["pytest", "tornado (>=4.5)", "typeguard"] -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -groups = ["dev"] -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - [[package]] name = "types-click" version = "7.1.8" @@ -2631,33 +2384,6 @@ files = [ ] [[package]] -<<<<<<< HEAD -name = "tzdata" -version = "2025.3" -description = "Provider of IANA time zone data" -optional = false -python-versions = ">=2" -groups = ["dev"] -files = [ - {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, - {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, -] - -[[package]] -name = "uri-template" -version = "1.3.0" -description = "RFC 6570 URI Template Processor" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7"}, - {file = "uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363"}, -] - -[package.extras] -dev = ["flake8", "flake8-annotations", "flake8-bandit", "flake8-bugbear", "flake8-commas", "flake8-comprehensions", "flake8-continuation", "flake8-datetimez", "flake8-docstrings", "flake8-import-order", "flake8-literal", "flake8-modern-annotations", "flake8-noqa", "flake8-pyproject", "flake8-requirements", "flake8-typechecking-import", "flake8-use-fstring", "mypy", "pep8-naming", "types-PyYAML"] -======= name = "typing-inspection" version = "0.4.2" description = "Runtime typing introspection tools" @@ -2671,7 +2397,6 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" ->>>>>>> b7ff9bc (First step towards using Pydantic.) [[package]] name = "urllib3" @@ -2709,31 +2434,6 @@ markupsafe = ">=2.1.1" [package.extras] watchdog = ["watchdog (>=2.3)"] -[[package]] -<<<<<<< HEAD -name = "wheel" -version = "0.46.3" -description = "Command line tool for manipulating wheel files" -======= -name = "yarl" -version = "1.23.0" -description = "Yet another URL library" ->>>>>>> b7ff9bc (First step towards using Pydantic.) -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ -<<<<<<< HEAD - {file = "wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d"}, - {file = "wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803"}, -] - -[package.dependencies] -packaging = ">=24.0" - -[package.extras] -test = ["pytest (>=6.0.0)", "setuptools (>=77)"] - [[package]] name = "yarl" version = "1.23.0" @@ -2742,8 +2442,6 @@ optional = false python-versions = ">=3.10" groups = ["dev"] files = [ -======= ->>>>>>> b7ff9bc (First step towards using Pydantic.) {file = "yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107"}, {file = "yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d"}, {file = "yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05"}, @@ -2882,8 +2580,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.14,<4.0.0" -<<<<<<< HEAD -content-hash = "5947de35cbf7651c9aafbc2237a54a1fe4bed5ad4d8fa81bcbc0e745067ea99c" -======= -content-hash = "f17d07bdb04fd23760e063de2205dbe09865afbfcd73d6c171751fea57d18271" ->>>>>>> b7ff9bc (First step towards using Pydantic.) +content-hash = "62db803a15c4fdf9c7a8488086fb07ac4c5895c916cc044dbc0fd0e0ac14a4dd" diff --git a/gateway-api/src/fhir/__init__.py b/gateway-api/src/fhir/__init__.py index 1bdbb939..2c79ab27 100644 --- a/gateway-api/src/fhir/__init__.py +++ b/gateway-api/src/fhir/__init__.py @@ -1,23 +1,3 @@ -"""FHIR data types and resources.""" - -from .elements.issue import Issue, IssueCode, IssueSeverity -from .resources.bundle import Bundle -from .resources.device import Device -from .resources.endpoint import Endpoint -from .resources.operation_outcome import OperationOutcome -from .resources.parameters import Parameters -from .resources.patient import Patient from .resources.resource import Resource -__all__ = [ - "Bundle", - "Device", - "Endpoint", - "Issue", - "IssueCode", - "IssueSeverity", - "OperationOutcome", - "Parameters", - "Patient", - "Resource", -] +__all__ = ["Resource"] diff --git a/gateway-api/src/fhir/elements/meta.py b/gateway-api/src/fhir/elements/meta.py deleted file mode 100644 index 8c227019..00000000 --- a/gateway-api/src/fhir/elements/meta.py +++ /dev/null @@ -1,31 +0,0 @@ -import datetime -from dataclasses import dataclass -from typing import Annotated - -from pydantic import Field - - -@dataclass(frozen=True) -class Meta: - """ - A FHIR R4 Meta element. See https://hl7.org/fhir/R4/datatypes.html#Meta. - Attributes: - version_id: The version id of the resource. - last_updated: The last updated timestamp of the resource. - """ - - last_updated: Annotated[datetime.datetime | None, Field(alias="lastUpdated")] = None - version_id: Annotated[str | None, Field(alias="versionId")] = None - - @classmethod - def with_last_updated(cls, last_updated: datetime.datetime | None = None) -> "Meta": - """ - Create a Meta instance with the provided last_updated timestamp. - Args: - last_updated: The last updated timestamp. - Returns: - A Meta instance with the specified last_updated. - """ - return cls( - last_updated=last_updated or datetime.datetime.now(tz=datetime.timezone.utc) - ) diff --git a/gateway-api/src/fhir/r4/__init__.py b/gateway-api/src/fhir/r4/__init__.py new file mode 100644 index 00000000..eaaa9cc2 --- /dev/null +++ b/gateway-api/src/fhir/r4/__init__.py @@ -0,0 +1,25 @@ +"""FHIR data types and resources.""" + +from .elements.identifier import Identifier, NHSNumberValueIdentifier, UUIDIdentifier +from .elements.issue import Issue, IssueCode, IssueSeverity +from .elements.reference import Reference +from .resources.bundle import Bundle +from .resources.device import Device +from .resources.endpoint import Endpoint +from .resources.operation_outcome import OperationOutcome +from .resources.patient import Patient + +__all__ = [ + "Bundle", + "Device", + "Endpoint", + "Identifier", + "Issue", + "IssueCode", + "IssueSeverity", + "OperationOutcome", + "Patient", + "NHSNumberValueIdentifier", + "Reference", + "UUIDIdentifier", +] diff --git a/gateway-api/src/fhir/elements/__init__.py b/gateway-api/src/fhir/r4/elements/__init__.py similarity index 100% rename from gateway-api/src/fhir/elements/__init__.py rename to gateway-api/src/fhir/r4/elements/__init__.py diff --git a/gateway-api/src/fhir/elements/identifier.py b/gateway-api/src/fhir/r4/elements/identifier.py similarity index 100% rename from gateway-api/src/fhir/elements/identifier.py rename to gateway-api/src/fhir/r4/elements/identifier.py diff --git a/gateway-api/src/fhir/elements/issue.py b/gateway-api/src/fhir/r4/elements/issue.py similarity index 100% rename from gateway-api/src/fhir/elements/issue.py rename to gateway-api/src/fhir/r4/elements/issue.py diff --git a/gateway-api/src/fhir/elements/py.typed b/gateway-api/src/fhir/r4/elements/py.typed similarity index 100% rename from gateway-api/src/fhir/elements/py.typed rename to gateway-api/src/fhir/r4/elements/py.typed diff --git a/gateway-api/src/fhir/elements/reference.py b/gateway-api/src/fhir/r4/elements/reference.py similarity index 100% rename from gateway-api/src/fhir/elements/reference.py rename to gateway-api/src/fhir/r4/elements/reference.py diff --git a/gateway-api/src/fhir/elements/test_elements.py b/gateway-api/src/fhir/r4/elements/test_elements.py similarity index 78% rename from gateway-api/src/fhir/elements/test_elements.py rename to gateway-api/src/fhir/r4/elements/test_elements.py index ecc6fb1d..171fc5a1 100644 --- a/gateway-api/src/fhir/elements/test_elements.py +++ b/gateway-api/src/fhir/r4/elements/test_elements.py @@ -1,86 +1,17 @@ -import datetime import uuid import pytest from pydantic import BaseModel, ValidationError -<<<<<<< HEAD:gateway-api/src/fhir/elements/test_elements.py -from fhir.elements.identifier import Identifier, UUIDIdentifier -from fhir.elements.meta import Meta -======= -from fhir.r4.elements.identifier import ( +from fhir.r4 import ( Identifier, + Issue, + IssueCode, + IssueSeverity, NHSNumberValueIdentifier, + Reference, UUIDIdentifier, ) -from fhir.r4.elements.issue import Issue, IssueCode, IssueSeverity -from fhir.r4.elements.meta import Meta -from fhir.r4.elements.reference import Reference ->>>>>>> e419cd7 (Add/restructure unit tests.):gateway-api/src/fhir/r4/elements/test_elements.py - - -class TestMeta: - def test_create(self) -> None: - meta = Meta( - version_id="1", - last_updated=datetime.datetime.fromisoformat("2023-10-01T12:00:00Z"), - ) - assert meta.version_id == "1", "version_id should be set to '1'" - assert meta.last_updated == datetime.datetime.fromisoformat( - "2023-10-01T12:00:00Z" - ), "last_updated should match the provided datetime" - - def test_create_without_last_updated(self) -> None: - meta = Meta(version_id="2") - - assert meta.version_id == "2", "version_id should be set to '2'" - assert meta.last_updated is None, "last_updated should default to None" - - def test_create_without_version(self) -> None: - meta = Meta( - last_updated=datetime.datetime.fromisoformat("2023-10-01T12:00:00Z") - ) - - assert meta.version_id is None, "version_id should default to None" - assert meta.last_updated == datetime.datetime.fromisoformat( - "2023-10-01T12:00:00Z" - ), "last_updated should match the provided datetime" - - def test_create_with_defaults(self) -> None: - meta = Meta() - - assert meta.version_id is None, "version_id should default to None" - assert meta.last_updated is None, "last_updated should default to None" - - def test_with_last_updated(self) -> None: - last_updated = datetime.datetime.fromisoformat("2023-10-01T12:00:00Z") - meta = Meta.with_last_updated(last_updated) - - assert meta.last_updated == last_updated, ( - "last_updated should match the provided datetime" - ) - assert meta.version_id is None, "version_id should default to None" - - def test_with_last_updated_defaults_to_now(self) -> None: - before_create = datetime.datetime.now(tz=datetime.timezone.utc) - meta = Meta.with_last_updated(None) - after_create = datetime.datetime.now(tz=datetime.timezone.utc) - - assert meta.last_updated is not None, "last_updated should not be None" - assert meta.version_id is None, "version_id should default to None" - - assert before_create <= meta.last_updated, ( - "last_updated should be >= the time before creation" - ) - assert meta.last_updated <= after_create, ( - "last_updated should be <= the time after creation" - ) - - def test_is_frozen(self) -> None: - meta = Meta(version_id="1") - - with pytest.raises(AttributeError): - meta.version_id = "2" # type: ignore[misc] class TestIdentifierInitSubclass: diff --git a/gateway-api/src/fhir/resources/py.typed b/gateway-api/src/fhir/r4/resources/__init__.py similarity index 100% rename from gateway-api/src/fhir/resources/py.typed rename to gateway-api/src/fhir/r4/resources/__init__.py diff --git a/gateway-api/src/fhir/resources/bundle.py b/gateway-api/src/fhir/r4/resources/bundle.py similarity index 97% rename from gateway-api/src/fhir/resources/bundle.py rename to gateway-api/src/fhir/r4/resources/bundle.py index a37fbc0d..f118793e 100644 --- a/gateway-api/src/fhir/resources/bundle.py +++ b/gateway-api/src/fhir/r4/resources/bundle.py @@ -2,8 +2,9 @@ from pydantic import BaseModel, Field, SerializeAsAny +from fhir import Resource + from ..elements.identifier import UUIDIdentifier -from .resource import Resource type BundleType = Literal["document", "transaction", "searchset", "collection"] diff --git a/gateway-api/src/fhir/resources/device.py b/gateway-api/src/fhir/r4/resources/device.py similarity index 95% rename from gateway-api/src/fhir/resources/device.py rename to gateway-api/src/fhir/r4/resources/device.py index 749eef7e..175c6e2e 100644 --- a/gateway-api/src/fhir/resources/device.py +++ b/gateway-api/src/fhir/r4/resources/device.py @@ -2,8 +2,9 @@ from pydantic import Field +from fhir import Resource + from ..elements.identifier import Identifier -from .resource import Resource class Device(Resource, resource_type="Device"): diff --git a/gateway-api/src/fhir/resources/endpoint.py b/gateway-api/src/fhir/r4/resources/endpoint.py similarity index 84% rename from gateway-api/src/fhir/resources/endpoint.py rename to gateway-api/src/fhir/r4/resources/endpoint.py index a639b2ed..8f64c43e 100644 --- a/gateway-api/src/fhir/resources/endpoint.py +++ b/gateway-api/src/fhir/r4/resources/endpoint.py @@ -1,6 +1,6 @@ from pydantic import Field -from .resource import Resource +from fhir import Resource class Endpoint(Resource, resource_type="Endpoint"): diff --git a/gateway-api/src/fhir/resources/operation_outcome.py b/gateway-api/src/fhir/r4/resources/operation_outcome.py similarity index 89% rename from gateway-api/src/fhir/resources/operation_outcome.py rename to gateway-api/src/fhir/r4/resources/operation_outcome.py index c065051d..8057f16e 100644 --- a/gateway-api/src/fhir/resources/operation_outcome.py +++ b/gateway-api/src/fhir/r4/resources/operation_outcome.py @@ -2,8 +2,9 @@ from pydantic import Field +from fhir import Resource + from ..elements.issue import Issue -from .resource import Resource class OperationOutcome(Resource, resource_type="OperationOutcome"): diff --git a/gateway-api/src/fhir/resources/patient.py b/gateway-api/src/fhir/r4/resources/patient.py similarity index 98% rename from gateway-api/src/fhir/resources/patient.py rename to gateway-api/src/fhir/r4/resources/patient.py index ac38f0b6..9988fca5 100644 --- a/gateway-api/src/fhir/resources/patient.py +++ b/gateway-api/src/fhir/r4/resources/patient.py @@ -2,9 +2,10 @@ from pydantic import Field +from fhir import Resource + from ..elements.identifier import Identifier from ..elements.reference import Reference -from .resource import Resource class Patient(Resource, resource_type="Patient"): diff --git a/gateway-api/src/fhir/r4/resources/py.typed b/gateway-api/src/fhir/r4/resources/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/gateway-api/src/fhir/resources/test_resources.py b/gateway-api/src/fhir/r4/resources/test_resources.py similarity index 98% rename from gateway-api/src/fhir/resources/test_resources.py rename to gateway-api/src/fhir/r4/resources/test_resources.py index 44cecf75..e23d8ce9 100644 --- a/gateway-api/src/fhir/resources/test_resources.py +++ b/gateway-api/src/fhir/r4/resources/test_resources.py @@ -3,13 +3,17 @@ import pytest from pydantic import ValidationError -from fhir.r4.elements.issue import Issue, IssueCode, IssueSeverity -from fhir.r4.resources.bundle import Bundle -from fhir.r4.resources.device import Device -from fhir.r4.resources.endpoint import Endpoint -from fhir.r4.resources.operation_outcome import OperationOutcome -from fhir.r4.resources.patient import Patient -from fhir.resources.resource import Resource +from fhir import Resource +from fhir.r4 import ( + Bundle, + Device, + Endpoint, + Issue, + IssueCode, + IssueSeverity, + OperationOutcome, + Patient, +) class TestBundle: diff --git a/gateway-api/src/fhir/resources/resource.py b/gateway-api/src/fhir/resources/resource.py index c9b44d0b..04b3b826 100644 --- a/gateway-api/src/fhir/resources/resource.py +++ b/gateway-api/src/fhir/resources/resource.py @@ -1,3 +1,5 @@ +import datetime +from dataclasses import dataclass from typing import Annotated, Any, ClassVar, Self from pydantic import ( @@ -9,7 +11,31 @@ model_validator, ) -from ..elements.meta import Meta + +@dataclass(frozen=True) +class Meta: + """ + A FHIR R4 Meta element. See https://hl7.org/fhir/R4/datatypes.html#Meta. + Attributes: + version_id: The version id of the resource. + last_updated: The last updated timestamp of the resource. + """ + + last_updated: Annotated[datetime.datetime | None, Field(alias="lastUpdated")] = None + version_id: Annotated[str | None, Field(alias="versionId")] = None + + @classmethod + def with_last_updated(cls, last_updated: datetime.datetime | None = None) -> "Meta": + """ + Create a Meta instance with the provided last_updated timestamp. + Args: + last_updated: The last updated timestamp. + Returns: + A Meta instance with the specified last_updated. + """ + return cls( + last_updated=last_updated or datetime.datetime.now(tz=datetime.timezone.utc) + ) class Resource(BaseModel): diff --git a/gateway-api/src/fhir/resources/test_resource.py b/gateway-api/src/fhir/resources/test_resource.py index 7884c6de..d5ea5eed 100644 --- a/gateway-api/src/fhir/resources/test_resource.py +++ b/gateway-api/src/fhir/resources/test_resource.py @@ -1,12 +1,76 @@ +import datetime import json from typing import Any import pytest from pydantic import BaseModel -from fhir.r4.resources.bundle import Bundle -from fhir.r4.resources.patient import Patient -from fhir.resources.resource import Resource +from fhir.r4 import Bundle, Patient +from fhir.resources.resource import Meta, Resource + + +class TestMeta: + def test_create(self) -> None: + meta = Meta( + version_id="1", + last_updated=datetime.datetime.fromisoformat("2023-10-01T12:00:00Z"), + ) + assert meta.version_id == "1", "version_id should be set to '1'" + assert meta.last_updated == datetime.datetime.fromisoformat( + "2023-10-01T12:00:00Z" + ), "last_updated should match the provided datetime" + + def test_create_without_last_updated(self) -> None: + meta = Meta(version_id="2") + + assert meta.version_id == "2", "version_id should be set to '2'" + assert meta.last_updated is None, "last_updated should default to None" + + def test_create_without_version(self) -> None: + meta = Meta( + last_updated=datetime.datetime.fromisoformat("2023-10-01T12:00:00Z") + ) + + assert meta.version_id is None, "version_id should default to None" + assert meta.last_updated == datetime.datetime.fromisoformat( + "2023-10-01T12:00:00Z" + ), "last_updated should match the provided datetime" + + def test_create_with_defaults(self) -> None: + meta = Meta() + + assert meta.version_id is None, "version_id should default to None" + assert meta.last_updated is None, "last_updated should default to None" + + def test_with_last_updated(self) -> None: + last_updated = datetime.datetime.fromisoformat("2023-10-01T12:00:00Z") + meta = Meta.with_last_updated(last_updated) + + assert meta.last_updated == last_updated, ( + "last_updated should match the provided datetime" + ) + assert meta.version_id is None, "version_id should default to None" + + def test_with_last_updated_defaults_to_now(self) -> None: + before_create = datetime.datetime.now(tz=datetime.timezone.utc) + meta = Meta.with_last_updated(None) + after_create = datetime.datetime.now(tz=datetime.timezone.utc) + + assert meta.last_updated is not None, "last_updated should not be None" + assert meta.version_id is None, "version_id should default to None" + + assert before_create <= meta.last_updated, ( + "last_updated should be >= the time before creation" + ) + assert meta.last_updated <= after_create, ( + "last_updated should be <= the time after creation" + ) + + def test_is_frozen(self) -> None: + meta = Meta(version_id="1") + + with pytest.raises(AttributeError): + meta.version_id = "2" # type: ignore[misc] class TestResource: diff --git a/gateway-api/src/fhir/stu3/__init__.py b/gateway-api/src/fhir/stu3/__init__.py new file mode 100644 index 00000000..d8cc8edf --- /dev/null +++ b/gateway-api/src/fhir/stu3/__init__.py @@ -0,0 +1,3 @@ +from .elements.parameters import Parameters + +__all__ = ["Parameters"] diff --git a/gateway-api/src/fhir/resources/parameters.py b/gateway-api/src/fhir/stu3/elements/parameters.py similarity index 83% rename from gateway-api/src/fhir/resources/parameters.py rename to gateway-api/src/fhir/stu3/elements/parameters.py index ea57ab8d..fcbe4005 100644 --- a/gateway-api/src/fhir/resources/parameters.py +++ b/gateway-api/src/fhir/stu3/elements/parameters.py @@ -4,8 +4,9 @@ from pydantic import Field -from ..elements.identifier import NHSNumberValueIdentifier -from .resource import Resource +from fhir import Resource + +from ...r4.elements.identifier import NHSNumberValueIdentifier class Parameters(Resource, resource_type="Parameters"): diff --git a/gateway-api/src/fhir/stu3/elements/test_elements.py b/gateway-api/src/fhir/stu3/elements/test_elements.py index 8093568b..169b6c70 100644 --- a/gateway-api/src/fhir/stu3/elements/test_elements.py +++ b/gateway-api/src/fhir/stu3/elements/test_elements.py @@ -2,7 +2,7 @@ from pydantic import ValidationError from fhir.r4.elements.identifier import NHSNumberValueIdentifier -from fhir.stu3.resources.parameters import Parameters +from fhir.stu3 import Parameters class TestParameters: diff --git a/gateway-api/src/gateway_api/clinical_jwt/jwt.py b/gateway-api/src/gateway_api/clinical_jwt/jwt.py index 96658364..e717f095 100644 --- a/gateway-api/src/gateway_api/clinical_jwt/jwt.py +++ b/gateway-api/src/gateway_api/clinical_jwt/jwt.py @@ -36,7 +36,7 @@ def exp_time(self) -> str: def encode(self) -> str: return pyjwt.encode( self.payload(), - key=None, # type: ignore[arg-type] + key=None, algorithm=self.algorithm, headers={"typ": self.type}, ) diff --git a/gateway-api/src/gateway_api/common/error.py b/gateway-api/src/gateway_api/common/error.py index 41cfd8df..6afa981e 100644 --- a/gateway-api/src/gateway_api/common/error.py +++ b/gateway-api/src/gateway_api/common/error.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from http.client import BAD_GATEWAY, BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND -from fhir import Issue, IssueCode, IssueSeverity, OperationOutcome +from fhir.r4 import Issue, IssueCode, IssueSeverity, OperationOutcome @dataclass diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index e61adcf3..5abf40c2 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from fhir import Patient + from fhir.r4 import Patient from requests import Response diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index fcec8d33..f179fea5 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -23,7 +23,7 @@ from collections.abc import Callable import requests -from fhir import Patient +from fhir.r4 import Patient from pydantic import ValidationError from gateway_api.common.error import PdsRequestFailedError diff --git a/gateway-api/src/gateway_api/pds/test_client.py b/gateway-api/src/gateway_api/pds/test_client.py index e5777629..0263ea89 100644 --- a/gateway-api/src/gateway_api/pds/test_client.py +++ b/gateway-api/src/gateway_api/pds/test_client.py @@ -6,7 +6,7 @@ from uuid import UUID, uuid4 import pytest -from fhir import Patient +from fhir.r4 import Patient from pytest_mock import MockerFixture from gateway_api.common.error import PdsRequestFailedError diff --git a/gateway-api/src/gateway_api/sds/client.py b/gateway-api/src/gateway_api/sds/client.py index ea1e9b97..c9e473c6 100644 --- a/gateway-api/src/gateway_api/sds/client.py +++ b/gateway-api/src/gateway_api/sds/client.py @@ -12,8 +12,8 @@ from enum import StrEnum from typing import Any +from fhir import Resource from fhir.r4 import Bundle, Device, Endpoint -from fhir.resources.resource import Resource from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID from gateway_api.sds.search_results import SdsSearchResults diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index facfe3b9..19590729 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from fhir import Patient +from fhir.r4 import Patient from flask import Request from pytest_mock import MockerFixture diff --git a/gateway-api/tests/contract/conftest.py b/gateway-api/tests/contract/conftest.py deleted file mode 100644 index 043699ca..00000000 --- a/gateway-api/tests/contract/conftest.py +++ /dev/null @@ -1,93 +0,0 @@ -import os -import threading -from collections.abc import Generator -from functools import partial -from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import Any - -import pytest -import requests - - -def get_mtls_cert() -> tuple[str, str] | None: - cert_path = os.getenv("MTLS_CERT") - key_path = os.getenv("MTLS_KEY") - if not cert_path or not key_path: - return None - return (cert_path, key_path) - - -class MtlsProxyHandler(BaseHTTPRequestHandler): - """ - A simple proxy that forwards requests to the target HTTPS URL - attaching the mTLS client certificates. - """ - - def __init__( - self, - target_base: str, - cert: tuple[str, str] | None, - *args: Any, - **kwargs: Any, - ) -> None: - self.target_base = target_base - self.cert = cert - super().__init__(*args, **kwargs) - - def do_proxy(self, method: str) -> None: - if not self.target_base: - self.send_error(500, "Target base URL not set") - return - - url = f"{self.target_base}{self.path}" - content_length_header = self.headers.get("Content-Length") - content_length = int(content_length_header) if content_length_header else 0 - body = self.rfile.read(content_length) if content_length > 0 else None - headers = {k: v for k, v in self.headers.items() if k.lower() != "host"} - - try: - response = requests.request( - method=method, - url=url, - headers=headers, - data=body, - cert=self.cert, - timeout=30, - ) - - self.send_response(response.status_code) - for k, v in response.headers.items(): - self.send_header(k, v) - self.end_headers() - self.wfile.write(response.content) - - except Exception as e: - self.send_error(500, f"Proxy Error: {str(e)}") - - def do_GET(self) -> None: - self.do_proxy("GET") - - def do_POST(self) -> None: - self.do_proxy("POST") - - def do_PUT(self) -> None: - self.do_proxy("PUT") - - -@pytest.fixture(scope="module") -def mtls_proxy(base_url: str) -> Generator[str]: - """ - Spins up a local HTTP server in a separate thread. - Returns the URL of this local proxy. - """ - - cert = get_mtls_cert() - handler_factory = partial(MtlsProxyHandler, base_url, cert) - server = HTTPServer(("localhost", 0), handler_factory) - thread = threading.Thread(target=server.serve_forever) - thread.daemon = True - thread.start() - - yield f"http://localhost:{server.server_port}" - - server.shutdown() From f1f34f83b7e23b9fa404ce374caef8bba25e92d2 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:53:41 +0000 Subject: [PATCH 31/33] Remove duplicated line. --- .github/workflows/preview-env.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index 88367970..00c56100 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -434,7 +434,6 @@ jobs: artifact-name: trivy-image-scan-${{ steps.meta.outputs.branch_name }} - name: Generate SBOM - uses: nhs-england-tools/trivy-action/sbom-scan@289984b2f03034233a347d6dbadecd5ca9ea9634 if: github.event.action != 'closed' uses: nhs-england-tools/trivy-action/image-scan@289984b2f03034233a347d6dbadecd5ca9ea9634 with: From ab266502bafbdb96808a8259ee4d1b116eb25a7d Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:03:21 +0000 Subject: [PATCH 32/33] Correct version in docstring. --- gateway-api/src/fhir/stu3/elements/parameters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway-api/src/fhir/stu3/elements/parameters.py b/gateway-api/src/fhir/stu3/elements/parameters.py index fcbe4005..8c106adf 100644 --- a/gateway-api/src/fhir/stu3/elements/parameters.py +++ b/gateway-api/src/fhir/stu3/elements/parameters.py @@ -10,11 +10,11 @@ class Parameters(Resource, resource_type="Parameters"): - """A FHIR R4 Parameters resource.""" + """A FHIR STU3 Parameters resource.""" @dataclass(frozen=True) class Parameter(ABC): - """A FHIR R4 Parameter resource.""" + """A FHIR STU3 Parameter resource.""" valueIdentifier: Annotated[NHSNumberValueIdentifier, Field(frozen=True)] From c48dcfc35fd059cfcd47f47f11130154bff8e963 Mon Sep 17 00:00:00 2001 From: David Hamill <109090521+davidhamill1-nhs@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:28:00 +0000 Subject: [PATCH 33/33] I messed up the merge conflict resolution. --- gateway-api/pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index 578d3d4b..8b198e6e 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -62,3 +62,11 @@ dev = [ [tool.mypy] strict = true + +[tool.pytest.ini_options] +bdd_features_base_dir = "tests/acceptance/features" +markers = [ + "remote_only: test only runs in remote environment (skipped when --env=local)", + "status_auth_headers", + "status_merged_auth_headers", +]