From 1a3e6575ede63ce9284f8d8f5443400e6d12de5b Mon Sep 17 00:00:00 2001 From: Lukas Plank Date: Tue, 20 Aug 2024 13:29:53 +0200 Subject: [PATCH] feat: redesign SPARQLModelAdapter class The redesign introduces major class API changes: 1. Initialization now takes the endpoint, query and model class directly, this simplifies class usage and allows for better state retention in the instance. 2. functionality previously defined in SPARQLModelAdapter.__call__ is transposed to SPARQLModelAdapter.query. Closes: #38. --- poetry.lock | 191 +++++++++------- pyproject.toml | 2 +- rdfproxy/adapter.py | 252 ++++++++++++++++------ rdfproxy/utils/_exceptions.py | 9 + rdfproxy/utils/_types.py | 21 ++ rdfproxy/utils/models.py | 23 ++ rdfproxy/utils/sparql/sparql_templates.py | 10 + rdfproxy/utils/sparql/sparql_utils.py | 125 +++++++++++ rdfproxy/utils/utils.py | 57 +++-- 9 files changed, 519 insertions(+), 171 deletions(-) create mode 100644 rdfproxy/utils/_exceptions.py create mode 100644 rdfproxy/utils/models.py create mode 100644 rdfproxy/utils/sparql/sparql_templates.py create mode 100644 rdfproxy/utils/sparql/sparql_utils.py diff --git a/poetry.lock b/poetry.lock index 73bfa10..5249782 100644 --- a/poetry.lock +++ b/poetry.lock @@ -38,63 +38,83 @@ files = [ [[package]] name = "coverage" -version = "7.6.0" +version = "7.6.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"}, - {file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"}, - {file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"}, - {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"}, - {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"}, - {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"}, - {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"}, - {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"}, - {file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"}, - {file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"}, - {file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"}, - {file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"}, - {file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"}, - {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"}, - {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"}, - {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"}, - {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"}, - {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"}, - {file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"}, - {file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"}, - {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, - {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, - {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, - {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, - {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, - {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, - {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, - {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, - {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, - {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, - {file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"}, - {file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"}, - {file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"}, - {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"}, - {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"}, - {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"}, - {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"}, - {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"}, - {file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"}, - {file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"}, - {file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"}, - {file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"}, - {file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"}, - {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"}, - {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"}, - {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"}, - {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"}, - {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"}, - {file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"}, - {file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"}, - {file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"}, - {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, ] [package.extras] @@ -314,13 +334,13 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "8.3.1" +version = "8.3.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.3.1-py3-none-any.whl", hash = "sha256:e9600ccf4f563976e2c99fa02c7624ab938296551f280835ee6516df8bc4ae8c"}, - {file = "pytest-8.3.1.tar.gz", hash = "sha256:7e8e5c5abd6e93cb1cc151f23e57adc31fcf8cfd2a3ff2da63e23f732de35db6"}, + {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, + {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, ] [package.dependencies] @@ -387,29 +407,29 @@ networkx = ["networkx (>=2.0.0,<3.0.0)"] [[package]] name = "ruff" -version = "0.5.4" +version = "0.5.7" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.5.4-py3-none-linux_armv6l.whl", hash = "sha256:82acef724fc639699b4d3177ed5cc14c2a5aacd92edd578a9e846d5b5ec18ddf"}, - {file = "ruff-0.5.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:da62e87637c8838b325e65beee485f71eb36202ce8e3cdbc24b9fcb8b99a37be"}, - {file = "ruff-0.5.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e98ad088edfe2f3b85a925ee96da652028f093d6b9b56b76fc242d8abb8e2059"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c55efbecc3152d614cfe6c2247a3054cfe358cefbf794f8c79c8575456efe19"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9b85eaa1f653abd0a70603b8b7008d9e00c9fa1bbd0bf40dad3f0c0bdd06793"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cf497a47751be8c883059c4613ba2f50dd06ec672692de2811f039432875278"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:09c14ed6a72af9ccc8d2e313d7acf7037f0faff43cde4b507e66f14e812e37f7"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:628f6b8f97b8bad2490240aa84f3e68f390e13fabc9af5c0d3b96b485921cd60"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3520a00c0563d7a7a7c324ad7e2cde2355733dafa9592c671fb2e9e3cd8194c1"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93789f14ca2244fb91ed481456f6d0bb8af1f75a330e133b67d08f06ad85b516"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:029454e2824eafa25b9df46882f7f7844d36fd8ce51c1b7f6d97e2615a57bbcc"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9492320eed573a13a0bc09a2957f17aa733fff9ce5bf00e66e6d4a88ec33813f"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a6e1f62a92c645e2919b65c02e79d1f61e78a58eddaebca6c23659e7c7cb4ac7"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:768fa9208df2bec4b2ce61dbc7c2ddd6b1be9fb48f1f8d3b78b3332c7d71c1ff"}, - {file = "ruff-0.5.4-py3-none-win32.whl", hash = "sha256:e1e7393e9c56128e870b233c82ceb42164966f25b30f68acbb24ed69ce9c3a4e"}, - {file = "ruff-0.5.4-py3-none-win_amd64.whl", hash = "sha256:58b54459221fd3f661a7329f177f091eb35cf7a603f01d9eb3eb11cc348d38c4"}, - {file = "ruff-0.5.4-py3-none-win_arm64.whl", hash = "sha256:bd53da65f1085fb5b307c38fd3c0829e76acf7b2a912d8d79cadcdb4875c1eb7"}, - {file = "ruff-0.5.4.tar.gz", hash = "sha256:2795726d5f71c4f4e70653273d1c23a8182f07dd8e48c12de5d867bfb7557eed"}, + {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, + {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, + {file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"}, + {file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"}, + {file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"}, + {file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"}, + {file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"}, + {file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"}, + {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, ] [[package]] @@ -444,16 +464,23 @@ keepalive = ["keepalive (>=0.5)"] pandas = ["pandas (>=1.3.5)"] [[package]] -name = "toolz" -version = "0.12.1" -description = "List processing tools and functional utilities" +name = "typeguard" +version = "4.3.0" +description = "Run-time type checker for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "toolz-0.12.1-py3-none-any.whl", hash = "sha256:d22731364c07d72eea0a0ad45bafb2c2937ab6fd38a3507bf55eae8744aa7d85"}, - {file = "toolz-0.12.1.tar.gz", hash = "sha256:ecca342664893f177a13dac0e6b41cbd8ac25a358e5f215316d43e2100224f4d"}, + {file = "typeguard-4.3.0-py3-none-any.whl", hash = "sha256:4d24c5b39a117f8a895b9da7a9b3114f04eb63bade45a4492de49b175b6f7dfa"}, + {file = "typeguard-4.3.0.tar.gz", hash = "sha256:92ee6a0aec9135181eae6067ebd617fd9de8d75d714fb548728a4933b1dea651"}, ] +[package.dependencies] +typing-extensions = ">=4.10.0" + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.3.0)"] +test = ["coverage[toml] (>=7)", "mypy (>=1.2.0)", "pytest (>=7)"] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -468,4 +495,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "19d4d0682490336d67ea3aab0f28b54347ff8083cd3e9ec28856506adaac4bd6" +content-hash = "f448d8ec11cfeaf26f58edd2fb9a1e9669ed1c8ec750c5e389363a64604b4621" diff --git a/pyproject.toml b/pyproject.toml index 3b3a86d..94a782d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,8 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.11" sparqlwrapper = "^2.0.0" -toolz = "^0.12.1" pydantic = "^2.8.2" +typeguard = "^4.3.0" [tool.poetry.group.dev.dependencies] diff --git a/rdfproxy/adapter.py b/rdfproxy/adapter.py index da9ece9..6deb7b0 100644 --- a/rdfproxy/adapter.py +++ b/rdfproxy/adapter.py @@ -1,87 +1,203 @@ -"""SPARQLModelAdapter class for QueryResult to Pydantic model conversions.""" +"""SPARQLModelAdapter class for SPARQL query result set to Pydantic model conversions.""" -from collections.abc import Iterable -from typing import cast +from collections import defaultdict +from collections.abc import Iterator +import math +from typing import Any, Generic, overload + +from typeguard import typechecked from SPARQLWrapper import JSON, QueryResult, SPARQLWrapper -from pydantic import BaseModel -from rdfproxy.utils._types import _TModelConstructorCallable, _TModelInstance +from rdfproxy.utils._exceptions import ( + InterdependentParametersException, + UndefinedBindingException, +) +from rdfproxy.utils._types import _TModelInstance +from rdfproxy.utils.models import Page +from rdfproxy.utils.sparql.sparql_templates import ungrouped_pagination_base_query +from rdfproxy.utils.sparql.sparql_utils import ( + calculate_offset, + construct_count_query, + construct_grouped_count_query, + construct_grouped_pagination_query, + query_with_wrapper, + temporary_query_override, +) from rdfproxy.utils.utils import ( get_bindings_from_query_result, instantiate_model_from_kwargs, ) -class SPARQLModelAdapter: - """Adapter/Mapper for QueryResult to Pydantic model conversions. - - The rdfproxy.SPARQLModelAdapter class allows to run a query against an endpoint - and map a flat SPARQL query result set to a potentially nested Pydantic model. - - Example: - - from SPARQLWrapper import SPARQLWrapper - from pydantic import BaseModel - from rdfproxy import SPARQLModelAdapter, _TModelInstance - - class SimpleModel(BaseModel): - x: int - y: int - - class NestedModel(BaseModel): - a: str - b: SimpleModel - - class ComplexModel(BaseModel): - p: str - q: NestedModel - +@typechecked +class SPARQLModelAdapter(Generic[_TModelInstance]): + """Adapter/Mapper for SPARQL query result set to Pydantic model conversions. - sparql_wrapper = SPARQLWrapper("https://query.wikidata.org/bigdata/namespace/wdq/sparql") - - query = ''' - select ?x ?y ?a ?p - where { - values (?x ?y ?a ?p) { - (1 2 "a value" "p value") - } - } - ''' - - adapter = SPARQLModelAdapter(sparql_wrapper=sparql_wrapper) - models: list[_TModelInstance] = adapter(query=query, model_constructor=ComplexModel) + The rdfproxy.SPARQLModelAdapter class allows to run a query against an endpoint, + map a flat SPARQL query result set to a potentially nested Pydantic model and + optionally paginate and/or group the results by a SPARQL binding. """ - def __init__(self, sparql_wrapper: SPARQLWrapper) -> None: - self.sparql_wrapper = sparql_wrapper - - if self.sparql_wrapper.returnFormat != "json": - self.sparql_wrapper.setReturnFormat(JSON) + def __init__( + self, target: str | SPARQLWrapper, query: str, model: type[_TModelInstance] + ) -> None: + self._query = query + self._model = model - def __call__( - self, - query: str, - model_constructor: type[_TModelInstance] | _TModelConstructorCallable, - ) -> Iterable[_TModelInstance]: + self.sparql_wrapper: SPARQLWrapper = ( + SPARQLWrapper(target) if isinstance(target, str) else target + ) + self.sparql_wrapper.setReturnFormat(JSON) self.sparql_wrapper.setQuery(query) - query_result: QueryResult = self.sparql_wrapper.query() - if isinstance(model_constructor, type(BaseModel)): - model_constructor = cast(type[_TModelInstance], model_constructor) + @overload + def query(self) -> list[_TModelInstance]: ... + + @overload + def query( + self, + *, + group_by: str, + ) -> dict[str, list[_TModelInstance]]: ... - bindings = get_bindings_from_query_result(query_result) - models: list[_TModelInstance] = [ - instantiate_model_from_kwargs(model_constructor, **binding) - for binding in bindings - ] + @overload + def query( + self, + *, + page: int, + size: int, + ) -> Page[_TModelInstance]: ... - elif isinstance(model_constructor, _TModelConstructorCallable): - models: Iterable[_TModelInstance] = model_constructor(query_result) + @overload + def query( + self, + *, + page: int, + size: int, + group_by: str, + ) -> Page[_TModelInstance]: ... + def query( + self, + *, + page: int | None = None, + size: int | None = None, + group_by: str | None = None, + ) -> ( + list[_TModelInstance] | dict[str, list[_TModelInstance]] | Page[_TModelInstance] + ): + """Run query against endpoint and map the SPARQL query result set to a Pydantic model. + + Optional pagination and/or grouping by a SPARQL binding is avaible by + supplying the group_by and/or page/size parameters. + """ + match page, size, group_by: + case None, None, None: + return self._query_collect_models() + case int(), int(), None: + return self._query_paginate_ungrouped(page=page, size=size) + case None, None, str(): + return self._query_group_by(group_by=group_by) + case int(), int(), str(): + return self._query_paginate_grouped( + page=page, size=size, group_by=group_by + ) + case (None, int(), Any()) | (int(), None, Any()): + raise InterdependentParametersException( + "Parameters 'page' and 'size' are mutually dependent." + ) + case _: + raise Exception("This should never happen.") + + def _query_generate_model_bindings_mapping( + self, query: str | None = None + ) -> Iterator[tuple[_TModelInstance, dict[str, Any]]]: + """Run query, construct model instances and generate a model-bindings mapping. + + The query parameter defaults to the initially defined query and + is run against the endpoint defined in the SPARQLModelAdapter instance. + + Note: The coupling of model instances with flat SPARQL results + allows for easier and more efficient grouping operations (see grouping functionality). + """ + if query is None: + query_result: QueryResult = self.sparql_wrapper.query() else: - raise TypeError( - "Argument 'model_constructor' must be a model class " - "or a model constructor callable." - ) - - return models + with temporary_query_override(self.sparql_wrapper): + self.sparql_wrapper.setQuery(query) + query_result: QueryResult = self.sparql_wrapper.query() + + _bindings = get_bindings_from_query_result(query_result) + + for bindings in _bindings: + model = instantiate_model_from_kwargs(self._model, **bindings) + yield model, bindings + + def _query_collect_models(self, query: str | None = None) -> list[_TModelInstance]: + """Run query against endpoint and collect model instances.""" + return [ + model + for model, _ in self._query_generate_model_bindings_mapping(query=query) + ] + + def _query_group_by( + self, group_by: str, query: str | None = None + ) -> dict[str, list[_TModelInstance]]: + """Run query against endpoint and group results by a SPARQL binding.""" + group = defaultdict(list) + + for model, bindings in self._query_generate_model_bindings_mapping(query): + try: + key = bindings[group_by] + except KeyError: + raise UndefinedBindingException( + f"SPARQL binding '{group_by}' requested for grouping " + f"not in query projection '{bindings}'." + ) + + group[str(key)].append(model) + + return group + + def _get_count(self, query: str) -> int: + """Construct a count query from the initialized query, run it and return the count result.""" + result = query_with_wrapper(query=query, sparql_wrapper=self.sparql_wrapper) + return int(next(result)["cnt"]) + + def _query_paginate_ungrouped(self, page: int, size: int) -> Page[_TModelInstance]: + """Run query with pagination according to page and size. + + The internal query is dynamically modified according to page (offset)/size (limit) + and run with SPARQLModelAdapter._query_collect_models. + """ + paginated_query = ungrouped_pagination_base_query.substitute( + query=self._query, offset=calculate_offset(page, size), limit=size + ) + count_query = construct_count_query(self._query) + + items = self._query_collect_models(query=paginated_query) + total = self._get_count(count_query) + pages = math.ceil(total / size) + + return Page(items=items, page=page, size=size, total=total, pages=pages) + + def _query_paginate_grouped( + self, page: int, size: int, group_by: str + ) -> Page[_TModelInstance]: + """Run query with pagination according to page/size and group result by a SPARQL binding. + + The internal query is dynamically modified according to page (offset)/size (limit) + and run with SPARQLModelAdapter._query_group_by. + """ + grouped_paginated_query = construct_grouped_pagination_query( + query=self._query, page=page, size=size, group_by=group_by + ) + grouped_count_query = construct_grouped_count_query( + query=self._query, group_by=group_by + ) + + items = self._query_group_by(group_by=group_by, query=grouped_paginated_query) + total = self._get_count(grouped_count_query) + pages = math.ceil(total / size) + + return Page(items=items, page=page, size=size, total=total, pages=pages) diff --git a/rdfproxy/utils/_exceptions.py b/rdfproxy/utils/_exceptions.py new file mode 100644 index 0000000..2a4d219 --- /dev/null +++ b/rdfproxy/utils/_exceptions.py @@ -0,0 +1,9 @@ +"""Custom exceptions for RDFProxy.""" + + +class UndefinedBindingException(KeyError): + """Exception for indicating that a requested key could not be retrieved from a SPARQL binding mapping.""" + + +class InterdependentParametersException(Exception): + """Exceptiono for indicating that two or more parameters are interdependent.""" diff --git a/rdfproxy/utils/_types.py b/rdfproxy/utils/_types.py index 65bc1bf..899327e 100644 --- a/rdfproxy/utils/_types.py +++ b/rdfproxy/utils/_types.py @@ -15,3 +15,24 @@ class _TModelConstructorCallable(Protocol[_TModelInstance]): """Callback protocol for model constructor callables.""" def __call__(self, query_result: QueryResult) -> Iterable[_TModelInstance]: ... + + +class SPARQLBinding(str): + """SPARQLBinding type for explicit SPARQL binding to model field allocation. + + This type's intended use is with typing.Annotated in the context of a Pyantic field definition. + + Example: + + class Work(BaseModel): + name: Annotated[str, SPARQLBinding("title")] + + class Person(BaseModel): + name: str + work: Work + + This signals to the RDFProxy SPARQL-to-model mapping logic + to use the "title" SPARQL binding (not the "name" binding) to populate the Work.name field. + """ + + ... diff --git a/rdfproxy/utils/models.py b/rdfproxy/utils/models.py new file mode 100644 index 0000000..d4ec9e4 --- /dev/null +++ b/rdfproxy/utils/models.py @@ -0,0 +1,23 @@ +"""Pydantic Model definitions for rdfproxy.""" + +from typing import Generic + +from pydantic import BaseModel +from rdfproxy.utils._types import _TModelInstance + + +class Page(BaseModel, Generic[_TModelInstance]): + """Page model for rdfproxy pagination functionality. + + This model is loosely inspired by the fastapi-pagination Page class, + see https://github.com/uriyyo/fastapi-pagination. + + Also see https://docs.pydantic.dev/latest/concepts/models/#generic-models + for Generic Pydantic models. + """ + + items: list[_TModelInstance] | dict[str, list[_TModelInstance]] + page: int + size: int + total: int + pages: int diff --git a/rdfproxy/utils/sparql/sparql_templates.py b/rdfproxy/utils/sparql/sparql_templates.py new file mode 100644 index 0000000..f9b141c --- /dev/null +++ b/rdfproxy/utils/sparql/sparql_templates.py @@ -0,0 +1,10 @@ +"""SPARQL Query templates for RDFProxy paginations.""" + +from string import Template + + +ungrouped_pagination_base_query = Template(""" +$query +limit $limit +offset $offset +""") diff --git a/rdfproxy/utils/sparql/sparql_utils.py b/rdfproxy/utils/sparql/sparql_utils.py new file mode 100644 index 0000000..146fabf --- /dev/null +++ b/rdfproxy/utils/sparql/sparql_utils.py @@ -0,0 +1,125 @@ +"""Functionality for dynamic SPARQL query modifcation.""" + +from collections.abc import Iterator +from contextlib import contextmanager +import re + +from SPARQLWrapper import QueryResult, SPARQLWrapper +from rdfproxy.utils.sparql.sparql_templates import ungrouped_pagination_base_query +from rdfproxy.utils.utils import get_bindings_from_query_result + + +def remove_query_prefixes(query: str) -> str: + """Remove prefix definitions from a SPARQL query. + + Prefix definitions need removing e.g. in injected subqueries. + """ + return re.sub( + pattern=r"^prefix.*", repl="", string=query, flags=re.I | re.MULTILINE + ) + + +def inject_subquery(query: str, subquery: str) -> str: + """Inject a subquery into query.""" + + def _indent_query(query: str, indent: int = 2) -> str: + """Indent a query by n spaces according to indent parameter.""" + indented_query = "".join( + [f"{' ' * indent}{line}\n" for line in query.splitlines()] + ) + return indented_query + + point: int = query.rfind("}") + partial_query: str = query[:point] + + _subquery = remove_query_prefixes(subquery) + indented_subquery: str = _indent_query(_subquery) + + new_query: str = f"{partial_query} " f"{{{indented_subquery}}}\n}}" + return new_query + + +def replace_query_select_clause(query: str, repl: str) -> str: + """Replace the SELECT clause of a query with repl.""" + if re.search(r"select\s.+", query, re.I) is None: + raise Exception("Unable to obtain SELECT clause.") + + count_query = re.sub( + pattern=r"select\s.+", + repl=repl, + string=query, + count=1, + flags=re.I, + ) + + return count_query + + +def construct_count_query(query: str) -> str: + """Construct a generic count query from a SELECT query.""" + count_query = replace_query_select_clause(query, "select (count(*) as ?cnt)") + return count_query + + +def calculate_offset(page: int, size: int) -> int: + """Calculate offset value for paginated SPARQL templates.""" + match page: + case 1: + return 0 + case 2: + return size + case _: + return size * (page - 1) + + +def construct_grouped_pagination_query( + query: str, page: int, size: int, group_by: str +) -> str: + """Dynamically construct a query for grouped pagination. + + Based on the initial query, construct a query with limit/offset according to page/size + and with a SELECT clause that distinctly selects the group_by variable; + then inject that query into the initial query as a subquery. + """ + _paginated_query = ungrouped_pagination_base_query.substitute( + query=query, offset=calculate_offset(page, size), limit=size + ) + subquery = replace_query_select_clause( + _paginated_query, f"select distinct ?{group_by}" + ) + + grouped_pagination_query = inject_subquery(query=query, subquery=subquery) + return grouped_pagination_query + + +def construct_grouped_count_query(query: str, group_by) -> str: + grouped_count_query = replace_query_select_clause( + query, f"select (count(distinct ?{group_by}) as ?cnt)" + ) + + return grouped_count_query + + +@contextmanager +def temporary_query_override(sparql_wrapper: SPARQLWrapper): + """Context manager that allows to contextually overwrite a query in a SPARQLWrapper object.""" + _query_cache = sparql_wrapper.queryString + + try: + yield sparql_wrapper + finally: + sparql_wrapper.setQuery(_query_cache) + + +def query_with_wrapper(query: str, sparql_wrapper: SPARQLWrapper) -> Iterator[dict]: + """Execute a SPARQL query using a predefined sparql_wrapper object. + + The query attribute of the wrapper object is temporarily overridden + and gets restored after query execution. + """ + with temporary_query_override(sparql_wrapper=sparql_wrapper): + sparql_wrapper.setQuery(query) + result: QueryResult = sparql_wrapper.query() + + bindings: Iterator[dict] = get_bindings_from_query_result(result) + return bindings diff --git a/rdfproxy/utils/utils.py b/rdfproxy/utils/utils.py index 9c8506c..979efaa 100644 --- a/rdfproxy/utils/utils.py +++ b/rdfproxy/utils/utils.py @@ -1,12 +1,12 @@ """SPARQL/FastAPI utils.""" -from collections.abc import Iterator, Mapping -from typing import cast +from collections.abc import Iterator +from typing import Any from SPARQLWrapper import QueryResult from pydantic import BaseModel -from rdfproxy.utils._types import _TModelInstance -from toolz import valmap +from pydantic.fields import FieldInfo +from rdfproxy.utils._types import SPARQLBinding, _TModelInstance def get_bindings_from_query_result(query_result: QueryResult) -> Iterator[dict]: @@ -17,9 +17,9 @@ def get_bindings_from_query_result(query_result: QueryResult) -> Iterator[dict]: f"Received object with requestedFormat '{result_format}'." ) - query_json = cast(Mapping, query_result.convert()) + query_json: dict = query_result.convert() bindings = map( - lambda binding: valmap(lambda v: v["value"], binding), + lambda binding: {k: v["value"] for k, v in binding.items()}, query_json["results"]["bindings"], ) @@ -31,6 +31,9 @@ def instantiate_model_from_kwargs( ) -> _TModelInstance: """Instantiate a (potentially nested) model from (flat) kwargs. + More a more generic version of this function see upto.init_model_from_kwargs + https://github.com/lu-pl/upto?tab=readme-ov-file#init_model_from_kwargs. + Example: class SimpleModel(BaseModel): @@ -52,22 +55,36 @@ class ComplexModel(BaseModel): print(model) # p='p value' q=NestedModel(a='a value', b=SimpleModel(x=1, y=2)) """ - def _get_bindings(model: type[_TModelInstance], **kwargs) -> dict: + def _get_key_from_metadata(v: FieldInfo): + """Try to get a SPARQLBinding object from a field's metadata attribute. + + Helper for _generate_binding_pairs. + """ + try: + value = next(filter(lambda x: isinstance(x, SPARQLBinding), v.metadata)) + return value + except StopIteration: + return None + + def _generate_binding_pairs( + model: type[_TModelInstance], **kwargs + ) -> Iterator[tuple[str, Any]]: """Get the bindings needed for model instantation. The function traverses model.model_fields - and constructs a bindings dict by either getting values from kwargs or field defaults. + and constructs binding pairs by either getting values from kwargs or field defaults. For model fields the recursive clause runs. - - Note: This needs exception handling and proper testing. """ - return { - k: ( - v.annotation(**_get_bindings(v.annotation, **kwargs)) - if isinstance(v.annotation, type(BaseModel)) - else kwargs.get(k, v.default) - ) - for k, v in model.model_fields.items() - } - - return model(**_get_bindings(model, **kwargs)) + for k, v in model.model_fields.items(): + if isinstance(v.annotation, type(BaseModel)): + value = v.annotation( + **dict(_generate_binding_pairs(v.annotation, **kwargs)) + ) + else: + binding_key = _get_key_from_metadata(v) or k + value = kwargs.get(binding_key, v.default) + + yield k, value + + bindings = dict(_generate_binding_pairs(model, **kwargs)) + return model(**bindings)