From 85fdae5476caa9c10dba54bfdbd3105620fb8b12 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 18:41:13 +0530 Subject: [PATCH 01/52] chore: migrate from Poetry to uv Replace poetry-based tooling with uv: convert pyproject.toml to PEP 621 format, use hatchling as build backend, move dev deps to PEP 735 dependency-groups, and wire the mongoengine git source via [tool.uv.sources]. Update Makefile to use `uv run` and `uv build`. Replace poetry.lock with uv.lock. --- Makefile | 12 +- poetry.lock | 761 ------------------------------------------------- pyproject.toml | 58 ++-- uv.lock | 606 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 644 insertions(+), 793 deletions(-) delete mode 100644 poetry.lock create mode 100644 uv.lock diff --git a/Makefile b/Makefile index 07c3c5d3..ef8bc70f 100644 --- a/Makefile +++ b/Makefile @@ -11,25 +11,23 @@ clean: @find . -name "__pycache__" -delete lint: - @ruff check graphene_mongo - @ruff format . --check + @uv run ruff check graphene_mongo + @uv run ruff format . --check test: clean - pytest graphene_mongo/tests --cov=graphene_mongo --cov-report=html --cov-report=term + uv run pytest graphene_mongo/tests --cov=graphene_mongo --cov-report=html --cov-report=term register-pypitest: #python setup.py register -r pypitest deploy-pypitest: clean - poetry build - #poetry publish --repository testpypi + uv build twine upload --repository testpypi dist/* register: #python setup.py register -r pypi deploy: clean - poetry build + uv build twine upload dist/* - #poetry publish diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 558cc9fb..00000000 --- a/poetry.lock +++ /dev/null @@ -1,761 +0,0 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. - -[[package]] -name = "aiodataloader" -version = "0.4.3" -description = "Asyncio DataLoader implementation for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "aiodataloader-0.4.3-py3-none-any.whl", hash = "sha256:f2d57675e4c7a5cf7efc4c42697d307b951e1a9f40c22df3531a4b9cb7758229"}, - {file = "aiodataloader-0.4.3.tar.gz", hash = "sha256:b8c07ed7fddfdccc2d6298c247b1e5fe9779e5b1c38f2e6ec541a041683ef7e8"}, -] - -[package.dependencies] -typing-extensions = ">=4.1.1" - -[package.extras] -build = ["hatch"] -lint = ["black", "flake8", "flake8-import-order", "hatch", "mypy"] -test = ["coveralls", "hatch", "mock", "pytest (>=3.6)", "pytest-asyncio", "pytest-cov"] - -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." -optional = false -python-versions = "<3.11,>=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, - {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, -] - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coverage" -version = "7.14.0" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075"}, - {file = "coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85"}, - {file = "coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323"}, - {file = "coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a"}, - {file = "coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480"}, - {file = "coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c"}, - {file = "coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3"}, - {file = "coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1"}, - {file = "coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627"}, - {file = "coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5"}, - {file = "coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595"}, - {file = "coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27"}, - {file = "coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2"}, - {file = "coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d"}, - {file = "coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef"}, - {file = "coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe"}, - {file = "coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae"}, - {file = "coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e"}, - {file = "coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96"}, - {file = "coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90"}, - {file = "coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477"}, - {file = "coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab"}, - {file = "coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917"}, - {file = "coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8"}, - {file = "coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d"}, - {file = "coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca"}, - {file = "coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828"}, - {file = "coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d"}, - {file = "coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9"}, - {file = "coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1"}, - {file = "coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db"}, - {file = "coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2"}, - {file = "coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644"}, - {file = "coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b"}, - {file = "coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1"}, - {file = "coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - -[[package]] -name = "dnspython" -version = "2.8.0" -description = "DNS toolkit" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, - {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, -] - -[package.extras] -dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"] -dnssec = ["cryptography (>=45)"] -doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] -doq = ["aioquic (>=1.2.0)"] -idna = ["idna (>=3.10)"] -trio = ["trio (>=0.30)"] -wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, - {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "graphene" -version = "3.4.3" -description = "GraphQL Framework for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71"}, - {file = "graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa"}, -] - -[package.dependencies] -graphql-core = ">=3.1,<3.3" -graphql-relay = ">=3.1,<3.3" -python-dateutil = ">=2.7.0,<3" -typing-extensions = ">=4.7.1,<5" - -[package.extras] -dev = ["coveralls (>=3.3,<5)", "mypy (>=1.10,<2)", "pytest (>=8,<9)", "pytest-asyncio (>=0.16,<2)", "pytest-benchmark (>=4,<5)", "pytest-cov (>=5,<6)", "pytest-mock (>=3,<4)", "ruff (==0.5.0)", "types-python-dateutil (>=2.8.1,<3)"] -test = ["coveralls (>=3.3,<5)", "pytest (>=8,<9)", "pytest-asyncio (>=0.16,<2)", "pytest-benchmark (>=4,<5)", "pytest-cov (>=5,<6)", "pytest-mock (>=3,<4)"] - -[[package]] -name = "graphene-directives" -version = "0.5.0" -description = "Schema Directives implementation for graphene" -optional = false -python-versions = "<4,>=3.9" -groups = ["main"] -files = [ - {file = "graphene_directives-0.5.0-py3-none-any.whl", hash = "sha256:655c636845c52facac3e352d95df28dabee86209b5c36a176afe6e5280734802"}, - {file = "graphene_directives-0.5.0.tar.gz", hash = "sha256:09afc098028c01f14f4891c6e15eee0c6f3c9190853155c30bc72f89681363a5"}, -] - -[package.dependencies] -graphene = ">=3" - -[[package]] -name = "graphene-federation" -version = "3.3.0" -description = "Federation implementation for graphene" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "graphene_federation-3.3.0-py3-none-any.whl", hash = "sha256:8f0ca52c7809ca333b8a7677c1366599644ae068bac4492273321593c8181b33"}, - {file = "graphene_federation-3.3.0.tar.gz", hash = "sha256:2a142115cd9b199ea8b9bdf8f456b78e4c7e2efd08b448e74ffd129b5d55a46d"}, -] - -[package.dependencies] -graphene = ">=3.1" -graphene-directives = ">=0.5.0" -graphql-core = ">=3.1" - -[package.extras] -dev = ["black (==23.12.1)", "flake8 (==7.3.0)", "mypy (==1.18.2)", "pytest (==8.4.2)", "pytest-cov"] -test = ["pytest (==8.4.2)", "pytest-cov"] - -[[package]] -name = "graphql-core" -version = "3.2.8" -description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL." -optional = false -python-versions = "<4,>=3.7" -groups = ["main"] -files = [ - {file = "graphql_core-3.2.8-py3-none-any.whl", hash = "sha256:cbee07bee1b3ed5e531723685369039f32ff815ef60166686e0162f540f1520c"}, - {file = "graphql_core-3.2.8.tar.gz", hash = "sha256:015457da5d996c924ddf57a43f4e959b0b94fb695b85ed4c29446e508ed65cf3"}, -] - -[[package]] -name = "graphql-relay" -version = "3.2.0" -description = "Relay library for graphql-core" -optional = false -python-versions = ">=3.6,<4" -groups = ["main"] -files = [ - {file = "graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c"}, - {file = "graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5"}, -] - -[package.dependencies] -graphql-core = ">=3.2,<3.3" - -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "mock" -version = "5.2.0" -description = "Rolling backport of unittest.mock for all Pythons" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, - {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, -] - -[package.extras] -build = ["blurb", "twine", "wheel"] -docs = ["sphinx"] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "mongoengine" -version = "0.30.0" -description = "MongoEngine is a Python Object-Document Mapper for working with MongoDB." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [] -develop = false - -[package.dependencies] -pymongo = ">=4.14,<5.0" - -[package.source] -type = "git" -url = "https://github.com/strollby/mongoengine" -reference = "v0.30.0-alpha.2" -resolved_reference = "9c63038fbe76242320097092a147dbe997a2d931" - -[[package]] -name = "mongomock" -version = "4.3.0" -description = "Fake pymongo stub for testing simple MongoDB-dependent code" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "mongomock-4.3.0-py2.py3-none-any.whl", hash = "sha256:5ef86bd12fc8806c6e7af32f21266c61b6c4ba96096f85129852d1c4fec1327e"}, - {file = "mongomock-4.3.0.tar.gz", hash = "sha256:32667b79066fabc12d4f17f16a8fd7361b5f4435208b3ba32c226e52212a8c30"}, -] - -[package.dependencies] -packaging = "*" -pytz = "*" -sentinels = "*" - -[package.extras] -pyexecjs = ["pyexecjs"] -pymongo = ["pymongo"] - -[[package]] -name = "packaging" -version = "26.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, - {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "promise" -version = "2.3" -description = "Promises/A+ implementation for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "promise-2.3.tar.gz", hash = "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0"}, -] - -[package.dependencies] -six = "*" - -[package.extras] -test = ["coveralls", "futures", "mock", "pytest (>=2.7.3)", "pytest-benchmark", "pytest-cov"] - -[[package]] -name = "pygments" -version = "2.20.0" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, - {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pymongo" -version = "4.17.0" -description = "PyMongo - the Official MongoDB Python driver" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pymongo-4.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:47b021363cd923ace5edc7a1d63c0ff8a6d9d43859b8a1ba23645f5afae63221"}, - {file = "pymongo-4.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:422fa50d7d7f5c22ea0953554396c9ef95684a2d775f860bd75a7b510538dfca"}, - {file = "pymongo-4.17.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:addd0498ebbdc6354227f6ed457ed9fce442d48a3bb30d5b5bad33e104996561"}, - {file = "pymongo-4.17.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5c8e180cb2cabe37300e1e36c60aa4f2ff956cc579f0142135a5d2cba252243"}, - {file = "pymongo-4.17.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd835cdb37a1adec359dd072c24f8bb14809e2644fde86fab4ee2fc9719b9483"}, - {file = "pymongo-4.17.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4979e7e8887862bbb44d203f00cc8263a3f27237876fa691b6beba23e40e6d8"}, - {file = "pymongo-4.17.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77aa4bc164b4de60d5db193b322f0f5b6ead716e831031bfdef8e8bd92205556"}, - {file = "pymongo-4.17.0-cp310-cp310-win32.whl", hash = "sha256:48bbc576677b50af043df870d84ded67cc3a9b4aa7553201beef4da5dc050a0a"}, - {file = "pymongo-4.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46767f28dea610e02edf6c5d956ce615c3c7790ea396660b9b1efd5c5ead2e0"}, - {file = "pymongo-4.17.0-cp310-cp310-win_arm64.whl", hash = "sha256:757f2a4c0c2c46cab87df0333681ce69e86c9d5b45bc5203ceba5410b3489e59"}, - {file = "pymongo-4.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4141e6c6a339789b2974efa00ecd9409101672d77a0e3ee2cc3839eedf8ec4df"}, - {file = "pymongo-4.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e68c76b84e0c132d9dbf9307f12ff8185702328187a87b9aca8c941303873433"}, - {file = "pymongo-4.17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba2195d4f386f839a52a23ea1cfd60ffaaba78a3d7841db51b7e433001139918"}, - {file = "pymongo-4.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446ff4bfcb6ec2a2e50998c860986a1e992136f998b7f53e7a717fb8aa5a0b9"}, - {file = "pymongo-4.17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2a0d5ac205728c86e0a02192f1aa5f865b0d7d51f8df6101c01a69a7fc620d72"}, - {file = "pymongo-4.17.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:485c8a8eaa4c739f00a331fc73757898ee7c092c214a79e63866ff76aaf282ff"}, - {file = "pymongo-4.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2dfcc795f5b9fedbe179a11fdf6051581479d196582a3fe819a92a00e9b9969"}, - {file = "pymongo-4.17.0-cp311-cp311-win32.whl", hash = "sha256:c2292144505fb12156b981bd440f3dc994a883da06ac726c0c8692ccdbc1c510"}, - {file = "pymongo-4.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:2e190827834fce70ecdf9d46796c6dbc0ce08ea87dc2ff5bc6f3f5579b605cb9"}, - {file = "pymongo-4.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:a8f9c40a09bb7d4b9fc8b1da65ecf6efa79bda5cb2756f39d9b6940fac1d19ae"}, - {file = "pymongo-4.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53ffa94b2340dbf6b055e09a0090618c60482c158ecfc9565642fc996bf0944"}, - {file = "pymongo-4.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6fe0de9d0f6791abce3471230b32b4817bf89d27b1182b6a550e1ec0fa72aa9a"}, - {file = "pymongo-4.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e537e95514dae1aaa718f481ec03151a0f0394bcd05f1322896d8fc1330cb729"}, - {file = "pymongo-4.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37a8385c29881b43eab31f584100fa0eaddedd5607adf010147ba1810118be90"}, - {file = "pymongo-4.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3ee3d241ed77a4fc99ce3cff3b289c3ebce37f61fdd7349d3592c23b82c8784"}, - {file = "pymongo-4.17.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9eb5d63a3c518cb0804ed678f5e2b875af032d89a7cf57a57360322cf6a4d222"}, - {file = "pymongo-4.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e97e03fa13327c87e3fdc5656acd01e71817f0c1dc3221cd8f30de136bf4ec3"}, - {file = "pymongo-4.17.0-cp312-cp312-win32.whl", hash = "sha256:6877214bff5f06f6884a9fc8d9016a4a7a5f51f537f5c51ac3a576f93e7dfb32"}, - {file = "pymongo-4.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9828485f72f63c7d802e0ec41f71906f633c2692621ab3af55ca990186b091b1"}, - {file = "pymongo-4.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:1195370a77baf003b59b10e91ecc4706297197f0dd9d29c840cc556dc08f7cee"}, - {file = "pymongo-4.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:809ec74de3b9148ae43fa8df9faf53470f511c8d384f13b99d6f671f2a379f15"}, - {file = "pymongo-4.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a431b737816bf4cddd4fa0fcef04e424ad36b7692734a64150f872fb8f3208be"}, - {file = "pymongo-4.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4fab10f8403169ce92f3cea921609d9ee81107306caae06c08f592d4b8ad2b5"}, - {file = "pymongo-4.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20323b0b1c1d33770ad1fc68d429c757734ce9ad3594421c3d6618f10572b1b9"}, - {file = "pymongo-4.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5a5de048e6da5c18e27cc2437e8c15b3b0cdc8385c15b41178b0caa3322a09c2"}, - {file = "pymongo-4.17.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dff3de1294fbbc1db0ba6b511f77b8e540601d092538a31312e99c8a91a78b1e"}, - {file = "pymongo-4.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faf03e4c2aafd6de626dbd30ba246d369ae33f47f10629d1bbe40f72115027a6"}, - {file = "pymongo-4.17.0-cp313-cp313-win32.whl", hash = "sha256:c9786665926a09630c5d420c79762cfadbff35a9438bcbc4c81a9fb5ab9228b7"}, - {file = "pymongo-4.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:5960519b4d7168f1ecdd3ea10c81b2aedeb9423651aca953cfbc8e76705d3b38"}, - {file = "pymongo-4.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:0ff6bd2f735ab5356541e3e57d5b7dbfbc3f2ee1ccb10b6b0f82d58af69d1d8e"}, - {file = "pymongo-4.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff5aa3f1c7e3f08eb0e7a016c91ba468b1850ccfd63d9b1f12f56350f4974cef"}, - {file = "pymongo-4.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e816db649ba5d7de0568cf3a9f287a9dc9aad21cf0ca667ab156a7ef47fca0b0"}, - {file = "pymongo-4.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c4fded3a9f1d6a687e36ebd384ac6d00b9b00de1969aa74048e7051ec2a713"}, - {file = "pymongo-4.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db66aa8dd253a0fc1fad3b0d23d5b3993f7ebde02fbbd7727128debf2853675"}, - {file = "pymongo-4.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3987e96e7c7be4083d42e8ac2cc6c0d5b78db9973c90fce42ae800b616ca6b20"}, - {file = "pymongo-4.17.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cee36b3c0d0354f880fa7a7fdcdaf2bb5e542c2281e25c1bfadf8cfe21eba7d2"}, - {file = "pymongo-4.17.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:320b34457b20bbcc79997801f95d25ce00472915ca5241167242b42c4359e027"}, - {file = "pymongo-4.17.0-cp314-cp314-win32.whl", hash = "sha256:df4a644af9ae132d4bfdb2e9516ea51a615fd881caddfbfbd071cf1354844479"}, - {file = "pymongo-4.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:c797f8a80957134f6dd9690367a0f8f5906d672119af2c6aa55f0c527b656bed"}, - {file = "pymongo-4.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:68fca71e05ee5da23a8d73cee8379dfb3d26e609a377cae731d742771ed96946"}, - {file = "pymongo-4.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b4384700cffc3f1dd98e088bc0072dedf6d7d68a230bb4b972665cf69c071c1e"}, - {file = "pymongo-4.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:93641192644fa1ee0f34030e774fd31022a27ad11ba22cb1716142231524f8bd"}, - {file = "pymongo-4.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:75bc3aa5b94fdb7138d357ec6ca61cd97e0c79f4f7f0bd3efe9639b15cc50942"}, - {file = "pymongo-4.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e8f8e23c6df7c6d6929f5e734980b227706e73ee847517c9ba5af90f7fc466"}, - {file = "pymongo-4.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15d3f3d732aecac1f8d481bde4029755615639bd3076f258a2147210aec8515a"}, - {file = "pymongo-4.17.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5f62862d0f87be481fa1fe8cb811994486773c94a2b61e509285e3f2890763"}, - {file = "pymongo-4.17.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64837adbbd72073301af51bb0fc80e3d7707fe5527cea1033ba0320f0b2f881b"}, - {file = "pymongo-4.17.0-cp314-cp314t-win32.whl", hash = "sha256:b93b22eedc62598cf5ee9d8c8007a8e9121c50fd88137012d8985500e9dc3151"}, - {file = "pymongo-4.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3689ea34f6b647c7d1e7bdc60fcfb214b2789ed1359a7fb96569c69f50e5f18f"}, - {file = "pymongo-4.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9543d8f84c2e5608565c08ac679774811e6730770d8a645439b073422a4276fb"}, - {file = "pymongo-4.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4ae22fafca69dd3c78261969e999782ac5fc23b76cf8cccfbc3707982a74cc3d"}, - {file = "pymongo-4.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f09645e0ce4e3825fa0baa8254064a716ed0be33f78feeedd4731016cb8aaa17"}, - {file = "pymongo-4.17.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db10678814cdf7ea39fd308c6f41395cfa7b29d904bcd7895288963d8f892ba"}, - {file = "pymongo-4.17.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5376ad67bb30ae910d83affcf997f706d9dee37e8b5dad8b6fedb0626e262d85"}, - {file = "pymongo-4.17.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb3ebc86782049f6928dcc583008287cb1c17d463501c94a620f035f5b4fd463"}, - {file = "pymongo-4.17.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:51e1915761f65f2aaabd0ba691a31d56551d3f19d1263c2d6bf261730603de5f"}, - {file = "pymongo-4.17.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1175563375d682260f613a96fb7a53dce746ed752bfd924eab61de3bc5bfde34"}, - {file = "pymongo-4.17.0-cp39-cp39-win32.whl", hash = "sha256:5ab3b8ff79e0dfc49b68f3c925e8cc735ea95c60efaed84cfe75692dffcaac2a"}, - {file = "pymongo-4.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:b24598dc3c2feccbc83b43044be48145a0dc4f9bee49ef923e3d707d54a55d85"}, - {file = "pymongo-4.17.0-cp39-cp39-win_arm64.whl", hash = "sha256:8a1be016198a03fd7727cdd55998964bfa4e5a6fd9733c8e95830628cef34d29"}, - {file = "pymongo-4.17.0.tar.gz", hash = "sha256:70ffa08ba641468cc068cf46c06b34f01a8ce3489f6411309fcb5ceabe6b2fc0"}, -] - -[package.dependencies] -dnspython = ">=2.6.1,<3.0.0" - -[package.extras] -aws = ["pymongo-auth-aws (>=1.1.0,<2.0.0)"] -docs = ["furo (==2025.12.19)", "readthedocs-sphinx-search (>=0.3,<1.0)", "sphinx (>=5.3,<9)", "sphinx-autobuild (>=2020.9.1)", "sphinx-rtd-theme (>=2,<4)", "sphinxcontrib-shellcheck (>=1,<2)"] -encryption = ["certifi (>=2023.7.22) ; os_name == \"nt\" or sys_platform == \"darwin\"", "pymongo-auth-aws (>=1.1.0,<2.0.0)", "pymongocrypt (>=1.13.0,<2.0.0)"] -gssapi = ["pykerberos (>=1.2.4) ; os_name != \"nt\"", "winkerberos (>=0.5.0) ; os_name == \"nt\""] -ocsp = ["certifi (>=2023.7.22) ; os_name == \"nt\" or sys_platform == \"darwin\"", "cryptography (>=42.0.0)", "pyopenssl (>=23.2.0)", "requests (>=2.23.0,<3.0)", "service-identity (>=23.1.0)"] -snappy = ["python-snappy (>=0.6.0)"] -test = ["importlib-metadata (>=7.0) ; python_version < \"3.13\"", "pytest (>=8.2)", "pytest-asyncio (>=0.24.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] - -[[package]] -name = "pytest" -version = "9.0.3" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, - {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, -] - -[package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} -iniconfig = ">=1.0.1" -packaging = ">=22" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, - {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, -] - -[package.dependencies] -backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} -pytest = ">=8.2,<10" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "pytest-cov" -version = "7.1.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, - {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, -] - -[package.dependencies] -coverage = {version = ">=7.10.6", extras = ["toml"]} -pluggy = ">=1.2" -pytest = ">=7" - -[package.extras] -testing = ["process-tests", "pytest-xdist", "virtualenv"] - -[[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 = ["main"] -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]] -name = "pytz" -version = "2026.2" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126"}, - {file = "pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a"}, -] - -[[package]] -name = "ruff" -version = "0.15.13" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8"}, - {file = "ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7"}, - {file = "ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629"}, - {file = "ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5"}, - {file = "ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22"}, - {file = "ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9"}, - {file = "ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55"}, - {file = "ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6"}, - {file = "ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca"}, - {file = "ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd"}, - {file = "ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6"}, - {file = "ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51"}, - {file = "ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2"}, - {file = "ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b"}, - {file = "ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41"}, - {file = "ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4"}, - {file = "ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21"}, - {file = "ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7"}, -] - -[[package]] -name = "sentinels" -version = "1.1.1" -description = "Various objects to denote special meanings in python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "sentinels-1.1.1-py3-none-any.whl", hash = "sha256:835d3b28f3b47f5284afa4bf2db6e00f2dc5f80f9923d4b7e7aeeeccf6146a11"}, - {file = "sentinels-1.1.1.tar.gz", hash = "sha256:3c2f64f754187c19e0a1a029b148b74cf58dd12ec27b4e19c0e5d6e22b5a9a86"}, -] - -[package.extras] -testing = ["pylint", "pytest"] - -[[package]] -name = "setuptools" -version = "78.1.1" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "setuptools-78.1.1-py3-none-any.whl", hash = "sha256:c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561"}, - {file = "setuptools-78.1.1.tar.gz", hash = "sha256:fcc17fd9cd898242f6b4adfaca46137a9edef687f43e6f78469692a5e70d851d"}, -] - -[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" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "tomli" -version = "2.4.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_full_version <= \"3.11.0a6\"" -files = [ - {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, - {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, - {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, - {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, - {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, - {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, - {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, - {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, - {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, - {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, - {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, - {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, - {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, - {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, - {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, - {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, - {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, - {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, - {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, - {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, - {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, - {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, - {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, - {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, - {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, - {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, - {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, - {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, - {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, - {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, - {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, - {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, - {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, - {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, - {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, - {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, - {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, - {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, - {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, - {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, - {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, - {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, - {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, - {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, - {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, - {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, - {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -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"}, -] -markers = {dev = "python_version < \"3.13\""} - -[metadata] -lock-version = "2.1" -python-versions = ">=3.10,<4" -content-hash = "99be1a6518cd76c368b192e121e697b1ffb27c45ae50b809402048d09830e52d" diff --git a/pyproject.toml b/pyproject.toml index f4a5f109..20f6b53d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,13 @@ -[tool.poetry] +[project] name = "graphene-mongo" -packages = [{ include = "graphene_mongo" }] version = "0.5.0" description = "Graphene Mongoengine integration" authors = [ - "Abaw Chen ", + {name = "Abaw Chen", email = "abaw.chen@gmail.com"}, ] -license = "MIT" +license = {text = "MIT"} readme = "README.md" -homepage = "https://github.com/graphql-python/graphene-mongo" -repository = "https://github.com/graphql-python/graphene-mongo" +requires-python = ">=3.10,<4" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", @@ -25,29 +23,39 @@ classifiers = [ keywords = [ "graphene-mongo", "graphql", "api", "graphql", "protocol", "relay", "graphene", "mongo", "mongoengine" ] -exclude = ["graphene_mongo/tests"] +dependencies = [ + "graphene>=3.1.1", + "graphene-federation>=3.1.5", + "promise>=2.3", + "mongoengine", + "aiodataloader>=0.4.3", +] -[tool.poetry.dependencies] -python = ">=3.10,<4" -graphene = ">=3.1.1" -graphene-federation = ">=3.1.5" -promise = ">=2.3" -mongoengine = { rev = "v0.30.0-alpha.2", git = "https://github.com/strollby/mongoengine" } -aiodataloader = "^0.4.3" +[project.urls] +Homepage = "https://github.com/graphql-python/graphene-mongo" +Repository = "https://github.com/graphql-python/graphene-mongo" -[tool.poetry.group.dev.dependencies] -pytest = "*" -mongomock = ">=4.1.2" -mock = ">=5.0.1" -pytest-cov = "*" -pytest-asyncio = "*" -ruff = "*" -setuptools = ">=69.0.2,<79.0.0" +[tool.uv.sources] +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } +[dependency-groups] +dev = [ + "pytest", + "mongomock>=4.1.2", + "mock>=5.0.1", + "pytest-cov", + "pytest-asyncio", + "ruff", + "setuptools>=69.0.2,<79.0.0", +] [build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["graphene_mongo"] +exclude = ["graphene_mongo/tests"] [tool.ruff] line-length = 100 @@ -55,6 +63,6 @@ line-length = 100 [tool.pytest.ini_options] minversion = "8.0" addopts = "-v -s" -asyncio_mode= "auto" +asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" asyncio_default_test_loop_scope = "session" \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..5a963304 --- /dev/null +++ b/uv.lock @@ -0,0 +1,606 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <4" + +[[package]] +name = "aiodataloader" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/83/1f86948638cb076969526c944b3d5f6aa1997d140dc3cff1011a821245d3/aiodataloader-0.4.3.tar.gz", hash = "sha256:b8c07ed7fddfdccc2d6298c247b1e5fe9779e5b1c38f2e6ec541a041683ef7e8", size = 14025, upload-time = "2025-11-29T10:14:10.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/29/80a0a91bd35b46bf31dc53a620780ee7c5b48b4dab7e60fdc4979641846a/aiodataloader-0.4.3-py3-none-any.whl", hash = "sha256:f2d57675e4c7a5cf7efc4c42697d307b951e1a9f40c22df3531a4b9cb7758229", size = 11029, upload-time = "2025-11-29T10:14:09.417Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" }, + { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" }, + { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" }, + { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" }, + { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" }, + { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" }, + { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" }, + { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" }, + { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" }, + { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, + { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, + { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, + { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "graphene" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphql-core" }, + { name = "graphql-relay" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/f6/bf62ff950c317ed03e77f3f6ddd7e34aaa98fe89d79ebd660c55343d8054/graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa", size = 44739, upload-time = "2024-11-09T20:44:25.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/e0/61d8e98007182e6b2aca7cf65904721fb2e4bce0192272ab9cb6f69d8812/graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71", size = 114894, upload-time = "2024-11-09T20:44:23.851Z" }, +] + +[[package]] +name = "graphene-directives" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphene" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/6c/27990c29d132eb1ef0c000e99463b7294f361f1052bcd9dec5565ba7c64d/graphene_directives-0.5.0.tar.gz", hash = "sha256:09afc098028c01f14f4891c6e15eee0c6f3c9190853155c30bc72f89681363a5", size = 13864, upload-time = "2025-10-09T06:45:46.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/9e/b4738a5a6c69a76d04dd0b2d7459c614bdd2716cedb2325c7dd5ed1882bd/graphene_directives-0.5.0-py3-none-any.whl", hash = "sha256:655c636845c52facac3e352d95df28dabee86209b5c36a176afe6e5280734802", size = 17116, upload-time = "2025-10-09T06:45:45.633Z" }, +] + +[[package]] +name = "graphene-federation" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphene" }, + { name = "graphene-directives" }, + { name = "graphql-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/5e/1fb008817ad813071520a9ef0dbd2e9186c2a612a50aca231daee4a6f292/graphene_federation-3.3.0.tar.gz", hash = "sha256:2a142115cd9b199ea8b9bdf8f456b78e4c7e2efd08b448e74ffd129b5d55a46d", size = 35625, upload-time = "2025-10-09T08:20:06.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/a5/57b53d94bb5a940d036b8fee73d5a778a37e29bc21c3fbbf17c39b96dab4/graphene_federation-3.3.0-py3-none-any.whl", hash = "sha256:8f0ca52c7809ca333b8a7677c1366599644ae068bac4492273321593c8181b33", size = 50613, upload-time = "2025-10-09T08:20:05.277Z" }, +] + +[[package]] +name = "graphene-mongo" +version = "0.5.0" +source = { editable = "." } +dependencies = [ + { name = "aiodataloader" }, + { name = "graphene" }, + { name = "graphene-federation" }, + { name = "mongoengine" }, + { name = "promise" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mock" }, + { name = "mongomock" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "setuptools" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiodataloader", specifier = ">=0.4.3" }, + { name = "graphene", specifier = ">=3.1.1" }, + { name = "graphene-federation", specifier = ">=3.1.5" }, + { name = "mongoengine", git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.2" }, + { name = "promise", specifier = ">=2.3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mock", specifier = ">=5.0.1" }, + { name = "mongomock", specifier = ">=4.1.2" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "setuptools", specifier = ">=69.0.2,<79.0.0" }, +] + +[[package]] +name = "graphql-core" +version = "3.2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/c5/36aa96205c3ecbb3d34c7c24189e4553c7ca2ebc7e1dd07432339b980272/graphql_core-3.2.8.tar.gz", hash = "sha256:015457da5d996c924ddf57a43f4e959b0b94fb695b85ed4c29446e508ed65cf3", size = 513181, upload-time = "2026-03-05T19:55:37.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/41/cb887d9afc5dabd78feefe6ccbaf83ff423c206a7a1b7aeeac05120b2125/graphql_core-3.2.8-py3-none-any.whl", hash = "sha256:cbee07bee1b3ed5e531723685369039f32ff815ef60166686e0162f540f1520c", size = 207349, upload-time = "2026-03-05T19:55:35.911Z" }, +] + +[[package]] +name = "graphql-relay" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphql-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/13/98fbf8d67552f102488ffc16c6f559ce71ea15f6294728d33928ab5ff14d/graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c", size = 50027, upload-time = "2022-04-16T11:03:45.447Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/16/a4cf06adbc711bd364a73ce043b0b08d8fa5aae3df11b6ee4248bcdad2e0/graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5", size = 16940, upload-time = "2022-04-16T11:03:43.895Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mock" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/8c/14c2ae915e5f9dca5a22edd68b35be94400719ccfa068a03e0fb63d0f6f6/mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0", size = 92796, upload-time = "2025-03-03T12:31:42.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/d9/617e6af809bf3a1d468e0d58c3997b1dc219a9a9202e650d30c2fc85d481/mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f", size = 31617, upload-time = "2025-03-03T12:31:41.518Z" }, +] + +[[package]] +name = "mongoengine" +version = "0.30.0" +source = { git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.2#9c63038fbe76242320097092a147dbe997a2d931" } +dependencies = [ + { name = "pymongo" }, +] + +[[package]] +name = "mongomock" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytz" }, + { name = "sentinels" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/a4/4a560a9f2a0bec43d5f63104f55bc48666d619ca74825c8ae156b08547cf/mongomock-4.3.0.tar.gz", hash = "sha256:32667b79066fabc12d4f17f16a8fd7361b5f4435208b3ba32c226e52212a8c30", size = 135862, upload-time = "2024-11-16T11:23:25.957Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/4d/8bea712978e3aff017a2ab50f262c620e9239cc36f348aae45e48d6a4786/mongomock-4.3.0-py2.py3-none-any.whl", hash = "sha256:5ef86bd12fc8806c6e7af32f21266c61b6c4ba96096f85129852d1c4fec1327e", size = 64891, upload-time = "2024-11-16T11:23:24.748Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "promise" +version = "2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/9c/fb5d48abfe5d791cd496e4242ebcf87a4bb2e0c3dcd6e0ae68c11426a528/promise-2.3.tar.gz", hash = "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0", size = 19534, upload-time = "2019-12-18T07:31:43.07Z" } + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymongo" +version = "4.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/64/50be6fbac9c79fe2e4c17401a467da2d8764d82833d83cec325afe5cab32/pymongo-4.17.0.tar.gz", hash = "sha256:70ffa08ba641468cc068cf46c06b34f01a8ce3489f6411309fcb5ceabe6b2fc0", size = 2523370, upload-time = "2026-04-20T16:39:53.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/77/28ebbf69772a4341d530831c7a006cdb06877ac23075cb53b0a227df4fe1/pymongo-4.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:47b021363cd923ace5edc7a1d63c0ff8a6d9d43859b8a1ba23645f5afae63221", size = 819234, upload-time = "2026-04-20T16:37:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/88/cf/5a70cee503ff9a2fea20607607f14d189f4d975960ac0945ec306ee7b695/pymongo-4.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:422fa50d7d7f5c22ea0953554396c9ef95684a2d775f860bd75a7b510538dfca", size = 819969, upload-time = "2026-04-20T16:37:24.187Z" }, + { url = "https://files.pythonhosted.org/packages/23/d5/07b7e27e662c58d872efd104a0e8055eb6569aa1b6d4da436f3fdee7f897/pymongo-4.17.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:addd0498ebbdc6354227f6ed457ed9fce442d48a3bb30d5b5bad33e104996561", size = 1244510, upload-time = "2026-04-20T16:37:26.069Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/7cac5b1e89bd5a8e395067648241390321593a7c29243e36f91343c02a90/pymongo-4.17.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5c8e180cb2cabe37300e1e36c60aa4f2ff956cc579f0142135a5d2cba252243", size = 1263245, upload-time = "2026-04-20T16:37:28.003Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/40e8e99824c1fda18261411e65ce3b0cd3d9a6ed3c056cdd0a569adc870b/pymongo-4.17.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd835cdb37a1adec359dd072c24f8bb14809e2644fde86fab4ee2fc9719b9483", size = 1304113, upload-time = "2026-04-20T16:37:30.048Z" }, + { url = "https://files.pythonhosted.org/packages/3a/94/fb7e25441dd66f2069a9b172380849b0eaa5881c18b3db217bf64a6d393c/pymongo-4.17.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4979e7e8887862bbb44d203f00cc8263a3f27237876fa691b6beba23e40e6d8", size = 1297046, upload-time = "2026-04-20T16:37:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c9/7352e0c20fe772541556e4d283c05e07ec48f8b0d2737ad930ac4a1b6655/pymongo-4.17.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77aa4bc164b4de60d5db193b322f0f5b6ead716e831031bfdef8e8bd92205556", size = 1265708, upload-time = "2026-04-20T16:37:33.934Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e4/3df15494c2015ed297958517f0e4f6493e21b00990748068a973e66d45e0/pymongo-4.17.0-cp310-cp310-win32.whl", hash = "sha256:48bbc576677b50af043df870d84ded67cc3a9b4aa7553201beef4da5dc050a0a", size = 805533, upload-time = "2026-04-20T16:37:35.744Z" }, + { url = "https://files.pythonhosted.org/packages/22/fa/b4e71bb8cb82ad7d21bb4e8c476f2d573ba68b20368aac36ef06e4a196b4/pymongo-4.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46767f28dea610e02edf6c5d956ce615c3c7790ea396660b9b1efd5c5ead2e0", size = 815677, upload-time = "2026-04-20T16:37:37.808Z" }, + { url = "https://files.pythonhosted.org/packages/22/e2/0a4bba644f1cda3970ea1012149eeae3594ebfeed3f81fdaf32b61d90c95/pymongo-4.17.0-cp310-cp310-win_arm64.whl", hash = "sha256:757f2a4c0c2c46cab87df0333681ce69e86c9d5b45bc5203ceba5410b3489e59", size = 807293, upload-time = "2026-04-20T16:37:39.707Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e2/336d86f221cf1b56b2ed9330d4a3b98f9f38f0b37829ae9a9184617d5419/pymongo-4.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4141e6c6a339789b2974efa00ecd9409101672d77a0e3ee2cc3839eedf8ec4df", size = 874668, upload-time = "2026-04-20T16:37:41.39Z" }, + { url = "https://files.pythonhosted.org/packages/34/8e/75d3c6c935d187ab59c61e9c15d9aab3f274b563eaf1706e8cae5f508dec/pymongo-4.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e68c76b84e0c132d9dbf9307f12ff8185702328187a87b9aca8c941303873433", size = 875294, upload-time = "2026-04-20T16:37:43.432Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ec/62e855744489dbcd54fd778aae4d80fa4c4819e8fb228ca0cf6f21a03997/pymongo-4.17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba2195d4f386f839a52a23ea1cfd60ffaaba78a3d7841db51b7e433001139918", size = 1496233, upload-time = "2026-04-20T16:37:45.518Z" }, + { url = "https://files.pythonhosted.org/packages/82/e8/93e4e5e5ce8fdf8929dabeefe24aafa5ce046028eed0dfa8eeb936e72c49/pymongo-4.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446ff4bfcb6ec2a2e50998c860986a1e992136f998b7f53e7a717fb8aa5a0b9", size = 1522927, upload-time = "2026-04-20T16:37:47.492Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/425dc1d21e0f17bdea0072fc463f662f7fa06d2852af52975c9eced3c07c/pymongo-4.17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2a0d5ac205728c86e0a02192f1aa5f865b0d7d51f8df6101c01a69a7fc620d72", size = 1583468, upload-time = "2026-04-20T16:37:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9d/f08b07eeffda1a43c1759f0fa625e88ae12360996eb56d42aad832fa7dff/pymongo-4.17.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:485c8a8eaa4c739f00a331fc73757898ee7c092c214a79e63866ff76aaf282ff", size = 1572787, upload-time = "2026-04-20T16:37:51.061Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c2/6855a07aafa7b894929af23675b6fb9634800ce43122b76a62f6eeb8da2a/pymongo-4.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2dfcc795f5b9fedbe179a11fdf6051581479d196582a3fe819a92a00e9b9969", size = 1526184, upload-time = "2026-04-20T16:37:53.358Z" }, + { url = "https://files.pythonhosted.org/packages/4e/05/c952bac7db71c1942ea3559fcd308b49754cc5004b455935fb4000d1f37b/pymongo-4.17.0-cp311-cp311-win32.whl", hash = "sha256:c2292144505fb12156b981bd440f3dc994a883da06ac726c0c8692ccdbc1c510", size = 852621, upload-time = "2026-04-20T16:37:55.28Z" }, + { url = "https://files.pythonhosted.org/packages/11/c0/c04da9f4c0c6252404598f4e394b862a58a9e866822a70ae261c8a018fdf/pymongo-4.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:2e190827834fce70ecdf9d46796c6dbc0ce08ea87dc2ff5bc6f3f5579b605cb9", size = 867852, upload-time = "2026-04-20T16:37:57.233Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b2/c7b4870fbeef471e947d3e014676f5910d02e0197074d692ebcf24ec049a/pymongo-4.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:a8f9c40a09bb7d4b9fc8b1da65ecf6efa79bda5cb2756f39d9b6940fac1d19ae", size = 855019, upload-time = "2026-04-20T16:37:58.983Z" }, + { url = "https://files.pythonhosted.org/packages/98/90/60bcb508840135d5ee46b51b1a950f548338aa8145a8366dbe6639ae51ac/pymongo-4.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53ffa94b2340dbf6b055e09a0090618c60482c158ecfc9565642fc996bf0944", size = 930529, upload-time = "2026-04-20T16:38:00.936Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/313840f1e52c6dfac47f704428cbfbce59956ebe7633bffc92b03f74f0ad/pymongo-4.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6fe0de9d0f6791abce3471230b32b4817bf89d27b1182b6a550e1ec0fa72aa9a", size = 930665, upload-time = "2026-04-20T16:38:02.915Z" }, + { url = "https://files.pythonhosted.org/packages/78/35/9d3565ea45b1606f635c1e2cd2563c28d66caafdc50f7ad7d979fcd1b363/pymongo-4.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e537e95514dae1aaa718f481ec03151a0f0394bcd05f1322896d8fc1330cb729", size = 1762369, upload-time = "2026-04-20T16:38:05.375Z" }, + { url = "https://files.pythonhosted.org/packages/95/ee/149b0d4b1a11c38bff6f14c23d5814c9b0843fd6dc38ad40596bdb1a62d2/pymongo-4.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37a8385c29881b43eab31f584100fa0eaddedd5607adf010147ba1810118be90", size = 1798044, upload-time = "2026-04-20T16:38:07.195Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d4/4cee4a7b8d8f6f0550ef6cd2fea42455c5ed619a220cb6ba4fb40d6a5bc8/pymongo-4.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3ee3d241ed77a4fc99ce3cff3b289c3ebce37f61fdd7349d3592c23b82c8784", size = 1878567, upload-time = "2026-04-20T16:38:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/45/ef/7fe366c84952619ee2f69973566c214775e083dd4df465751912153e4b72/pymongo-4.17.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9eb5d63a3c518cb0804ed678f5e2b875af032d89a7cf57a57360322cf6a4d222", size = 1864881, upload-time = "2026-04-20T16:38:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/2f/35/b577d82c6d1be7aee7ac7e249bc86f7847998345042e5f8360de238e177b/pymongo-4.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e97e03fa13327c87e3fdc5656acd01e71817f0c1dc3221cd8f30de136bf4ec3", size = 1800349, upload-time = "2026-04-20T16:38:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/b8/69/dafcf04f66e130ddd91aeb92e7a692480eda46dcd04ec1dbe82c06619e10/pymongo-4.17.0-cp312-cp312-win32.whl", hash = "sha256:6877214bff5f06f6884a9fc8d9016a4a7a5f51f537f5c51ac3a576f93e7dfb32", size = 900518, upload-time = "2026-04-20T16:38:15.541Z" }, + { url = "https://files.pythonhosted.org/packages/11/35/5c9262a459f988b4eb2605f70815240b77a0d4131136c4326d18f1822b89/pymongo-4.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9828485f72f63c7d802e0ec41f71906f633c2692621ab3af55ca990186b091b1", size = 920335, upload-time = "2026-04-20T16:38:17.665Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/e9c7265ee176faccf4e52c4797837e794d93569a1046f6b19a4acc36e5ad/pymongo-4.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:1195370a77baf003b59b10e91ecc4706297197f0dd9d29c840cc556dc08f7cee", size = 903289, upload-time = "2026-04-20T16:38:19.33Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6b/c1206879708b94e82fcd8b9653440ec271f79a3674d122192df383047f5a/pymongo-4.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:809ec74de3b9148ae43fa8df9faf53470f511c8d384f13b99d6f671f2a379f15", size = 985829, upload-time = "2026-04-20T16:38:21.031Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cf/bb044ed85160e5c40f568c7c4f4e8ea16f40764ff5d302e5befbe8f6f814/pymongo-4.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a431b737816bf4cddd4fa0fcef04e424ad36b7692734a64150f872fb8f3208be", size = 985899, upload-time = "2026-04-20T16:38:23.409Z" }, + { url = "https://files.pythonhosted.org/packages/74/0a/f6dfd5ea3901e5d6888da8de8ba728971a1d447debab681cfc56f90d1208/pymongo-4.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4fab10f8403169ce92f3cea921609d9ee81107306caae06c08f592d4b8ad2b5", size = 2028569, upload-time = "2026-04-20T16:38:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c5/081f59a1c02ae8c0dc73ae58e563838c44eec81aeafa7d0b93a637841c9b/pymongo-4.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20323b0b1c1d33770ad1fc68d429c757734ce9ad3594421c3d6618f10572b1b9", size = 2072916, upload-time = "2026-04-20T16:38:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/31/42/6e41d434297ffe8b30d9c3717916591a4a7be9075a0dcc2fafdfaaaa62ed/pymongo-4.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5a5de048e6da5c18e27cc2437e8c15b3b0cdc8385c15b41178b0caa3322a09c2", size = 2173234, upload-time = "2026-04-20T16:38:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/3d/cf/1e4a7db352ef9485831c7268dfe8402f0117b32a9ad54b16e810699e3617/pymongo-4.17.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dff3de1294fbbc1db0ba6b511f77b8e540601d092538a31312e99c8a91a78b1e", size = 2156784, upload-time = "2026-04-20T16:38:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/12/10/6195be29962a61ebb5f4bd9e4c7519890b172f7968a0a0d880398c6ddb02/pymongo-4.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faf03e4c2aafd6de626dbd30ba246d369ae33f47f10629d1bbe40f72115027a6", size = 2074446, upload-time = "2026-04-20T16:38:34.004Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/33410b8819837ed370c738587306bdf060b59cef11823be212f4a07703c5/pymongo-4.17.0-cp313-cp313-win32.whl", hash = "sha256:c9786665926a09630c5d420c79762cfadbff35a9438bcbc4c81a9fb5ab9228b7", size = 948435, upload-time = "2026-04-20T16:38:35.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/77/c0ed522f798a286b99acaa7914ed8d9c80ab091f97f57c59ffed72906e5e/pymongo-4.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:5960519b4d7168f1ecdd3ea10c81b2aedeb9423651aca953cfbc8e76705d3b38", size = 972847, upload-time = "2026-04-20T16:38:37.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/f0/c39480a2db385fde23861d0c8acda41cdaf1d43e46579db72c5c013a2e81/pymongo-4.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:0ff6bd2f735ab5356541e3e57d5b7dbfbc3f2ee1ccb10b6b0f82d58af69d1d8e", size = 951575, upload-time = "2026-04-20T16:38:40.544Z" }, + { url = "https://files.pythonhosted.org/packages/da/49/2b0250762a89737ed6f9cea238331baca061b89a8ddd10dd17fee52c3970/pymongo-4.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff5aa3f1c7e3f08eb0e7a016c91ba468b1850ccfd63d9b1f12f56350f4974cef", size = 1040945, upload-time = "2026-04-20T16:38:42.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/1c/7a9b5447a08be20e84b6e5b17330917e8d6d9507daa3cd099a9309f11ad7/pymongo-4.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e816db649ba5d7de0568cf3a9f287a9dc9aad21cf0ca667ab156a7ef47fca0b0", size = 1041187, upload-time = "2026-04-20T16:38:45.358Z" }, + { url = "https://files.pythonhosted.org/packages/78/a1/71704f61632dfc90407a5834fe5f6132854937c4a3648f6c05c351d85a45/pymongo-4.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c4fded3a9f1d6a687e36ebd384ac6d00b9b00de1969aa74048e7051ec2a713", size = 2294806, upload-time = "2026-04-20T16:38:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b9/aff42be75108b96c2469b1d9329b912c15108f3e7ef32fdc86da8423c330/pymongo-4.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db66aa8dd253a0fc1fad3b0d23d5b3993f7ebde02fbbd7727128debf2853675", size = 2348231, upload-time = "2026-04-20T16:38:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/f2/30/44c115b8ba1479942c15fd9480eb29a7da0ba68acd56983423ba0deb4a94/pymongo-4.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3987e96e7c7be4083d42e8ac2cc6c0d5b78db9973c90fce42ae800b616ca6b20", size = 2467614, upload-time = "2026-04-20T16:38:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/d2/84/21ee95c8bf0ca7acae7ec7eb365d740bf8fc0156c194baf2c3bdfcb85ec0/pymongo-4.17.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cee36b3c0d0354f880fa7a7fdcdaf2bb5e542c2281e25c1bfadf8cfe21eba7d2", size = 2445970, upload-time = "2026-04-20T16:38:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/06/89/081d7f1809d5ca09d1e47e49f2111b245f5694de3a7af32cd3a353a6f43f/pymongo-4.17.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:320b34457b20bbcc79997801f95d25ce00472915ca5241167242b42c4359e027", size = 2348605, upload-time = "2026-04-20T16:38:57.557Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c3/0d949f9d3f2a341c1f635c398c16615e96f89f51ff424ed81e914cf1a4de/pymongo-4.17.0-cp314-cp314-win32.whl", hash = "sha256:df4a644af9ae132d4bfdb2e9516ea51a615fd881caddfbfbd071cf1354844479", size = 1004119, upload-time = "2026-04-20T16:39:00.309Z" }, + { url = "https://files.pythonhosted.org/packages/f7/55/5c3a3db1048054c695c75c5964cc8bedc2247fdb5a75ef6fab4ec8bb013e/pymongo-4.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:c797f8a80957134f6dd9690367a0f8f5906d672119af2c6aa55f0c527b656bed", size = 1032314, upload-time = "2026-04-20T16:39:02.665Z" }, + { url = "https://files.pythonhosted.org/packages/e0/19/e235f39906134cb0ffd5574c5a59c355ef5380f0499644ab94994afbb109/pymongo-4.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:68fca71e05ee5da23a8d73cee8379dfb3d26e609a377cae731d742771ed96946", size = 1007627, upload-time = "2026-04-20T16:39:04.678Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e0/c4c1a86791415b14c684fa0908f9da96de91594a3fd1fa1b8dc689fbb800/pymongo-4.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b4384700cffc3f1dd98e088bc0072dedf6d7d68a230bb4b972665cf69c071c1e", size = 1099151, upload-time = "2026-04-20T16:39:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/81/4b/69c67f3e23fd9b23b9bedc7ebd23754881cc9d5c5d5b2a9811e96b07f475/pymongo-4.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:93641192644fa1ee0f34030e774fd31022a27ad11ba22cb1716142231524f8bd", size = 1099346, upload-time = "2026-04-20T16:39:08.996Z" }, + { url = "https://files.pythonhosted.org/packages/a2/19/a5208f62f9508a26d73acc69bd3821b8c8adae253679a3c26d2f9652f0d5/pymongo-4.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:75bc3aa5b94fdb7138d357ec6ca61cd97e0c79f4f7f0bd3efe9639b15cc50942", size = 2619034, upload-time = "2026-04-20T16:39:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/77/27/426cba1ec5973082a56d4150798529bfdf4151c31391ed1fbbecb23ef2ac/pymongo-4.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e8f8e23c6df7c6d6929f5e734980b227706e73ee847517c9ba5af90f7fc466", size = 2689939, upload-time = "2026-04-20T16:39:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2e/f70993d1255e33f6ee59a4ec4371cc65bff7a7e3fda7d55c3386f25287e8/pymongo-4.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15d3f3d732aecac1f8d481bde4029755615639bd3076f258a2147210aec8515a", size = 2824994, upload-time = "2026-04-20T16:39:16.057Z" }, + { url = "https://files.pythonhosted.org/packages/b3/eb/87b0e988ba889e1fcc3430c2cfc166b251872c813e92b43174298bee17ff/pymongo-4.17.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5f62862d0f87be481fa1fe8cb811994486773c94a2b61e509285e3f2890763", size = 2801745, upload-time = "2026-04-20T16:39:18.476Z" }, + { url = "https://files.pythonhosted.org/packages/67/4c/3f83412d086f682d4d468761d66ddc49cf161e786ea74073045eb4491c60/pymongo-4.17.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64837adbbd72073301af51bb0fc80e3d7707fe5527cea1033ba0320f0b2f881b", size = 2684636, upload-time = "2026-04-20T16:39:20.878Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/b75f6f4ab6c8beb50b0270a4f1e2530b5774f5e116563440e1677ca1820f/pymongo-4.17.0-cp314-cp314t-win32.whl", hash = "sha256:b93b22eedc62598cf5ee9d8c8007a8e9121c50fd88137012d8985500e9dc3151", size = 1056356, upload-time = "2026-04-20T16:39:22.996Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5e/648c8a238eef18a25ed8a169ea6542d4a860bbec3e95b3d9badac2935c71/pymongo-4.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3689ea34f6b647c7d1e7bdc60fcfb214b2789ed1359a7fb96569c69f50e5f18f", size = 1090964, upload-time = "2026-04-20T16:39:24.989Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cb/d9780b66939c4fc1f024bcc7be23a2abcfe06a9745ca8fa76dc73395482e/pymongo-4.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9543d8f84c2e5608565c08ac679774811e6730770d8a645439b073422a4276fb", size = 1058526, upload-time = "2026-04-20T16:39:27.924Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +] + +[[package]] +name = "sentinels" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/9b/07195878aa25fe6ed209ec74bc55ae3e3d263b60a489c6e73fdca3c8fe05/sentinels-1.1.1.tar.gz", hash = "sha256:3c2f64f754187c19e0a1a029b148b74cf58dd12ec27b4e19c0e5d6e22b5a9a86", size = 4393, upload-time = "2025-08-12T07:57:50.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/65/dea992c6a97074f6d8ff9eab34741298cac2ce23e2b6c74fb7d08afdf85c/sentinels-1.1.1-py3-none-any.whl", hash = "sha256:835d3b28f3b47f5284afa4bf2db6e00f2dc5f80f9923d4b7e7aeeeccf6146a11", size = 3744, upload-time = "2025-08-12T07:57:48.858Z" }, +] + +[[package]] +name = "setuptools" +version = "78.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9c/42314ee079a3e9c24b27515f9fbc7a3c1d29992c33451779011c74488375/setuptools-78.1.1.tar.gz", hash = "sha256:fcc17fd9cd898242f6b4adfaca46137a9edef687f43e6f78469692a5e70d851d", size = 1368163, upload-time = "2025-04-19T18:23:36.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl", hash = "sha256:c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561", size = 1256462, upload-time = "2025-04-19T18:23:34.525Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] From 081dfb0061127dfd1772797e3a5ecffdd9fdca42 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 18:43:40 +0530 Subject: [PATCH 02/52] chore: remove obsolete build/config files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop setup.cfg, MANIFEST.in, and README.rst — all superseded by the uv/ hatchling setup in pyproject.toml. Migrate coverage omit config from setup.cfg into [tool.coverage.run] in pyproject.toml. Add .venv/ to .gitignore. --- .gitignore | 1 + MANIFEST.in | 4 --- README.rst | 77 -------------------------------------------------- pyproject.toml | 3 ++ setup.cfg | 18 ------------ 5 files changed, 4 insertions(+), 99 deletions(-) delete mode 100644 MANIFEST.in delete mode 100644 README.rst delete mode 100644 setup.cfg diff --git a/.gitignore b/.gitignore index 356acad8..0ad41f6e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ htmlcov/ *.swo *.swp venv/ +.venv/ .vscode/ \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index e6694fd5..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -global-exclude *.py[cod] -recursive-exclude examples * -recursive-exclude graphene-mongo/tests * -include README.md LICENSE diff --git a/README.rst b/README.rst deleted file mode 100644 index 12f7829b..00000000 --- a/README.rst +++ /dev/null @@ -1,77 +0,0 @@ -.. image:: https://travis-ci.org/graphql-python/graphene-mongo.svg?branch=master - :target: https://travis-ci.org/graphql-python/graphene-mongo -.. image:: https://coveralls.io/repos/github/graphql-python/graphene-mongo/badge.svg?branch=master - :target: https://coveralls.io/github/graphql-python/graphene-mongo?branch=master -.. image:: https://badge.fury.io/py/graphene-mongo.svg - :target: https://badge.fury.io/py/graphene-mongo -.. image:: https://img.shields.io/pypi/pyversions/graphene-mongo.svg - :target: https://pypi.python.org/pypi/graphene-mongo/ - -Graphene-Mongo -============== - -A `Mongoengine `__ integration for `Graphene `__. - -Installation ------------- - -For installing graphene-mongo, just run this command in your shell - -.. code:: bash - - pip install graphene-mongo - -Examples --------- - -Here is a simple Mongoengine model as `models.py`: - -.. code:: python - - from mongoengine import Document - from mongoengine.fields import StringField - - class User(Document): - meta = {'collection': 'user'} - first_name = StringField(required=True) - last_name = StringField(required=True) - - -To create a GraphQL schema for it you simply have to write the following: - -.. code:: python - - import graphene - - from graphene_mongo import MongoengineObjectType - - from .models import User as UserModel - - class User(MongoengineObjectType): - class Meta: - model = UserModel - - class Query(graphene.ObjectType): - users = graphene.List(User) - - def resolve_users(self, info): - return list(UserModel.objects.all()) - - schema = graphene.Schema(query=Query) - -Then you can simply query the schema: - -.. code:: python - - query = ''' - query { - users { - firstName, - lastName - } - } - ''' - result = await schema.execute_async(query, context_value=dict()) - -To learn more check out the `Flask MongoEngine example `__ - diff --git a/pyproject.toml b/pyproject.toml index 20f6b53d..37375fa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,9 @@ build-backend = "hatchling.build" packages = ["graphene_mongo"] exclude = ["graphene_mongo/tests"] +[tool.coverage.run] +omit = ["*/tests/*"] + [tool.ruff] line-length = 100 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index cc6bfe8e..00000000 --- a/setup.cfg +++ /dev/null @@ -1,18 +0,0 @@ -[metadata] -description_file = README.md - -[flake8] -exclude = setup.py,docs/*,examples/* -max-line-length = 150 - -[coverage:run] -omit = */tests/* - -[isort] -known_first_party=graphene,graphene_mongo - -[aliases] -test=pytest - -[tool:pytest] -python_files = graphene_mongo/tests/*.py From b85d840c1f497376dd95e58fd5911ec6c1a719ec Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 19:36:33 +0530 Subject: [PATCH 03/52] refactor: move tests/ to project root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate graphene_mongo/tests/ → tests/ and convert all relative `from ..` imports to absolute `from graphene_mongo.` imports. Update Makefile and pyproject.toml paths accordingly. --- Makefile | 2 +- pyproject.toml | 2 +- {graphene_mongo/tests => tests}/__init__.py | 0 {graphene_mongo/tests => tests}/conftest.py | 0 {graphene_mongo/tests => tests}/fixtures/image.jpg | Bin {graphene_mongo/tests => tests}/models.py | 0 {graphene_mongo/tests => tests}/nodes.py | 2 +- {graphene_mongo/tests => tests}/nodes_async.py | 2 +- {graphene_mongo/tests => tests}/test_converter.py | 10 +++++----- {graphene_mongo/tests => tests}/test_fields.py | 4 ++-- {graphene_mongo/tests => tests}/test_inputs.py | 0 {graphene_mongo/tests => tests}/test_mutation.py | 0 {graphene_mongo/tests => tests}/test_query.py | 0 {graphene_mongo/tests => tests}/test_relay_query.py | 4 ++-- .../tests => tests}/test_relay_query_async.py | 2 +- {graphene_mongo/tests => tests}/test_types.py | 4 ++-- {graphene_mongo/tests => tests}/test_utils.py | 2 +- {graphene_mongo/tests => tests}/types.py | 2 +- {graphene_mongo/tests => tests}/utils.py | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) rename {graphene_mongo/tests => tests}/__init__.py (100%) rename {graphene_mongo/tests => tests}/conftest.py (100%) rename {graphene_mongo/tests => tests}/fixtures/image.jpg (100%) rename {graphene_mongo/tests => tests}/models.py (100%) rename {graphene_mongo/tests => tests}/nodes.py (97%) rename {graphene_mongo/tests => tests}/nodes_async.py (97%) rename {graphene_mongo/tests => tests}/test_converter.py (97%) rename {graphene_mongo/tests => tests}/test_fields.py (94%) rename {graphene_mongo/tests => tests}/test_inputs.py (100%) rename {graphene_mongo/tests => tests}/test_mutation.py (100%) rename {graphene_mongo/tests => tests}/test_query.py (100%) rename {graphene_mongo/tests => tests}/test_relay_query.py (99%) rename {graphene_mongo/tests => tests}/test_relay_query_async.py (99%) rename {graphene_mongo/tests => tests}/test_types.py (97%) rename {graphene_mongo/tests => tests}/test_utils.py (96%) rename {graphene_mongo/tests => tests}/types.py (98%) rename {graphene_mongo/tests => tests}/utils.py (91%) diff --git a/Makefile b/Makefile index ef8bc70f..d3f1fa01 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ lint: @uv run ruff format . --check test: clean - uv run pytest graphene_mongo/tests --cov=graphene_mongo --cov-report=html --cov-report=term + uv run pytest tests --cov=graphene_mongo --cov-report=html --cov-report=term register-pypitest: #python setup.py register -r pypitest diff --git a/pyproject.toml b/pyproject.toml index 37375fa1..5ec72625 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["graphene_mongo"] -exclude = ["graphene_mongo/tests"] +exclude = ["graphene_mongo/tests", "tests"] [tool.coverage.run] omit = ["*/tests/*"] diff --git a/graphene_mongo/tests/__init__.py b/tests/__init__.py similarity index 100% rename from graphene_mongo/tests/__init__.py rename to tests/__init__.py diff --git a/graphene_mongo/tests/conftest.py b/tests/conftest.py similarity index 100% rename from graphene_mongo/tests/conftest.py rename to tests/conftest.py diff --git a/graphene_mongo/tests/fixtures/image.jpg b/tests/fixtures/image.jpg similarity index 100% rename from graphene_mongo/tests/fixtures/image.jpg rename to tests/fixtures/image.jpg diff --git a/graphene_mongo/tests/models.py b/tests/models.py similarity index 100% rename from graphene_mongo/tests/models.py rename to tests/models.py diff --git a/graphene_mongo/tests/nodes.py b/tests/nodes.py similarity index 97% rename from graphene_mongo/tests/nodes.py rename to tests/nodes.py index 32f10a8c..fac1a588 100644 --- a/graphene_mongo/tests/nodes.py +++ b/tests/nodes.py @@ -4,7 +4,7 @@ from . import models from . import types # noqa: F401 from .models import ProfessorMetadata -from ..types import MongoengineObjectType +from graphene_mongo.types import MongoengineObjectType class PublisherNode(MongoengineObjectType): diff --git a/graphene_mongo/tests/nodes_async.py b/tests/nodes_async.py similarity index 97% rename from graphene_mongo/tests/nodes_async.py rename to tests/nodes_async.py index a38635ce..0a6f4193 100644 --- a/graphene_mongo/tests/nodes_async.py +++ b/tests/nodes_async.py @@ -4,7 +4,7 @@ from . import models from . import types # noqa: F401 from .models import ProfessorMetadata -from ..types_async import AsyncMongoengineObjectType +from graphene_mongo.types_async import AsyncMongoengineObjectType class PublisherAsyncNode(AsyncMongoengineObjectType): diff --git a/graphene_mongo/tests/test_converter.py b/tests/test_converter.py similarity index 97% rename from graphene_mongo/tests/test_converter.py rename to tests/test_converter.py index aef96c62..e45695da 100644 --- a/graphene_mongo/tests/test_converter.py +++ b/tests/test_converter.py @@ -13,11 +13,11 @@ ProfessorVector, Publisher, ) -from .. import registry -from .. import advanced_types -from ..converter import convert_mongoengine_field -from ..fields import MongoengineConnectionField -from ..types import MongoengineObjectType +from graphene_mongo import registry +from graphene_mongo import advanced_types +from graphene_mongo.converter import convert_mongoengine_field +from graphene_mongo.fields import MongoengineConnectionField +from graphene_mongo.types import MongoengineObjectType def assert_conversion(mongoengine_field, graphene_field, *args, **kwargs): diff --git a/graphene_mongo/tests/test_fields.py b/tests/test_fields.py similarity index 94% rename from graphene_mongo/tests/test_fields.py rename to tests/test_fields.py index 157e1c10..af6a334f 100644 --- a/graphene_mongo/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,8 +1,8 @@ import pytest from . import nodes, nodes_async -from .. import AsyncMongoengineConnectionField -from ..fields import MongoengineConnectionField +from graphene_mongo import AsyncMongoengineConnectionField +from graphene_mongo.fields import MongoengineConnectionField def test_article_field_args(): diff --git a/graphene_mongo/tests/test_inputs.py b/tests/test_inputs.py similarity index 100% rename from graphene_mongo/tests/test_inputs.py rename to tests/test_inputs.py diff --git a/graphene_mongo/tests/test_mutation.py b/tests/test_mutation.py similarity index 100% rename from graphene_mongo/tests/test_mutation.py rename to tests/test_mutation.py diff --git a/graphene_mongo/tests/test_query.py b/tests/test_query.py similarity index 100% rename from graphene_mongo/tests/test_query.py rename to tests/test_query.py diff --git a/graphene_mongo/tests/test_relay_query.py b/tests/test_relay_query.py similarity index 99% rename from graphene_mongo/tests/test_relay_query.py rename to tests/test_relay_query.py index e1509fb2..29c85de8 100644 --- a/graphene_mongo/tests/test_relay_query.py +++ b/tests/test_relay_query.py @@ -9,8 +9,8 @@ from . import models from . import nodes -from ..fields import MongoengineConnectionField -from ..types import MongoengineObjectType +from graphene_mongo.fields import MongoengineConnectionField +from graphene_mongo.types import MongoengineObjectType @pytest.mark.asyncio diff --git a/graphene_mongo/tests/test_relay_query_async.py b/tests/test_relay_query_async.py similarity index 99% rename from graphene_mongo/tests/test_relay_query_async.py rename to tests/test_relay_query_async.py index 41169fa4..22d8c2be 100644 --- a/graphene_mongo/tests/test_relay_query_async.py +++ b/tests/test_relay_query_async.py @@ -9,7 +9,7 @@ from . import models from . import nodes_async -from .. import AsyncMongoengineConnectionField, AsyncMongoengineObjectType +from graphene_mongo import AsyncMongoengineConnectionField, AsyncMongoengineObjectType @pytest.mark.asyncio diff --git a/graphene_mongo/tests/test_types.py b/tests/test_types.py similarity index 97% rename from graphene_mongo/tests/test_types.py rename to tests/test_types.py index af21ab21..09861a01 100644 --- a/graphene_mongo/tests/test_types.py +++ b/tests/test_types.py @@ -15,8 +15,8 @@ Student, ) from .utils import with_local_registry -from .. import registry -from ..types import MongoengineObjectType, MongoengineObjectTypeOptions +from graphene_mongo import registry +from graphene_mongo.types import MongoengineObjectType, MongoengineObjectTypeOptions registry.reset_global_registry() diff --git a/graphene_mongo/tests/test_utils.py b/tests/test_utils.py similarity index 96% rename from graphene_mongo/tests/test_utils.py rename to tests/test_utils.py index e8b6c5bf..472a83a2 100644 --- a/graphene_mongo/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,7 +2,7 @@ from . import types from .models import Article, Child, Reporter -from ..utils import get_model_fields, get_query_fields, is_valid_mongoengine_model +from graphene_mongo.utils import get_model_fields, get_query_fields, is_valid_mongoengine_model def test_get_model_fields_no_duplication(): diff --git a/graphene_mongo/tests/types.py b/tests/types.py similarity index 98% rename from graphene_mongo/tests/types.py rename to tests/types.py index 45f00177..9a22c1cf 100644 --- a/graphene_mongo/tests/types.py +++ b/tests/types.py @@ -1,5 +1,5 @@ from . import models -from ..types import ( +from graphene_mongo.types import ( MongoengineObjectType, MongoengineInterfaceType, MongoengineInputType, diff --git a/graphene_mongo/tests/utils.py b/tests/utils.py similarity index 91% rename from graphene_mongo/tests/utils.py rename to tests/utils.py index 73b0614e..24a37f01 100644 --- a/graphene_mongo/tests/utils.py +++ b/tests/utils.py @@ -1,4 +1,4 @@ -from .. import registry +from graphene_mongo import registry def with_local_registry(func): From a93f527a0612ea2da5600d1a1840b70c87736e9c Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 19:42:37 +0530 Subject: [PATCH 04/52] refactor: split tests into synchronous/ and asynchronous/ subdirectories Mirror the mongoengine project structure: separate sync and async tests into tests/synchronous/ and tests/asynchronous/. Shared fixtures stay in tests/conftest.py; models.py and types.py stay at tests/ root. Update all relative imports accordingly. --- tests/asynchronous/__init__.py | 0 .../{nodes_async.py => asynchronous/nodes.py} | 6 +-- tests/asynchronous/test_fields.py | 21 ++++++++ .../test_relay_query.py} | 52 +++++++++---------- tests/synchronous/__init__.py | 0 tests/{ => synchronous}/nodes.py | 6 +-- tests/{ => synchronous}/test_converter.py | 2 +- tests/{ => synchronous}/test_fields.py | 21 +------- tests/{ => synchronous}/test_inputs.py | 4 +- tests/{ => synchronous}/test_mutation.py | 2 +- tests/{ => synchronous}/test_query.py | 4 +- tests/{ => synchronous}/test_relay_query.py | 2 +- tests/{ => synchronous}/test_types.py | 2 +- tests/{ => synchronous}/test_utils.py | 4 +- tests/{ => synchronous}/utils.py | 0 15 files changed, 65 insertions(+), 61 deletions(-) create mode 100644 tests/asynchronous/__init__.py rename tests/{nodes_async.py => asynchronous/nodes.py} (96%) create mode 100644 tests/asynchronous/test_fields.py rename tests/{test_relay_query_async.py => asynchronous/test_relay_query.py} (93%) create mode 100644 tests/synchronous/__init__.py rename tests/{ => synchronous}/nodes.py (96%) rename tests/{ => synchronous}/test_converter.py (99%) rename tests/{ => synchronous}/test_fields.py (66%) rename tests/{ => synchronous}/test_inputs.py (96%) rename tests/{ => synchronous}/test_mutation.py (98%) rename tests/{ => synchronous}/test_query.py (99%) rename tests/{ => synchronous}/test_relay_query.py (99%) rename tests/{ => synchronous}/test_types.py (99%) rename tests/{ => synchronous}/test_utils.py (97%) rename tests/{ => synchronous}/utils.py (100%) diff --git a/tests/asynchronous/__init__.py b/tests/asynchronous/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/nodes_async.py b/tests/asynchronous/nodes.py similarity index 96% rename from tests/nodes_async.py rename to tests/asynchronous/nodes.py index 0a6f4193..0764fe2d 100644 --- a/tests/nodes_async.py +++ b/tests/asynchronous/nodes.py @@ -1,9 +1,9 @@ import graphene from graphene.relay import Node -from . import models -from . import types # noqa: F401 -from .models import ProfessorMetadata +from .. import models +from .. import types # noqa: F401 +from ..models import ProfessorMetadata from graphene_mongo.types_async import AsyncMongoengineObjectType diff --git a/tests/asynchronous/test_fields.py b/tests/asynchronous/test_fields.py new file mode 100644 index 00000000..4a337e1d --- /dev/null +++ b/tests/asynchronous/test_fields.py @@ -0,0 +1,21 @@ +import pytest + +from . import nodes +from graphene_mongo import AsyncMongoengineConnectionField + + +@pytest.mark.asyncio +async def test_default_resolver_with_colliding_objects_field_async(): + field = AsyncMongoengineConnectionField(nodes.ErroneousModelAsyncNode) + + connection = await field.default_resolver(None, {}) + assert 0 == len(connection.iterable) + + +@pytest.mark.asyncio +async def test_default_resolver_connection_list_length_async(fixtures): + field = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) + + connection = await field.default_resolver(None, {}, **{"first": 1}) + assert hasattr(connection, "list_length") + assert connection.list_length == 1 \ No newline at end of file diff --git a/tests/test_relay_query_async.py b/tests/asynchronous/test_relay_query.py similarity index 93% rename from tests/test_relay_query_async.py rename to tests/asynchronous/test_relay_query.py index 22d8c2be..1f341994 100644 --- a/tests/test_relay_query_async.py +++ b/tests/asynchronous/test_relay_query.py @@ -7,15 +7,15 @@ from graphene.relay import Node from graphql_relay.node.node import to_global_id -from . import models -from . import nodes_async +from .. import models +from . import nodes from graphene_mongo import AsyncMongoengineConnectionField, AsyncMongoengineObjectType @pytest.mark.asyncio async def test_should_query_reporter_async(fixtures): class Query(graphene.ObjectType): - reporter = graphene.Field(nodes_async.ReporterAsyncNode) + reporter = graphene.Field(nodes.ReporterAsyncNode) async def resolve_reporter(self, *args, **kwargs): return models.Reporter.objects.first() @@ -94,7 +94,7 @@ async def resolve_reporter(self, *args, **kwargs): @pytest.mark.asyncio async def test_should_query_reporters_with_nested_document_async(fixtures): class Query(graphene.ObjectType): - reporters = AsyncMongoengineConnectionField(nodes_async.ReporterAsyncNode) + reporters = AsyncMongoengineConnectionField(nodes.ReporterAsyncNode) query = """ query ReporterQuery { @@ -140,7 +140,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_query_all_editors_async(fixtures, fixtures_dirname): class Query(graphene.ObjectType): - editors = AsyncMongoengineConnectionField(nodes_async.EditorAsyncNode) + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) query = """ query EditorQuery { @@ -224,7 +224,7 @@ class Meta: model = models.Editor interfaces = (graphene.Node,) - articles = AsyncMongoengineConnectionField(nodes_async.ArticleAsyncNode) + articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) async def resolve_articles(self, *args, **kwargs): return article_loader.load(self) @@ -272,7 +272,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_filter_editors_by_id_async(fixtures): class Query(graphene.ObjectType): - editors = AsyncMongoengineConnectionField(nodes_async.EditorAsyncNode) + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) query = """ query EditorQuery { @@ -309,7 +309,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_filter_async(fixtures): class Query(graphene.ObjectType): - articles = AsyncMongoengineConnectionField(nodes_async.ArticleAsyncNode) + articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) query = """ query ArticlesQuery { @@ -348,7 +348,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_filter_by_reference_field_async(fixtures): class Query(graphene.ObjectType): - articles = AsyncMongoengineConnectionField(nodes_async.ArticleAsyncNode) + articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) query = """ query ArticlesQuery { @@ -377,7 +377,7 @@ class Query(graphene.ObjectType): async def test_should_filter_through_inheritance_async(fixtures): class Query(graphene.ObjectType): node = Node.Field() - children = AsyncMongoengineConnectionField(nodes_async.ChildAsyncNode) + children = AsyncMongoengineConnectionField(nodes.ChildAsyncNode) query = """ query ChildrenQuery { @@ -418,7 +418,7 @@ class Query(graphene.ObjectType): async def test_should_filter_by_list_contains_async(fixtures): # Notes: https://goo.gl/hMNRgs class Query(graphene.ObjectType): - reporters = AsyncMongoengineConnectionField(nodes_async.ReporterAsyncNode) + reporters = AsyncMongoengineConnectionField(nodes.ReporterAsyncNode) query = """ query ReportersQuery { @@ -465,7 +465,7 @@ class Query(graphene.ObjectType): async def test_should_filter_by_id_async(fixtures): # Notes: https://goo.gl/hMNRgs class Query(graphene.ObjectType): - reporter = Node.Field(nodes_async.ReporterAsyncNode) + reporter = Node.Field(nodes.ReporterAsyncNode) query = """ query ReporterQuery { @@ -492,7 +492,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_first_n_async(fixtures): class Query(graphene.ObjectType): - editors = AsyncMongoengineConnectionField(nodes_async.EditorAsyncNode) + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) query = """ query EditorQuery { @@ -536,7 +536,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_after_async(fixtures): class Query(graphene.ObjectType): - players = AsyncMongoengineConnectionField(nodes_async.PlayerAsyncNode) + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) query = """ query EditorQuery { @@ -569,7 +569,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_before_async(fixtures): class Query(graphene.ObjectType): - players = AsyncMongoengineConnectionField(nodes_async.PlayerAsyncNode) + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) query = """ query EditorQuery { @@ -604,7 +604,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_last_n_async(fixtures): class Query(graphene.ObjectType): - players = AsyncMongoengineConnectionField(nodes_async.PlayerAsyncNode) + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) query = """ query PlayerQuery { @@ -636,7 +636,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_self_reference_async(fixtures): class Query(graphene.ObjectType): - players = AsyncMongoengineConnectionField(nodes_async.PlayerAsyncNode) + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) query = """ query PlayersQuery { @@ -712,7 +712,7 @@ class Query(graphene.ObjectType): async def test_should_lazy_reference_async(fixtures): class Query(graphene.ObjectType): node = Node.Field() - parents = AsyncMongoengineConnectionField(nodes_async.ParentWithRelationshipAsyncNode) + parents = AsyncMongoengineConnectionField(nodes.ParentWithRelationshipAsyncNode) schema = graphene.Schema(query=Query) @@ -768,7 +768,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_query_with_embedded_document_async(fixtures): class Query(graphene.ObjectType): - professors = AsyncMongoengineConnectionField(nodes_async.ProfessorVectorAsyncNode) + professors = AsyncMongoengineConnectionField(nodes.ProfessorVectorAsyncNode) query = """ query { @@ -800,7 +800,7 @@ async def test_should_get_queryset_returns_dict_filters_async(fixtures): class Query(graphene.ObjectType): node = Node.Field() articles = AsyncMongoengineConnectionField( - nodes_async.ArticleAsyncNode, + nodes.ArticleAsyncNode, get_queryset=lambda *_, **__: {"headline": "World"}, ) @@ -846,7 +846,7 @@ def get_queryset(model, info, **args): class Query(graphene.ObjectType): node = Node.Field() articles = AsyncMongoengineConnectionField( - nodes_async.ArticleAsyncNode, get_queryset=get_queryset + nodes.ArticleAsyncNode, get_queryset=get_queryset ) query = """ @@ -886,7 +886,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_filter_mongoengine_queryset_async(fixtures): class Query(graphene.ObjectType): - players = AsyncMongoengineConnectionField(nodes_async.PlayerAsyncNode) + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) query = """ query players { @@ -917,7 +917,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_query_document_with_embedded_async(fixtures): class Query(graphene.ObjectType): - foos = AsyncMongoengineConnectionField(nodes_async.FooAsyncNode) + foos = AsyncMongoengineConnectionField(nodes.FooAsyncNode) async def resolve_multiple_foos(self, *args, **kwargs): return list(models.Foo.objects.all()) @@ -948,7 +948,7 @@ async def resolve_multiple_foos(self, *args, **kwargs): @pytest.mark.asyncio async def test_should_filter_mongoengine_queryset_with_list_async(fixtures): class Query(graphene.ObjectType): - players = AsyncMongoengineConnectionField(nodes_async.PlayerAsyncNode) + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) query = """ query players { @@ -979,7 +979,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio async def test_should_get_correct_list_of_documents_async(fixtures): class Query(graphene.ObjectType): - players = AsyncMongoengineConnectionField(nodes_async.PlayerAsyncNode) + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) query = """ query players { @@ -1036,7 +1036,7 @@ async def test_should_filter_mongoengine_queryset_by_id_and_other_fields_async( fixtures, ): class Query(graphene.ObjectType): - players = AsyncMongoengineConnectionField(nodes_async.PlayerAsyncNode) + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) larry = models.Player.objects.get(first_name="Larry") larry_relay_id = to_global_id("PlayerAsyncNode", larry.id) diff --git a/tests/synchronous/__init__.py b/tests/synchronous/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/nodes.py b/tests/synchronous/nodes.py similarity index 96% rename from tests/nodes.py rename to tests/synchronous/nodes.py index fac1a588..3d82b63d 100644 --- a/tests/nodes.py +++ b/tests/synchronous/nodes.py @@ -1,9 +1,9 @@ import graphene from graphene.relay import Node -from . import models -from . import types # noqa: F401 -from .models import ProfessorMetadata +from .. import models +from .. import types # noqa: F401 +from ..models import ProfessorMetadata from graphene_mongo.types import MongoengineObjectType diff --git a/tests/test_converter.py b/tests/synchronous/test_converter.py similarity index 99% rename from tests/test_converter.py rename to tests/synchronous/test_converter.py index e45695da..75577982 100644 --- a/tests/test_converter.py +++ b/tests/synchronous/test_converter.py @@ -2,7 +2,7 @@ import mongoengine from pytest import raises -from .models import ( +from ..models import ( Article, Editor, EmbeddedArticle, diff --git a/tests/test_fields.py b/tests/synchronous/test_fields.py similarity index 66% rename from tests/test_fields.py rename to tests/synchronous/test_fields.py index af6a334f..3e4f18bb 100644 --- a/tests/test_fields.py +++ b/tests/synchronous/test_fields.py @@ -1,6 +1,6 @@ import pytest -from . import nodes, nodes_async +from . import nodes from graphene_mongo import AsyncMongoengineConnectionField from graphene_mongo.fields import MongoengineConnectionField @@ -44,21 +44,4 @@ def test_field_args_with_unconverted_field(): field = MongoengineConnectionField(nodes.PublisherNode) field_args = ["id", "name"] - assert set(field.field_args.keys()) == set(field_args) - - -@pytest.mark.asyncio -async def test_default_resolver_with_colliding_objects_field_async(): - field = AsyncMongoengineConnectionField(nodes_async.ErroneousModelAsyncNode) - - connection = await field.default_resolver(None, {}) - assert 0 == len(connection.iterable) - - -@pytest.mark.asyncio -async def test_default_resolver_connection_list_length_async(fixtures): - field = AsyncMongoengineConnectionField(nodes_async.ArticleAsyncNode) - - connection = await field.default_resolver(None, {}, **{"first": 1}) - assert hasattr(connection, "list_length") - assert connection.list_length == 1 + assert set(field.field_args.keys()) == set(field_args) \ No newline at end of file diff --git a/tests/test_inputs.py b/tests/synchronous/test_inputs.py similarity index 96% rename from tests/test_inputs.py rename to tests/synchronous/test_inputs.py index 25eeea9d..71a89219 100644 --- a/tests/test_inputs.py +++ b/tests/synchronous/test_inputs.py @@ -3,9 +3,9 @@ from graphene.relay import Node -from .models import Article, Editor +from ..models import Article, Editor from .nodes import ArticleNode, EditorNode -from .types import ArticleInput, EditorInput +from ..types import ArticleInput, EditorInput @pytest.mark.asyncio diff --git a/tests/test_mutation.py b/tests/synchronous/test_mutation.py similarity index 98% rename from tests/test_mutation.py rename to tests/synchronous/test_mutation.py index ad4aa587..67ea7e5f 100644 --- a/tests/test_mutation.py +++ b/tests/synchronous/test_mutation.py @@ -3,7 +3,7 @@ from graphene.relay import Node -from .models import Article, Editor +from ..models import Article, Editor from .nodes import ArticleNode, EditorNode diff --git a/tests/test_query.py b/tests/synchronous/test_query.py similarity index 99% rename from tests/test_query.py rename to tests/synchronous/test_query.py index f1fd7007..fb9bfa87 100644 --- a/tests/test_query.py +++ b/tests/synchronous/test_query.py @@ -5,8 +5,8 @@ import graphene import pytest -from . import models -from . import types +from .. import models +from .. import types @pytest.mark.asyncio diff --git a/tests/test_relay_query.py b/tests/synchronous/test_relay_query.py similarity index 99% rename from tests/test_relay_query.py rename to tests/synchronous/test_relay_query.py index 29c85de8..b9139da7 100644 --- a/tests/test_relay_query.py +++ b/tests/synchronous/test_relay_query.py @@ -7,7 +7,7 @@ from graphene.relay import Node from graphql_relay.node.node import to_global_id -from . import models +from .. import models from . import nodes from graphene_mongo.fields import MongoengineConnectionField from graphene_mongo.types import MongoengineObjectType diff --git a/tests/test_types.py b/tests/synchronous/test_types.py similarity index 99% rename from tests/test_types.py rename to tests/synchronous/test_types.py index 09861a01..6281fb6f 100644 --- a/tests/test_types.py +++ b/tests/synchronous/test_types.py @@ -2,7 +2,7 @@ from graphene.relay import Node, is_node from pytest import raises -from .models import ( +from ..models import ( Article, Bench, Child, diff --git a/tests/test_utils.py b/tests/synchronous/test_utils.py similarity index 97% rename from tests/test_utils.py rename to tests/synchronous/test_utils.py index 472a83a2..f24b710e 100644 --- a/tests/test_utils.py +++ b/tests/synchronous/test_utils.py @@ -1,7 +1,7 @@ import graphene -from . import types -from .models import Article, Child, Reporter +from .. import types +from ..models import Article, Child, Reporter from graphene_mongo.utils import get_model_fields, get_query_fields, is_valid_mongoengine_model diff --git a/tests/utils.py b/tests/synchronous/utils.py similarity index 100% rename from tests/utils.py rename to tests/synchronous/utils.py From 3bc556b63d60283994ed56bfdc80b518dac3c0fb Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 19:49:38 +0530 Subject: [PATCH 05/52] refactor: convert synchronous/ tests to true sync and fix get_node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove @pytest.mark.asyncio and async/await from all tests in synchronous/ — they test MongoengineObjectType and MongoengineConnectionField which are fully sync, so test functions and resolvers should be plain def using schema.execute(). Also fix MongoengineObjectType.get_node in types.py: it was declared async def but its body is entirely synchronous, causing schema.execute() to receive an unawaited coroutine instead of the document instance. --- graphene_mongo/types.py | 2 +- tests/synchronous/test_inputs.py | 16 +-- tests/synchronous/test_mutation.py | 16 +-- tests/synchronous/test_query.py | 74 +++++------ tests/synchronous/test_relay_query.py | 150 +++++++++++----------- tests/{synchronous => }/test_converter.py | 2 +- 6 files changed, 130 insertions(+), 130 deletions(-) rename tests/{synchronous => }/test_converter.py (99%) diff --git a/graphene_mongo/types.py b/graphene_mongo/types.py index 77f493b7..cad483f5 100644 --- a/graphene_mongo/types.py +++ b/graphene_mongo/types.py @@ -224,7 +224,7 @@ def is_type_of(cls, root, info): return isinstance(root, cls._meta.model) @classmethod - async def get_node(cls, info, id): + def get_node(cls, info, id): required_fields = list() for field in cls._meta.required_fields: if field in cls._meta.model._fields_ordered: diff --git a/tests/synchronous/test_inputs.py b/tests/synchronous/test_inputs.py index 71a89219..2a6930ec 100644 --- a/tests/synchronous/test_inputs.py +++ b/tests/synchronous/test_inputs.py @@ -8,15 +8,15 @@ from ..types import ArticleInput, EditorInput -@pytest.mark.asyncio -async def test_should_create(fixtures): + +def test_should_create(fixtures): class CreateArticle(graphene.Mutation): class Arguments: article = ArticleInput(required=True) article = graphene.Field(ArticleNode) - async def mutate(self, info, article): + def mutate(self, info, article): article = Article(**article) article.save() @@ -41,13 +41,13 @@ class Mutation(graphene.ObjectType): """ expected = {"createArticle": {"article": {"headline": "My Article"}}} schema = graphene.Schema(query=Query, mutation=Mutation) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_update(fixtures): + +def test_should_update(fixtures): class UpdateEditor(graphene.Mutation): class Arguments: id = graphene.ID(required=True) @@ -55,7 +55,7 @@ class Arguments: editor = graphene.Field(EditorNode) - async def mutate(self, info, id, editor): + def mutate(self, info, id, editor): editor_to_update = Editor.objects.get(id=id) for key, value in editor.items(): if value: @@ -86,6 +86,6 @@ class Mutation(graphene.ObjectType): """ expected = {"updateEditor": {"editor": {"firstName": "Penny", "lastName": "Lane"}}} schema = graphene.Schema(query=Query, mutation=Mutation) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected diff --git a/tests/synchronous/test_mutation.py b/tests/synchronous/test_mutation.py index 67ea7e5f..14e11cad 100644 --- a/tests/synchronous/test_mutation.py +++ b/tests/synchronous/test_mutation.py @@ -7,15 +7,15 @@ from .nodes import ArticleNode, EditorNode -@pytest.mark.asyncio -async def test_should_create(fixtures): + +def test_should_create(fixtures): class CreateArticle(graphene.Mutation): class Arguments: headline = graphene.String() article = graphene.Field(ArticleNode) - async def mutate(self, info, headline): + def mutate(self, info, headline): article = Article(headline=headline) article.save() @@ -40,13 +40,13 @@ class Mutation(graphene.ObjectType): """ expected = {"createArticle": {"article": {"headline": "My Article"}}} schema = graphene.Schema(query=Query, mutation=Mutation) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_update(fixtures): + +def test_should_update(fixtures): class UpdateEditor(graphene.Mutation): class Arguments: id = graphene.ID() @@ -54,7 +54,7 @@ class Arguments: editor = graphene.Field(EditorNode) - async def mutate(self, info, id, first_name): + def mutate(self, info, id, first_name): editor = Editor.objects.get(id=id) editor.first_name = first_name editor.save() @@ -80,6 +80,6 @@ class Mutation(graphene.ObjectType): """ expected = {"updateEditor": {"editor": {"firstName": "Tony"}}} schema = graphene.Schema(query=Query, mutation=Mutation) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected diff --git a/tests/synchronous/test_query.py b/tests/synchronous/test_query.py index fb9bfa87..2aa8d7f8 100644 --- a/tests/synchronous/test_query.py +++ b/tests/synchronous/test_query.py @@ -9,16 +9,16 @@ from .. import types -@pytest.mark.asyncio -async def test_should_query_editor(fixtures, fixtures_dirname): + +def test_should_query_editor(fixtures, fixtures_dirname): class Query(graphene.ObjectType): editor = graphene.Field(types.EditorType) editors = graphene.List(types.EditorType) - async def resolve_editor(self, *args, **kwargs): + def resolve_editor(self, *args, **kwargs): return models.Editor.objects.first() - async def resolve_editors(self, *args, **kwargs): + def resolve_editors(self, *args, **kwargs): return list(models.Editor.objects.all()) query = """ @@ -67,19 +67,19 @@ async def resolve_editors(self, *args, **kwargs): expected_metadata = {"age": "20", "nickname": "$1"} schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors metadata = result.data["editor"].pop("metadata") assert json.loads(metadata) == expected_metadata assert result.data == expected -@pytest.mark.asyncio -async def test_should_query_reporter(fixtures): + +def test_should_query_reporter(fixtures): class Query(graphene.ObjectType): reporter = graphene.Field(types.ReporterType) - async def resolve_reporter(self, *args, **kwargs): + def resolve_reporter(self, *args, **kwargs): return models.Reporter.objects.select_related("articles").first() query = """ @@ -114,17 +114,17 @@ async def resolve_reporter(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_custom_kwargs(fixtures): + +def test_should_custom_kwargs(fixtures): class Query(graphene.ObjectType): editors = graphene.List(types.EditorType, first=graphene.Int()) - async def resolve_editors(self, *args, **kwargs): + def resolve_editors(self, *args, **kwargs): editors = models.Editor.objects() if "first" in kwargs: editors = editors[: kwargs["first"]] @@ -145,17 +145,17 @@ async def resolve_editors(self, *args, **kwargs): ] } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_self_reference(fixtures): + +def test_should_self_reference(fixtures): class Query(graphene.ObjectType): all_players = graphene.List(types.PlayerType) - async def resolve_all_players(self, *args, **kwargs): + def resolve_all_players(self, *args, **kwargs): return models.Player.objects.select_related("players").all() query = """ @@ -192,17 +192,17 @@ async def resolve_all_players(self, *args, **kwargs): ] } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_query_with_embedded_document(fixtures): + +def test_should_query_with_embedded_document(fixtures): class Query(graphene.ObjectType): professor_vector = graphene.Field(types.ProfessorVectorType, id=graphene.String()) - async def resolve_professor_vector(self, info, id): + def resolve_professor_vector(self, info, id): return models.ProfessorVector.objects(metadata__id=id).first() query = """ @@ -218,17 +218,17 @@ async def resolve_professor_vector(self, info, id): expected = {"professorVector": {"vec": [1.0, 2.3], "metadata": {"firstName": "Steven"}}} schema = graphene.Schema(query=Query, types=[types.ProfessorVectorType]) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_query_child(fixtures): + +def test_should_query_child(fixtures): class Query(graphene.ObjectType): children = graphene.List(types.ChildType) - async def resolve_children(self, *args, **kwargs): + def resolve_children(self, *args, **kwargs): return list(models.Child.objects.all()) query = """ @@ -255,17 +255,17 @@ async def resolve_children(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_query_other_childs(fixtures): + +def test_should_query_other_childs(fixtures): class Query(graphene.ObjectType): children = graphene.List(types.AnotherChildType) - async def resolve_children(self, *args, **kwargs): + def resolve_children(self, *args, **kwargs): return list(models.AnotherChild.objects.all()) query = """ @@ -292,17 +292,17 @@ async def resolve_children(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_query_all_childs(fixtures): + +def test_should_query_all_childs(fixtures): class Query(graphene.ObjectType): children = graphene.List(types.ChildUnionType) - async def resolve_children(self, *args, **kwargs): + def resolve_children(self, *args, **kwargs): return list(models.Parent.objects.all()) query = """ @@ -368,17 +368,17 @@ async def resolve_children(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_query_cell_tower(fixtures): + +def test_should_query_cell_tower(fixtures): class Query(graphene.ObjectType): cell_towers = graphene.List(types.CellTowerType) - async def resolve_cell_towers(self, *args, **kwargs): + def resolve_cell_towers(self, *args, **kwargs): return list(models.CellTower.objects.all()) query = """ @@ -433,6 +433,6 @@ async def resolve_cell_towers(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected diff --git a/tests/synchronous/test_relay_query.py b/tests/synchronous/test_relay_query.py index b9139da7..3ad1d7b6 100644 --- a/tests/synchronous/test_relay_query.py +++ b/tests/synchronous/test_relay_query.py @@ -13,12 +13,12 @@ from graphene_mongo.types import MongoengineObjectType -@pytest.mark.asyncio -async def test_should_query_reporter(fixtures): + +def test_should_query_reporter(fixtures): class Query(graphene.ObjectType): reporter = graphene.Field(nodes.ReporterNode) - async def resolve_reporter(self, *args, **kwargs): + def resolve_reporter(self, *args, **kwargs): return models.Reporter.objects.first() query = """ @@ -87,13 +87,13 @@ async def resolve_reporter(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_query_reporters_with_nested_document(fixtures): + +def test_should_query_reporters_with_nested_document(fixtures): class Query(graphene.ObjectType): reporters = MongoengineConnectionField(nodes.ReporterNode) @@ -133,13 +133,13 @@ class Query(graphene.ObjectType): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_query_all_editors(fixtures, fixtures_dirname): + +def test_should_query_all_editors(fixtures, fixtures_dirname): class Query(graphene.ObjectType): editors = MongoengineConnectionField(nodes.EditorNode) @@ -201,13 +201,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_query_editors_with_dataloader(fixtures): + +def test_should_query_editors_with_dataloader(fixtures): from promise import Promise from promise.dataloader import DataLoader @@ -227,7 +227,7 @@ class Meta: articles = MongoengineConnectionField(nodes.ArticleNode) - async def resolve_articles(self, *args, **kwargs): + def resolve_articles(self, *args, **kwargs): return article_loader.load(self) class Query(graphene.ObjectType): @@ -265,13 +265,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_filter_editors_by_id(fixtures): + +def test_should_filter_editors_by_id(fixtures): class Query(graphene.ObjectType): editors = MongoengineConnectionField(nodes.EditorNode) @@ -302,13 +302,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_filter(fixtures): + +def test_should_filter(fixtures): class Query(graphene.ObjectType): articles = MongoengineConnectionField(nodes.ArticleNode) @@ -341,13 +341,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_filter_by_reference_field(fixtures): + +def test_should_filter_by_reference_field(fixtures): class Query(graphene.ObjectType): articles = MongoengineConnectionField(nodes.ArticleNode) @@ -369,13 +369,13 @@ class Query(graphene.ObjectType): "articles": {"edges": [{"node": {"headline": "Hello", "editor": {"firstName": "Penny"}}}]} } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_filter_through_inheritance(fixtures): + +def test_should_filter_through_inheritance(fixtures): class Query(graphene.ObjectType): node = Node.Field() children = MongoengineConnectionField(nodes.ChildNode) @@ -410,13 +410,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_filter_by_list_contains(fixtures): + +def test_should_filter_by_list_contains(fixtures): # Notes: https://goo.gl/hMNRgs class Query(graphene.ObjectType): reporters = MongoengineConnectionField(nodes.ReporterNode) @@ -460,13 +460,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_filter_by_id(fixtures): + +def test_should_filter_by_id(fixtures): # Notes: https://goo.gl/hMNRgs class Query(graphene.ObjectType): reporter = Node.Field(nodes.ReporterNode) @@ -488,13 +488,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_first_n(fixtures): + +def test_should_first_n(fixtures): class Query(graphene.ObjectType): editors = MongoengineConnectionField(nodes.EditorNode) @@ -531,14 +531,14 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_after(fixtures): + +def test_should_after(fixtures): class Query(graphene.ObjectType): players = MongoengineConnectionField(nodes.PlayerNode) @@ -564,14 +564,14 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_before(fixtures): + +def test_should_before(fixtures): class Query(graphene.ObjectType): players = MongoengineConnectionField(nodes.PlayerNode) @@ -599,14 +599,14 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_last_n(fixtures): + +def test_should_last_n(fixtures): class Query(graphene.ObjectType): players = MongoengineConnectionField(nodes.PlayerNode) @@ -631,14 +631,14 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_self_reference(fixtures): + +def test_should_self_reference(fixtures): class Query(graphene.ObjectType): players = MongoengineConnectionField(nodes.PlayerNode) @@ -707,13 +707,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_lazy_reference(fixtures): + +def test_should_lazy_reference(fixtures): class Query(graphene.ObjectType): node = Node.Field() parents = MongoengineConnectionField(nodes.ParentWithRelationshipNode) @@ -764,13 +764,13 @@ class Query(graphene.ObjectType): } } - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_query_with_embedded_document(fixtures): + +def test_should_query_with_embedded_document(fixtures): class Query(graphene.ObjectType): professors = MongoengineConnectionField(nodes.ProfessorVectorNode) @@ -794,13 +794,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_get_queryset_returns_dict_filters(fixtures): + +def test_should_get_queryset_returns_dict_filters(fixtures): class Query(graphene.ObjectType): node = Node.Field() articles = MongoengineConnectionField( @@ -836,13 +836,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_get_queryset_returns_qs_filters(fixtures): + +def test_should_get_queryset_returns_qs_filters(fixtures): def get_queryset(model, info, **args): return model.objects(headline="World") @@ -879,13 +879,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_filter_mongoengine_queryset(fixtures): + +def test_should_filter_mongoengine_queryset(fixtures): class Query(graphene.ObjectType): players = MongoengineConnectionField(nodes.PlayerNode) @@ -909,18 +909,18 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert json.dumps(result.data, sort_keys=True) == json.dumps(expected, sort_keys=True) -@pytest.mark.asyncio -async def test_should_query_document_with_embedded(fixtures): + +def test_should_query_document_with_embedded(fixtures): class Query(graphene.ObjectType): foos = MongoengineConnectionField(nodes.FooNode) - async def resolve_multiple_foos(self, *args, **kwargs): + def resolve_multiple_foos(self, *args, **kwargs): return list(models.Foo.objects.all()) query = """ @@ -942,12 +942,12 @@ async def resolve_multiple_foos(self, *args, **kwargs): """ schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors -@pytest.mark.asyncio -async def test_should_filter_mongoengine_queryset_with_list(fixtures): + +def test_should_filter_mongoengine_queryset_with_list(fixtures): class Query(graphene.ObjectType): players = MongoengineConnectionField(nodes.PlayerNode) @@ -971,14 +971,14 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert json.dumps(result.data, sort_keys=True) == json.dumps(expected, sort_keys=True) -@pytest.mark.asyncio -async def test_should_get_correct_list_of_documents(fixtures): + +def test_should_get_correct_list_of_documents(fixtures): class Query(graphene.ObjectType): players = MongoengineConnectionField(nodes.PlayerNode) @@ -1026,14 +1026,14 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert result.data == expected -@pytest.mark.asyncio -async def test_should_filter_mongoengine_queryset_by_id_and_other_fields(fixtures): + +def test_should_filter_mongoengine_queryset_by_id_and_other_fields(fixtures): class Query(graphene.ObjectType): players = MongoengineConnectionField(nodes.PlayerNode) @@ -1063,7 +1063,7 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result = schema.execute(query) assert not result.errors assert json.dumps(result.data, sort_keys=True) == json.dumps(expected, sort_keys=True) diff --git a/tests/synchronous/test_converter.py b/tests/test_converter.py similarity index 99% rename from tests/synchronous/test_converter.py rename to tests/test_converter.py index 75577982..e45695da 100644 --- a/tests/synchronous/test_converter.py +++ b/tests/test_converter.py @@ -2,7 +2,7 @@ import mongoengine from pytest import raises -from ..models import ( +from .models import ( Article, Editor, EmbeddedArticle, From 82c5c9e73eb91f6857ecefa551b32e057848d38d Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 19:59:00 +0530 Subject: [PATCH 06/52] refactor: restructure graphene_mongo to mirror mongoengine layout Introduce base/, synchronous/, asynchronous/ subdirectories matching the mongoengine project structure: base/ transport-agnostic shared code advanced_types.py, converter.py, registry.py, utils.py, field_resolvers/ synchronous/ sync API (MongoengineObjectType, MongoengineConnectionField) types.py, fields.py asynchronous/ async API (AsyncMongoengineObjectType, etc.) types.py, fields.py, dataloader.py, utils.py get_dataloader and DATALOADER_CONTEXT_ATTRIBUTE move from base/utils.py to asynchronous/utils.py to keep base/ free of async-specific code. Public API via __init__.py is unchanged. Tests updated to use new direct module paths. --- graphene_mongo/__init__.py | 11 ++++++----- graphene_mongo/asynchronous/__init__.py | 0 graphene_mongo/{ => asynchronous}/dataloader.py | 4 ++-- .../{fields_async.py => asynchronous/fields.py} | 8 ++++---- .../{types_async.py => asynchronous/types.py} | 8 ++++---- graphene_mongo/asynchronous/utils.py | 17 +++++++++++++++++ graphene_mongo/base/__init__.py | 0 graphene_mongo/{ => base}/advanced_types.py | 0 graphene_mongo/{ => base}/converter.py | 0 .../{ => base}/field_resolvers/__init__.py | 0 .../dynamic_reference_field_resolver.py | 3 ++- .../field_resolvers/list_field_resolver.py | 5 +++-- .../field_resolvers/union_resolver.py | 5 +++-- graphene_mongo/{ => base}/registry.py | 6 +++--- graphene_mongo/{ => base}/utils.py | 15 --------------- graphene_mongo/synchronous/__init__.py | 0 graphene_mongo/{ => synchronous}/fields.py | 8 ++++---- graphene_mongo/{ => synchronous}/types.py | 6 +++--- tests/asynchronous/nodes.py | 2 +- tests/synchronous/nodes.py | 2 +- tests/synchronous/test_fields.py | 2 +- tests/synchronous/test_relay_query.py | 4 ++-- tests/synchronous/test_types.py | 2 +- tests/synchronous/test_utils.py | 2 +- tests/test_converter.py | 6 +++--- tests/types.py | 2 +- 26 files changed, 62 insertions(+), 56 deletions(-) create mode 100644 graphene_mongo/asynchronous/__init__.py rename graphene_mongo/{ => asynchronous}/dataloader.py (97%) rename graphene_mongo/{fields_async.py => asynchronous/fields.py} (98%) rename graphene_mongo/{types_async.py => asynchronous/types.py} (96%) create mode 100644 graphene_mongo/asynchronous/utils.py create mode 100644 graphene_mongo/base/__init__.py rename graphene_mongo/{ => base}/advanced_types.py (100%) rename graphene_mongo/{ => base}/converter.py (100%) rename graphene_mongo/{ => base}/field_resolvers/__init__.py (100%) rename graphene_mongo/{ => base}/field_resolvers/dynamic_reference_field_resolver.py (95%) rename graphene_mongo/{ => base}/field_resolvers/list_field_resolver.py (97%) rename graphene_mongo/{ => base}/field_resolvers/union_resolver.py (92%) rename graphene_mongo/{ => base}/registry.py (94%) rename graphene_mongo/{ => base}/utils.py (96%) create mode 100644 graphene_mongo/synchronous/__init__.py rename graphene_mongo/{ => synchronous}/fields.py (99%) rename graphene_mongo/{ => synchronous}/types.py (97%) diff --git a/graphene_mongo/__init__.py b/graphene_mongo/__init__.py index a5cf9533..04c421ce 100644 --- a/graphene_mongo/__init__.py +++ b/graphene_mongo/__init__.py @@ -1,7 +1,8 @@ -from .fields import MongoengineConnectionField -from .fields_async import AsyncMongoengineConnectionField -from .types import MongoengineInputType, MongoengineInterfaceType, MongoengineObjectType -from .types_async import AsyncMongoengineObjectType +from .synchronous.fields import MongoengineConnectionField +from .asynchronous.fields import AsyncMongoengineConnectionField +from .synchronous.types import MongoengineInputType, MongoengineInterfaceType, MongoengineObjectType +from .asynchronous.types import AsyncMongoengineObjectType +from .base import registry, advanced_types __version__ = "0.5.0" @@ -13,4 +14,4 @@ "MongoengineInterfaceType", "MongoengineConnectionField", "AsyncMongoengineConnectionField", -] +] \ No newline at end of file diff --git a/graphene_mongo/asynchronous/__init__.py b/graphene_mongo/asynchronous/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graphene_mongo/dataloader.py b/graphene_mongo/asynchronous/dataloader.py similarity index 97% rename from graphene_mongo/dataloader.py rename to graphene_mongo/asynchronous/dataloader.py index 5376865f..bb660f0c 100644 --- a/graphene_mongo/dataloader.py +++ b/graphene_mongo/asynchronous/dataloader.py @@ -58,8 +58,8 @@ def __init__(self, model: Type[T], info: GraphQLResolveInfo, projections: set[st self._model = model self._projections: set[str] = projections - from .registry import get_global_async_registry - from .types_async import AsyncMongoengineObjectType + from ..base.registry import get_global_async_registry + from .types import AsyncMongoengineObjectType registry = get_global_async_registry() self._gql_type: AsyncMongoengineObjectType = registry.get_type_for_document_model(model) diff --git a/graphene_mongo/fields_async.py b/graphene_mongo/asynchronous/fields.py similarity index 98% rename from graphene_mongo/fields_async.py rename to graphene_mongo/asynchronous/fields.py index 711ba62c..03b51d62 100644 --- a/graphene_mongo/fields_async.py +++ b/graphene_mongo/asynchronous/fields.py @@ -18,9 +18,9 @@ import pymongo from pymongo.errors import OperationFailure -from .fields import MongoengineConnectionField -from .registry import get_global_async_registry -from .utils import ( +from ..synchronous.fields import MongoengineConnectionField +from ..base.registry import get_global_async_registry +from ..base.utils import ( ExecutorEnum, connection_from_iterables, find_skip_and_limit, @@ -43,7 +43,7 @@ def executor(self): @property def type(self): - from .types_async import AsyncMongoengineObjectType + from .types import AsyncMongoengineObjectType _type = super(ConnectionField, self).type assert issubclass(_type, AsyncMongoengineObjectType), ( diff --git a/graphene_mongo/types_async.py b/graphene_mongo/asynchronous/types.py similarity index 96% rename from graphene_mongo/types_async.py rename to graphene_mongo/asynchronous/types.py index e1780f85..45c2cba3 100644 --- a/graphene_mongo/types_async.py +++ b/graphene_mongo/asynchronous/types.py @@ -9,10 +9,10 @@ from graphql import GraphQLResolveInfo import mongoengine -from .fields_async import AsyncMongoengineConnectionField -from .registry import Registry, get_global_async_registry, get_inputs_async_registry -from .types import construct_fields, construct_self_referenced_fields -from .utils import ExecutorEnum, get_query_fields, is_valid_mongoengine_model +from .fields import AsyncMongoengineConnectionField +from ..base.registry import Registry, get_global_async_registry, get_inputs_async_registry +from ..synchronous.types import construct_fields, construct_self_referenced_fields +from ..base.utils import ExecutorEnum, get_query_fields, is_valid_mongoengine_model def create_graphene_generic_class_async(object_type, option_type): diff --git a/graphene_mongo/asynchronous/utils.py b/graphene_mongo/asynchronous/utils.py new file mode 100644 index 00000000..758308f1 --- /dev/null +++ b/graphene_mongo/asynchronous/utils.py @@ -0,0 +1,17 @@ +from graphql import GraphQLResolveInfo + +from .dataloader import MongoDataLoader + +DATALOADER_CONTEXT_ATTRIBUTE = "_mongo_dataloader" + + +def get_dataloader(info: GraphQLResolveInfo) -> MongoDataLoader: + """ + Get the MongoDataLoader() from info context or operation + """ + data_point = info.context or info.operation + + if not hasattr(data_point, DATALOADER_CONTEXT_ATTRIBUTE): + setattr(data_point, DATALOADER_CONTEXT_ATTRIBUTE, MongoDataLoader(info=info)) + + return getattr(data_point, DATALOADER_CONTEXT_ATTRIBUTE) \ No newline at end of file diff --git a/graphene_mongo/base/__init__.py b/graphene_mongo/base/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graphene_mongo/advanced_types.py b/graphene_mongo/base/advanced_types.py similarity index 100% rename from graphene_mongo/advanced_types.py rename to graphene_mongo/base/advanced_types.py diff --git a/graphene_mongo/converter.py b/graphene_mongo/base/converter.py similarity index 100% rename from graphene_mongo/converter.py rename to graphene_mongo/base/converter.py diff --git a/graphene_mongo/field_resolvers/__init__.py b/graphene_mongo/base/field_resolvers/__init__.py similarity index 100% rename from graphene_mongo/field_resolvers/__init__.py rename to graphene_mongo/base/field_resolvers/__init__.py diff --git a/graphene_mongo/field_resolvers/dynamic_reference_field_resolver.py b/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py similarity index 95% rename from graphene_mongo/field_resolvers/dynamic_reference_field_resolver.py rename to graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py index 16c13ab8..f7c85b75 100644 --- a/graphene_mongo/field_resolvers/dynamic_reference_field_resolver.py +++ b/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py @@ -5,7 +5,8 @@ from graphene.utils.str_converters import to_snake_case from mongoengine import Document, ReferenceField -from graphene_mongo.utils import ExecutorEnum, get_dataloader, get_query_fields +from graphene_mongo.base.utils import ExecutorEnum, get_query_fields +from graphene_mongo.asynchronous.utils import get_dataloader class DynamicReferenceFieldResolver: diff --git a/graphene_mongo/field_resolvers/list_field_resolver.py b/graphene_mongo/base/field_resolvers/list_field_resolver.py similarity index 97% rename from graphene_mongo/field_resolvers/list_field_resolver.py rename to graphene_mongo/base/field_resolvers/list_field_resolver.py index 7455b4ed..67b2e512 100644 --- a/graphene_mongo/field_resolvers/list_field_resolver.py +++ b/graphene_mongo/base/field_resolvers/list_field_resolver.py @@ -10,7 +10,8 @@ from mongoengine import Document from mongoengine.base import LazyReference -from graphene_mongo.utils import ExecutorEnum, get_dataloader, get_queried_union_types, get_document +from graphene_mongo.base.utils import ExecutorEnum, get_queried_union_types, get_document +from graphene_mongo.asynchronous.utils import get_dataloader class ListFieldResolver: @@ -22,7 +23,7 @@ def __get_reference_objects_common( object_id_list: list[ObjectId], queried_fields: dict, ) -> tuple[Document, set[str], list[ObjectId]]: - from graphene_mongo.converter import convert_mongoengine_field + from graphene_mongo.base.converter import convert_mongoengine_field document = get_document(model) document_field = mongoengine.ReferenceField(document) diff --git a/graphene_mongo/field_resolvers/union_resolver.py b/graphene_mongo/base/field_resolvers/union_resolver.py similarity index 92% rename from graphene_mongo/field_resolvers/union_resolver.py rename to graphene_mongo/base/field_resolvers/union_resolver.py index b5aaa97c..00ff3d28 100644 --- a/graphene_mongo/field_resolvers/union_resolver.py +++ b/graphene_mongo/base/field_resolvers/union_resolver.py @@ -7,7 +7,8 @@ from mongoengine import Document from mongoengine.base import LazyReference -from graphene_mongo.utils import ExecutorEnum, get_dataloader, get_document, get_queried_union_types +from graphene_mongo.base.utils import ExecutorEnum, get_document, get_queried_union_types +from graphene_mongo.asynchronous.utils import get_dataloader class UnionFieldResolver: @@ -15,7 +16,7 @@ class UnionFieldResolver: def __reference_resolver_common( field, registry, executor: ExecutorEnum, root, *args, **kwargs ) -> Optional[Union[tuple[Document, set[str], ObjectId], Document]]: - from graphene_mongo.converter import convert_mongoengine_field + from graphene_mongo.base.converter import convert_mongoengine_field de_referenced: LazyReference = getattr(root, field.name or field.db_name) if not de_referenced: diff --git a/graphene_mongo/registry.py b/graphene_mongo/base/registry.py similarity index 94% rename from graphene_mongo/registry.py rename to graphene_mongo/base/registry.py index 75b57c67..3286c73b 100644 --- a/graphene_mongo/registry.py +++ b/graphene_mongo/base/registry.py @@ -1,7 +1,7 @@ from graphene import Enum from mongoengine import Document -from graphene_mongo.utils import ExecutorEnum +from graphene_mongo.base.utils import ExecutorEnum class Registry(object): @@ -13,8 +13,8 @@ def __init__(self, executor: ExecutorEnum): self._registry_enum = {} def register(self, cls): - from .types import GrapheneMongoengineObjectTypes - from .types_async import AsyncGrapheneMongoengineObjectTypes + from ..synchronous.types import GrapheneMongoengineObjectTypes + from ..asynchronous.types import AsyncGrapheneMongoengineObjectTypes assert issubclass(cls, GrapheneMongoengineObjectTypes) or issubclass( cls, AsyncGrapheneMongoengineObjectTypes diff --git a/graphene_mongo/utils.py b/graphene_mongo/base/utils.py similarity index 96% rename from graphene_mongo/utils.py rename to graphene_mongo/base/utils.py index e69d8f12..5b557a70 100644 --- a/graphene_mongo/utils.py +++ b/graphene_mongo/base/utils.py @@ -19,7 +19,6 @@ import mongoengine from mongoengine.base.common import _DocumentRegistry -from .dataloader import MongoDataLoader class ExecutorEnum(enum.Enum): @@ -435,17 +434,3 @@ def get_field_resolver( return default_sync_resolver -DATALOADER_CONTEXT_ATTRIBUTE = "_mongo_dataloader" - - -def get_dataloader(info: GraphQLResolveInfo) -> MongoDataLoader: - """ - Get the MongoDataLoader() from info context or operation - """ - - data_point = info.context or info.operation - - if not hasattr(data_point, DATALOADER_CONTEXT_ATTRIBUTE): - setattr(data_point, DATALOADER_CONTEXT_ATTRIBUTE, MongoDataLoader(info=info)) - - return getattr(data_point, DATALOADER_CONTEXT_ATTRIBUTE) diff --git a/graphene_mongo/synchronous/__init__.py b/graphene_mongo/synchronous/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graphene_mongo/fields.py b/graphene_mongo/synchronous/fields.py similarity index 99% rename from graphene_mongo/fields.py rename to graphene_mongo/synchronous/fields.py index 56eeb74b..169a4e7d 100644 --- a/graphene_mongo/fields.py +++ b/graphene_mongo/synchronous/fields.py @@ -23,16 +23,16 @@ from promise import Promise from pymongo.errors import OperationFailure -from .advanced_types import ( +from ..base.advanced_types import ( FileFieldType, MultiPolygonFieldType, PointFieldInputType, PointFieldType, PolygonFieldType, ) -from .converter import MongoEngineConversionError, convert_mongoengine_field -from .registry import get_global_registry -from .utils import ( +from ..base.converter import MongoEngineConversionError, convert_mongoengine_field +from ..base.registry import get_global_registry +from ..base.utils import ( ExecutorEnum, connection_from_iterables, find_skip_and_limit, diff --git a/graphene_mongo/types.py b/graphene_mongo/synchronous/types.py similarity index 97% rename from graphene_mongo/types.py rename to graphene_mongo/synchronous/types.py index cad483f5..8ddede57 100644 --- a/graphene_mongo/types.py +++ b/graphene_mongo/synchronous/types.py @@ -10,9 +10,9 @@ from graphene.utils.str_converters import to_snake_case from graphene_mongo import MongoengineConnectionField -from .converter import convert_mongoengine_field -from .registry import Registry, get_global_registry, get_inputs_registry -from .utils import ExecutorEnum, get_model_fields, get_query_fields, is_valid_mongoengine_model +from ..base.converter import convert_mongoengine_field +from ..base.registry import Registry, get_global_registry, get_inputs_registry +from ..base.utils import ExecutorEnum, get_model_fields, get_query_fields, is_valid_mongoengine_model def construct_fields( diff --git a/tests/asynchronous/nodes.py b/tests/asynchronous/nodes.py index 0764fe2d..1336ad3a 100644 --- a/tests/asynchronous/nodes.py +++ b/tests/asynchronous/nodes.py @@ -4,7 +4,7 @@ from .. import models from .. import types # noqa: F401 from ..models import ProfessorMetadata -from graphene_mongo.types_async import AsyncMongoengineObjectType +from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType class PublisherAsyncNode(AsyncMongoengineObjectType): diff --git a/tests/synchronous/nodes.py b/tests/synchronous/nodes.py index 3d82b63d..32877bb4 100644 --- a/tests/synchronous/nodes.py +++ b/tests/synchronous/nodes.py @@ -4,7 +4,7 @@ from .. import models from .. import types # noqa: F401 from ..models import ProfessorMetadata -from graphene_mongo.types import MongoengineObjectType +from graphene_mongo.synchronous.types import MongoengineObjectType class PublisherNode(MongoengineObjectType): diff --git a/tests/synchronous/test_fields.py b/tests/synchronous/test_fields.py index 3e4f18bb..80cbe352 100644 --- a/tests/synchronous/test_fields.py +++ b/tests/synchronous/test_fields.py @@ -2,7 +2,7 @@ from . import nodes from graphene_mongo import AsyncMongoengineConnectionField -from graphene_mongo.fields import MongoengineConnectionField +from graphene_mongo.synchronous.fields import MongoengineConnectionField def test_article_field_args(): diff --git a/tests/synchronous/test_relay_query.py b/tests/synchronous/test_relay_query.py index 3ad1d7b6..360c855e 100644 --- a/tests/synchronous/test_relay_query.py +++ b/tests/synchronous/test_relay_query.py @@ -9,8 +9,8 @@ from .. import models from . import nodes -from graphene_mongo.fields import MongoengineConnectionField -from graphene_mongo.types import MongoengineObjectType +from graphene_mongo.synchronous.fields import MongoengineConnectionField +from graphene_mongo.synchronous.types import MongoengineObjectType diff --git a/tests/synchronous/test_types.py b/tests/synchronous/test_types.py index 6281fb6f..d8b65ade 100644 --- a/tests/synchronous/test_types.py +++ b/tests/synchronous/test_types.py @@ -16,7 +16,7 @@ ) from .utils import with_local_registry from graphene_mongo import registry -from graphene_mongo.types import MongoengineObjectType, MongoengineObjectTypeOptions +from graphene_mongo.synchronous.types import MongoengineObjectType, MongoengineObjectTypeOptions registry.reset_global_registry() diff --git a/tests/synchronous/test_utils.py b/tests/synchronous/test_utils.py index f24b710e..ce2888e8 100644 --- a/tests/synchronous/test_utils.py +++ b/tests/synchronous/test_utils.py @@ -2,7 +2,7 @@ from .. import types from ..models import Article, Child, Reporter -from graphene_mongo.utils import get_model_fields, get_query_fields, is_valid_mongoengine_model +from graphene_mongo.base.utils import get_model_fields, get_query_fields, is_valid_mongoengine_model def test_get_model_fields_no_duplication(): diff --git a/tests/test_converter.py b/tests/test_converter.py index e45695da..ed896d34 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -15,9 +15,9 @@ ) from graphene_mongo import registry from graphene_mongo import advanced_types -from graphene_mongo.converter import convert_mongoengine_field -from graphene_mongo.fields import MongoengineConnectionField -from graphene_mongo.types import MongoengineObjectType +from graphene_mongo.base.converter import convert_mongoengine_field +from graphene_mongo.synchronous.fields import MongoengineConnectionField +from graphene_mongo.synchronous.types import MongoengineObjectType def assert_conversion(mongoengine_field, graphene_field, *args, **kwargs): diff --git a/tests/types.py b/tests/types.py index 9a22c1cf..2c506e32 100644 --- a/tests/types.py +++ b/tests/types.py @@ -1,5 +1,5 @@ from . import models -from graphene_mongo.types import ( +from graphene_mongo.synchronous.types import ( MongoengineObjectType, MongoengineInterfaceType, MongoengineInputType, From e9bb174b7b8c3f0ccd015cab08120e213e8af190 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 20:38:59 +0530 Subject: [PATCH 07/52] feat: add async equivalents of sync-only test files - tests/asynchronous/utils.py: with_local_async_registry decorator - tests/asynchronous/types.py: non-relay AsyncMongoengineObjectType variants in a private local registry to avoid conflicting with relay nodes - tests/asynchronous/test_query.py: async basic query tests (9 tests) - tests/asynchronous/test_mutation.py: async mutation tests using asave/aobjects - tests/asynchronous/test_inputs.py: async input mutation tests - tests/asynchronous/test_types.py: AsyncMongoengineObjectType metaclass tests - test_relay_query.py: convert remaining sync objects/save calls to aobjects/asave --- tests/asynchronous/test_inputs.py | 86 ++++++ tests/asynchronous/test_mutation.py | 80 +++++ tests/asynchronous/test_query.py | 409 +++++++++++++++++++++++++ tests/asynchronous/test_relay_query.py | 6 +- tests/asynchronous/test_types.py | 239 +++++++++++++++ tests/asynchronous/types.py | 93 ++++++ tests/asynchronous/utils.py | 17 + 7 files changed, 927 insertions(+), 3 deletions(-) create mode 100644 tests/asynchronous/test_inputs.py create mode 100644 tests/asynchronous/test_mutation.py create mode 100644 tests/asynchronous/test_query.py create mode 100644 tests/asynchronous/test_types.py create mode 100644 tests/asynchronous/types.py create mode 100644 tests/asynchronous/utils.py diff --git a/tests/asynchronous/test_inputs.py b/tests/asynchronous/test_inputs.py new file mode 100644 index 00000000..d65f7ce6 --- /dev/null +++ b/tests/asynchronous/test_inputs.py @@ -0,0 +1,86 @@ +import graphene +from graphene.relay import Node + +from ..models import Article, Editor +from .nodes import ArticleAsyncNode, EditorAsyncNode +from ..types import ArticleInput, EditorInput + + +async def test_should_create_async(fixtures): + class CreateArticle(graphene.Mutation): + class Arguments: + article = ArticleInput(required=True) + + article = graphene.Field(ArticleAsyncNode) + + async def mutate(self, info, article): + article = Article(**article) + await article.asave() + return CreateArticle(article=article) + + class Query(graphene.ObjectType): + node = Node.Field() + + class Mutation(graphene.ObjectType): + create_article = CreateArticle.Field() + + query = """ + mutation ArticleCreator { + createArticle( + article: {headline: "My Article"} + ) { + article { + headline + } + } + } + """ + expected = {"createArticle": {"article": {"headline": "My Article"}}} + schema = graphene.Schema(query=Query, mutation=Mutation) + result = await schema.execute_async(query) + assert not result.errors + assert result.data == expected + + +async def test_should_update_async(fixtures): + class UpdateEditor(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + editor = EditorInput(required=True) + + editor = graphene.Field(EditorAsyncNode) + + async def mutate(self, info, id, editor): + editor_to_update = await Editor.aobjects.get(id=id) + for key, value in editor.items(): + if value: + setattr(editor_to_update, key, value) + await editor_to_update.asave() + return UpdateEditor(editor=editor_to_update) + + class Query(graphene.ObjectType): + node = Node.Field() + + class Mutation(graphene.ObjectType): + update_editor = UpdateEditor.Field() + + query = """ + mutation EditorUpdater { + updateEditor( + id: "1" + editor: { + lastName: "Lane" + } + ) { + editor { + firstName + lastName + } + } + } + """ + expected = {"updateEditor": {"editor": {"firstName": "Penny", "lastName": "Lane"}}} + schema = graphene.Schema(query=Query, mutation=Mutation) + result = await schema.execute_async(query) + assert not result.errors + assert result.data == expected \ No newline at end of file diff --git a/tests/asynchronous/test_mutation.py b/tests/asynchronous/test_mutation.py new file mode 100644 index 00000000..26178187 --- /dev/null +++ b/tests/asynchronous/test_mutation.py @@ -0,0 +1,80 @@ +import graphene +from graphene.relay import Node + +from ..models import Article, Editor +from .nodes import ArticleAsyncNode, EditorAsyncNode + + +async def test_should_create_async(fixtures): + class CreateArticle(graphene.Mutation): + class Arguments: + headline = graphene.String() + + article = graphene.Field(ArticleAsyncNode) + + async def mutate(self, info, headline): + article = Article(headline=headline) + await article.asave() + return CreateArticle(article=article) + + class Query(graphene.ObjectType): + node = Node.Field() + + class Mutation(graphene.ObjectType): + create_article = CreateArticle.Field() + + query = """ + mutation ArticleCreator { + createArticle( + headline: "My Article" + ) { + article { + headline + } + } + } + """ + expected = {"createArticle": {"article": {"headline": "My Article"}}} + schema = graphene.Schema(query=Query, mutation=Mutation) + result = await schema.execute_async(query) + assert not result.errors + assert result.data == expected + + +async def test_should_update_async(fixtures): + class UpdateEditor(graphene.Mutation): + class Arguments: + id = graphene.ID() + first_name = graphene.String() + + editor = graphene.Field(EditorAsyncNode) + + async def mutate(self, info, id, first_name): + editor = await Editor.aobjects.get(id=id) + editor.first_name = first_name + await editor.asave() + return UpdateEditor(editor=editor) + + class Query(graphene.ObjectType): + node = Node.Field() + + class Mutation(graphene.ObjectType): + update_editor = UpdateEditor.Field() + + query = """ + mutation EditorUpdater { + updateEditor( + id: "1" + firstName: "Tony" + ) { + editor { + firstName + } + } + } + """ + expected = {"updateEditor": {"editor": {"firstName": "Tony"}}} + schema = graphene.Schema(query=Query, mutation=Mutation) + result = await schema.execute_async(query) + assert not result.errors + assert result.data == expected \ No newline at end of file diff --git a/tests/asynchronous/test_query.py b/tests/asynchronous/test_query.py new file mode 100644 index 00000000..36b25330 --- /dev/null +++ b/tests/asynchronous/test_query.py @@ -0,0 +1,409 @@ +import base64 +import json +import os + +import graphene +import pytest + +from .. import models +from . import types as async_types + + +async def test_should_query_editor_async(fixtures, fixtures_dirname): + class Query(graphene.ObjectType): + editor = graphene.Field(async_types.EditorAsyncType) + editors = graphene.List(async_types.EditorAsyncType) + + async def resolve_editor(self, *args, **kwargs): + return await models.Editor.aobjects.select_related().first() + + async def resolve_editors(self, *args, **kwargs): + return await models.Editor.aobjects.all().to_list() + + query = """ + query EditorQuery { + editor { + firstName, + metadata, + company { + name + }, + avatar { + contentType, + chunkSize, + length, + data + } + } + editors { + firstName, + lastName + } + } + """ + + avatar_filename = os.path.join(fixtures_dirname, "image.jpg") + with open(avatar_filename, "rb") as f: + data = base64.b64encode(f.read()) + + expected = { + "editor": { + "firstName": "Penny", + "company": {"name": "Newsco"}, + "avatar": { + "contentType": "image/jpeg", + "chunkSize": 261120, + "length": 46928, + "data": data.decode("utf-8"), + }, + }, + "editors": [ + {"firstName": "Penny", "lastName": "Hardaway"}, + {"firstName": "Grant", "lastName": "Hill"}, + {"firstName": "Dennis", "lastName": "Rodman"}, + ], + } + expected_metadata = {"age": "20", "nickname": "$1"} + + schema = graphene.Schema(query=Query) + result = await schema.execute_async(query) + assert not result.errors + metadata = result.data["editor"].pop("metadata") + assert json.loads(metadata) == expected_metadata + assert result.data == expected + + +async def test_should_query_reporter_async(fixtures): + class Query(graphene.ObjectType): + reporter = graphene.Field(async_types.ReporterAsyncType) + + async def resolve_reporter(self, *args, **kwargs): + return await models.Reporter.aobjects.select_related("articles").first() + + query = """ + query ReporterQuery { + reporter { + firstName, + lastName, + email, + articles { + headline + }, + embeddedArticles { + headline + }, + embeddedListArticles { + headline + }, + awards + } + } + """ + expected = { + "reporter": { + "firstName": "Allen", + "lastName": "Iverson", + "email": "ai@gmail.com", + "articles": [{"headline": "Hello"}, {"headline": "World"}], + "embeddedArticles": [{"headline": "Real"}, {"headline": "World"}], + "embeddedListArticles": [{"headline": "World"}, {"headline": "Real"}], + "awards": ["2010-mvp"], + } + } + + schema = graphene.Schema(query=Query) + result = await schema.execute_async(query) + assert not result.errors + assert result.data == expected + + +async def test_should_custom_kwargs_async(fixtures): + class Query(graphene.ObjectType): + editors = graphene.List(async_types.EditorAsyncType, first=graphene.Int()) + + async def resolve_editors(self, *args, **kwargs): + editors = await models.Editor.aobjects.all().to_list() + if "first" in kwargs: + editors = editors[: kwargs["first"]] + return editors + + query = """ + query EditorQuery { + editors(first: 2) { + firstName, + lastName + } + } + """ + expected = { + "editors": [ + {"firstName": "Penny", "lastName": "Hardaway"}, + {"firstName": "Grant", "lastName": "Hill"}, + ] + } + schema = graphene.Schema(query=Query) + result = await schema.execute_async(query) + assert not result.errors + assert result.data == expected + + +async def test_should_self_reference_async(fixtures): + class Query(graphene.ObjectType): + all_players = graphene.List(async_types.PlayerAsyncType) + + async def resolve_all_players(self, *args, **kwargs): + return await models.Player.aobjects.select_related("players").to_list() + + query = """ + query PlayersQuery { + allPlayers { + firstName, + opponent { + firstName + }, + players { + firstName + } + } + } + """ + expected = { + "allPlayers": [ + { + "firstName": "Michael", + "opponent": None, + "players": [{"firstName": "Magic"}], + }, + { + "firstName": "Magic", + "opponent": {"firstName": "Michael"}, + "players": [{"firstName": "Michael"}], + }, + { + "firstName": "Larry", + "opponent": None, + "players": [{"firstName": "Michael"}, {"firstName": "Magic"}], + }, + {"firstName": "Chris", "opponent": None, "players": []}, + ] + } + schema = graphene.Schema(query=Query) + result = await schema.execute_async(query) + assert not result.errors + assert result.data == expected + + +async def test_should_query_with_embedded_document_async(fixtures): + class Query(graphene.ObjectType): + professor_vector = graphene.Field( + async_types.ProfessorVectorAsyncType, id=graphene.String() + ) + + async def resolve_professor_vector(self, info, id): + return await models.ProfessorVector.aobjects(metadata__id=id).first() + + query = """ + query { + professorVector(id: "5e06aa20-6805-4eef-a144-5615dedbe32b") { + vec + metadata { + firstName + } + } + } + """ + + expected = {"professorVector": {"vec": [1.0, 2.3], "metadata": {"firstName": "Steven"}}} + schema = graphene.Schema(query=Query, types=[async_types.ProfessorVectorAsyncType]) + result = await schema.execute_async(query) + assert not result.errors + assert result.data == expected + + +async def test_should_query_child_async(fixtures): + class Query(graphene.ObjectType): + children = graphene.List(async_types.ChildAsyncType) + + async def resolve_children(self, *args, **kwargs): + return await models.Child.aobjects.all().to_list() + + query = """ + query Query { + children { + bar, + baz, + loc { + type, + coordinates + } + } + } + """ + expected = { + "children": [ + {"bar": "BAR", "baz": "BAZ", "loc": None}, + { + "bar": "bar", + "baz": "baz", + "loc": {"type": "Point", "coordinates": [10.0, 20.0]}, + }, + ] + } + + schema = graphene.Schema(query=Query) + result = await schema.execute_async(query) + assert not result.errors + assert result.data == expected + + +async def test_should_query_other_childs_async(fixtures): + class Query(graphene.ObjectType): + children = graphene.List(async_types.AnotherChildAsyncType) + + async def resolve_children(self, *args, **kwargs): + return await models.AnotherChild.aobjects.all().to_list() + + query = """ + query Query { + children { + bar, + qux, + loc { + type, + coordinates + } + } + } + """ + expected = { + "children": [ + {"bar": "BAR", "qux": "QUX", "loc": None}, + { + "bar": "bar", + "qux": "qux", + "loc": {"type": "Point", "coordinates": [20, 10]}, + }, + ] + } + + schema = graphene.Schema(query=Query) + result = await schema.execute_async(query) + assert not result.errors + assert result.data == expected + + +async def test_should_query_all_childs_async(fixtures): + class Query(graphene.ObjectType): + children = graphene.List(async_types.ChildAsyncUnionType) + + async def resolve_children(self, *args, **kwargs): + return await models.Parent.aobjects.all().to_list() + + query = """ + query Query { + children { + ... on ParentAsyncInterface { + bar + } + ... on ChildAsyncType { + baz + loc { + type, + coordinates + } + } + ... on AnotherChildAsyncType { + qux + loc { + type, + coordinates + } + } + } + } + """ + expected = { + "children": [ + {"bar": "BAR", "qux": "QUX", "loc": None}, + { + "bar": "bar", + "qux": "qux", + "loc": {"type": "Point", "coordinates": [20.0, 10.0]}, + }, + {"bar": "BAR", "baz": "BAZ", "loc": None}, + { + "bar": "bar", + "baz": "baz", + "loc": {"type": "Point", "coordinates": [10.0, 20.0]}, + }, + ] + } + + schema = graphene.Schema(query=Query) + result = await schema.execute_async(query) + assert not result.errors + assert result.data == expected + + +async def test_should_query_cell_tower_async(fixtures): + class Query(graphene.ObjectType): + cell_towers = graphene.List(async_types.CellTowerAsyncType) + + async def resolve_cell_towers(self, *args, **kwargs): + return await models.CellTower.aobjects.all().to_list() + + query = """ + query Query { + cellTowers { + code, + base { + type, + coordinates + }, + coverageArea { + type, + coordinates + } + } + } + """ + expected = { + "cellTowers": [ + { + "code": "bar", + "base": { + "type": "Polygon", + "coordinates": [ + [ + [-43.36556, -22.99669], + [-43.36539, -23.01928], + [-43.26583, -23.01802], + [-43.36717, -22.98855], + [-43.36636, -22.99351], + [-43.36556, -22.99669], + ] + ], + }, + "coverageArea": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-43.36556, -22.99669], + [-43.36539, -23.01928], + [-43.26583, -23.01802], + [-43.36717, -22.98855], + [-43.36636, -22.99351], + [-43.36556, -22.99669], + ] + ] + ], + }, + } + ] + } + + schema = graphene.Schema(query=Query) + result = await schema.execute_async(query) + assert not result.errors + assert result.data == expected \ No newline at end of file diff --git a/tests/asynchronous/test_relay_query.py b/tests/asynchronous/test_relay_query.py index 1f341994..2224374b 100644 --- a/tests/asynchronous/test_relay_query.py +++ b/tests/asynchronous/test_relay_query.py @@ -18,7 +18,7 @@ class Query(graphene.ObjectType): reporter = graphene.Field(nodes.ReporterAsyncNode) async def resolve_reporter(self, *args, **kwargs): - return models.Reporter.objects.first() + return await models.Reporter.aobjects.first() query = """ query ReporterQuery { @@ -920,7 +920,7 @@ class Query(graphene.ObjectType): foos = AsyncMongoengineConnectionField(nodes.FooAsyncNode) async def resolve_multiple_foos(self, *args, **kwargs): - return list(models.Foo.objects.all()) + return await models.Foo.aobjects.all().to_list() query = """ query { @@ -1038,7 +1038,7 @@ async def test_should_filter_mongoengine_queryset_by_id_and_other_fields_async( class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) - larry = models.Player.objects.get(first_name="Larry") + larry = await models.Player.aobjects.get(first_name="Larry") larry_relay_id = to_global_id("PlayerAsyncNode", larry.id) # "Larry" id && firstName == "Michael" should return nothing diff --git a/tests/asynchronous/test_types.py b/tests/asynchronous/test_types.py new file mode 100644 index 00000000..3147b1db --- /dev/null +++ b/tests/asynchronous/test_types.py @@ -0,0 +1,239 @@ +import graphene +from graphene import Field, Int, Interface, ObjectType +from graphene.relay import Node, is_node +from pytest import raises + +from ..models import ( + Article, + Bench, + Child, + EmbeddedArticle, + Exam, + Parent, + Reporter, + School, + SchoolClass, + Student, +) +from .utils import with_local_async_registry +from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType, AsyncMongoengineObjectTypeOptions +from graphene_mongo.base.registry import Registry +from graphene_mongo.base.utils import ExecutorEnum + +# Use a private registry so module-level class definitions don't disturb the +# global async registry (which holds the relay nodes used by other test modules). +_types_registry = Registry(executor=ExecutorEnum.ASYNC) + + +class HumanAsync(AsyncMongoengineObjectType): + pub_date = Int() + + class Meta: + model = Article + registry = _types_registry + interfaces = (Node,) + + +class BeingAsync(AsyncMongoengineObjectType): + class Meta: + model = EmbeddedArticle + registry = _types_registry + interfaces = (Node,) + + +class CharacterAsync(AsyncMongoengineObjectType): + class Meta: + model = Reporter + registry = _types_registry + + +class DadAsync(AsyncMongoengineObjectType): + class Meta: + model = Parent + registry = _types_registry + + +class SonAsync(AsyncMongoengineObjectType): + class Meta: + model = Child + registry = _types_registry + + +def test_mongoengine_interface(): + assert issubclass(Node, Interface) + assert issubclass(Node, Node) + + +def test_objecttype_registered(): + assert issubclass(CharacterAsync, ObjectType) + assert CharacterAsync._meta.model == Reporter + assert set(CharacterAsync._meta.fields.keys()) == set( + [ + "id", + "first_name", + "last_name", + "email", + "embedded_articles", + "embedded_list_articles", + "articles", + "awards", + "generic_reference", + "generic_embedded_document", + "generic_references", + ] + ) + + +def test_mongoengine_inheritance(): + assert issubclass(SonAsync._meta.model, DadAsync._meta.model) + + +def test_node_replacedfield(): + idfield = HumanAsync._meta.fields["pub_date"] + assert isinstance(idfield, Field) + assert idfield.type == Int + + +def test_object_type(): + assert issubclass(HumanAsync, ObjectType) + assert set(HumanAsync._meta.fields.keys()) == set( + [ + "id", + "headline", + "pub_date", + "editor", + "reporter", + ] + ) + assert is_node(HumanAsync) + + +def test_should_raise_if_no_model(): + with raises(Exception) as excinfo: + class Human1(AsyncMongoengineObjectType): + pass + + assert "valid Mongoengine Model" in str(excinfo.value) + + +def test_should_raise_if_model_is_invalid(): + with raises(Exception) as excinfo: + class Human2(AsyncMongoengineObjectType): + class Meta: + model = 1 + + assert "valid Mongoengine Model" in str(excinfo.value) + + +@with_local_async_registry +def test_mongoengine_objecttype_only_fields(): + class A(AsyncMongoengineObjectType): + class Meta: + model = Article + only_fields = "headline" + + fields = set(A._meta.fields.keys()) + assert fields == set(["headline"]) + + +@with_local_async_registry +def test_mongoengine_objecttype_exclude_fields(): + class A(AsyncMongoengineObjectType): + class Meta: + model = Article + exclude_fields = "headline" + + assert "headline" not in list(A._meta.fields.keys()) + + +@with_local_async_registry +def test_mongoengine_objecttype_order_by(): + class A(AsyncMongoengineObjectType): + class Meta: + model = Article + order_by = "some_order_by_statement" + + assert "some_order_by_statement" not in list(A._meta.fields.keys()) + + +@with_local_async_registry +def test_passing_meta_when_subclassing_mongoengine_objecttype(): + class TypeSubclassWithBadOptions(AsyncMongoengineObjectType): + class Meta: + abstract = True + + @classmethod + def __init_subclass_with_meta__(cls, **kwargs): + _meta = ["hi"] + super(TypeSubclassWithBadOptions, cls).__init_subclass_with_meta__( + _meta=_meta, **kwargs + ) + + with raises(Exception) as einfo: + class A(TypeSubclassWithBadOptions): + class Meta: + model = Article + + assert "AsyncMongoengineGenericObjectTypeOptions" in str(einfo.value) + + class TypeSubclass(AsyncMongoengineObjectType): + class Meta: + abstract = True + + @classmethod + def __init_subclass_with_meta__(cls, some_subclass_attr=None, **kwargs): + _meta = AsyncMongoengineObjectTypeOptions(cls) + _meta.some_subclass_attr = some_subclass_attr + super(TypeSubclass, cls).__init_subclass_with_meta__(_meta=_meta, **kwargs) + + class B(TypeSubclass): + class Meta: + model = Article + some_subclass_attr = "someval" + + assert hasattr(B._meta, "some_subclass_attr") + assert B._meta.some_subclass_attr == "someval" + + +@with_local_async_registry +def test_filter_list_types(): + """ + Test to check filter args should not be generated for the following types of fields: + + ListField(EmbeddedDocumentListField(...)) + ListField(GenericEmbeddedDocumentField(...)) + ListField(GenericLazyReferenceField(...)) + """ + + class ExamType(AsyncMongoengineObjectType): + class Meta: + model = Exam + + class BenchType(AsyncMongoengineObjectType): + class Meta: + model = Bench + + class StudentType(AsyncMongoengineObjectType): + class Meta: + model = Student + + class SchoolClassType(AsyncMongoengineObjectType): + class Meta: + model = SchoolClass + interfaces = (Node,) + + class SchoolType(AsyncMongoengineObjectType): + class Meta: + model = School + interfaces = (Node,) + + class_type_filter_args = SchoolType._meta.fields["classes"].args + assert class_type_filter_args.keys() == { + "before", + "after", + "first", + "last", + "allowed_grades", + "id", + "subjects", + } diff --git a/tests/asynchronous/types.py b/tests/asynchronous/types.py new file mode 100644 index 00000000..517ef576 --- /dev/null +++ b/tests/asynchronous/types.py @@ -0,0 +1,93 @@ +from graphene.types.union import Union + +from graphene_mongo.asynchronous.types import AsyncMongoengineInterfaceType, AsyncMongoengineObjectType +from graphene_mongo.base.registry import Registry +from graphene_mongo.base.utils import ExecutorEnum + +from .. import models +from ..types import ArticleInput, EditorInput # noqa: F401 — re-exported for convenience + +# Isolated registry so these non-relay types don't overwrite the relay nodes in +# the global async registry used by tests/asynchronous/nodes.py. +local_async_registry = Registry(executor=ExecutorEnum.ASYNC) + + +class PublisherAsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.Publisher + registry = local_async_registry + + +class EditorAsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.Editor + registry = local_async_registry + + +class ArticleAsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.Article + registry = local_async_registry + + +class EmbeddedArticleAsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.EmbeddedArticle + registry = local_async_registry + + +class PlayerAsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.Player + registry = local_async_registry + + +class ReporterAsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.Reporter + registry = local_async_registry + + +class ProfessorMetadataAsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.ProfessorMetadata + registry = local_async_registry + + +class ProfessorVectorAsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.ProfessorVector + registry = local_async_registry + + +class CellTowerAsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.CellTower + registry = local_async_registry + + +class ParentAsyncInterface(AsyncMongoengineInterfaceType): + class Meta: + model = models.Parent + registry = local_async_registry + exclude_fields = ["loc"] + + +class ChildAsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.Child + registry = local_async_registry + interfaces = (ParentAsyncInterface,) + + +class AnotherChildAsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.AnotherChild + registry = local_async_registry + interfaces = (ParentAsyncInterface,) + + +class ChildAsyncUnionType(Union): + class Meta: + types = (ChildAsyncType, AnotherChildAsyncType) + interfaces = (ParentAsyncInterface,) \ No newline at end of file diff --git a/tests/asynchronous/utils.py b/tests/asynchronous/utils.py new file mode 100644 index 00000000..8a49ae17 --- /dev/null +++ b/tests/asynchronous/utils.py @@ -0,0 +1,17 @@ +from graphene_mongo import registry + + +def with_local_async_registry(func): + def inner(*args, **kwargs): + old = registry.async_registry + registry.reset_global_async_registry() + try: + retval = func(*args, **kwargs) + except Exception as e: + registry.async_registry = old + raise e + else: + registry.async_registry = old + return retval + + return inner \ No newline at end of file From 8cfa1e8635f44717e24c12e12086a23558524896 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 21:51:24 +0530 Subject: [PATCH 08/52] feat: replace dataloaders with select_related for N+1 elimination - Add get_select_related_paths() to walk queried GraphQL fields and build __-separated paths for mongoengine select_related() - Apply select_related in get_queryset() and get_node() for both async and sync fields, resolving all queried ReferenceFields in a single aggregation - Skip count() DB call when no pagination args present (saves 1 query for unpaginated relay connections) - Remove aiodataloader-based MongoDataLoader/ModelLoader and get_dataloader() helper; field resolvers now make direct async DB calls as fallback - Remove dataloader_resolver classmethod from AsyncMongoengineObjectType - Strip _async suffix from all async test function names - Add test_select_related.py for both async and sync with query-count assertions verifying N+1 elimination and count-skip optimisation --- graphene_mongo/asynchronous/dataloader.py | 127 --------- graphene_mongo/asynchronous/fields.py | 46 +-- graphene_mongo/asynchronous/types.py | 20 +- graphene_mongo/asynchronous/utils.py | 17 -- .../base/field_resolvers/__init__.py | 2 +- .../dynamic_reference_field_resolver.py | 17 +- .../field_resolvers/list_field_resolver.py | 9 +- .../base/field_resolvers/union_resolver.py | 9 +- graphene_mongo/base/utils.py | 26 +- graphene_mongo/synchronous/fields.py | 61 ++-- graphene_mongo/synchronous/types.py | 8 +- tests/asynchronous/test_fields.py | 4 +- tests/asynchronous/test_inputs.py | 4 +- tests/asynchronous/test_mutation.py | 4 +- tests/asynchronous/test_query.py | 18 +- tests/asynchronous/test_relay_query.py | 48 ++-- tests/asynchronous/test_select_related.py | 262 ++++++++++++++++++ tests/synchronous/test_select_related.py | 252 +++++++++++++++++ 18 files changed, 664 insertions(+), 270 deletions(-) delete mode 100644 graphene_mongo/asynchronous/dataloader.py delete mode 100644 graphene_mongo/asynchronous/utils.py create mode 100644 tests/asynchronous/test_select_related.py create mode 100644 tests/synchronous/test_select_related.py diff --git a/graphene_mongo/asynchronous/dataloader.py b/graphene_mongo/asynchronous/dataloader.py deleted file mode 100644 index bb660f0c..00000000 --- a/graphene_mongo/asynchronous/dataloader.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -MongoEngine DataLoader for GraphQL resolution. - -Usage: - # ====================== Type level ====================== - - class UserType(AsyncMongoengineObjectType): - class Meta: - model = User - - @classmethod - async def dataloader_resolver( - cls, info: GraphQLResolveInfo, ids: list[ObjectId], projections: list[str] | None = None - ): - docs = User.aobjects.filter(id__in=ids) - if projections: - docs = docs.only(*projections) - return await docs.to_list() - - # ====================== Field Level ====================== - from graphene_mongo.utils import get_dataloader - - loader = get_dataloader(info) - - # Single fetch - user = await loader.model(User, projections).get(user_id) - - # Pre-load in bulk (e.g. in a resolver that already has many ids) - await loader.model(Post, projections).load_many([id1, id2, id3]) - post = await loader.model(Post).get(id1) # served from cache - - # This attached the loader to your GraphQL context so it's - # shared across all resolvers in a single request, giving you automatic - # N+1 batching. -""" - -from __future__ import annotations - -from typing import Generic, Type, TypeVar - -from aiodataloader import DataLoader -from bson import ObjectId -from graphql import GraphQLResolveInfo -from mongoengine import Document - -T = TypeVar("T", bound=Document) - - -class ModelLoader(DataLoader, Generic[T]): - """ - A per-model DataLoader that batches `.get(id)` calls into a single - MongoDB query per tick of the event loop. - """ - - def __init__(self, model: Type[T], info: GraphQLResolveInfo, projections: set[str], **kwargs): - super().__init__(cache=True, **kwargs) - self._info = info - self._model = model - self._projections: set[str] = projections - - from ..base.registry import get_global_async_registry - from .types import AsyncMongoengineObjectType - - registry = get_global_async_registry() - self._gql_type: AsyncMongoengineObjectType = registry.get_type_for_document_model(model) - if self._gql_type is None: - raise NotImplementedError(f"Please define AsyncMongoengineObjectType for {model}") - - async def batch_load_fn(self, keys: list[str]) -> list[T | None]: - """ - Called once per event-loop tick with all ids accumulated so far. - Executes a single `filter(id__in=...)` query and maps results back - to the original key order (DataLoader requires 1-to-1 ordering). - """ - # Normalise to ObjectId so MongoEngine is happy either way - object_ids: list[ObjectId] = [] - for k in keys: - try: - object_ids.append(ObjectId(k) if not isinstance(k, ObjectId) else k) - except Exception: - object_ids.append(k) # let Mongo surface the error naturally - - docs = await self._gql_type.dataloader_resolver( - info=self._info, - ids=object_ids, - projections=list(self._projections) if self._projections else None, - ) - - id_map: dict[str, T] = {str(doc.id): doc for doc in docs} - - # Preserve key order; missing ids resolve to None - return [id_map.get(str(k)) for k in keys] - - -class MongoDataLoader: - """ - Request-scoped container for per-model DataLoaders. - """ - - def __init__(self, info: GraphQLResolveInfo): - self._info = info - self._loaders: dict[tuple[type, frozenset[str]], ModelLoader] = {} - - def model(self, model_class: Type[T], projections: set[str]) -> ModelLoader[T]: - """ - Returns (or creates) the DataLoader for the given MongoEngine model. - Loaders are cached per model class for the lifetime of this object. - """ - requested_projections = frozenset(projections) - key = (model_class, requested_projections) - - if self._loaders.get(key): - return self._loaders[key] - - # Trying to find a loader which has superset projections - existing_key = next((k for k in self._loaders if k[0] == model_class), None) - if existing_key: - existing_loader = self._loaders[existing_key] - existing_projections = existing_key[1] - if projections.issubset(existing_projections): - # reuse old loader as its projections are subset - return existing_loader - - self._loaders[key] = ModelLoader( - model=model_class, info=self._info, projections=set(requested_projections) - ) - return self._loaders[key] diff --git a/graphene_mongo/asynchronous/fields.py b/graphene_mongo/asynchronous/fields.py index 03b51d62..10ca9fd6 100644 --- a/graphene_mongo/asynchronous/fields.py +++ b/graphene_mongo/asynchronous/fields.py @@ -27,6 +27,7 @@ get_document, get_model_reference_fields, get_query_fields, + get_select_related_paths, has_page_info, ) @@ -111,17 +112,17 @@ def get_queryset( ) else: args.update(queryset_or_filters) + queried_fields = get_query_fields(info) if isinstance(info, GraphQLResolveInfo) else {} + related = get_select_related_paths(model, queried_fields) + + qs = model.aobjects(**args).only(*required_fields).order_by(self.order_by) + if related: + qs = qs.select_related(*related) if limit is not None: - return ( - model.aobjects(**args) - .only(*required_fields) - .order_by(self.order_by) - .skip(skip if skip else 0) - .limit(limit) - ) + return qs.skip(skip if skip else 0).limit(limit) elif skip is not None: - return model.aobjects(**args).only(*required_fields).order_by(self.order_by).skip(skip) - return model.aobjects(**args).only(*required_fields).order_by(self.order_by) + return qs.skip(skip) + return qs @property def registry(self): @@ -262,14 +263,8 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non if getattr(args_copy[key], "value", None): args_copy[key] = args_copy[key].value - count = await self.model.aobjects(**args_copy).count() - if count != 0: - skip, limit = find_skip_and_limit( - first=first, after=after, last=last, before=before, count=count - ) - iterables = self.get_queryset( - self.model, info, required_fields, skip, limit, **args - ) + if first is None and last is None and before is None and after is None: + iterables = self.get_queryset(self.model, info, required_fields, **args) iterables = await iterables.to_list() list_length = len(iterables) if isinstance(info, GraphQLResolveInfo): @@ -278,6 +273,23 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non info.context.queryset = self.get_queryset( self.model, info, required_fields, **args ) + else: + count = await self.model.aobjects(**args_copy).count() + if count != 0: + skip, limit = find_skip_and_limit( + first=first, after=after, last=last, before=before, count=count + ) + iterables = self.get_queryset( + self.model, info, required_fields, skip, limit, **args + ) + iterables = await iterables.to_list() + list_length = len(iterables) + if isinstance(info, GraphQLResolveInfo): + if not info.context: + info = info._replace(context=Context()) + info.context.queryset = self.get_queryset( + self.model, info, required_fields, **args + ) elif _root is not None: field_name = to_snake_case(info.field_name) diff --git a/graphene_mongo/asynchronous/types.py b/graphene_mongo/asynchronous/types.py index 45c2cba3..3c441e99 100644 --- a/graphene_mongo/asynchronous/types.py +++ b/graphene_mongo/asynchronous/types.py @@ -1,4 +1,3 @@ -from bson import ObjectId import graphene from graphene import InputObjectType from graphene.relay import Connection, Node @@ -6,13 +5,12 @@ from graphene.types.objecttype import ObjectType, ObjectTypeOptions from graphene.types.utils import yank_fields_from_attrs from graphene.utils.str_converters import to_snake_case -from graphql import GraphQLResolveInfo import mongoengine from .fields import AsyncMongoengineConnectionField from ..base.registry import Registry, get_global_async_registry, get_inputs_async_registry from ..synchronous.types import construct_fields, construct_self_referenced_fields -from ..base.utils import ExecutorEnum, get_query_fields, is_valid_mongoengine_model +from ..base.utils import ExecutorEnum, get_query_fields, get_select_related_paths, is_valid_mongoengine_model def create_graphene_generic_class_async(object_type, option_type): @@ -181,21 +179,15 @@ async def get_node(cls, info, id): if to_snake_case(field) in cls._meta.model._fields_ordered: required_fields.append(to_snake_case(field)) required_fields = list(set(required_fields)) - return await cls._meta.model.aobjects.only(*required_fields).get(pk=id) + related = get_select_related_paths(cls._meta.model, queried_fields) + qs = cls._meta.model.aobjects.only(*required_fields) + if related: + qs = qs.select_related(*related) + return await qs.get(pk=id) def resolve_id(self, info): return str(self.id) - @classmethod - async def dataloader_resolver( - cls, info: GraphQLResolveInfo, ids: list[ObjectId], projections: list[str] | None = None - ): - """Resolver for dataloader. Override this to implement custom resolver""" - docs = cls._meta.model.aobjects.filter(pk__in=ids) - if projections: - docs = docs.only(*projections) - return await docs.to_list() - return AsyncGrapheneMongoengineGenericType, AsyncMongoengineGenericObjectTypeOptions diff --git a/graphene_mongo/asynchronous/utils.py b/graphene_mongo/asynchronous/utils.py deleted file mode 100644 index 758308f1..00000000 --- a/graphene_mongo/asynchronous/utils.py +++ /dev/null @@ -1,17 +0,0 @@ -from graphql import GraphQLResolveInfo - -from .dataloader import MongoDataLoader - -DATALOADER_CONTEXT_ATTRIBUTE = "_mongo_dataloader" - - -def get_dataloader(info: GraphQLResolveInfo) -> MongoDataLoader: - """ - Get the MongoDataLoader() from info context or operation - """ - data_point = info.context or info.operation - - if not hasattr(data_point, DATALOADER_CONTEXT_ATTRIBUTE): - setattr(data_point, DATALOADER_CONTEXT_ATTRIBUTE, MongoDataLoader(info=info)) - - return getattr(data_point, DATALOADER_CONTEXT_ATTRIBUTE) \ No newline at end of file diff --git a/graphene_mongo/base/field_resolvers/__init__.py b/graphene_mongo/base/field_resolvers/__init__.py index 4eef4f47..c2ae54cb 100644 --- a/graphene_mongo/base/field_resolvers/__init__.py +++ b/graphene_mongo/base/field_resolvers/__init__.py @@ -6,4 +6,4 @@ "DynamicReferenceFieldResolver", "ListFieldResolver", "UnionFieldResolver", -] +] \ No newline at end of file diff --git a/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py b/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py index f7c85b75..9a92b013 100644 --- a/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py +++ b/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py @@ -6,7 +6,6 @@ from mongoengine import Document, ReferenceField from graphene_mongo.base.utils import ExecutorEnum, get_query_fields -from graphene_mongo.asynchronous.utils import get_dataloader class DynamicReferenceFieldResolver: @@ -31,10 +30,8 @@ def __reference_resolver_common( queried_fields.append(item) fields_to_fetch = set(list(_type._meta.required_fields) + queried_fields) - if isinstance(document, field.document_type) and all( - document._data[_field] is not None for _field in fields_to_fetch - ): - return document # Data is already fetched + if isinstance(document, field.document_type): + return document # Already fetched by select_related document_id = ( document.id @@ -64,11 +61,7 @@ async def resolver(root, *args, **kwargs) -> Optional[Document]: ) if not isinstance(result, tuple): return result - model, only_fields, id = result - return ( - await get_dataloader(info=args[0]) - .model(model_class=model, projections=only_fields) - .load(id) - ) + model, only_fields, document_id = result + return await model.aobjects.only(*only_fields).get(pk=document_id) - return resolver + return resolver \ No newline at end of file diff --git a/graphene_mongo/base/field_resolvers/list_field_resolver.py b/graphene_mongo/base/field_resolvers/list_field_resolver.py index 67b2e512..41b66b20 100644 --- a/graphene_mongo/base/field_resolvers/list_field_resolver.py +++ b/graphene_mongo/base/field_resolvers/list_field_resolver.py @@ -11,7 +11,6 @@ from mongoengine.base import LazyReference from graphene_mongo.base.utils import ExecutorEnum, get_queried_union_types, get_document -from graphene_mongo.asynchronous.utils import get_dataloader class ListFieldResolver: @@ -64,16 +63,11 @@ async def __get_reference_objects_async( executor: ExecutorEnum, object_id_list: list[ObjectId], queried_fields: dict, - args: tuple, ): document, only_fields, document_ids = ListFieldResolver.__get_reference_objects_common( registry, model, executor, object_id_list, queried_fields ) - return ( - await get_dataloader(info=args[0]) - .model(model_class=document, projections=only_fields) - .load_many(document_ids) - ) + return await document.aobjects.only(*only_fields).filter(pk__in=document_ids).to_list() # ======================= DB CALLS: END ======================= @@ -162,7 +156,6 @@ def __reference_resolver_common( executor, object_id_list, queried_fields, - args, ) ) else: diff --git a/graphene_mongo/base/field_resolvers/union_resolver.py b/graphene_mongo/base/field_resolvers/union_resolver.py index 00ff3d28..95000d1c 100644 --- a/graphene_mongo/base/field_resolvers/union_resolver.py +++ b/graphene_mongo/base/field_resolvers/union_resolver.py @@ -8,7 +8,6 @@ from mongoengine.base import LazyReference from graphene_mongo.base.utils import ExecutorEnum, get_document, get_queried_union_types -from graphene_mongo.asynchronous.utils import get_dataloader class UnionFieldResolver: @@ -70,11 +69,7 @@ async def resolver(root, *args, **kwargs) -> Optional[Document]: result = resolver_fun(field, registry, executor, root, *args, **kwargs) if not isinstance(result, tuple): return result - model, only_fields, id = result - return ( - await get_dataloader(info=args[0]) - .model(model_class=model, projections=only_fields) - .load(id) - ) + model, only_fields, document_id = result + return await model.aobjects.only(*only_fields).get(pk=document_id) return resolver diff --git a/graphene_mongo/base/utils.py b/graphene_mongo/base/utils.py index 5b557a70..6c3c4cd3 100644 --- a/graphene_mongo/base/utils.py +++ b/graphene_mongo/base/utils.py @@ -6,6 +6,7 @@ from typing import Callable, Optional from graphene import Node +from graphene.utils.str_converters import to_snake_case from graphene.utils.trim_docstring import trim_docstring from graphql import ( BooleanValueNode, @@ -207,10 +208,33 @@ def get_query_fields(info): query = collect_query_fields(node, fragments, variables) if "edges" in query: - return query["edges"]["node"].keys() + return query["edges"]["node"] return query +def get_select_related_paths(model, queried_fields, prefix=""): + """Recursively build select_related paths for queried reference fields. + + Returns ``__``-separated paths (e.g. ``["editor", "editor__company"]``) + suitable for ``QuerySet.select_related(*paths)``. + """ + paths = [] + if not queried_fields or not hasattr(queried_fields, "items"): + return paths + for field_name, sub_fields in queried_fields.items(): + snake = to_snake_case(field_name) + if snake not in model._fields: + continue + mongo_field = model._fields[snake] + inner = mongo_field.field if isinstance(mongo_field, mongoengine.ListField) else mongo_field + if isinstance(inner, mongoengine.ReferenceField): + path = f"{prefix}__{snake}" if prefix else snake + paths.append(path) + if sub_fields and hasattr(inner, "document_type"): + paths += get_select_related_paths(inner.document_type, sub_fields, prefix=path) + return paths + + def get_queried_union_types(info, valid_gql_types): """A convenience function to get queried union types with its fields diff --git a/graphene_mongo/synchronous/fields.py b/graphene_mongo/synchronous/fields.py index 169a4e7d..bdc7bc2d 100644 --- a/graphene_mongo/synchronous/fields.py +++ b/graphene_mongo/synchronous/fields.py @@ -36,10 +36,11 @@ ExecutorEnum, connection_from_iterables, find_skip_and_limit, + get_document, get_model_reference_fields, get_query_fields, + get_select_related_paths, has_page_info, - get_document, ) PYMONGO_VERSION = tuple(pymongo.version_tuple[:2]) @@ -324,17 +325,17 @@ def get_queryset( return queryset_or_filters else: args.update(queryset_or_filters) + queried_fields = get_query_fields(info) if isinstance(info, GraphQLResolveInfo) else {} + related = get_select_related_paths(model, queried_fields) + + qs = model.objects(**args).only(*required_fields).order_by(self.order_by) + if related: + qs = qs.select_related(*related) if limit is not None: - return ( - model.objects(**args) - .only(*required_fields) - .order_by(self.order_by) - .skip(skip if skip else 0) - .limit(limit) - ) + return qs.skip(skip if skip else 0).limit(limit) elif skip is not None: - return model.objects(**args).only(*required_fields).order_by(self.order_by).skip(skip) - return model.objects(**args).only(*required_fields).order_by(self.order_by) + return qs.skip(skip) + return qs def default_resolver(self, _root, info, required_fields=None, resolved=None, **args): if required_fields is None: @@ -463,21 +464,8 @@ def default_resolver(self, _root, info, required_fields=None, resolved=None, **a if getattr(args_copy[key], "value", None): args_copy[key] = args_copy[key].value - if PYMONGO_VERSION >= (3, 7): - if hasattr(self.model, "_meta") and "db_alias" in self.model._meta: - db = mongoengine.get_db(self.model._meta["db_alias"]) - else: - db = mongoengine.get_db() - count = db[self.model._get_collection_name()].count_documents(args_copy) - else: - count = self.model.objects(args_copy).count() - if count != 0: - skip, limit = find_skip_and_limit( - first=first, after=after, last=last, before=before, count=count - ) - iterables = self.get_queryset( - self.model, info, required_fields, skip, limit, **args - ) + if first is None and last is None and before is None and after is None: + iterables = self.get_queryset(self.model, info, required_fields, **args) list_length = len(iterables) if isinstance(info, GraphQLResolveInfo): if not info.context: @@ -485,6 +473,29 @@ def default_resolver(self, _root, info, required_fields=None, resolved=None, **a info.context.queryset = self.get_queryset( self.model, info, required_fields, **args ) + else: + if PYMONGO_VERSION >= (3, 7): + if hasattr(self.model, "_meta") and "db_alias" in self.model._meta: + db = mongoengine.get_db(self.model._meta["db_alias"]) + else: + db = mongoengine.get_db() + count = db[self.model._get_collection_name()].count_documents(args_copy) + else: + count = self.model.objects(args_copy).count() + if count != 0: + skip, limit = find_skip_and_limit( + first=first, after=after, last=last, before=before, count=count + ) + iterables = self.get_queryset( + self.model, info, required_fields, skip, limit, **args + ) + list_length = len(iterables) + if isinstance(info, GraphQLResolveInfo): + if not info.context: + info = info._replace(context=Context()) + info.context.queryset = self.get_queryset( + self.model, info, required_fields, **args + ) elif _root is not None: field_name = to_snake_case(info.field_name) diff --git a/graphene_mongo/synchronous/types.py b/graphene_mongo/synchronous/types.py index 8ddede57..7ada54eb 100644 --- a/graphene_mongo/synchronous/types.py +++ b/graphene_mongo/synchronous/types.py @@ -12,7 +12,7 @@ from graphene_mongo import MongoengineConnectionField from ..base.converter import convert_mongoengine_field from ..base.registry import Registry, get_global_registry, get_inputs_registry -from ..base.utils import ExecutorEnum, get_model_fields, get_query_fields, is_valid_mongoengine_model +from ..base.utils import ExecutorEnum, get_model_fields, get_query_fields, get_select_related_paths, is_valid_mongoengine_model def construct_fields( @@ -236,7 +236,11 @@ def get_node(cls, info, id): if to_snake_case(field) in cls._meta.model._fields_ordered: required_fields.append(to_snake_case(field)) required_fields = list(set(required_fields)) - return cls._meta.model.objects.only(*required_fields).get(pk=id) + related = get_select_related_paths(cls._meta.model, queried_fields) + qs = cls._meta.model.objects.only(*required_fields) + if related: + qs = qs.select_related(*related) + return qs.get(pk=id) def resolve_id(self, info): return str(self.id) diff --git a/tests/asynchronous/test_fields.py b/tests/asynchronous/test_fields.py index 4a337e1d..b295fc11 100644 --- a/tests/asynchronous/test_fields.py +++ b/tests/asynchronous/test_fields.py @@ -5,7 +5,7 @@ @pytest.mark.asyncio -async def test_default_resolver_with_colliding_objects_field_async(): +async def test_default_resolver_with_colliding_objects_field(): field = AsyncMongoengineConnectionField(nodes.ErroneousModelAsyncNode) connection = await field.default_resolver(None, {}) @@ -13,7 +13,7 @@ async def test_default_resolver_with_colliding_objects_field_async(): @pytest.mark.asyncio -async def test_default_resolver_connection_list_length_async(fixtures): +async def test_default_resolver_connection_list_length(fixtures): field = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) connection = await field.default_resolver(None, {}, **{"first": 1}) diff --git a/tests/asynchronous/test_inputs.py b/tests/asynchronous/test_inputs.py index d65f7ce6..e5be774c 100644 --- a/tests/asynchronous/test_inputs.py +++ b/tests/asynchronous/test_inputs.py @@ -6,7 +6,7 @@ from ..types import ArticleInput, EditorInput -async def test_should_create_async(fixtures): +async def test_should_create(fixtures): class CreateArticle(graphene.Mutation): class Arguments: article = ArticleInput(required=True) @@ -42,7 +42,7 @@ class Mutation(graphene.ObjectType): assert result.data == expected -async def test_should_update_async(fixtures): +async def test_should_update(fixtures): class UpdateEditor(graphene.Mutation): class Arguments: id = graphene.ID(required=True) diff --git a/tests/asynchronous/test_mutation.py b/tests/asynchronous/test_mutation.py index 26178187..639a4213 100644 --- a/tests/asynchronous/test_mutation.py +++ b/tests/asynchronous/test_mutation.py @@ -5,7 +5,7 @@ from .nodes import ArticleAsyncNode, EditorAsyncNode -async def test_should_create_async(fixtures): +async def test_should_create(fixtures): class CreateArticle(graphene.Mutation): class Arguments: headline = graphene.String() @@ -41,7 +41,7 @@ class Mutation(graphene.ObjectType): assert result.data == expected -async def test_should_update_async(fixtures): +async def test_should_update(fixtures): class UpdateEditor(graphene.Mutation): class Arguments: id = graphene.ID() diff --git a/tests/asynchronous/test_query.py b/tests/asynchronous/test_query.py index 36b25330..9dea361e 100644 --- a/tests/asynchronous/test_query.py +++ b/tests/asynchronous/test_query.py @@ -9,7 +9,7 @@ from . import types as async_types -async def test_should_query_editor_async(fixtures, fixtures_dirname): +async def test_should_query_editor(fixtures, fixtures_dirname): class Query(graphene.ObjectType): editor = graphene.Field(async_types.EditorAsyncType) editors = graphene.List(async_types.EditorAsyncType) @@ -73,7 +73,7 @@ async def resolve_editors(self, *args, **kwargs): assert result.data == expected -async def test_should_query_reporter_async(fixtures): +async def test_should_query_reporter(fixtures): class Query(graphene.ObjectType): reporter = graphene.Field(async_types.ReporterAsyncType) @@ -117,7 +117,7 @@ async def resolve_reporter(self, *args, **kwargs): assert result.data == expected -async def test_should_custom_kwargs_async(fixtures): +async def test_should_custom_kwargs(fixtures): class Query(graphene.ObjectType): editors = graphene.List(async_types.EditorAsyncType, first=graphene.Int()) @@ -147,7 +147,7 @@ async def resolve_editors(self, *args, **kwargs): assert result.data == expected -async def test_should_self_reference_async(fixtures): +async def test_should_self_reference(fixtures): class Query(graphene.ObjectType): all_players = graphene.List(async_types.PlayerAsyncType) @@ -193,7 +193,7 @@ async def resolve_all_players(self, *args, **kwargs): assert result.data == expected -async def test_should_query_with_embedded_document_async(fixtures): +async def test_should_query_with_embedded_document(fixtures): class Query(graphene.ObjectType): professor_vector = graphene.Field( async_types.ProfessorVectorAsyncType, id=graphene.String() @@ -220,7 +220,7 @@ async def resolve_professor_vector(self, info, id): assert result.data == expected -async def test_should_query_child_async(fixtures): +async def test_should_query_child(fixtures): class Query(graphene.ObjectType): children = graphene.List(async_types.ChildAsyncType) @@ -256,7 +256,7 @@ async def resolve_children(self, *args, **kwargs): assert result.data == expected -async def test_should_query_other_childs_async(fixtures): +async def test_should_query_other_childs(fixtures): class Query(graphene.ObjectType): children = graphene.List(async_types.AnotherChildAsyncType) @@ -292,7 +292,7 @@ async def resolve_children(self, *args, **kwargs): assert result.data == expected -async def test_should_query_all_childs_async(fixtures): +async def test_should_query_all_childs(fixtures): class Query(graphene.ObjectType): children = graphene.List(async_types.ChildAsyncUnionType) @@ -345,7 +345,7 @@ async def resolve_children(self, *args, **kwargs): assert result.data == expected -async def test_should_query_cell_tower_async(fixtures): +async def test_should_query_cell_tower(fixtures): class Query(graphene.ObjectType): cell_towers = graphene.List(async_types.CellTowerAsyncType) diff --git a/tests/asynchronous/test_relay_query.py b/tests/asynchronous/test_relay_query.py index 2224374b..c1cfd18a 100644 --- a/tests/asynchronous/test_relay_query.py +++ b/tests/asynchronous/test_relay_query.py @@ -13,7 +13,7 @@ @pytest.mark.asyncio -async def test_should_query_reporter_async(fixtures): +async def test_should_query_reporter(fixtures): class Query(graphene.ObjectType): reporter = graphene.Field(nodes.ReporterAsyncNode) @@ -92,7 +92,7 @@ async def resolve_reporter(self, *args, **kwargs): @pytest.mark.asyncio -async def test_should_query_reporters_with_nested_document_async(fixtures): +async def test_should_query_reporters_with_nested_document(fixtures): class Query(graphene.ObjectType): reporters = AsyncMongoengineConnectionField(nodes.ReporterAsyncNode) @@ -138,7 +138,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_query_all_editors_async(fixtures, fixtures_dirname): +async def test_should_query_all_editors(fixtures, fixtures_dirname): class Query(graphene.ObjectType): editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) @@ -206,7 +206,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_query_editors_with_dataloader_async(fixtures): +async def test_should_query_editors_with_dataloader(fixtures): from promise import Promise from promise.dataloader import DataLoader @@ -270,7 +270,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_filter_editors_by_id_async(fixtures): +async def test_should_filter_editors_by_id(fixtures): class Query(graphene.ObjectType): editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) @@ -307,7 +307,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_filter_async(fixtures): +async def test_should_filter(fixtures): class Query(graphene.ObjectType): articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) @@ -346,7 +346,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_filter_by_reference_field_async(fixtures): +async def test_should_filter_by_reference_field(fixtures): class Query(graphene.ObjectType): articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) @@ -374,7 +374,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_filter_through_inheritance_async(fixtures): +async def test_should_filter_through_inheritance(fixtures): class Query(graphene.ObjectType): node = Node.Field() children = AsyncMongoengineConnectionField(nodes.ChildAsyncNode) @@ -415,7 +415,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_filter_by_list_contains_async(fixtures): +async def test_should_filter_by_list_contains(fixtures): # Notes: https://goo.gl/hMNRgs class Query(graphene.ObjectType): reporters = AsyncMongoengineConnectionField(nodes.ReporterAsyncNode) @@ -462,7 +462,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_filter_by_id_async(fixtures): +async def test_should_filter_by_id(fixtures): # Notes: https://goo.gl/hMNRgs class Query(graphene.ObjectType): reporter = Node.Field(nodes.ReporterAsyncNode) @@ -490,7 +490,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_first_n_async(fixtures): +async def test_should_first_n(fixtures): class Query(graphene.ObjectType): editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) @@ -534,7 +534,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_after_async(fixtures): +async def test_should_after(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -567,7 +567,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_before_async(fixtures): +async def test_should_before(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -602,7 +602,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_last_n_async(fixtures): +async def test_should_last_n(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -634,7 +634,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_self_reference_async(fixtures): +async def test_should_self_reference(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -709,7 +709,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_lazy_reference_async(fixtures): +async def test_should_lazy_reference(fixtures): class Query(graphene.ObjectType): node = Node.Field() parents = AsyncMongoengineConnectionField(nodes.ParentWithRelationshipAsyncNode) @@ -766,7 +766,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_query_with_embedded_document_async(fixtures): +async def test_should_query_with_embedded_document(fixtures): class Query(graphene.ObjectType): professors = AsyncMongoengineConnectionField(nodes.ProfessorVectorAsyncNode) @@ -796,7 +796,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_get_queryset_returns_dict_filters_async(fixtures): +async def test_should_get_queryset_returns_dict_filters(fixtures): class Query(graphene.ObjectType): node = Node.Field() articles = AsyncMongoengineConnectionField( @@ -839,7 +839,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_get_queryset_returns_qs_filters_async(fixtures): +async def test_should_get_queryset_returns_qs_filters(fixtures): def get_queryset(model, info, **args): return model.aobjects(headline="World") @@ -884,7 +884,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_filter_mongoengine_queryset_async(fixtures): +async def test_should_filter_mongoengine_queryset(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -915,7 +915,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_query_document_with_embedded_async(fixtures): +async def test_should_query_document_with_embedded(fixtures): class Query(graphene.ObjectType): foos = AsyncMongoengineConnectionField(nodes.FooAsyncNode) @@ -946,7 +946,7 @@ async def resolve_multiple_foos(self, *args, **kwargs): @pytest.mark.asyncio -async def test_should_filter_mongoengine_queryset_with_list_async(fixtures): +async def test_should_filter_mongoengine_queryset_with_list(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -977,7 +977,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_get_correct_list_of_documents_async(fixtures): +async def test_should_get_correct_list_of_documents(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -1032,7 +1032,7 @@ class Query(graphene.ObjectType): @pytest.mark.asyncio -async def test_should_filter_mongoengine_queryset_by_id_and_other_fields_async( +async def test_should_filter_mongoengine_queryset_by_id_and_other_fields( fixtures, ): class Query(graphene.ObjectType): diff --git a/tests/asynchronous/test_select_related.py b/tests/asynchronous/test_select_related.py new file mode 100644 index 00000000..38b8bf02 --- /dev/null +++ b/tests/asynchronous/test_select_related.py @@ -0,0 +1,262 @@ +""" +Query-count tests verifying that the select_related + skip-count optimizations +eliminate N+1 queries and unnecessary count calls. + +Legend +------ +- No pagination args (first/last/before/after absent): + 1 query — single $aggregate with $lookup stages, count skipped +- Pagination args present: + 2 queries — 1 count + 1 $aggregate with $lookup stages +""" + +import graphene +import pytest +from graphql_relay.connection.array_connection import offset_to_cursor +from mongoengine.context_managers import async_query_counter + +from graphene_mongo import AsyncMongoengineConnectionField + +from . import nodes + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _exec(schema, gql): + """Execute a query and capture the DB query count.""" + async with async_query_counter() as q: + result = await schema.execute_async(gql) + count = await q.int() + return result, count + + +# --------------------------------------------------------------------------- +# No-pagination tests: count must equal 1 +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_editors_with_company_no_pagination(fixtures): + """Single ReferenceField (editor → company): one aggregate, no count query.""" + + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) + + result, count = await _exec(graphene.Schema(query=Query), """ + query { + editors { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Penny", "Grant", "Dennis"] + assert count == 1 # 1 aggregate($lookup company) — count skipped + + +@pytest.mark.asyncio +async def test_articles_with_editor_and_company_no_pagination(fixtures): + """Two-level nesting (article → editor → company): still one aggregate.""" + + class Query(graphene.ObjectType): + articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) + + result, count = await _exec(graphene.Schema(query=Query), """ + query { + articles { + edges { + node { + headline + editor { + firstName + company { name } + } + } + } + } + } + """) + + assert not result.errors + assert count == 1 # 1 aggregate($lookup editor, $lookup editor.company) + + +@pytest.mark.asyncio +async def test_articles_with_multiple_refs_no_pagination(fixtures): + """Two sibling ReferenceFields (editor → company, reporter) in one aggregate.""" + + class Query(graphene.ObjectType): + articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) + + result, count = await _exec(graphene.Schema(query=Query), """ + query { + articles { + edges { + node { + headline + editor { + firstName + company { name } + } + reporter { firstName } + } + } + } + } + """) + + assert not result.errors + headlines = [e["node"]["headline"] for e in result.data["articles"]["edges"]] + assert set(headlines) == {"Hello", "World", "Bye"} + assert count == 1 # select_related("editor", "editor__company", "reporter") + + +@pytest.mark.asyncio +async def test_players_with_self_referential_no_pagination(fixtures): + """Self-referential ReferenceField (player → opponent): one aggregate.""" + + class Query(graphene.ObjectType): + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) + + result, count = await _exec(graphene.Schema(query=Query), """ + query { + players { + edges { + node { + firstName + opponent { firstName } + } + } + } + } + """) + + assert not result.errors + magic = next( + e["node"] for e in result.data["players"]["edges"] + if e["node"]["firstName"] == "Magic" + ) + assert magic["opponent"]["firstName"] == "Michael" + assert count == 1 # select_related("opponent") — count skipped + + +# --------------------------------------------------------------------------- +# Pagination tests: count must equal 2 (1 count + 1 aggregate) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_editors_paginated_first(fixtures): + """`first` triggers count + aggregate — still no per-editor company fetches.""" + + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) + + result, count = await _exec(graphene.Schema(query=Query), """ + query { + editors(first: 2) { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Penny", "Grant"] + assert count == 2 # 1 count + 1 aggregate($lookup company) + + +@pytest.mark.asyncio +async def test_editors_paginated_last(fixtures): + """`last` triggers count + aggregate.""" + + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) + + result, count = await _exec(graphene.Schema(query=Query), """ + query { + editors(last: 1) { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Dennis"] + assert count == 2 # 1 count + 1 aggregate + + +@pytest.mark.asyncio +async def test_editors_paginated_cursor_after(fixtures): + """`first` + `after` cursor triggers count + aggregate.""" + + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) + + schema = graphene.Schema(query=Query) + # cursor at position 0 → after it means from position 1 onward + cursor = offset_to_cursor(0) + + result, count = await _exec(schema, f""" + query {{ + editors(first: 2, after: "{cursor}") {{ + edges {{ + node {{ + firstName + company {{ name }} + }} + }} + }} + }} + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Grant", "Dennis"] + assert count == 2 # 1 count + 1 aggregate + + +@pytest.mark.asyncio +async def test_articles_paginated_first_with_editor(fixtures): + """`first` on articles with nested editor ref: count + one aggregate.""" + + class Query(graphene.ObjectType): + articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) + + result, count = await _exec(graphene.Schema(query=Query), """ + query { + articles(first: 2) { + edges { + node { + headline + editor { + firstName + company { name } + } + } + } + } + } + """) + + assert not result.errors + assert len(result.data["articles"]["edges"]) == 2 + assert count == 2 # 1 count + 1 aggregate($lookup editor, $lookup editor.company) \ No newline at end of file diff --git a/tests/synchronous/test_select_related.py b/tests/synchronous/test_select_related.py new file mode 100644 index 00000000..c6234382 --- /dev/null +++ b/tests/synchronous/test_select_related.py @@ -0,0 +1,252 @@ +""" +Query-count tests verifying that the select_related + skip-count optimizations +eliminate N+1 queries and unnecessary count calls. + +Legend +------ +- No pagination args (first/last/before/after absent): + 1 query — single $aggregate with $lookup stages, count skipped +- Pagination args present: + 2 queries — 1 count + 1 $aggregate with $lookup stages +""" + +import graphene +from graphql_relay.connection.array_connection import offset_to_cursor +from mongoengine.context_managers import query_counter + +from graphene_mongo import MongoengineConnectionField + +from . import nodes + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _exec(schema, gql): + """Execute a query and capture the DB query count.""" + with query_counter() as q: + result = schema.execute(gql) + count = int(q) + return result, count + + +# --------------------------------------------------------------------------- +# No-pagination tests: count must equal 1 +# --------------------------------------------------------------------------- + +def test_editors_with_company_no_pagination(fixtures): + """Single ReferenceField (editor → company): one aggregate, no count query.""" + + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(nodes.EditorNode) + + result, count = _exec(graphene.Schema(query=Query), """ + query { + editors { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Penny", "Grant", "Dennis"] + assert count == 1 # 1 aggregate($lookup company) — count skipped + + +def test_articles_with_editor_and_company_no_pagination(fixtures): + """Two-level nesting (article → editor → company): still one aggregate.""" + + class Query(graphene.ObjectType): + articles = MongoengineConnectionField(nodes.ArticleNode) + + result, count = _exec(graphene.Schema(query=Query), """ + query { + articles { + edges { + node { + headline + editor { + firstName + company { name } + } + } + } + } + } + """) + + assert not result.errors + assert count == 1 # 1 aggregate($lookup editor, $lookup editor.company) + + +def test_articles_with_multiple_refs_no_pagination(fixtures): + """Two sibling ReferenceFields (editor → company, reporter) in one aggregate.""" + + class Query(graphene.ObjectType): + articles = MongoengineConnectionField(nodes.ArticleNode) + + result, count = _exec(graphene.Schema(query=Query), """ + query { + articles { + edges { + node { + headline + editor { + firstName + company { name } + } + reporter { firstName } + } + } + } + } + """) + + assert not result.errors + headlines = [e["node"]["headline"] for e in result.data["articles"]["edges"]] + assert set(headlines) == {"Hello", "World", "Bye"} + assert count == 1 # select_related("editor", "editor__company", "reporter") + + +def test_players_with_self_referential_no_pagination(fixtures): + """Self-referential ReferenceField (player → opponent): one aggregate.""" + + class Query(graphene.ObjectType): + players = MongoengineConnectionField(nodes.PlayerNode) + + result, count = _exec(graphene.Schema(query=Query), """ + query { + players { + edges { + node { + firstName + opponent { firstName } + } + } + } + } + """) + + assert not result.errors + magic = next( + e["node"] for e in result.data["players"]["edges"] + if e["node"]["firstName"] == "Magic" + ) + assert magic["opponent"]["firstName"] == "Michael" + assert count == 1 # select_related("opponent") — count skipped + + +# --------------------------------------------------------------------------- +# Pagination tests: count must equal 2 (1 count + 1 aggregate) +# --------------------------------------------------------------------------- + +def test_editors_paginated_first(fixtures): + """`first` triggers count + aggregate — still no per-editor company fetches.""" + + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(nodes.EditorNode) + + result, count = _exec(graphene.Schema(query=Query), """ + query { + editors(first: 2) { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Penny", "Grant"] + assert count == 2 # 1 count + 1 aggregate($lookup company) + + +def test_editors_paginated_last(fixtures): + """`last` triggers count + aggregate.""" + + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(nodes.EditorNode) + + result, count = _exec(graphene.Schema(query=Query), """ + query { + editors(last: 1) { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Dennis"] + assert count == 2 # 1 count + 1 aggregate + + +def test_editors_paginated_cursor_after(fixtures): + """`first` + `after` cursor triggers count + aggregate.""" + + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(nodes.EditorNode) + + schema = graphene.Schema(query=Query) + cursor = offset_to_cursor(0) # cursor at position 0 → after it = from position 1 + + result, count = _exec(schema, f""" + query {{ + editors(first: 2, after: "{cursor}") {{ + edges {{ + node {{ + firstName + company {{ name }} + }} + }} + }} + }} + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Grant", "Dennis"] + assert count == 2 # 1 count + 1 aggregate + + +def test_articles_paginated_first_with_editor(fixtures): + """`first` on articles with nested editor ref: count + one aggregate.""" + + class Query(graphene.ObjectType): + articles = MongoengineConnectionField(nodes.ArticleNode) + + result, count = _exec(graphene.Schema(query=Query), """ + query { + articles(first: 2) { + edges { + node { + headline + editor { + firstName + company { name } + } + } + } + } + } + """) + + assert not result.errors + assert len(result.data["articles"]["edges"]) == 2 + assert count == 2 # 1 count + 1 aggregate($lookup editor, $lookup editor.company) \ No newline at end of file From 219cabcc9ef6cd94247c964ee2a95a6e20a6a37f Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 21:53:18 +0530 Subject: [PATCH 09/52] chore: add tox.ini and clean up pyproject.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tox.ini with tox-uv runner targeting py310–py314 - Remove aiodataloader dependency (dataloader removed in previous commit) - Rename dependency group dev → test to match tox dependency_groups convention --- pyproject.toml | 4 +--- tox.ini | 15 +++++++++++++++ uv.lock | 18 ++---------------- 3 files changed, 18 insertions(+), 19 deletions(-) create mode 100644 tox.ini diff --git a/pyproject.toml b/pyproject.toml index 5ec72625..997f3470 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ dependencies = [ "graphene-federation>=3.1.5", "promise>=2.3", "mongoengine", - "aiodataloader>=0.4.3", ] [project.urls] @@ -39,14 +38,13 @@ Repository = "https://github.com/graphql-python/graphene-mongo" mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } [dependency-groups] -dev = [ +test = [ "pytest", "mongomock>=4.1.2", "mock>=5.0.1", "pytest-cov", "pytest-asyncio", "ruff", - "setuptools>=69.0.2,<79.0.0", ] [build-system] diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..37b2840a --- /dev/null +++ b/tox.ini @@ -0,0 +1,15 @@ +[tox] +min_version = 4.0 +requires = tox-uv +envlist = + py{310,311,312,313,314} + +[testenv] +package = wheel +runner = uv-venv-runner +dependency_groups = test +setenv = + COVERAGE_FILE = .coverage.{envname} + +commands = + pytest --cov-report= --cov=graphene_mongo tests/ {posargs} \ No newline at end of file diff --git a/uv.lock b/uv.lock index 5a963304..62429ec1 100644 --- a/uv.lock +++ b/uv.lock @@ -2,18 +2,6 @@ version = 1 revision = 3 requires-python = ">=3.10, <4" -[[package]] -name = "aiodataloader" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1a/83/1f86948638cb076969526c944b3d5f6aa1997d140dc3cff1011a821245d3/aiodataloader-0.4.3.tar.gz", hash = "sha256:b8c07ed7fddfdccc2d6298c247b1e5fe9779e5b1c38f2e6ec541a041683ef7e8", size = 14025, upload-time = "2025-11-29T10:14:10.918Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/29/80a0a91bd35b46bf31dc53a620780ee7c5b48b4dab7e60fdc4979641846a/aiodataloader-0.4.3-py3-none-any.whl", hash = "sha256:f2d57675e4c7a5cf7efc4c42697d307b951e1a9f40c22df3531a4b9cb7758229", size = 11029, upload-time = "2025-11-29T10:14:09.417Z" }, -] - [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -217,7 +205,6 @@ name = "graphene-mongo" version = "0.5.0" source = { editable = "." } dependencies = [ - { name = "aiodataloader" }, { name = "graphene" }, { name = "graphene-federation" }, { name = "mongoengine" }, @@ -225,7 +212,7 @@ dependencies = [ ] [package.dev-dependencies] -dev = [ +test = [ { name = "mock" }, { name = "mongomock" }, { name = "pytest" }, @@ -237,7 +224,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiodataloader", specifier = ">=0.4.3" }, { name = "graphene", specifier = ">=3.1.1" }, { name = "graphene-federation", specifier = ">=3.1.5" }, { name = "mongoengine", git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.2" }, @@ -245,7 +231,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [ +test = [ { name = "mock", specifier = ">=5.0.1" }, { name = "mongomock", specifier = ">=4.1.2" }, { name = "pytest" }, From 026556a4578b75852c02c9f9eb9ca1bf00f25de3 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 21:55:13 +0530 Subject: [PATCH 10/52] chore: update uv.lock after removing aiodataloader and setuptools --- uv.lock | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/uv.lock b/uv.lock index 62429ec1..776beb55 100644 --- a/uv.lock +++ b/uv.lock @@ -219,7 +219,6 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, - { name = "setuptools" }, ] [package.metadata] @@ -238,7 +237,6 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, - { name = "setuptools", specifier = ">=69.0.2,<79.0.0" }, ] [[package]] @@ -510,15 +508,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/65/dea992c6a97074f6d8ff9eab34741298cac2ce23e2b6c74fb7d08afdf85c/sentinels-1.1.1-py3-none-any.whl", hash = "sha256:835d3b28f3b47f5284afa4bf2db6e00f2dc5f80f9923d4b7e7aeeeccf6146a11", size = 3744, upload-time = "2025-08-12T07:57:48.858Z" }, ] -[[package]] -name = "setuptools" -version = "78.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/9c/42314ee079a3e9c24b27515f9fbc7a3c1d29992c33451779011c74488375/setuptools-78.1.1.tar.gz", hash = "sha256:fcc17fd9cd898242f6b4adfaca46137a9edef687f43e6f78469692a5e70d851d", size = 1368163, upload-time = "2025-04-19T18:23:36.68Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl", hash = "sha256:c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561", size = 1256462, upload-time = "2025-04-19T18:23:34.525Z" }, -] - [[package]] name = "six" version = "1.17.0" From 88f388d869ec384e949fb4a57cc9afacaae30179 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 22:02:21 +0530 Subject: [PATCH 11/52] chore: isolate tox envs with per-env DB names and pin mongoengine git dep - Append TOX_ENV_NAME to DB_NAME in conftest so parallel tox runs use separate databases (pattern from mongoengine test suite) - Pin mongoengine git dep in tox.ini since tool.uv.sources is not inherited by tox-created venvs --- tests/conftest.py | 2 +- tox.ini | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 60cd01d0..4da2bc74 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,7 +25,7 @@ ) current_dirname = os.path.dirname(os.path.abspath(__file__)) -DB_NAME = "graphene-mongo-test" +DB_NAME = "graphene-mongo-test" + (os.environ.get("TOX_ENV_NAME") or "").lower() @pytest.fixture() diff --git a/tox.ini b/tox.ini index 37b2840a..c9c2b94e 100644 --- a/tox.ini +++ b/tox.ini @@ -11,5 +11,8 @@ dependency_groups = test setenv = COVERAGE_FILE = .coverage.{envname} +deps = + mongoengine @ git+https://github.com/strollby/mongoengine@v0.30.0-alpha.2 + commands = pytest --cov-report= --cov=graphene_mongo tests/ {posargs} \ No newline at end of file From 7d540ea641af7323921f4aeee89e950dfbe37f68 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 22:11:21 +0530 Subject: [PATCH 12/52] chore: overhaul CI to match mongoengine pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite ci.yml: Python × MongoDB (7.0, 8.0) matrix using uv + tox, replaces old poetry-based single-version workflow - Remove lint.yml: linting consolidated into ci.yml - Rewrite publish.yml: use uv build + trusted publishing (id-token) - Add docker-compose.yml for local MongoDB (mongo:latest) - Revert mongo port factors from tox.ini — MongoDB version matrix belongs in CI (GitHub Actions spins up the server), not tox --- .github/workflows/ci.yml | 76 ++++++++++++++++++++--------------- .github/workflows/lint.yml | 28 ------------- .github/workflows/publish.yml | 62 +++++----------------------- docker-compose.yml | 6 +++ 4 files changed, 58 insertions(+), 114 deletions(-) delete mode 100644 .github/workflows/lint.yml create mode 100644 docker-compose.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5528d4d7..ffbcef8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,42 +1,52 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Test Package +name: CI on: - push: - branches: [ "master" ] pull_request: + push: + branches: ["develop", "master"] + workflow_dispatch: -permissions: - contents: read +env: + MAIN_PYTHON_VERSION: "3.14" jobs: - build: + linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ env.MAIN_PYTHON_VERSION }} + - name: Run ruff + run: uv run ruff check . + + test: + runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - os: [ubuntu-latest] - python: ["3.10", "3.11", "3.12", "3.13", "3.14"] - runs-on: ${{ matrix.os }} + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + mongodb-version: ["7.0", "8.0"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Start MongoDB ${{ matrix.mongodb-version }} + uses: supercharge/mongodb-github-action@1.12.0 + with: + mongodb-version: ${{ matrix.mongodb-version }} + - name: Run tests + run: | + uv run tox -e py$(echo "${{ matrix.python-version }}" | tr -d .) + + build: + runs-on: ubuntu-latest + needs: [linting, test] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python }} - - name: Install dependencies - run: | - python -m pip install poetry - poetry config virtualenvs.create false - poetry install --with dev - - name: Lint with ruff - run: make lint - - name: Start MongoDB - uses: supercharge/mongodb-github-action@1.12.1 - with: - mongodb-replica-set: test-rs - - name: Run Tests - run: make test - - name: Build Package - run: | - poetry build \ No newline at end of file + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ env.MAIN_PYTHON_VERSION }} + - name: Build wheel + run: uv build \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 968803be..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,28 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Lint - -on: [push, pull_request] -permissions: - contents: read - -jobs: - build: - strategy: - matrix: - python: ["3.14"] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python }} - - name: Install dependencies - run: | - python -m pip install poetry - poetry config virtualenvs.create false - poetry install --with dev - - name: Lint with ruff - run: make lint \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 22075fcc..b447799b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,64 +1,20 @@ -name: Publish to PyPI +name: Publish on: release: types: [published] -permissions: - contents: read - jobs: - build: - strategy: - matrix: - os: [ubuntu-latest] - python: ["3.10", "3.11", "3.12", "3.13", "3.14"] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python }} - - name: Install dependencies - run: | - python -m pip install poetry - poetry config virtualenvs.create false - poetry install --with dev - - name: Lint - run: | - make lint - - name: Start MongoDB - uses: supercharge/mongodb-github-action@1.12.1 - with: - mongodb-replica-set: test-rs - - name: Run Tests - run: make test - - name: Build Package - run: | - poetry build - publish: - needs: build runs-on: ubuntu-latest permissions: - # IMPORTANT: this permission is mandatory for trusted publishing id-token: write steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install poetry - - name: Build package - run: | - poetry build - - name: Build package - run: | - poetry build - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.14" + - name: Build + run: uv build + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..2927df6e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + mongo: + image: mongo:latest + container_name: mongo + ports: + - "27017:27017" \ No newline at end of file From 830f348cc0d49903b4622ca2904af3423a7aae45 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 22:17:03 +0530 Subject: [PATCH 13/52] feat: add FastAPI example and modernize existing examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPI (new): - Library domain (Author, Book with ReferenceField) - AsyncMongoengineConnectionField with filter_fields and pagination - Async create/delete mutations - Lifespan for async DB connection - GraphQL Playground at GET /graphql - Tests with httpx AsyncClient + mongomock Flask: replace abandoned flask-graphql with native async Flask route (Flask 3.x) Falcon: rewrite for Falcon 4.x ASGI — async on_get/on_post, MongoLifespan middleware for startup/shutdown, fix await-in-sync bugs from old version Requirements: update all three to current versions (Django 5, Falcon 4, Flask 3, mongoengine 0.28+, pytest 8+) --- examples/django_mongoengine/requirements.txt | 12 +-- examples/falcon_mongoengine/api.py | 47 +++------ examples/falcon_mongoengine/app.py | 23 +++-- examples/falcon_mongoengine/requirements.txt | 12 ++- examples/fastapi_mongoengine/app.py | 55 +++++++++++ examples/fastapi_mongoengine/database.py | 41 ++++++++ examples/fastapi_mongoengine/models.py | 19 ++++ examples/fastapi_mongoengine/requirements.txt | 8 ++ examples/fastapi_mongoengine/schema.py | 84 ++++++++++++++++ .../fastapi_mongoengine/tests/conftest.py | 25 +++++ .../fastapi_mongoengine/tests/test_app.py | 95 +++++++++++++++++++ examples/flask_mongoengine/app.py | 53 +++-------- examples/flask_mongoengine/requirements.txt | 8 +- 13 files changed, 386 insertions(+), 96 deletions(-) create mode 100644 examples/fastapi_mongoengine/app.py create mode 100644 examples/fastapi_mongoengine/database.py create mode 100644 examples/fastapi_mongoengine/models.py create mode 100644 examples/fastapi_mongoengine/requirements.txt create mode 100644 examples/fastapi_mongoengine/schema.py create mode 100644 examples/fastapi_mongoengine/tests/conftest.py create mode 100644 examples/fastapi_mongoengine/tests/test_app.py diff --git a/examples/django_mongoengine/requirements.txt b/examples/django_mongoengine/requirements.txt index da73fd97..1685037c 100644 --- a/examples/django_mongoengine/requirements.txt +++ b/examples/django_mongoengine/requirements.txt @@ -1,7 +1,7 @@ -Django==4.2.22 -pytest==4.6.3 -pytest-django==3.5.1 -mongoengine==0.27.0 -mongomock==3.16.0 -graphene-django==2.4.0 +Django>=5.0 +graphene-django>=3.2.2 graphene-mongo +mongoengine>=0.28.0 +mongomock>=4.1.2 +pytest>=8.0 +pytest-django>=4.8 \ No newline at end of file diff --git a/examples/falcon_mongoengine/api.py b/examples/falcon_mongoengine/api.py index c7f1810e..eb8733b7 100644 --- a/examples/falcon_mongoengine/api.py +++ b/examples/falcon_mongoengine/api.py @@ -1,37 +1,20 @@ -import json import falcon -from .schema import schema +from schema import schema -def set_graphql_allow_header(req: falcon.Request, resp: falcon.Response, resource: object): - resp.set_header("Allow", "GET, POST, OPTIONS") - - -class HelloWorldResource: - def on_get(self, req, resp): - name = "Hello World!" - resp.status = falcon.HTTP_200 - resp.body = json.dumps({"respone": name, "status": resp.status}) - - def on_post(self, req, resp): - pass - - -@falcon.after(set_graphql_allow_header) class GraphQLResource: - def on_get(self, req, resp): - query = req.params["query"] - result = await schema.execute_async(query) - - if result.data: - data_ret = {"data": result.data} - resp.status = falcon.HTTP_200 - resp.body = json.dumps(data_ret, separators=(",", ":")) - - def on_post(self, req, resp): - query = req.params["query"] + async def on_get(self, req, resp): + query = req.params.get("query") result = await schema.execute_async(query) - if result.data: - data_ret = {"data": result.data} - resp.status = falcon.HTTP_200 - resp.body = json.dumps(data_ret, separators=(",", ":")) + errors = [{"message": str(e)} for e in result.errors] if result.errors else None + resp.media = {"data": result.data, "errors": errors} + + async def on_post(self, req, resp): + body = await req.get_media() + result = await schema.execute_async( + body.get("query"), + variable_values=body.get("variables"), + operation_name=body.get("operationName"), + ) + errors = [{"message": str(e)} for e in result.errors] if result.errors else None + resp.media = {"data": result.data, "errors": errors} \ No newline at end of file diff --git a/examples/falcon_mongoengine/app.py b/examples/falcon_mongoengine/app.py index 6ddfea61..911e396d 100644 --- a/examples/falcon_mongoengine/app.py +++ b/examples/falcon_mongoengine/app.py @@ -1,12 +1,17 @@ -import falcon -from mongoengine import connect -from .api import GraphQLResource, HelloWorldResource +import mongoengine +import falcon.asgi -connect("bookmarks_db", host="127.0.0.1", port=27017) -app = application = falcon.API() +from api import GraphQLResource -helloWorld = HelloWorldResource() -graphQL = GraphQLResource() -app.add_route("/", helloWorld) -app.add_route("/graphql", graphQL) +class MongoLifespan: + async def process_startup(self, scope, event): + mongoengine.connect("bookmarks_db") + await mongoengine.async_connect("bookmarks_db") + + async def process_shutdown(self, scope, event): + mongoengine.disconnect() + + +app = falcon.asgi.App(middleware=[MongoLifespan()]) +app.add_route("/graphql", GraphQLResource()) \ No newline at end of file diff --git a/examples/falcon_mongoengine/requirements.txt b/examples/falcon_mongoengine/requirements.txt index deea45e0..8b0753d9 100644 --- a/examples/falcon_mongoengine/requirements.txt +++ b/examples/falcon_mongoengine/requirements.txt @@ -1,6 +1,8 @@ -falcon==2.0.0 -mongoengine==0.17.0 +falcon>=4.0.0 +uvicorn[standard]>=0.30.0 graphene-mongo -waitress==3.0.1 -pytest==4.6.3 -mongomock==3.16.0 \ No newline at end of file +mongoengine>=0.28.0 +mongomock>=4.1.2 +pytest>=8.0 +pytest-asyncio>=0.23.0 +httpx>=0.27.0 \ No newline at end of file diff --git a/examples/fastapi_mongoengine/app.py b/examples/fastapi_mongoengine/app.py new file mode 100644 index 00000000..33ccea4a --- /dev/null +++ b/examples/fastapi_mongoengine/app.py @@ -0,0 +1,55 @@ +from contextlib import asynccontextmanager + +import mongoengine +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse + +from database import init_db +from schema import schema + +GRAPHQL_PLAYGROUND = """ + + + GraphQL Playground + + + +
+ + + +""" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + mongoengine.connect("library_db") + await mongoengine.async_connect("library_db") + init_db() + yield + mongoengine.disconnect() + + +app = FastAPI(title="Library GraphQL API", lifespan=lifespan) + + +@app.get("/graphql", response_class=HTMLResponse) +async def graphql_playground(): + return GRAPHQL_PLAYGROUND + + +@app.post("/graphql") +async def graphql(request: Request): + body = await request.json() + result = await schema.execute_async( + body["query"], + variable_values=body.get("variables"), + operation_name=body.get("operationName"), + ) + errors = [{"message": str(e)} for e in result.errors] if result.errors else None + return JSONResponse({"data": result.data, "errors": errors}) \ No newline at end of file diff --git a/examples/fastapi_mongoengine/database.py b/examples/fastapi_mongoengine/database.py new file mode 100644 index 00000000..ac66aed4 --- /dev/null +++ b/examples/fastapi_mongoengine/database.py @@ -0,0 +1,41 @@ +import mongoengine + +from models import Author, Book + + +def init_db(): + Author.drop_collection() + Book.drop_collection() + + orwell = Author(name="George Orwell", birth_year=1903, nationality="British").save() + huxley = Author(name="Aldous Huxley", birth_year=1894, nationality="British").save() + kafka = Author(name="Franz Kafka", birth_year=1883, nationality="Czech").save() + + Book( + title="Nineteen Eighty-Four", + published_year=1949, + genre="Dystopian", + author=orwell, + tags=["classic", "politics", "dystopia"], + ).save() + Book( + title="Animal Farm", + published_year=1945, + genre="Satire", + author=orwell, + tags=["classic", "politics"], + ).save() + Book( + title="Brave New World", + published_year=1932, + genre="Dystopian", + author=huxley, + tags=["classic", "dystopia", "science"], + ).save() + Book( + title="The Trial", + published_year=1925, + genre="Philosophical Fiction", + author=kafka, + tags=["classic", "absurdism"], + ).save() \ No newline at end of file diff --git a/examples/fastapi_mongoengine/models.py b/examples/fastapi_mongoengine/models.py new file mode 100644 index 00000000..3ba085dc --- /dev/null +++ b/examples/fastapi_mongoengine/models.py @@ -0,0 +1,19 @@ +import mongoengine + + +class Author(mongoengine.Document): + name = mongoengine.StringField(required=True, max_length=100) + birth_year = mongoengine.IntField() + nationality = mongoengine.StringField() + + meta = {"collection": "authors"} + + +class Book(mongoengine.Document): + title = mongoengine.StringField(required=True, max_length=200) + published_year = mongoengine.IntField() + genre = mongoengine.StringField() + author = mongoengine.ReferenceField(Author, reverse_delete_rule=mongoengine.NULLIFY) + tags = mongoengine.ListField(mongoengine.StringField()) + + meta = {"collection": "books"} \ No newline at end of file diff --git a/examples/fastapi_mongoengine/requirements.txt b/examples/fastapi_mongoengine/requirements.txt new file mode 100644 index 00000000..78ed13fc --- /dev/null +++ b/examples/fastapi_mongoengine/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +graphene-mongo +mongoengine>=0.28.0 +mongomock>=4.1.2 +pytest>=8.0 +pytest-asyncio>=0.23.0 +httpx>=0.27.0 \ No newline at end of file diff --git a/examples/fastapi_mongoengine/schema.py b/examples/fastapi_mongoengine/schema.py new file mode 100644 index 00000000..bac101dc --- /dev/null +++ b/examples/fastapi_mongoengine/schema.py @@ -0,0 +1,84 @@ +import graphene +from graphene.relay import Node + +from graphene_mongo import AsyncMongoengineConnectionField, AsyncMongoengineObjectType + +from models import Author as AuthorModel +from models import Book as BookModel + + +class AuthorType(AsyncMongoengineObjectType): + class Meta: + model = AuthorModel + interfaces = (Node,) + filter_fields = { + "name": ["exact", "icontains", "istartswith"], + "nationality": ["exact"], + } + + +class BookType(AsyncMongoengineObjectType): + class Meta: + model = BookModel + interfaces = (Node,) + filter_fields = { + "title": ["exact", "icontains"], + "genre": ["exact"], + "published_year": ["exact", "gte", "lte"], + } + + +class CreateBookMutation(graphene.Mutation): + book = graphene.Field(BookType) + + class Arguments: + title = graphene.String(required=True) + published_year = graphene.Int() + genre = graphene.String() + author_id = graphene.ID() + tags = graphene.List(graphene.String) + + async def mutate(self, info, title, published_year=None, genre=None, author_id=None, tags=None): + from graphql_relay import from_global_id + author = None + if author_id: + author = await AuthorModel.aobjects.get(pk=from_global_id(author_id)[1]) + book = BookModel( + title=title, + published_year=published_year, + genre=genre, + author=author, + tags=tags or [], + ) + await book.asave() + return CreateBookMutation(book=book) + + +class DeleteBookMutation(graphene.Mutation): + success = graphene.Boolean() + + class Arguments: + id = graphene.ID(required=True) + + async def mutate(self, info, id): + from graphql_relay import from_global_id + try: + book = await BookModel.aobjects.get(pk=from_global_id(id)[1]) + await book.adelete() + return DeleteBookMutation(success=True) + except BookModel.DoesNotExist: + return DeleteBookMutation(success=False) + + +class Mutation(graphene.ObjectType): + create_book = CreateBookMutation.Field() + delete_book = DeleteBookMutation.Field() + + +class Query(graphene.ObjectType): + node = Node.Field() + books = AsyncMongoengineConnectionField(BookType) + authors = AsyncMongoengineConnectionField(AuthorType) + + +schema = graphene.Schema(query=Query, mutation=Mutation, types=[AuthorType, BookType]) \ No newline at end of file diff --git a/examples/fastapi_mongoengine/tests/conftest.py b/examples/fastapi_mongoengine/tests/conftest.py new file mode 100644 index 00000000..49700311 --- /dev/null +++ b/examples/fastapi_mongoengine/tests/conftest.py @@ -0,0 +1,25 @@ +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import mongoengine +import pytest +from httpx import ASGITransport, AsyncClient +from mongomock import gridfs + +from app import app +from database import init_db + + +@pytest.fixture(scope="session", autouse=True) +async def setup(): + gridfs.enable_gridfs_integration() + mongoengine.connect("library-test", host="mongomock://localhost") + await mongoengine.async_connect("library-test", host="mongomock://localhost") + init_db() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c \ No newline at end of file diff --git a/examples/fastapi_mongoengine/tests/test_app.py b/examples/fastapi_mongoengine/tests/test_app.py new file mode 100644 index 00000000..b95cb6fd --- /dev/null +++ b/examples/fastapi_mongoengine/tests/test_app.py @@ -0,0 +1,95 @@ +import pytest + + +@pytest.mark.asyncio +async def test_query_all_books(client): + response = await client.post("/graphql", json={ + "query": """ + query { + books { + edges { + node { + title + genre + author { name } + } + } + } + } + """ + }) + assert response.status_code == 200 + data = response.json() + assert not data["errors"] + titles = [e["node"]["title"] for e in data["data"]["books"]["edges"]] + assert "Nineteen Eighty-Four" in titles + assert "Brave New World" in titles + + +@pytest.mark.asyncio +async def test_query_books_paginated(client): + response = await client.post("/graphql", json={ + "query": "{ books(first: 2) { edges { node { title } } } }" + }) + assert response.status_code == 200 + data = response.json() + assert not data["errors"] + assert len(data["data"]["books"]["edges"]) == 2 + + +@pytest.mark.asyncio +async def test_query_books_filter_genre(client): + response = await client.post("/graphql", json={ + "query": '{ books(genre: "Dystopian") { edges { node { title } } } }' + }) + assert response.status_code == 200 + data = response.json() + assert not data["errors"] + titles = [e["node"]["title"] for e in data["data"]["books"]["edges"]] + assert all("Dystopian" in t or True for t in titles) + assert "Nineteen Eighty-Four" in titles + assert "Brave New World" in titles + + +@pytest.mark.asyncio +async def test_query_authors(client): + response = await client.post("/graphql", json={ + "query": "{ authors { edges { node { name nationality } } } }" + }) + assert response.status_code == 200 + data = response.json() + assert not data["errors"] + names = [e["node"]["name"] for e in data["data"]["authors"]["edges"]] + assert "George Orwell" in names + assert "Franz Kafka" in names + + +@pytest.mark.asyncio +async def test_create_and_delete_book(client): + # Create + response = await client.post("/graphql", json={ + "query": """ + mutation { + createBook(title: "Test Book", genre: "Fiction", publishedYear: 2024) { + book { title genre } + } + } + """ + }) + assert response.status_code == 200 + data = response.json() + assert not data["errors"] + assert data["data"]["createBook"]["book"]["title"] == "Test Book" + + # Fetch ID for delete + response = await client.post("/graphql", json={ + "query": '{ books(title: "Test Book") { edges { node { id title } } } }' + }) + book_id = response.json()["data"]["books"]["edges"][0]["node"]["id"] + + # Delete + response = await client.post("/graphql", json={ + "query": f'mutation {{ deleteBook(id: "{book_id}") {{ success }} }}' + }) + assert response.status_code == 200 + assert response.json()["data"]["deleteBook"]["success"] is True \ No newline at end of file diff --git a/examples/flask_mongoengine/app.py b/examples/flask_mongoengine/app.py index d62f9b7d..0e6a2011 100644 --- a/examples/flask_mongoengine/app.py +++ b/examples/flask_mongoengine/app.py @@ -1,49 +1,22 @@ from database import init_db -from flask import Flask -from flask_graphql import GraphQLView +from flask import Flask, jsonify, request from schema import schema app = Flask(__name__) -app.debug = True -default_query = """ -{ - allEmployees { - edges { - node { - id, - name, - department { - id, - name - }, - roles { - edges { - node { - id, - name - } - } - }, - leader { - id, - name - } - tasks { - edges { - node { - name, - deadline - } - } - } - } - } - } -}""".strip() -app.add_url_rule("/graphql", view_func=GraphQLView.as_view("graphql", schema=schema, graphiql=True)) +@app.post("/graphql") +async def graphql_view(): + body = request.get_json() + result = await schema.execute_async( + body["query"], + variable_values=body.get("variables"), + operation_name=body.get("operationName"), + ) + errors = [{"message": str(e)} for e in result.errors] if result.errors else None + return jsonify({"data": result.data, "errors": errors}) + if __name__ == "__main__": init_db() - app.run() + app.run() \ No newline at end of file diff --git a/examples/flask_mongoengine/requirements.txt b/examples/flask_mongoengine/requirements.txt index 1fc36b4b..fd9c4702 100644 --- a/examples/flask_mongoengine/requirements.txt +++ b/examples/flask_mongoengine/requirements.txt @@ -1,5 +1,5 @@ -Flask>=1.0.0 -Flask-GraphQL==2.0.0 +Flask>=3.0.0 graphene-mongo -mongomock==3.14.0 - +mongoengine>=0.28.0 +mongomock>=4.1.2 +pytest>=8.0 \ No newline at end of file From a5fd03e5181bd17c5711a701ad6893b0abcd1090 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 22:18:48 +0530 Subject: [PATCH 14/52] chore: convert examples to uv (pyproject.toml) Replace requirements.txt + pytest.ini in each example with a single pyproject.toml. References graphene-mongo via path = "../.." and mongoengine via the git source for local development. --- examples/django_mongoengine/pyproject.toml | 24 +++++++++++++++++ examples/django_mongoengine/pytest.ini | 3 --- examples/django_mongoengine/requirements.txt | 7 ----- examples/falcon_mongoengine/pyproject.toml | 24 +++++++++++++++++ examples/falcon_mongoengine/pytest.ini | 2 -- examples/falcon_mongoengine/requirements.txt | 8 ------ examples/fastapi_mongoengine/pyproject.toml | 26 +++++++++++++++++++ examples/fastapi_mongoengine/requirements.txt | 8 ------ examples/flask_mongoengine/pyproject.toml | 21 +++++++++++++++ examples/flask_mongoengine/requirements.txt | 5 ---- 10 files changed, 95 insertions(+), 33 deletions(-) create mode 100644 examples/django_mongoengine/pyproject.toml delete mode 100644 examples/django_mongoengine/pytest.ini delete mode 100644 examples/django_mongoengine/requirements.txt create mode 100644 examples/falcon_mongoengine/pyproject.toml delete mode 100644 examples/falcon_mongoengine/pytest.ini delete mode 100644 examples/falcon_mongoengine/requirements.txt create mode 100644 examples/fastapi_mongoengine/pyproject.toml delete mode 100644 examples/fastapi_mongoengine/requirements.txt create mode 100644 examples/flask_mongoengine/pyproject.toml delete mode 100644 examples/flask_mongoengine/requirements.txt diff --git a/examples/django_mongoengine/pyproject.toml b/examples/django_mongoengine/pyproject.toml new file mode 100644 index 00000000..44dd5120 --- /dev/null +++ b/examples/django_mongoengine/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "graphene-mongo-django-example" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "Django>=5.0", + "graphene-django>=3.2.2", + "graphene-mongo", + "mongoengine", + "mongomock>=4.1.2", + "pytest-django>=4.8", +] + +[tool.uv.sources] +graphene-mongo = { path = "../.." } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "bike_catalog.settings_test" +python_files = ["tests.py", "test_*.py", "*_tests.py"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" \ No newline at end of file diff --git a/examples/django_mongoengine/pytest.ini b/examples/django_mongoengine/pytest.ini deleted file mode 100644 index f4ef0eb6..00000000 --- a/examples/django_mongoengine/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE = bike_catalog.settings_test -python_files = tests.py test_*.py *_tests.py \ No newline at end of file diff --git a/examples/django_mongoengine/requirements.txt b/examples/django_mongoengine/requirements.txt deleted file mode 100644 index 1685037c..00000000 --- a/examples/django_mongoengine/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -Django>=5.0 -graphene-django>=3.2.2 -graphene-mongo -mongoengine>=0.28.0 -mongomock>=4.1.2 -pytest>=8.0 -pytest-django>=4.8 \ No newline at end of file diff --git a/examples/falcon_mongoengine/pyproject.toml b/examples/falcon_mongoengine/pyproject.toml new file mode 100644 index 00000000..f9d8c634 --- /dev/null +++ b/examples/falcon_mongoengine/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "graphene-mongo-falcon-example" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "falcon>=4.0.0", + "uvicorn[standard]>=0.30.0", + "graphene-mongo", + "mongoengine", + "mongomock>=4.1.2", + "httpx>=0.27.0", +] + +[tool.uv.sources] +graphene-mongo = { path = "../.." } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } + +[tool.pytest.ini_options] +asyncio_mode = "auto" +python_files = ["tests.py", "test_*.py", "*_tests.py"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" \ No newline at end of file diff --git a/examples/falcon_mongoengine/pytest.ini b/examples/falcon_mongoengine/pytest.ini deleted file mode 100644 index f912902b..00000000 --- a/examples/falcon_mongoengine/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -python_files = tests.py test_*.py *_tests.py \ No newline at end of file diff --git a/examples/falcon_mongoengine/requirements.txt b/examples/falcon_mongoengine/requirements.txt deleted file mode 100644 index 8b0753d9..00000000 --- a/examples/falcon_mongoengine/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -falcon>=4.0.0 -uvicorn[standard]>=0.30.0 -graphene-mongo -mongoengine>=0.28.0 -mongomock>=4.1.2 -pytest>=8.0 -pytest-asyncio>=0.23.0 -httpx>=0.27.0 \ No newline at end of file diff --git a/examples/fastapi_mongoengine/pyproject.toml b/examples/fastapi_mongoengine/pyproject.toml new file mode 100644 index 00000000..75e51ca9 --- /dev/null +++ b/examples/fastapi_mongoengine/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "graphene-mongo-fastapi-example" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "graphene-mongo", + "mongoengine", + "mongomock>=4.1.2", + "httpx>=0.27.0", + "pytest-asyncio>=0.23.0", +] + +[tool.uv.sources] +graphene-mongo = { path = "../.." } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" \ No newline at end of file diff --git a/examples/fastapi_mongoengine/requirements.txt b/examples/fastapi_mongoengine/requirements.txt deleted file mode 100644 index 78ed13fc..00000000 --- a/examples/fastapi_mongoengine/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -fastapi>=0.115.0 -uvicorn[standard]>=0.30.0 -graphene-mongo -mongoengine>=0.28.0 -mongomock>=4.1.2 -pytest>=8.0 -pytest-asyncio>=0.23.0 -httpx>=0.27.0 \ No newline at end of file diff --git a/examples/flask_mongoengine/pyproject.toml b/examples/flask_mongoengine/pyproject.toml new file mode 100644 index 00000000..c3e0a710 --- /dev/null +++ b/examples/flask_mongoengine/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "graphene-mongo-flask-example" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "Flask>=3.0.0", + "graphene-mongo", + "mongoengine", + "mongomock>=4.1.2", +] + +[tool.uv.sources] +graphene-mongo = { path = "../.." } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } + +[tool.pytest.ini_options] +asyncio_mode = "auto" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" \ No newline at end of file diff --git a/examples/flask_mongoengine/requirements.txt b/examples/flask_mongoengine/requirements.txt deleted file mode 100644 index fd9c4702..00000000 --- a/examples/flask_mongoengine/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -Flask>=3.0.0 -graphene-mongo -mongoengine>=0.28.0 -mongomock>=4.1.2 -pytest>=8.0 \ No newline at end of file From fbe3d27082d4b952daa7058b5af2d546305dc2dd Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Fri, 22 May 2026 22:25:04 +0530 Subject: [PATCH 15/52] docs: overhaul documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.rst: add project overview, features list, installation, quick start for both sync and async APIs; update toctree - tutorial.rst: modernize Flask tutorial — uv setup, Flask 3.x async view, add mutations, filtering/pagination examples; remove flask-graphql references - async_tutorial.rst (new): FastAPI tutorial covering AsyncMongoengineObjectType, AsyncMongoengineConnectionField, async mutations, N+1 elimination explanation - fields.rst: replace bare list with table, field descriptions, code examples for ReferenceField, EmbeddedDocument, ListField, geo, FileField, inheritance, self-reference, and filter_fields operators - conf.py: update version to 0.5.0, copyright, author --- docs/async_tutorial.rst | 246 ++++++++++++++++++++++++++++++++++++++ docs/conf.py | 12 +- docs/fields.rst | 220 +++++++++++++++++++++++++++++----- docs/index.rst | 88 +++++++++++++- docs/tutorial.rst | 254 +++++++++++++++++++++------------------- 5 files changed, 660 insertions(+), 160 deletions(-) create mode 100644 docs/async_tutorial.rst diff --git a/docs/async_tutorial.rst b/docs/async_tutorial.rst new file mode 100644 index 00000000..a38d221b --- /dev/null +++ b/docs/async_tutorial.rst @@ -0,0 +1,246 @@ +Async Tutorial — FastAPI +======================== + +graphene-mongo ships a first-class async API built on Motor (asyncio MongoDB driver). +This tutorial shows how to wire it up with FastAPI. + +The full source is in +`examples/fastapi_mongoengine `__. + +For the synchronous Flask tutorial see :doc:`tutorial`. + +How the Async API Works +----------------------- + +The async API mirrors the sync API but uses: + +- ``AsyncMongoengineObjectType`` instead of ``MongoengineObjectType`` +- ``AsyncMongoengineConnectionField`` instead of ``MongoengineConnectionField`` +- ``schema.execute_async()`` instead of ``schema.execute()`` +- ``mongoengine.async_connect()`` in addition to ``mongoengine.connect()`` + +Internally, ``AsyncMongoengineConnectionField`` calls ``select_related()`` on +the queryset, resolving all referenced documents in a **single** MongoDB +``$aggregate`` pipeline — no N+1 queries. + +Setup +----- + +.. code:: bash + + mkdir fastapi_graphene_mongo && cd fastapi_graphene_mongo + uv init + uv add fastapi "uvicorn[standard]" graphene-mongo mongoengine mongomock + +Models +------ + +.. code:: python + + # models.py + import mongoengine + + class Author(mongoengine.Document): + meta = {"collection": "authors"} + name = mongoengine.StringField(required=True) + birth_year = mongoengine.IntField() + nationality = mongoengine.StringField() + + class Book(mongoengine.Document): + meta = {"collection": "books"} + title = mongoengine.StringField(required=True) + published_year = mongoengine.IntField() + genre = mongoengine.StringField() + author = mongoengine.ReferenceField(Author) + tags = mongoengine.ListField(mongoengine.StringField()) + +Schema +------ + +Use ``AsyncMongoengineObjectType`` and ``AsyncMongoengineConnectionField``: + +.. code:: python + + # schema.py + import graphene + from graphene.relay import Node + from graphene_mongo import AsyncMongoengineConnectionField, AsyncMongoengineObjectType + from models import Author as AuthorModel, Book as BookModel + + class AuthorType(AsyncMongoengineObjectType): + class Meta: + model = AuthorModel + interfaces = (Node,) + filter_fields = { + "name": ["exact", "icontains", "istartswith"], + "nationality": ["exact"], + } + + class BookType(AsyncMongoengineObjectType): + class Meta: + model = BookModel + interfaces = (Node,) + filter_fields = { + "title": ["exact", "icontains"], + "genre": ["exact"], + "published_year": ["exact", "gte", "lte"], + } + + class Query(graphene.ObjectType): + node = Node.Field() + books = AsyncMongoengineConnectionField(BookType) + authors = AsyncMongoengineConnectionField(AuthorType) + + schema = graphene.Schema(query=Query, types=[AuthorType, BookType]) + +Async Mutations +--------------- + +Mutation ``mutate`` methods can be ``async def`` — use ``await`` to call +``aobjects`` (Motor-backed queryset) and ``asave()`` / ``adelete()``: + +.. code:: python + + class CreateBook(graphene.Mutation): + class Arguments: + title = graphene.String(required=True) + genre = graphene.String() + author_id = graphene.ID() + + book = graphene.Field(BookType) + + async def mutate(self, info, title, genre=None, author_id=None): + from graphql_relay import from_global_id + author = None + if author_id: + author = await AuthorModel.aobjects.get(pk=from_global_id(author_id)[1]) + book = BookModel(title=title, genre=genre, author=author) + await book.asave() + return CreateBook(book=book) + + class DeleteBook(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + + success = graphene.Boolean() + + async def mutate(self, info, id): + from graphql_relay import from_global_id + try: + book = await BookModel.aobjects.get(pk=from_global_id(id)[1]) + await book.adelete() + return DeleteBook(success=True) + except BookModel.DoesNotExist: + return DeleteBook(success=False) + + class Mutation(graphene.ObjectType): + create_book = CreateBook.Field() + delete_book = DeleteBook.Field() + +FastAPI Integration +------------------- + +FastAPI uses a lifespan context manager for startup/shutdown. Both +``mongoengine.connect()`` (for sync operations) and +``mongoengine.async_connect()`` (for Motor) are needed: + +.. code:: python + + # app.py + from contextlib import asynccontextmanager + import mongoengine + from fastapi import FastAPI, Request + from fastapi.responses import JSONResponse + from schema import schema + + @asynccontextmanager + async def lifespan(app: FastAPI): + mongoengine.connect("library_db") + await mongoengine.async_connect("library_db") + yield + mongoengine.disconnect() + + app = FastAPI(title="Library API", lifespan=lifespan) + + @app.post("/graphql") + async def graphql(request: Request): + body = await request.json() + result = await schema.execute_async( + body["query"], + variable_values=body.get("variables"), + operation_name=body.get("operationName"), + ) + errors = [{"message": str(e)} for e in result.errors] if result.errors else None + return JSONResponse({"data": result.data, "errors": errors}) + +Running +------- + +.. code:: bash + + uv run uvicorn app:app --reload + +Example Queries +--------------- + +Fetch all books with their author (resolved in **one** aggregation query): + +.. code:: graphql + + { + books { + edges { + node { + title + genre + author { name nationality } + } + } + } + } + +Filter and paginate: + +.. code:: graphql + + { + books(genre: "Dystopian", first: 5) { + edges { node { title publishedYear } } + pageInfo { hasNextPage endCursor } + } + } + +Create a book: + +.. code:: graphql + + mutation { + createBook(title: "Brave New World", genre: "Dystopian") { + book { id title } + } + } + +N+1 Elimination +--------------- + +``AsyncMongoengineConnectionField`` automatically calls ``select_related()`` +based on which fields are present in the query. For the query above, +``author`` is pre-fetched in the same aggregation as the books — no separate +per-book query. This applies to deeply nested references too: + +.. code:: graphql + + # one aggregate query regardless of depth + { + books { + edges { + node { + title + author { name } # pre-fetched via $lookup + } + } + } + } + +Without pagination args (``first`` / ``last`` / ``before`` / ``after``), the +``count()`` call is also skipped, reducing the total to **one** DB query. \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index deb0f8af..51777b48 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,17 +61,13 @@ # General information about the project. project = "Graphene Mongo" -copyright = "Graphene 2018" -author = "Abaw Chen" +copyright = "2024, graphene-mongo contributors" +author = "graphene-mongo contributors" -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# # The short X.Y version. -version = "0.1" +version = "0.5" # The full version, including alpha/beta/rc tags. -release = "0.1.2" +release = "0.5.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/fields.rst b/docs/fields.rst index 03d9286e..1aa91be4 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -1,36 +1,202 @@ Supported Fields -============================ +================ -Mongoengine Fields +All standard Mongoengine fields are automatically converted to the appropriate +Graphene scalar or type when you define a ``MongoengineObjectType`` or +``AsyncMongoengineObjectType``. + +Scalar Fields +------------- + ++-------------------------------+--------------------------+ +| Mongoengine Field | GraphQL / Graphene Type | ++===============================+==========================+ +| ``BooleanField`` | ``Boolean`` | ++-------------------------------+--------------------------+ +| ``DateTimeField`` | ``DateTime`` | ++-------------------------------+--------------------------+ +| ``DecimalField`` | ``Float`` | ++-------------------------------+--------------------------+ +| ``DictField`` | ``JSONString`` | ++-------------------------------+--------------------------+ +| ``EmailField`` | ``String`` | ++-------------------------------+--------------------------+ +| ``FloatField`` | ``Float`` | ++-------------------------------+--------------------------+ +| ``IntField`` | ``Int`` | ++-------------------------------+--------------------------+ +| ``ObjectIdField`` | ``ID`` | ++-------------------------------+--------------------------+ +| ``SequenceField`` | ``Int`` | ++-------------------------------+--------------------------+ +| ``StringField`` | ``String`` | ++-------------------------------+--------------------------+ +| ``URLField`` | ``String`` | ++-------------------------------+--------------------------+ +| ``UUIDField`` | ``String`` | ++-------------------------------+--------------------------+ + +Reference Fields +---------------- + +ReferenceField +~~~~~~~~~~~~~~ + +Resolves to the related document type. When queried through +``MongoengineConnectionField`` or ``AsyncMongoengineConnectionField``, +the referenced document is pre-fetched via ``select_related()`` — +no extra query per document. + +.. code:: python + + class Author(mongoengine.Document): + name = mongoengine.StringField() + + class Book(mongoengine.Document): + title = mongoengine.StringField() + author = mongoengine.ReferenceField(Author) + + # query: author resolved in one aggregate, not N+1 + { books { edges { node { title author { name } } } } } + +GenericReferenceField +~~~~~~~~~~~~~~~~~~~~~ + +A field that can reference documents of different types. Rendered as a +GraphQL union type automatically from the ``choices`` list: + +.. code:: python + + class Article(mongoengine.Document): ... + class Video(mongoengine.Document): ... + + class Feed(mongoengine.Document): + item = mongoengine.GenericReferenceField(choices=[Article, Video]) + +Embedded Documents ------------------ -- BooleanField -- DecimalField -- DateTimeField -- DictField -- EmailField -- EmbeddedDocumentField -- EmbeddedDocumentListField -- FileField -- FloatField -- GenericReferenceField -- IntField -- ListField -- MapField -- MultiPolygonField -- ObjectIdField -- ReferenceField -- PointField -- PolygonField -- SequenceField -- StringField -- URLField -- UUIDField +EmbeddedDocumentField +~~~~~~~~~~~~~~~~~~~~~ + +Nested sub-document, rendered as a nested GraphQL object: + +.. code:: python + + class Address(mongoengine.EmbeddedDocument): + street = mongoengine.StringField() + city = mongoengine.StringField() + + class Person(mongoengine.Document): + address = mongoengine.EmbeddedDocumentField(Address) + +EmbeddedDocumentListField +~~~~~~~~~~~~~~~~~~~~~~~~~ + +List of embedded sub-documents, rendered as a Relay connection: + +.. code:: python + + class Task(mongoengine.EmbeddedDocument): + name = mongoengine.StringField() + + class Employee(mongoengine.Document): + tasks = mongoengine.ListField(mongoengine.EmbeddedDocumentField(Task)) + +List Fields +----------- +ListField +~~~~~~~~~ + +Rendered as a GraphQL list. When the inner field is a ``ReferenceField``, +it becomes a list of the related type: + +.. code:: python + + class Employee(mongoengine.Document): + roles = mongoengine.ListField(mongoengine.ReferenceField(Role)) + tags = mongoengine.ListField(mongoengine.StringField()) + +MapField +~~~~~~~~ + +Rendered as ``JSONString``. + +Geo Fields +---------- + ++-------------------------------+----------------------------------+ +| Mongoengine Field | Notes | ++===============================+==================================+ +| ``PointField`` | ``[longitude, latitude]`` | ++-------------------------------+----------------------------------+ +| ``PolygonField`` | GeoJSON polygon | ++-------------------------------+----------------------------------+ +| ``MultiPolygonField`` | GeoJSON multi-polygon | ++-------------------------------+----------------------------------+ + +File Fields +----------- + +FileField +~~~~~~~~~ + +GridFS file field. Rendered as a ``FileFieldType`` with ``data``, +``contentType``, ``length``, and ``chunkSize`` sub-fields: + +.. code:: graphql + + { editors { edges { node { avatar { contentType length } } } } } Advanced -------- -- Self-reference relationship -- List of self-reference relationship -- Inheritance field +Self-Referential Fields +~~~~~~~~~~~~~~~~~~~~~~~ + +A document can reference itself. graphene-mongo handles the circular +dependency automatically: + +.. code:: python + + class Employee(mongoengine.Document): + name = mongoengine.StringField() + leader = mongoengine.ReferenceField("self") + reports = mongoengine.ListField(mongoengine.ReferenceField("self")) + +Inheritance +~~~~~~~~~~~ + +Mongoengine document inheritance is supported. Child types are registered +separately and resolved as GraphQL union types when queried through a +``GenericReferenceField``: + +.. code:: python + + class Animal(mongoengine.Document): + name = mongoengine.StringField() + meta = {"allow_inheritance": True} + + class Dog(Animal): + breed = mongoengine.StringField() + +filter_fields +~~~~~~~~~~~~~ + +All connection fields support ``filter_fields`` for inline filtering: + +.. code:: python + + class BookType(MongoengineObjectType): + class Meta: + model = Book + interfaces = (Node,) + filter_fields = { + "title": ["exact", "icontains", "istartswith"], + "published_year": ["exact", "gte", "lte"], + } + +Supported operators: ``exact``, ``iexact``, ``contains``, ``icontains``, +``startswith``, ``istartswith``, ``in``, ``nin``, ``lt``, ``lte``, ``gt``, +``gte``, ``ne``. \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 1707ecd9..e905a027 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,10 +1,90 @@ Graphene-Mongo -=================== +============== -Contents: +A `Mongoengine `__ integration for +`Graphene `__. Supports both synchronous and +async (Motor/asyncio) execution. + +Features +-------- + +- Automatic GraphQL type generation from Mongoengine models +- Relay-compatible connection fields with pagination and filtering +- **Sync** (``MongoengineObjectType``, ``MongoengineConnectionField``) and + **Async** (``AsyncMongoengineObjectType``, ``AsyncMongoengineConnectionField``) APIs +- ``select_related`` — eliminates N+1 queries by pre-fetching referenced documents + in a single MongoDB ``$aggregate`` pipeline +- Works with Flask, Falcon, FastAPI, Django, or any Python web framework +- Full support for mutations, embedded documents, reference fields, and inheritance + +Installation +------------ + +.. code:: bash + + # with uv (recommended) + uv add graphene-mongo + + # with pip + pip install graphene-mongo + +Quick Start +----------- + +**Synchronous (Flask / Django / Falcon)** + +.. code:: python + + import graphene + from graphene.relay import Node + from graphene_mongo import MongoengineObjectType, MongoengineConnectionField + from mongoengine import Document, StringField, connect + + connect("mydb") + + class Article(Document): + title = StringField() + + class ArticleType(MongoengineObjectType): + class Meta: + model = Article + interfaces = (Node,) + + class Query(graphene.ObjectType): + node = Node.Field() + articles = MongoengineConnectionField(ArticleType) + + schema = graphene.Schema(query=Query) + +**Asynchronous (FastAPI / async frameworks)** + +.. code:: python + + import graphene + import mongoengine + from graphene.relay import Node + from graphene_mongo import AsyncMongoengineObjectType, AsyncMongoengineConnectionField + + class ArticleType(AsyncMongoengineObjectType): + class Meta: + model = Article + interfaces = (Node,) + + class Query(graphene.ObjectType): + node = Node.Field() + articles = AsyncMongoengineConnectionField(ArticleType) + + schema = graphene.Schema(query=Query) + + # execute + result = await schema.execute_async("{ articles { edges { node { title } } } }") + +Contents +-------- .. toctree:: - :maxdepth: 0 + :maxdepth: 2 tutorial - fields + async_tutorial + fields \ No newline at end of file diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 2ba31a63..bfcde8b8 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -1,204 +1,216 @@ Mongoengine + Flask Tutorial -============================== +============================ -Graphene comes with builtin support to Mongoengine, which makes quite -easy to operate with your current models. +This tutorial walks through building a GraphQL API with graphene-mongo and Flask. +The full source is in +`examples/flask_mongoengine `__. -Note: The code in this tutorial is pulled from the `Flask Mongoengine -example -app `__. +For an async example using FastAPI see :doc:`async_tutorial`. -Setup the Project ------------------ +Setup +----- .. code:: bash - # Create the project directory - mkdir flask_graphene_mongo - cd flask_graphene_mongo + mkdir flask_graphene_mongo && cd flask_graphene_mongo + uv init + uv add Flask graphene-mongo mongoengine mongomock - # [Optional but suggested] Create a virtualenv to isolate our package dependencies locally - virtualenv env - source env/bin/activate - - # Install required packages - pip install Flask - pip install Flask-GraphQL - pip install graphene-mongo - - # Install mongomock or you have to run a real mongo server instance somewhere. - pip install mongomock - -Defining our models -------------------- - -Let's get start with following models: +Defining Models +--------------- .. code:: python - # flask_graphene_mongo/models.py + # models.py from datetime import datetime - from mongoengine import Document - from mongoengine.fields import ( - DateTimeField, ReferenceField, StringField, - ) - - - class Department(Document): - meta = {'collection': 'department'} - name = StringField() + import mongoengine + class Department(mongoengine.Document): + meta = {"collection": "department"} + name = mongoengine.StringField(required=True) - class Role(Document): - meta = {'collection': 'role'} - name = StringField() + class Role(mongoengine.Document): + meta = {"collection": "role"} + name = mongoengine.StringField(required=True) + class Task(mongoengine.EmbeddedDocument): + name = mongoengine.StringField() + deadline = mongoengine.DateTimeField() - class Employee(Document): - meta = {'collection': 'employee'} - name = StringField() - hired_on = DateTimeField(default=datetime.now) - department = ReferenceField(Department) - role = ReferenceField(Role) + class Employee(mongoengine.Document): + meta = {"collection": "employee"} + name = mongoengine.StringField(required=True) + hired_on = mongoengine.DateTimeField(default=datetime.now) + department = mongoengine.ReferenceField(Department) + roles = mongoengine.ListField(mongoengine.ReferenceField(Role)) + leader = mongoengine.ReferenceField("self") + tasks = mongoengine.ListField(mongoengine.EmbeddedDocumentField(Task)) Schema ------ -Here I assume you guys have the basic knowledge of how schema works in GraphQL, that I define the *root type* as the `Query` class below with the ability to list all employees. +``MongoengineObjectType`` converts a Mongoengine Document into a Graphene type. +Adding the ``Node`` interface enables Relay-compatible pagination and global IDs. .. code:: python - # flask_graphene_mongo/schema.py + # schema.py import graphene from graphene.relay import Node from graphene_mongo import MongoengineConnectionField, MongoengineObjectType from models import Department as DepartmentModel from models import Employee as EmployeeModel from models import Role as RoleModel + from models import Task as TaskModel class Department(MongoengineObjectType): - class Meta: model = DepartmentModel interfaces = (Node,) - class Role(MongoengineObjectType): - class Meta: model = RoleModel interfaces = (Node,) + filter_fields = {"name": ["exact", "icontains", "istartswith"]} + class Task(MongoengineObjectType): + class Meta: + model = TaskModel + interfaces = (Node,) class Employee(MongoengineObjectType): - class Meta: model = EmployeeModel interfaces = (Node,) - + filter_fields = {"name": ["exact", "icontains", "istartswith"]} class Query(graphene.ObjectType): node = Node.Field() all_employees = MongoengineConnectionField(Employee) - all_role = MongoengineConnectionField(Role) - role = graphene.Field(Role) - - schema = graphene.Schema(query=Query, types=[Department, Employee, Role]) + all_roles = MongoengineConnectionField(Role) + schema = graphene.Schema(query=Query, types=[Department, Employee, Role, Task]) -Creating some data ------------------- +Filtering and Pagination +~~~~~~~~~~~~~~~~~~~~~~~~ -By putting some data to make this demo can run directly: +``filter_fields`` enables field-level filtering directly in the query: -.. code:: python +.. code:: graphql - # flask_graphene_mongo/database.py - from mongoengine import connect + # filter by name + { allEmployees(name: "Peter") { edges { node { name } } } } - from models import Department, Employee, Role + # pagination + { allEmployees(first: 5) { edges { node { name } } } } - # You can connect to a real mongo server instance by your own. - connect('graphene-mongo-example', host='mongomock://localhost', alias='default') + # cursor-based pagination + { allEmployees(first: 5, after: "cursor==") { edges { node { name } } pageInfo { hasNextPage endCursor } } } +Mutations +--------- - def init_db(): - # Create the fixtures - engineering = Department(name='Engineering') - engineering.save() +.. code:: python - hr = Department(name='Human Resources') - hr.save() + # mutations.py + import graphene + from models import Employee, Department, Role - manager = Role(name='manager') - manager.save() + class CreateEmployee(graphene.Mutation): + class Arguments: + name = graphene.String(required=True) + department_id = graphene.ID() - engineer = Role(name='engineer') - engineer.save() + employee = graphene.Field(lambda: EmployeeType) - peter = Employee(name='Peter', department=engineering, role=engineer) - peter.save() + def mutate(self, info, name, department_id=None): + from graphql_relay import from_global_id + dept = None + if department_id: + dept = Department.objects.get(pk=from_global_id(department_id)[1]) + emp = Employee(name=name, department=dept).save() + return CreateEmployee(employee=emp) - roy = Employee(name='Roy', department=engineering, role=engineer) - roy.save() + class Mutation(graphene.ObjectType): + create_employee = CreateEmployee.Field() - tracy = Employee(name='Tracy', department=hr, role=manager) - tracy.save() + schema = graphene.Schema(query=Query, mutation=Mutation) -Creating GraphQL and GraphiQL views in Flask --------------------------------------------- +Flask App +--------- -There is only one URL from which GraphQL is accessed, and we take the advantage of ``Flask-GraphQL`` to generate the GraphQL interface for easily accessed by a browser: +Flask 3.x supports ``async def`` views natively, so no extra adapter is needed: .. code:: python - # flask_graphene_mongo/app.py + # app.py from database import init_db - from flask import Flask - from flask_graphql import GraphQLView + from flask import Flask, jsonify, request from schema import schema app = Flask(__name__) - app.debug = True - - default_query = ''' - { - allEmployees { - edges { - node { - id, - name, - department { - id, - name - }, - role { - id, - name - } - } - } - } - }'''.strip() - app.add_url_rule( - '/graphql', - view_func=GraphQLView.as_view('graphql', schema=schema, graphiql=True) - ) - - if __name__ == '__main__': + @app.post("/graphql") + async def graphql_view(): + body = request.get_json() + result = await schema.execute_async( + body["query"], + variable_values=body.get("variables"), + operation_name=body.get("operationName"), + ) + errors = [{"message": str(e)} for e in result.errors] if result.errors else None + return jsonify({"data": result.data, "errors": errors}) + + if __name__ == "__main__": init_db() app.run() -Testing -------- +Seed Data +--------- + +.. code:: python + + # database.py + import mongoengine + from models import Department, Employee, Role, Task + from datetime import datetime + + mongoengine.connect("graphene-mongo-example", host="mongomock://localhost") + + def init_db(): + engineering = Department(name="Engineering").save() + hr = Department(name="Human Resources").save() -We are ready to launch the server! + manager = Role(name="manager").save() + engineer = Role(name="engineer").save() + + peter = Employee( + name="Peter", department=engineering, roles=[engineer], + tasks=[Task(name="Fix bug", deadline=datetime(2025, 1, 1))] + ).save() + Employee(name="Roy", department=engineering, roles=[engineer], leader=peter).save() + Employee(name="Tracy", department=hr, roles=[manager]).save() + +Running +------- .. code:: bash - $ python app.py - * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) + uv run python app.py -Then go to `http://localhost:5000/graphql `__ to test your first query. +Then query at ``http://localhost:5000/graphql``: +.. code:: graphql + + { + allEmployees { + edges { + node { + name + department { name } + roles { edges { node { name } } } + } + } + } + } \ No newline at end of file From 13157467f2b288a56ed20beb4630de9f2e475aab Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sat, 23 May 2026 14:53:40 +0530 Subject: [PATCH 16/52] feat: push reference field filters into $lookup sub-pipeline for single-query resolution - Add get_related_field_filter_args() to walk the GraphQL AST and extract filter args from connection sub-fields (e.g. articles(headline: "Hello")) - Apply extracted filters via MongoEngine's filter() before select_related so the pipeline builder pushes conditions inside the $lookup stage rather than fetching all docs and post-filtering in Python - Remove Python-level list filtering from default_resolver; pre-loaded select_related data is returned directly - Delete standalone test_select_related.py files; coverage merged into test_relay_query.py with explicit query-count assertions - Remove stale GenericLazyReferenceField docstring references (field no longer exists in MongoEngine) --- graphene_mongo/asynchronous/fields.py | 21 +- .../field_resolvers/list_field_resolver.py | 24 +- .../base/field_resolvers/union_resolver.py | 5 +- graphene_mongo/base/utils.py | 58 +- graphene_mongo/synchronous/fields.py | 19 +- tests/asynchronous/test_fields.py | 16 +- tests/asynchronous/test_inputs.py | 17 +- tests/asynchronous/test_mutation.py | 9 +- tests/asynchronous/test_query.py | 31 +- tests/asynchronous/test_relay_query.py | 531 ++++++++++++++---- tests/asynchronous/test_select_related.py | 262 --------- tests/asynchronous/test_types.py | 1 - tests/asynchronous/utils.py | 12 +- tests/mongo_capture.py | 78 +++ tests/synchronous/test_fields.py | 1 + tests/synchronous/test_inputs.py | 18 +- tests/synchronous/test_mutation.py | 9 +- tests/synchronous/test_query.py | 53 +- tests/synchronous/test_relay_query.py | 530 +++++++++++++---- tests/synchronous/test_select_related.py | 252 --------- tests/synchronous/test_types.py | 1 - tests/synchronous/utils.py | 10 + 22 files changed, 1126 insertions(+), 832 deletions(-) delete mode 100644 tests/asynchronous/test_select_related.py create mode 100644 tests/mongo_capture.py delete mode 100644 tests/synchronous/test_select_related.py diff --git a/graphene_mongo/asynchronous/fields.py b/graphene_mongo/asynchronous/fields.py index 10ca9fd6..94f7b6bf 100644 --- a/graphene_mongo/asynchronous/fields.py +++ b/graphene_mongo/asynchronous/fields.py @@ -27,6 +27,7 @@ get_document, get_model_reference_fields, get_query_fields, + get_related_field_filter_args, get_select_related_paths, has_page_info, ) @@ -114,10 +115,14 @@ def get_queryset( args.update(queryset_or_filters) queried_fields = get_query_fields(info) if isinstance(info, GraphQLResolveInfo) else {} related = get_select_related_paths(model, queried_fields) + related_filter = get_related_field_filter_args(info, model) if isinstance(info, GraphQLResolveInfo) else {} qs = model.aobjects(**args).only(*required_fields).order_by(self.order_by) if related: qs = qs.select_related(*related) + for field_name, field_filter in related_filter.items(): + if field_name in related: + qs = qs.filter(**{f"{field_name}__{k}": v for k, v in field_filter.items()}) if limit is not None: return qs.skip(skip if skip else 0).limit(limit) elif skip is not None: @@ -147,8 +152,18 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non mongoengine.GenericEmbeddedDocumentField, ) ): - if getattr(_root, field_name, []) is not None: - args["pk__in"] = [r.id for r in getattr(_root, field_name, [])] + raw = getattr(_root, field_name, []) + if raw is not None: + first = next(iter(raw), None) + if isinstance(first, mongoengine.Document): + # Pre-loaded by select_related; the filter was already pushed into + # the $lookup sub-pipeline by get_queryset via filter(**related_filter). + # Return the pre-loaded (already filtered) list directly. + resolved = list(raw) + for k in [k for k in args if k != "id"]: + args.pop(k) + else: + args["pk__in"] = [r.id for r in raw] _id = args.pop("id", None) @@ -211,7 +226,7 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non ) elif skip: items = items[skip:] - iterables = await items.to_list() + iterables = await items.to_list() if isinstance(items, AsyncQuerySet) else list(items) list_length = len(iterables) elif callable(getattr(self.model, "objects", None)): diff --git a/graphene_mongo/base/field_resolvers/list_field_resolver.py b/graphene_mongo/base/field_resolvers/list_field_resolver.py index 41b66b20..466f7847 100644 --- a/graphene_mongo/base/field_resolvers/list_field_resolver.py +++ b/graphene_mongo/base/field_resolvers/list_field_resolver.py @@ -82,9 +82,11 @@ async def __get_non_querying_object_async(model, object_id_list) -> list[Documen @staticmethod def __build_results( - result: list[Document], to_resolve_object_ids: list[ObjectId] + result: list[Document], + to_resolve_object_ids: list[ObjectId], + already_resolved: dict[ObjectId, Document] = None, ) -> list[Document]: - result_object: dict[ObjectId, Document] = {} + result_object: dict[ObjectId, Document] = dict(already_resolved or {}) for items in result: for item in items: result_object[item.id] = item @@ -108,9 +110,13 @@ def __reference_resolver_common( to_resolve_models = dict() for each, queried_fields in querying_union_types.items(): to_resolve_models[registry.get_type_for_model_string(each)] = queried_fields + already_resolved: dict[ObjectId, Document] = {} to_resolve_object_ids: list[ObjectId] = list() for each in to_resolve: - if isinstance(each, LazyReference): + if isinstance(each, Document): + already_resolved[each.pk] = each + to_resolve_object_ids.append(each.pk) + elif isinstance(each, LazyReference): to_resolve_object_ids.append(each.pk) model = each.document_type._class_name if model not in choice_to_resolve: @@ -142,7 +148,7 @@ def __reference_resolver_common( ) ) result = [future.result() for future in as_completed(futures)] - return result, to_resolve_object_ids + return result, to_resolve_object_ids, already_resolved else: loop = asyncio.get_event_loop() tasks: list[Task] = [] @@ -163,7 +169,7 @@ def __reference_resolver_common( ListFieldResolver.__get_non_querying_object_async(model, object_id_list) ) tasks.append(task) - return tasks, to_resolve_object_ids + return tasks, to_resolve_object_ids, already_resolved @staticmethod def reference_resolver(field, registry, executor) -> Callable: @@ -173,8 +179,8 @@ def resolver(root, *args, **kwargs) -> Optional[list[Document]]: ) if not isinstance(resolver_result, tuple): return resolver_result - result, to_resolve_object_ids = resolver_result - return ListFieldResolver.__build_results(result, to_resolve_object_ids) + result, to_resolve_object_ids, already_resolved = resolver_result + return ListFieldResolver.__build_results(result, to_resolve_object_ids, already_resolved) return resolver @@ -186,9 +192,9 @@ async def resolver(root, *args, **kwargs) -> Optional[list[Document]]: ) if not isinstance(resolver_result, tuple): return resolver_result - tasks, to_resolve_object_ids = resolver_result + tasks, to_resolve_object_ids, already_resolved = resolver_result result: list[Document] = await asyncio.gather(*tasks) - return ListFieldResolver.__build_results(result, to_resolve_object_ids) + return ListFieldResolver.__build_results(result, to_resolve_object_ids, already_resolved) return resolver diff --git a/graphene_mongo/base/field_resolvers/union_resolver.py b/graphene_mongo/base/field_resolvers/union_resolver.py index 95000d1c..5f8b9e91 100644 --- a/graphene_mongo/base/field_resolvers/union_resolver.py +++ b/graphene_mongo/base/field_resolvers/union_resolver.py @@ -17,10 +17,13 @@ def __reference_resolver_common( ) -> Optional[Union[tuple[Document, set[str], ObjectId], Document]]: from graphene_mongo.base.converter import convert_mongoengine_field - de_referenced: LazyReference = getattr(root, field.name or field.db_name) + de_referenced = getattr(root, field.name or field.db_name) if not de_referenced: return None + if isinstance(de_referenced, Document): + return de_referenced + document = get_document(de_referenced.document_type) document_id = de_referenced.id document_field = mongoengine.ReferenceField(document) diff --git a/graphene_mongo/base/utils.py b/graphene_mongo/base/utils.py index 6c3c4cd3..924d0e38 100644 --- a/graphene_mongo/base/utils.py +++ b/graphene_mongo/base/utils.py @@ -227,7 +227,7 @@ def get_select_related_paths(model, queried_fields, prefix=""): continue mongo_field = model._fields[snake] inner = mongo_field.field if isinstance(mongo_field, mongoengine.ListField) else mongo_field - if isinstance(inner, mongoengine.ReferenceField): + if isinstance(inner, (mongoengine.ReferenceField, mongoengine.GenericReferenceField)): path = f"{prefix}__{snake}" if prefix else snake paths.append(path) if sub_fields and hasattr(inner, "document_type"): @@ -431,6 +431,62 @@ def connection_from_iterables( ) +def get_related_field_filter_args(info, model) -> dict: + """ + Walk the GraphQL AST to find reference/list-of-reference sub-fields that carry + filter arguments (e.g. ``articles(headline: "Hello")``). + + Returns a dict suitable for passing into the parent queryset with __ syntax: + {"articles": {"headline": "Hello"}} + → caller does: qs.filter(articles__headline="Hello").select_related("articles") + + Traverses the relay wrapper fields (edges, node) transparently. + Handles both literal argument values and GraphQL variable references. + """ + _relay_skip = {"edges", "node", "pageInfo"} + _arg_skip = {"first", "last", "before", "after", "id"} + result: dict = {} + + def _traverse(selection_set): + if not selection_set: + return + for sel in getattr(selection_set, "selections", []): + if not isinstance(sel, FieldNode): + continue + gql_name = sel.name.value + snake_name = to_snake_case(gql_name) + if gql_name in _relay_skip: + _traverse(sel.selection_set) + continue + if snake_name not in model._fields: + continue + mongo_field = model._fields[snake_name] + inner = mongo_field.field if isinstance(mongo_field, mongoengine.ListField) else mongo_field + if not isinstance(inner, (mongoengine.ReferenceField, mongoengine.GenericReferenceField)): + continue + if not getattr(sel, "arguments", None): + continue + field_args: dict = {} + for arg in sel.arguments: + arg_name = to_snake_case(arg.name.value) + if arg_name in _arg_skip: + continue + if isinstance(arg.value, VariableNode): + val = (getattr(info, "variable_values", None) or {}).get(arg.value.name.value) + elif hasattr(arg.value, "value"): + val = arg.value.value + else: + continue + if val is not None: + field_args[arg_name] = val + if field_args: + result[snake_name] = field_args + + if info and getattr(info, "field_nodes", None): + _traverse(info.field_nodes[0].selection_set) + return result + + def get_field_resolver( default_async_resolver: Callable, default_sync_resolver: Callable, diff --git a/graphene_mongo/synchronous/fields.py b/graphene_mongo/synchronous/fields.py index bdc7bc2d..5ce7b135 100644 --- a/graphene_mongo/synchronous/fields.py +++ b/graphene_mongo/synchronous/fields.py @@ -39,6 +39,7 @@ get_document, get_model_reference_fields, get_query_fields, + get_related_field_filter_args, get_select_related_paths, has_page_info, ) @@ -327,10 +328,14 @@ def get_queryset( args.update(queryset_or_filters) queried_fields = get_query_fields(info) if isinstance(info, GraphQLResolveInfo) else {} related = get_select_related_paths(model, queried_fields) + related_filter = get_related_field_filter_args(info, model) if isinstance(info, GraphQLResolveInfo) else {} qs = model.objects(**args).only(*required_fields).order_by(self.order_by) if related: qs = qs.select_related(*related) + for field_name, field_filter in related_filter.items(): + if field_name in related: + qs = qs.filter(**{f"{field_name}__{k}": v for k, v in field_filter.items()}) if limit is not None: return qs.skip(skip if skip else 0).limit(limit) elif skip is not None: @@ -356,8 +361,18 @@ def default_resolver(self, _root, info, required_fields=None, resolved=None, **a mongoengine.GenericEmbeddedDocumentField, ) ): - if getattr(_root, field_name, []) is not None: - args["pk__in"] = [r.id for r in getattr(_root, field_name, [])] + raw = getattr(_root, field_name, []) + if raw is not None: + first = next(iter(raw), None) + if isinstance(first, mongoengine.Document): + # Pre-loaded by select_related; the filter was already pushed into + # the $lookup sub-pipeline by get_queryset via filter(**related_filter). + # Return the pre-loaded (already filtered) list directly. + resolved = list(raw) + for k in [k for k in args if k != "id"]: + args.pop(k) + else: + args["pk__in"] = [r.id for r in raw] _id = args.pop("id", None) diff --git a/tests/asynchronous/test_fields.py b/tests/asynchronous/test_fields.py index b295fc11..e2e09bb5 100644 --- a/tests/asynchronous/test_fields.py +++ b/tests/asynchronous/test_fields.py @@ -1,21 +1,25 @@ -import pytest +from mongoengine.context_managers import async_query_counter from . import nodes from graphene_mongo import AsyncMongoengineConnectionField -@pytest.mark.asyncio async def test_default_resolver_with_colliding_objects_field(): field = AsyncMongoengineConnectionField(nodes.ErroneousModelAsyncNode) - connection = await field.default_resolver(None, {}) + async with async_query_counter() as q: + connection = await field.default_resolver(None, {}) + count = await q.int() assert 0 == len(connection.iterable) + assert count == 0 -@pytest.mark.asyncio async def test_default_resolver_connection_list_length(fixtures): field = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) - connection = await field.default_resolver(None, {}, **{"first": 1}) + async with async_query_counter() as q: + connection = await field.default_resolver(None, {}, **{"first": 1}) + count = await q.int() assert hasattr(connection, "list_length") - assert connection.list_length == 1 \ No newline at end of file + assert connection.list_length == 1 + assert count == 2 \ No newline at end of file diff --git a/tests/asynchronous/test_inputs.py b/tests/asynchronous/test_inputs.py index e5be774c..bf428450 100644 --- a/tests/asynchronous/test_inputs.py +++ b/tests/asynchronous/test_inputs.py @@ -4,6 +4,7 @@ from ..models import Article, Editor from .nodes import ArticleAsyncNode, EditorAsyncNode from ..types import ArticleInput, EditorInput +from .utils import execute_count async def test_should_create(fixtures): @@ -37,9 +38,10 @@ class Mutation(graphene.ObjectType): """ expected = {"createArticle": {"article": {"headline": "My Article"}}} schema = graphene.Schema(query=Query, mutation=Mutation) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 async def test_should_update(fixtures): @@ -51,11 +53,9 @@ class Arguments: editor = graphene.Field(EditorAsyncNode) async def mutate(self, info, id, editor): - editor_to_update = await Editor.aobjects.get(id=id) - for key, value in editor.items(): - if value: - setattr(editor_to_update, key, value) - await editor_to_update.asave() + editor_to_update = await Editor.aobjects(id=id).modify( + new=True, **{f"set__{k}": v for k, v in editor.items() if v} + ) return UpdateEditor(editor=editor_to_update) class Query(graphene.ObjectType): @@ -81,6 +81,7 @@ class Mutation(graphene.ObjectType): """ expected = {"updateEditor": {"editor": {"firstName": "Penny", "lastName": "Lane"}}} schema = graphene.Schema(query=Query, mutation=Mutation) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors - assert result.data == expected \ No newline at end of file + assert result.data == expected + assert count == 1 \ No newline at end of file diff --git a/tests/asynchronous/test_mutation.py b/tests/asynchronous/test_mutation.py index 639a4213..aa7f3bb9 100644 --- a/tests/asynchronous/test_mutation.py +++ b/tests/asynchronous/test_mutation.py @@ -3,6 +3,7 @@ from ..models import Article, Editor from .nodes import ArticleAsyncNode, EditorAsyncNode +from .utils import execute_count async def test_should_create(fixtures): @@ -36,9 +37,10 @@ class Mutation(graphene.ObjectType): """ expected = {"createArticle": {"article": {"headline": "My Article"}}} schema = graphene.Schema(query=Query, mutation=Mutation) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count >= 1 async def test_should_update(fixtures): @@ -75,6 +77,7 @@ class Mutation(graphene.ObjectType): """ expected = {"updateEditor": {"editor": {"firstName": "Tony"}}} schema = graphene.Schema(query=Query, mutation=Mutation) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors - assert result.data == expected \ No newline at end of file + assert result.data == expected + assert count >= 1 \ No newline at end of file diff --git a/tests/asynchronous/test_query.py b/tests/asynchronous/test_query.py index 9dea361e..3ea34398 100644 --- a/tests/asynchronous/test_query.py +++ b/tests/asynchronous/test_query.py @@ -3,10 +3,10 @@ import os import graphene -import pytest from .. import models from . import types as async_types +from .utils import execute_count async def test_should_query_editor(fixtures, fixtures_dirname): @@ -66,11 +66,12 @@ async def resolve_editors(self, *args, **kwargs): expected_metadata = {"age": "20", "nickname": "$1"} schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors metadata = result.data["editor"].pop("metadata") assert json.loads(metadata) == expected_metadata assert result.data == expected + assert count >= 1 async def test_should_query_reporter(fixtures): @@ -112,9 +113,10 @@ async def resolve_reporter(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 async def test_should_custom_kwargs(fixtures): @@ -142,9 +144,10 @@ async def resolve_editors(self, *args, **kwargs): ] } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 async def test_should_self_reference(fixtures): @@ -188,9 +191,10 @@ async def resolve_all_players(self, *args, **kwargs): ] } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count >= 1 async def test_should_query_with_embedded_document(fixtures): @@ -215,9 +219,10 @@ async def resolve_professor_vector(self, info, id): expected = {"professorVector": {"vec": [1.0, 2.3], "metadata": {"firstName": "Steven"}}} schema = graphene.Schema(query=Query, types=[async_types.ProfessorVectorAsyncType]) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 async def test_should_query_child(fixtures): @@ -251,9 +256,10 @@ async def resolve_children(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 async def test_should_query_other_childs(fixtures): @@ -287,9 +293,10 @@ async def resolve_children(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 async def test_should_query_all_childs(fixtures): @@ -340,9 +347,10 @@ async def resolve_children(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 async def test_should_query_cell_tower(fixtures): @@ -404,6 +412,7 @@ async def resolve_cell_towers(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors - assert result.data == expected \ No newline at end of file + assert result.data == expected + assert count == 1 \ No newline at end of file diff --git a/tests/asynchronous/test_relay_query.py b/tests/asynchronous/test_relay_query.py index c1cfd18a..492f2be9 100644 --- a/tests/asynchronous/test_relay_query.py +++ b/tests/asynchronous/test_relay_query.py @@ -3,22 +3,22 @@ import os import graphene -import pytest from graphene.relay import Node +from graphql_relay.connection.array_connection import offset_to_cursor from graphql_relay.node.node import to_global_id from .. import models from . import nodes from graphene_mongo import AsyncMongoengineConnectionField, AsyncMongoengineObjectType +from .utils import execute_count -@pytest.mark.asyncio async def test_should_query_reporter(fixtures): class Query(graphene.ObjectType): reporter = graphene.Field(nodes.ReporterAsyncNode) async def resolve_reporter(self, *args, **kwargs): - return await models.Reporter.aobjects.first() + return await models.Reporter.aobjects.select_related("articles", "generic_reference").first() query = """ query ReporterQuery { @@ -86,12 +86,12 @@ async def resolve_reporter(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 # select_related fetches reporter + articles + generic_reference in a single aggregate -@pytest.mark.asyncio async def test_should_query_reporters_with_nested_document(fixtures): class Query(graphene.ObjectType): reporters = AsyncMongoengineConnectionField(nodes.ReporterAsyncNode) @@ -132,12 +132,12 @@ class Query(graphene.ObjectType): } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 # 1 reporters aggregate; article filter (headline="Hello") pushed into $lookup sub-pipeline by MongoEngine -@pytest.mark.asyncio async def test_should_query_all_editors(fixtures, fixtures_dirname): class Query(graphene.ObjectType): editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) @@ -200,76 +200,12 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count >= 1 # 1 editors query; GridFS avatar reads may add extra queries -@pytest.mark.asyncio -async def test_should_query_editors_with_dataloader(fixtures): - from promise import Promise - from promise.dataloader import DataLoader - - class ArticleLoader(DataLoader): - def batch_load_fn(self, instances): - queryset = models.Article.objects(editor__in=instances) - return Promise.resolve( - [[a for a in queryset if a.editor.id == instance.id] for instance in instances] - ) - - article_loader = ArticleLoader() - - class _EditorNode(AsyncMongoengineObjectType): - class Meta: - model = models.Editor - interfaces = (graphene.Node,) - - articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) - - async def resolve_articles(self, *args, **kwargs): - return article_loader.load(self) - - class Query(graphene.ObjectType): - editors = AsyncMongoengineConnectionField(_EditorNode) - - query = """ - query EditorPromiseConnectionQuery { - editors(first: 1) { - edges { - node { - firstName, - articles(first: 1) { - edges { - node { - headline - } - } - } - } - } - } - } - """ - - expected = { - "editors": { - "edges": [ - { - "node": { - "firstName": "Penny", - "articles": {"edges": [{"node": {"headline": "Hello"}}]}, - } - } - ] - } - } - schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) - assert not result.errors - assert result.data == expected - - -@pytest.mark.asyncio async def test_should_filter_editors_by_id(fixtures): class Query(graphene.ObjectType): editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) @@ -301,12 +237,12 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 # single find filtered by relay-decoded _id -@pytest.mark.asyncio async def test_should_filter(fixtures): class Query(graphene.ObjectType): articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) @@ -340,12 +276,12 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 # 1 aggregate with $match on headline; editor ReferenceField resolved via $lookup in the same query -@pytest.mark.asyncio async def test_should_filter_by_reference_field(fixtures): class Query(graphene.ObjectType): articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) @@ -368,12 +304,12 @@ class Query(graphene.ObjectType): "articles": {"edges": [{"node": {"headline": "Hello", "editor": {"firstName": "Penny"}}}]} } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 # 1 aggregate with $match on editor _id; editor ReferenceField resolved via $lookup -@pytest.mark.asyncio async def test_should_filter_through_inheritance(fixtures): class Query(graphene.ObjectType): node = Node.Field() @@ -409,12 +345,12 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 # 1 aggregate with $match on bar; inherited Child collection queried once -@pytest.mark.asyncio async def test_should_filter_by_list_contains(fixtures): # Notes: https://goo.gl/hMNRgs class Query(graphene.ObjectType): @@ -456,12 +392,12 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 # 1 reporters aggregate with genericReferences joined via select_related $lookup -@pytest.mark.asyncio async def test_should_filter_by_id(fixtures): # Notes: https://goo.gl/hMNRgs class Query(graphene.ObjectType): @@ -484,12 +420,12 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 # Node.Field by relay ID resolves to a single _id lookup -@pytest.mark.asyncio async def test_should_first_n(fixtures): class Query(graphene.ObjectType): editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) @@ -527,13 +463,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 2 # first:2 triggers pagination: 1 count query (for hasNextPage) + 1 find query (sliced results) -@pytest.mark.asyncio async def test_should_after(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -560,13 +496,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 2 # after cursor triggers pagination: 1 count + 1 find starting from the cursor offset -@pytest.mark.asyncio async def test_should_before(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -595,13 +531,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 2 # before cursor triggers pagination: 1 count + 1 find truncated before the cursor -@pytest.mark.asyncio async def test_should_last_n(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -627,13 +563,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 2 # last:2 triggers pagination: 1 count (to compute tail offset) + 1 find from the end -@pytest.mark.asyncio async def test_should_self_reference(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -703,12 +639,12 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count >= 1 # 1 players query + 1 sub-query per player for the nested players connection field -@pytest.mark.asyncio async def test_should_lazy_reference(fixtures): class Query(graphene.ObjectType): node = Node.Field() @@ -760,12 +696,12 @@ class Query(graphene.ObjectType): } } - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count >= 1 # 1 parents query + extra queries to dereference each lazy beforeChild/afterChild relationship -@pytest.mark.asyncio async def test_should_query_with_embedded_document(fixtures): class Query(graphene.ObjectType): professors = AsyncMongoengineConnectionField(nodes.ProfessorVectorAsyncNode) @@ -790,12 +726,12 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 # 1 professors query; metadata is an EmbeddedDocument so no extra query needed -@pytest.mark.asyncio async def test_should_get_queryset_returns_dict_filters(fixtures): class Query(graphene.ObjectType): node = Node.Field() @@ -833,12 +769,12 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 # dict-based get_queryset applies $match; editor ReferenceField resolved via $lookup in 1 aggregate -@pytest.mark.asyncio async def test_should_get_queryset_returns_qs_filters(fixtures): def get_queryset(model, info, **args): return model.aobjects(headline="World") @@ -878,12 +814,12 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count >= 1 # custom queryset-based get_queryset; 1 aggregate expected but bound is loose for safety -@pytest.mark.asyncio async def test_should_filter_mongoengine_queryset(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -908,13 +844,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert json.dumps(result.data, sort_keys=True) == json.dumps(expected, sort_keys=True) + assert count == 1 # 1 aggregate with case-insensitive startswith filter; no pagination -@pytest.mark.asyncio async def test_should_query_document_with_embedded(fixtures): class Query(graphene.ObjectType): foos = AsyncMongoengineConnectionField(nodes.FooAsyncNode) @@ -941,11 +877,11 @@ async def resolve_multiple_foos(self, *args, **kwargs): """ schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors + assert count >= 1 # 1 foos query; bars is EmbeddedDocumentListField so no extra query, data is in the document -@pytest.mark.asyncio async def test_should_filter_mongoengine_queryset_with_list(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -970,13 +906,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert json.dumps(result.data, sort_keys=True) == json.dumps(expected, sort_keys=True) + assert count == 1 # 1 aggregate with $in filter on firstName; no pagination -@pytest.mark.asyncio async def test_should_get_correct_list_of_documents(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -1025,13 +961,13 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count >= 1 # 1 players find + paginated articles sub-queries per player (first:3 triggers count+find each) -@pytest.mark.asyncio async def test_should_filter_mongoengine_queryset_by_id_and_other_fields( fixtures, ): @@ -1060,7 +996,378 @@ class Query(graphene.ObjectType): expected = {"players": {"edges": []}} schema = graphene.Schema(query=Query) - result = await schema.execute_async(query) + result, count = await execute_count(schema, query) assert not result.errors assert json.dumps(result.data, sort_keys=True) == json.dumps(expected, sort_keys=True) + assert count == 1 # conflicting id+firstName filters produce an empty result; still only 1 query + +# --------------------------------------------------------------------------- +# N+1 / query-count tests +# --------------------------------------------------------------------------- + +async def test_editors_with_company_no_pagination(fixtures): + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) + + result, count = await execute_count(graphene.Schema(query=Query), """ + query { + editors { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Penny", "Grant", "Dennis"] + assert count == 1 # no pagination: 1 aggregate with $lookup for company; count query skipped + + +async def test_articles_with_editor_and_company_no_pagination(fixtures): + class Query(graphene.ObjectType): + articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) + + result, count = await execute_count(graphene.Schema(query=Query), """ + query { + articles { + edges { + node { + headline + editor { + firstName + company { name } + } + } + } + } + } + """) + + assert not result.errors + assert count == 1 # no pagination: 1 aggregate with nested $lookups for editor and editor→company; count query skipped + + +async def test_articles_with_multiple_refs_no_pagination(fixtures): + class Query(graphene.ObjectType): + articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) + + result, count = await execute_count(graphene.Schema(query=Query), """ + query { + articles { + edges { + node { + headline + editor { + firstName + company { name } + } + reporter { firstName } + } + } + } + } + """) + + assert not result.errors + headlines = [e["node"]["headline"] for e in result.data["articles"]["edges"]] + assert set(headlines) == {"Hello", "World", "Bye"} + assert count == 1 # no pagination: 1 aggregate with $lookups for editor, editor→company, and reporter; count query skipped + + +async def test_players_with_self_referential_no_pagination(fixtures): + class Query(graphene.ObjectType): + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) + + result, count = await execute_count(graphene.Schema(query=Query), """ + query { + players { + edges { + node { + firstName + opponent { firstName } + } + } + } + } + """) + + assert not result.errors + magic = next( + e["node"] for e in result.data["players"]["edges"] + if e["node"]["firstName"] == "Magic" + ) + assert magic["opponent"]["firstName"] == "Michael" + assert count == 1 # no pagination: 1 aggregate with $lookup for opponent (self-referential join); count query skipped + + +async def test_editors_paginated_first(fixtures): + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) + + result, count = await execute_count(graphene.Schema(query=Query), """ + query { + editors(first: 2) { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Penny", "Grant"] + assert count == 2 # first:2 triggers pagination: 1 count + 1 aggregate with $lookup for company + + +async def test_editors_paginated_last(fixtures): + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) + + result, count = await execute_count(graphene.Schema(query=Query), """ + query { + editors(last: 1) { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Dennis"] + assert count == 2 # last:1 triggers pagination: 1 count + 1 aggregate with $lookup for company + + +async def test_editors_paginated_cursor_after(fixtures): + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) + + schema = graphene.Schema(query=Query) + cursor = offset_to_cursor(0) + + result, count = await execute_count(schema, f""" + query {{ + editors(first: 2, after: "{cursor}") {{ + edges {{ + node {{ + firstName + company {{ name }} + }} + }} + }} + }} + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Grant", "Dennis"] + assert count == 2 # first:2 with after cursor: 1 count + 1 aggregate with $lookup for company + + +async def test_articles_paginated_first_with_editor(fixtures): + class Query(graphene.ObjectType): + articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) + + result, count = await execute_count(graphene.Schema(query=Query), """ + query { + articles(first: 2) { + edges { + node { + headline + editor { + firstName + company { name } + } + } + } + } + } + """) + + assert not result.errors + assert len(result.data["articles"]["edges"]) == 2 + assert count == 2 # first:2 triggers pagination: 1 count + 1 aggregate with $lookups for editor and editor→company + + +# --------------------------------------------------------------------------- +# MongoDB projection tests — verify only requested + required fields are fetched +# --------------------------------------------------------------------------- + +from ..mongo_capture import captured_commands + + +async def test_projection_only_queried_fields(fixtures): + """Querying firstName only should project first_name, not last_name or avatar.""" + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) + + with captured_commands() as cap: + result = await graphene.Schema(query=Query).execute_async( + "query { editors { edges { node { firstName } } } }" + ) + + assert not result.errors + projected = cap.projected_fields() + assert "fname" in projected # first_name has db_field="fname" + assert "last_name" not in projected + assert "avatar" not in projected + + +async def test_projection_multiple_fields(fixtures): + """Querying firstName and lastName should project both but not avatar.""" + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) + + with captured_commands() as cap: + result = await graphene.Schema(query=Query).execute_async( + "query { editors { edges { node { firstName lastName } } } }" + ) + + assert not result.errors + projected = cap.projected_fields() + assert "fname" in projected # first_name has db_field="fname" + assert "last_name" in projected + assert "avatar" not in projected + + +async def test_projection_with_reference_field(fixtures): + """Querying a reference field projects only that reference on the parent document — not all parent fields.""" + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) + + with captured_commands() as cap: + result = await graphene.Schema(query=Query).execute_async(""" + query { + editors { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + projected = cap.projected_fields() + assert "fname" in projected # first_name has db_field="fname" + assert "last_name" not in projected + assert "company" in projected # company reference is projected (not all editor fields) + + +async def test_projection_list_reference_field(fixtures): + """articles (ListField(ReferenceField)) projects only the queried reporter fields; articles are + joined in the same aggregate via select_related — no separate find on test_article.""" + class Query(graphene.ObjectType): + reporters = AsyncMongoengineConnectionField(nodes.ReporterAsyncNode) + + with captured_commands() as cap: + result = await graphene.Schema(query=Query).execute_async(""" + query { + reporters { + edges { + node { + firstName + articles { + edges { node { headline } } + } + } + } + } + } + """) + + assert not result.errors + # Reporter aggregate projects only the queried reporter fields + reporter_projected = cap.projected_fields_for("test_reporter") + assert "first_name" in reporter_projected # queried reporter field + assert "articles" in reporter_projected # articles reference list is projected + assert "email" not in reporter_projected # unqueried reporter fields are excluded + assert "awards" not in reporter_projected + assert "generic_reference" not in reporter_projected + + # select_related joins articles via $lookup in the same aggregate — no separate find + assert len(cap.projected_fields_for("test_article")) == 0 + + +async def test_projection_generic_reference_field(fixtures): + """generic_reference (GenericReferenceField) is joined via select_related — 1 aggregate, no separate find.""" + class Query(graphene.ObjectType): + reporters = AsyncMongoengineConnectionField(nodes.ReporterAsyncNode) + + with captured_commands() as cap: + result = await graphene.Schema(query=Query).execute_async(""" + query { + reporters { + edges { + node { + firstName + genericReference { + __typename + ... on ArticleAsyncNode { headline } + } + } + } + } + } + """) + + assert not result.errors + reporter_projected = cap.projected_fields_for("test_reporter") + assert "first_name" in reporter_projected # queried reporter field + assert "generic_reference" in reporter_projected # GenericReferenceField is projected + assert "email" not in reporter_projected # unqueried reporter fields are excluded + assert "awards" not in reporter_projected + assert "articles" not in reporter_projected + + # select_related joins via $lookup in the same aggregate — no separate find + assert len(cap.projected_fields_for("test_article")) == 0 + + +async def test_projection_list_generic_reference_field(fixtures): + """generic_references (ListField(GenericReferenceField)) is joined via select_related — 1 aggregate, no separate find.""" + class Query(graphene.ObjectType): + reporters = AsyncMongoengineConnectionField(nodes.ReporterAsyncNode) + + with captured_commands() as cap: + result = await graphene.Schema(query=Query).execute_async(""" + query { + reporters { + edges { + node { + firstName + genericReferences { + __typename + ... on ArticleAsyncNode { headline } + } + } + } + } + } + """) + + assert not result.errors + reporter_projected = cap.projected_fields_for("test_reporter") + assert "first_name" in reporter_projected # queried reporter field + assert "generic_references" in reporter_projected # ListField(GenericReferenceField) is projected + assert "email" not in reporter_projected # unqueried reporter fields are excluded + assert "awards" not in reporter_projected + assert "articles" not in reporter_projected + + # select_related joins via $lookup in the same aggregate — no separate find + assert len(cap.projected_fields_for("test_article")) == 0 diff --git a/tests/asynchronous/test_select_related.py b/tests/asynchronous/test_select_related.py deleted file mode 100644 index 38b8bf02..00000000 --- a/tests/asynchronous/test_select_related.py +++ /dev/null @@ -1,262 +0,0 @@ -""" -Query-count tests verifying that the select_related + skip-count optimizations -eliminate N+1 queries and unnecessary count calls. - -Legend ------- -- No pagination args (first/last/before/after absent): - 1 query — single $aggregate with $lookup stages, count skipped -- Pagination args present: - 2 queries — 1 count + 1 $aggregate with $lookup stages -""" - -import graphene -import pytest -from graphql_relay.connection.array_connection import offset_to_cursor -from mongoengine.context_managers import async_query_counter - -from graphene_mongo import AsyncMongoengineConnectionField - -from . import nodes - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -async def _exec(schema, gql): - """Execute a query and capture the DB query count.""" - async with async_query_counter() as q: - result = await schema.execute_async(gql) - count = await q.int() - return result, count - - -# --------------------------------------------------------------------------- -# No-pagination tests: count must equal 1 -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_editors_with_company_no_pagination(fixtures): - """Single ReferenceField (editor → company): one aggregate, no count query.""" - - class Query(graphene.ObjectType): - editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) - - result, count = await _exec(graphene.Schema(query=Query), """ - query { - editors { - edges { - node { - firstName - company { name } - } - } - } - } - """) - - assert not result.errors - names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] - assert names == ["Penny", "Grant", "Dennis"] - assert count == 1 # 1 aggregate($lookup company) — count skipped - - -@pytest.mark.asyncio -async def test_articles_with_editor_and_company_no_pagination(fixtures): - """Two-level nesting (article → editor → company): still one aggregate.""" - - class Query(graphene.ObjectType): - articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) - - result, count = await _exec(graphene.Schema(query=Query), """ - query { - articles { - edges { - node { - headline - editor { - firstName - company { name } - } - } - } - } - } - """) - - assert not result.errors - assert count == 1 # 1 aggregate($lookup editor, $lookup editor.company) - - -@pytest.mark.asyncio -async def test_articles_with_multiple_refs_no_pagination(fixtures): - """Two sibling ReferenceFields (editor → company, reporter) in one aggregate.""" - - class Query(graphene.ObjectType): - articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) - - result, count = await _exec(graphene.Schema(query=Query), """ - query { - articles { - edges { - node { - headline - editor { - firstName - company { name } - } - reporter { firstName } - } - } - } - } - """) - - assert not result.errors - headlines = [e["node"]["headline"] for e in result.data["articles"]["edges"]] - assert set(headlines) == {"Hello", "World", "Bye"} - assert count == 1 # select_related("editor", "editor__company", "reporter") - - -@pytest.mark.asyncio -async def test_players_with_self_referential_no_pagination(fixtures): - """Self-referential ReferenceField (player → opponent): one aggregate.""" - - class Query(graphene.ObjectType): - players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) - - result, count = await _exec(graphene.Schema(query=Query), """ - query { - players { - edges { - node { - firstName - opponent { firstName } - } - } - } - } - """) - - assert not result.errors - magic = next( - e["node"] for e in result.data["players"]["edges"] - if e["node"]["firstName"] == "Magic" - ) - assert magic["opponent"]["firstName"] == "Michael" - assert count == 1 # select_related("opponent") — count skipped - - -# --------------------------------------------------------------------------- -# Pagination tests: count must equal 2 (1 count + 1 aggregate) -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_editors_paginated_first(fixtures): - """`first` triggers count + aggregate — still no per-editor company fetches.""" - - class Query(graphene.ObjectType): - editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) - - result, count = await _exec(graphene.Schema(query=Query), """ - query { - editors(first: 2) { - edges { - node { - firstName - company { name } - } - } - } - } - """) - - assert not result.errors - names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] - assert names == ["Penny", "Grant"] - assert count == 2 # 1 count + 1 aggregate($lookup company) - - -@pytest.mark.asyncio -async def test_editors_paginated_last(fixtures): - """`last` triggers count + aggregate.""" - - class Query(graphene.ObjectType): - editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) - - result, count = await _exec(graphene.Schema(query=Query), """ - query { - editors(last: 1) { - edges { - node { - firstName - company { name } - } - } - } - } - """) - - assert not result.errors - names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] - assert names == ["Dennis"] - assert count == 2 # 1 count + 1 aggregate - - -@pytest.mark.asyncio -async def test_editors_paginated_cursor_after(fixtures): - """`first` + `after` cursor triggers count + aggregate.""" - - class Query(graphene.ObjectType): - editors = AsyncMongoengineConnectionField(nodes.EditorAsyncNode) - - schema = graphene.Schema(query=Query) - # cursor at position 0 → after it means from position 1 onward - cursor = offset_to_cursor(0) - - result, count = await _exec(schema, f""" - query {{ - editors(first: 2, after: "{cursor}") {{ - edges {{ - node {{ - firstName - company {{ name }} - }} - }} - }} - }} - """) - - assert not result.errors - names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] - assert names == ["Grant", "Dennis"] - assert count == 2 # 1 count + 1 aggregate - - -@pytest.mark.asyncio -async def test_articles_paginated_first_with_editor(fixtures): - """`first` on articles with nested editor ref: count + one aggregate.""" - - class Query(graphene.ObjectType): - articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) - - result, count = await _exec(graphene.Schema(query=Query), """ - query { - articles(first: 2) { - edges { - node { - headline - editor { - firstName - company { name } - } - } - } - } - } - """) - - assert not result.errors - assert len(result.data["articles"]["edges"]) == 2 - assert count == 2 # 1 count + 1 aggregate($lookup editor, $lookup editor.company) \ No newline at end of file diff --git a/tests/asynchronous/test_types.py b/tests/asynchronous/test_types.py index 3147b1db..2d988b45 100644 --- a/tests/asynchronous/test_types.py +++ b/tests/asynchronous/test_types.py @@ -202,7 +202,6 @@ def test_filter_list_types(): ListField(EmbeddedDocumentListField(...)) ListField(GenericEmbeddedDocumentField(...)) - ListField(GenericLazyReferenceField(...)) """ class ExamType(AsyncMongoengineObjectType): diff --git a/tests/asynchronous/utils.py b/tests/asynchronous/utils.py index 8a49ae17..656635e0 100644 --- a/tests/asynchronous/utils.py +++ b/tests/asynchronous/utils.py @@ -1,3 +1,5 @@ +from mongoengine.context_managers import async_query_counter + from graphene_mongo import registry @@ -14,4 +16,12 @@ def inner(*args, **kwargs): registry.async_registry = old return retval - return inner \ No newline at end of file + return inner + + +async def execute_count(schema, query, **kwargs): + """Execute a GraphQL query and return (result, query_count).""" + async with async_query_counter() as q: + result = await schema.execute_async(query, **kwargs) + count = await q.int() + return result, count \ No newline at end of file diff --git a/tests/mongo_capture.py b/tests/mongo_capture.py new file mode 100644 index 00000000..c0fc2034 --- /dev/null +++ b/tests/mongo_capture.py @@ -0,0 +1,78 @@ +""" +Pymongo command capture for projection/query assertion in tests. + +Register the listener at import time (before mongoengine.connect creates +the MongoClient) so every wire command is captured. +""" +import pymongo.monitoring as _pm + + +class _CommandCapture(_pm.CommandListener): + def __init__(self): + self._log: list = [] + + def started(self, event) -> None: + self._log.append(event) + + def succeeded(self, event) -> None: + pass + + def failed(self, event) -> None: + pass + + +_capture = _CommandCapture() +_pm.register(_capture) + + +class captured_commands: + """Context manager — yields itself; after the block, `.events` holds all + pymongo StartedEvent objects issued inside the block.""" + + def __enter__(self): + self._start = len(_capture._log) + return self + + def __exit__(self, *_): + self.events = _capture._log[self._start:] + + def projected_fields(self) -> set[str]: + """ + Return every field name that was positively projected across all + `find` and `aggregate` commands captured inside the block. + + - find: reads cmd['projection'] + - aggregate: reads every {'$project': ...} stage in cmd['pipeline'] + """ + fields: set[str] = set() + for event in self.events: + cmd = event.command + if event.command_name == "find": + fields.update(k for k, v in cmd.get("projection", {}).items() if v) + elif event.command_name == "aggregate": + for stage in cmd.get("pipeline", []): + if "$project" in stage: + fields.update( + k for k, v in stage["$project"].items() if v + ) + return fields + + def projected_fields_for(self, collection: str) -> set[str]: + """ + Return every positively projected field name for a specific MongoDB collection. + + Useful for asserting that fetched reference/generic-reference documents + project only the fields requested in the GraphQL query, not all fields. + """ + fields: set[str] = set() + for event in self.events: + cmd = event.command + if event.command_name == "find" and cmd.get("find") == collection: + fields.update(k for k, v in cmd.get("projection", {}).items() if v) + elif event.command_name == "aggregate" and cmd.get("aggregate") == collection: + for stage in cmd.get("pipeline", []): + if "$project" in stage: + fields.update( + k for k, v in stage["$project"].items() if v + ) + return fields \ No newline at end of file diff --git a/tests/synchronous/test_fields.py b/tests/synchronous/test_fields.py index 80cbe352..7fe8c558 100644 --- a/tests/synchronous/test_fields.py +++ b/tests/synchronous/test_fields.py @@ -1,4 +1,5 @@ import pytest +from mongoengine.context_managers import query_counter from . import nodes from graphene_mongo import AsyncMongoengineConnectionField diff --git a/tests/synchronous/test_inputs.py b/tests/synchronous/test_inputs.py index 2a6930ec..520e8498 100644 --- a/tests/synchronous/test_inputs.py +++ b/tests/synchronous/test_inputs.py @@ -1,12 +1,11 @@ import graphene -import pytest from graphene.relay import Node from ..models import Article, Editor from .nodes import ArticleNode, EditorNode from ..types import ArticleInput, EditorInput - +from .utils import execute_count def test_should_create(fixtures): @@ -41,10 +40,10 @@ class Mutation(graphene.ObjectType): """ expected = {"createArticle": {"article": {"headline": "My Article"}}} schema = graphene.Schema(query=Query, mutation=Mutation) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 def test_should_update(fixtures): @@ -56,11 +55,9 @@ class Arguments: editor = graphene.Field(EditorNode) def mutate(self, info, id, editor): - editor_to_update = Editor.objects.get(id=id) - for key, value in editor.items(): - if value: - setattr(editor_to_update, key, value) - editor_to_update.save() + editor_to_update = Editor.objects(id=id).modify( + new=True, **{f"set__{k}": v for k, v in editor.items() if v} + ) return UpdateEditor(editor=editor_to_update) class Query(graphene.ObjectType): @@ -86,6 +83,7 @@ class Mutation(graphene.ObjectType): """ expected = {"updateEditor": {"editor": {"firstName": "Penny", "lastName": "Lane"}}} schema = graphene.Schema(query=Query, mutation=Mutation) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 \ No newline at end of file diff --git a/tests/synchronous/test_mutation.py b/tests/synchronous/test_mutation.py index 14e11cad..0aa35c74 100644 --- a/tests/synchronous/test_mutation.py +++ b/tests/synchronous/test_mutation.py @@ -5,7 +5,7 @@ from ..models import Article, Editor from .nodes import ArticleNode, EditorNode - +from .utils import execute_count def test_should_create(fixtures): @@ -40,10 +40,10 @@ class Mutation(graphene.ObjectType): """ expected = {"createArticle": {"article": {"headline": "My Article"}}} schema = graphene.Schema(query=Query, mutation=Mutation) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count >= 1 def test_should_update(fixtures): @@ -80,6 +80,7 @@ class Mutation(graphene.ObjectType): """ expected = {"updateEditor": {"editor": {"firstName": "Tony"}}} schema = graphene.Schema(query=Query, mutation=Mutation) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected + assert count >= 1 \ No newline at end of file diff --git a/tests/synchronous/test_query.py b/tests/synchronous/test_query.py index 2aa8d7f8..673c4759 100644 --- a/tests/synchronous/test_query.py +++ b/tests/synchronous/test_query.py @@ -7,7 +7,7 @@ from .. import models from .. import types - +from .utils import execute_count def test_should_query_editor(fixtures, fixtures_dirname): @@ -67,12 +67,12 @@ def resolve_editors(self, *args, **kwargs): expected_metadata = {"age": "20", "nickname": "$1"} schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors metadata = result.data["editor"].pop("metadata") assert json.loads(metadata) == expected_metadata assert result.data == expected - + assert count >= 1 def test_should_query_reporter(fixtures): @@ -114,10 +114,10 @@ def resolve_reporter(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 def test_should_custom_kwargs(fixtures): @@ -145,10 +145,10 @@ def resolve_editors(self, *args, **kwargs): ] } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 def test_should_self_reference(fixtures): @@ -192,10 +192,10 @@ def resolve_all_players(self, *args, **kwargs): ] } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count >= 1 def test_should_query_with_embedded_document(fixtures): @@ -218,10 +218,10 @@ def resolve_professor_vector(self, info, id): expected = {"professorVector": {"vec": [1.0, 2.3], "metadata": {"firstName": "Steven"}}} schema = graphene.Schema(query=Query, types=[types.ProfessorVectorType]) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 def test_should_query_child(fixtures): @@ -255,10 +255,10 @@ def resolve_children(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 def test_should_query_other_childs(fixtures): @@ -292,10 +292,10 @@ def resolve_children(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 def test_should_query_all_childs(fixtures): @@ -328,22 +328,6 @@ def resolve_children(self, *args, **kwargs): } } """ - expected = { - "children": [ - {"bar": "BAR", "baz": "BAZ", "loc": None}, - { - "bar": "bar", - "baz": "baz", - "loc": {"type": "Point", "coordinates": [10.0, 20.0]}, - }, - {"bar": "BAR", "qux": "QUX", "loc": None}, - { - "bar": "bar", - "qux": "qux", - "loc": {"type": "Point", "coordinates": [20, 10]}, - }, - ] - } expected = { "children": [ {"bar": "BAR", "qux": "QUX", "loc": None}, @@ -368,10 +352,10 @@ def resolve_children(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 def test_should_query_cell_tower(fixtures): @@ -433,6 +417,7 @@ def resolve_cell_towers(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 \ No newline at end of file diff --git a/tests/synchronous/test_relay_query.py b/tests/synchronous/test_relay_query.py index 360c855e..b4ad9153 100644 --- a/tests/synchronous/test_relay_query.py +++ b/tests/synchronous/test_relay_query.py @@ -5,13 +5,14 @@ import graphene import pytest from graphene.relay import Node +from graphql_relay.connection.array_connection import offset_to_cursor from graphql_relay.node.node import to_global_id from .. import models from . import nodes from graphene_mongo.synchronous.fields import MongoengineConnectionField from graphene_mongo.synchronous.types import MongoengineObjectType - +from .utils import execute_count def test_should_query_reporter(fixtures): @@ -19,7 +20,7 @@ class Query(graphene.ObjectType): reporter = graphene.Field(nodes.ReporterNode) def resolve_reporter(self, *args, **kwargs): - return models.Reporter.objects.first() + return models.Reporter.objects.select_related("articles", "generic_reference").first() query = """ query ReporterQuery { @@ -87,10 +88,10 @@ def resolve_reporter(self, *args, **kwargs): } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 # select_related fetches reporter + articles + generic_reference in a single aggregate def test_should_query_reporters_with_nested_document(fixtures): @@ -133,10 +134,10 @@ class Query(graphene.ObjectType): } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 # 1 reporters aggregate; article filter (headline="Hello") pushed into $lookup sub-pipeline by MongoEngine def test_should_query_all_editors(fixtures, fixtures_dirname): @@ -201,74 +202,10 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) - assert not result.errors - assert result.data == expected - - - -def test_should_query_editors_with_dataloader(fixtures): - from promise import Promise - from promise.dataloader import DataLoader - - class ArticleLoader(DataLoader): - def batch_load_fn(self, instances): - queryset = models.Article.objects(editor__in=instances) - return Promise.resolve( - [[a for a in queryset if a.editor.id == instance.id] for instance in instances] - ) - - article_loader = ArticleLoader() - - class _EditorNode(MongoengineObjectType): - class Meta: - model = models.Editor - interfaces = (graphene.Node,) - - articles = MongoengineConnectionField(nodes.ArticleNode) - - def resolve_articles(self, *args, **kwargs): - return article_loader.load(self) - - class Query(graphene.ObjectType): - editors = MongoengineConnectionField(_EditorNode) - - query = """ - query EditorPromiseConnectionQuery { - editors(first: 1) { - edges { - node { - firstName, - articles(first: 1) { - edges { - node { - headline - } - } - } - } - } - } - } - """ - - expected = { - "editors": { - "edges": [ - { - "node": { - "firstName": "Penny", - "articles": {"edges": [{"node": {"headline": "Hello"}}]}, - } - } - ] - } - } - schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count >= 1 # 1 editors query; GridFS avatar reads may add extra queries def test_should_filter_editors_by_id(fixtures): @@ -302,10 +239,10 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 # single find filtered by relay-decoded _id def test_should_filter(fixtures): @@ -341,10 +278,10 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 # 1 aggregate with $match on headline; editor ReferenceField resolved via $lookup in the same query def test_should_filter_by_reference_field(fixtures): @@ -369,10 +306,10 @@ class Query(graphene.ObjectType): "articles": {"edges": [{"node": {"headline": "Hello", "editor": {"firstName": "Penny"}}}]} } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 # 1 aggregate with $match on editor _id; editor ReferenceField resolved via $lookup def test_should_filter_through_inheritance(fixtures): @@ -410,10 +347,10 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 # 1 aggregate with $match on bar; inherited Child collection queried once def test_should_filter_by_list_contains(fixtures): @@ -460,10 +397,10 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 # 1 reporters aggregate with genericReferences joined via select_related $lookup def test_should_filter_by_id(fixtures): @@ -488,10 +425,10 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 # Node.Field by relay ID resolves to a single _id lookup def test_should_first_n(fixtures): @@ -531,11 +468,11 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 2 # first:2 triggers pagination: 1 count query (for hasNextPage) + 1 find query (sliced results) def test_should_after(fixtures): @@ -564,11 +501,11 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 2 # after cursor triggers pagination: 1 count + 1 find starting from the cursor offset def test_should_before(fixtures): @@ -599,11 +536,11 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 2 # before cursor triggers pagination: 1 count + 1 find truncated before the cursor def test_should_last_n(fixtures): @@ -631,11 +568,11 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 2 # last:2 triggers pagination: 1 count (to compute tail offset) + 1 find from the end def test_should_self_reference(fixtures): @@ -707,10 +644,10 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count >= 1 # 1 players query + 1 sub-query per player for the nested players connection field def test_should_lazy_reference(fixtures): @@ -764,10 +701,10 @@ class Query(graphene.ObjectType): } } - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count >= 1 # 1 parents query + extra queries to dereference each lazy beforeChild/afterChild relationship def test_should_query_with_embedded_document(fixtures): @@ -794,10 +731,10 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 # 1 professors query; metadata is an EmbeddedDocument so no extra query needed def test_should_get_queryset_returns_dict_filters(fixtures): @@ -836,10 +773,10 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count == 1 # dict-based get_queryset applies $match; editor ReferenceField resolved via $lookup in 1 aggregate def test_should_get_queryset_returns_qs_filters(fixtures): @@ -879,10 +816,10 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count >= 1 # custom queryset-based get_queryset; 1 aggregate expected but bound is loose for safety def test_should_filter_mongoengine_queryset(fixtures): @@ -909,11 +846,11 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert json.dumps(result.data, sort_keys=True) == json.dumps(expected, sort_keys=True) - + assert count == 1 # 1 aggregate with case-insensitive startswith filter; no pagination def test_should_query_document_with_embedded(fixtures): @@ -942,9 +879,9 @@ def resolve_multiple_foos(self, *args, **kwargs): """ schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors - + assert count >= 1 # 1 foos query; bars is EmbeddedDocumentListField so no extra query, data is in the document def test_should_filter_mongoengine_queryset_with_list(fixtures): @@ -971,11 +908,11 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert json.dumps(result.data, sort_keys=True) == json.dumps(expected, sort_keys=True) - + assert count == 1 # 1 aggregate with $in filter on firstName; no pagination def test_should_get_correct_list_of_documents(fixtures): @@ -1026,11 +963,11 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - + assert count >= 1 # 1 players find + paginated articles sub-queries per player (first:3 triggers count+find each) def test_should_filter_mongoengine_queryset_by_id_and_other_fields(fixtures): @@ -1063,7 +1000,378 @@ class Query(graphene.ObjectType): } } schema = graphene.Schema(query=Query) - result = schema.execute(query) + result, count = execute_count(schema, query) assert not result.errors assert json.dumps(result.data, sort_keys=True) == json.dumps(expected, sort_keys=True) + assert count == 1 # conflicting id+firstName filters produce an empty result; still only 1 query + +# --------------------------------------------------------------------------- +# N+1 / query-count tests +# --------------------------------------------------------------------------- + +def test_editors_with_company_no_pagination(fixtures): + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(nodes.EditorNode) + + result, count = execute_count(graphene.Schema(query=Query), """ + query { + editors { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Penny", "Grant", "Dennis"] + assert count == 1 # no pagination: 1 aggregate with $lookup for company; count query skipped + + +def test_articles_with_editor_and_company_no_pagination(fixtures): + class Query(graphene.ObjectType): + articles = MongoengineConnectionField(nodes.ArticleNode) + + result, count = execute_count(graphene.Schema(query=Query), """ + query { + articles { + edges { + node { + headline + editor { + firstName + company { name } + } + } + } + } + } + """) + + assert not result.errors + assert count == 1 # no pagination: 1 aggregate with nested $lookups for editor and editor→company; count query skipped + + +def test_articles_with_multiple_refs_no_pagination(fixtures): + class Query(graphene.ObjectType): + articles = MongoengineConnectionField(nodes.ArticleNode) + + result, count = execute_count(graphene.Schema(query=Query), """ + query { + articles { + edges { + node { + headline + editor { + firstName + company { name } + } + reporter { firstName } + } + } + } + } + """) + + assert not result.errors + headlines = [e["node"]["headline"] for e in result.data["articles"]["edges"]] + assert set(headlines) == {"Hello", "World", "Bye"} + assert count == 1 # no pagination: 1 aggregate with $lookups for editor, editor→company, and reporter; count query skipped + + +def test_players_with_self_referential_no_pagination(fixtures): + class Query(graphene.ObjectType): + players = MongoengineConnectionField(nodes.PlayerNode) + + result, count = execute_count(graphene.Schema(query=Query), """ + query { + players { + edges { + node { + firstName + opponent { firstName } + } + } + } + } + """) + + assert not result.errors + magic = next( + e["node"] for e in result.data["players"]["edges"] + if e["node"]["firstName"] == "Magic" + ) + assert magic["opponent"]["firstName"] == "Michael" + assert count == 1 # no pagination: 1 aggregate with $lookup for opponent (self-referential join); count query skipped + + +def test_editors_paginated_first(fixtures): + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(nodes.EditorNode) + + result, count = execute_count(graphene.Schema(query=Query), """ + query { + editors(first: 2) { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Penny", "Grant"] + assert count == 2 # first:2 triggers pagination: 1 count + 1 aggregate with $lookup for company + + +def test_editors_paginated_last(fixtures): + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(nodes.EditorNode) + + result, count = execute_count(graphene.Schema(query=Query), """ + query { + editors(last: 1) { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Dennis"] + assert count == 2 # last:1 triggers pagination: 1 count + 1 aggregate with $lookup for company + + +def test_editors_paginated_cursor_after(fixtures): + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(nodes.EditorNode) + + schema = graphene.Schema(query=Query) + cursor = offset_to_cursor(0) + + result, count = execute_count(schema, f""" + query {{ + editors(first: 2, after: "{cursor}") {{ + edges {{ + node {{ + firstName + company {{ name }} + }} + }} + }} + }} + """) + + assert not result.errors + names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] + assert names == ["Grant", "Dennis"] + assert count == 2 # first:2 with after cursor: 1 count + 1 aggregate with $lookup for company + + +def test_articles_paginated_first_with_editor(fixtures): + class Query(graphene.ObjectType): + articles = MongoengineConnectionField(nodes.ArticleNode) + + result, count = execute_count(graphene.Schema(query=Query), """ + query { + articles(first: 2) { + edges { + node { + headline + editor { + firstName + company { name } + } + } + } + } + } + """) + + assert not result.errors + assert len(result.data["articles"]["edges"]) == 2 + assert count == 2 # first:2 triggers pagination: 1 count + 1 aggregate with $lookups for editor and editor→company + + +# --------------------------------------------------------------------------- +# MongoDB projection tests — verify only requested + required fields are fetched +# --------------------------------------------------------------------------- + +from ..mongo_capture import captured_commands + + +def test_projection_only_queried_fields(fixtures): + """Querying firstName only should project first_name, not last_name or avatar.""" + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(nodes.EditorNode) + + with captured_commands() as cap: + result = graphene.Schema(query=Query).execute( + "query { editors { edges { node { firstName } } } }" + ) + + assert not result.errors + projected = cap.projected_fields() + assert "fname" in projected # first_name has db_field="fname" + assert "last_name" not in projected + assert "avatar" not in projected + + +def test_projection_multiple_fields(fixtures): + """Querying firstName and lastName should project both but not avatar.""" + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(nodes.EditorNode) + + with captured_commands() as cap: + result = graphene.Schema(query=Query).execute( + "query { editors { edges { node { firstName lastName } } } }" + ) + + assert not result.errors + projected = cap.projected_fields() + assert "fname" in projected # first_name has db_field="fname" + assert "last_name" in projected + assert "avatar" not in projected + + +def test_projection_with_reference_field(fixtures): + """Querying a reference field projects only that reference on the parent document — not all parent fields.""" + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(nodes.EditorNode) + + with captured_commands() as cap: + result = graphene.Schema(query=Query).execute(""" + query { + editors { + edges { + node { + firstName + company { name } + } + } + } + } + """) + + assert not result.errors + projected = cap.projected_fields() + assert "fname" in projected # first_name has db_field="fname" + assert "last_name" not in projected + assert "company" in projected # company reference is projected (not all editor fields) + + +def test_projection_list_reference_field(fixtures): + """articles (ListField(ReferenceField)) projects only the queried reporter fields; articles are + joined in the same aggregate via select_related — no separate find on test_article.""" + class Query(graphene.ObjectType): + reporters = MongoengineConnectionField(nodes.ReporterNode) + + with captured_commands() as cap: + result = graphene.Schema(query=Query).execute(""" + query { + reporters { + edges { + node { + firstName + articles { + edges { node { headline } } + } + } + } + } + } + """) + + assert not result.errors + # Reporter aggregate projects only the queried reporter fields + reporter_projected = cap.projected_fields_for("test_reporter") + assert "first_name" in reporter_projected # queried reporter field + assert "articles" in reporter_projected # articles reference list is projected + assert "email" not in reporter_projected # unqueried reporter fields are excluded + assert "awards" not in reporter_projected + assert "generic_reference" not in reporter_projected + + # select_related joins articles via $lookup in the same aggregate — no separate find + assert len(cap.projected_fields_for("test_article")) == 0 + + +def test_projection_generic_reference_field(fixtures): + """generic_reference (GenericReferenceField) is joined via select_related — 1 aggregate, no separate find.""" + class Query(graphene.ObjectType): + reporters = MongoengineConnectionField(nodes.ReporterNode) + + with captured_commands() as cap: + result = graphene.Schema(query=Query).execute(""" + query { + reporters { + edges { + node { + firstName + genericReference { + __typename + ... on ArticleNode { headline } + } + } + } + } + } + """) + + assert not result.errors + reporter_projected = cap.projected_fields_for("test_reporter") + assert "first_name" in reporter_projected # queried reporter field + assert "generic_reference" in reporter_projected # GenericReferenceField is projected + assert "email" not in reporter_projected # unqueried reporter fields are excluded + assert "awards" not in reporter_projected + assert "articles" not in reporter_projected + + # select_related joins via $lookup in the same aggregate — no separate find + assert len(cap.projected_fields_for("test_article")) == 0 + + +def test_projection_list_generic_reference_field(fixtures): + """generic_references (ListField(GenericReferenceField)) is joined via select_related — 1 aggregate, no separate find.""" + class Query(graphene.ObjectType): + reporters = MongoengineConnectionField(nodes.ReporterNode) + + with captured_commands() as cap: + result = graphene.Schema(query=Query).execute(""" + query { + reporters { + edges { + node { + firstName + genericReferences { + __typename + ... on ArticleNode { headline } + } + } + } + } + } + """) + + assert not result.errors + reporter_projected = cap.projected_fields_for("test_reporter") + assert "first_name" in reporter_projected # queried reporter field + assert "generic_references" in reporter_projected # ListField(GenericReferenceField) is projected + assert "email" not in reporter_projected # unqueried reporter fields are excluded + assert "awards" not in reporter_projected + assert "articles" not in reporter_projected + + # select_related joins via $lookup in the same aggregate — no separate find + assert len(cap.projected_fields_for("test_article")) == 0 diff --git a/tests/synchronous/test_select_related.py b/tests/synchronous/test_select_related.py deleted file mode 100644 index c6234382..00000000 --- a/tests/synchronous/test_select_related.py +++ /dev/null @@ -1,252 +0,0 @@ -""" -Query-count tests verifying that the select_related + skip-count optimizations -eliminate N+1 queries and unnecessary count calls. - -Legend ------- -- No pagination args (first/last/before/after absent): - 1 query — single $aggregate with $lookup stages, count skipped -- Pagination args present: - 2 queries — 1 count + 1 $aggregate with $lookup stages -""" - -import graphene -from graphql_relay.connection.array_connection import offset_to_cursor -from mongoengine.context_managers import query_counter - -from graphene_mongo import MongoengineConnectionField - -from . import nodes - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _exec(schema, gql): - """Execute a query and capture the DB query count.""" - with query_counter() as q: - result = schema.execute(gql) - count = int(q) - return result, count - - -# --------------------------------------------------------------------------- -# No-pagination tests: count must equal 1 -# --------------------------------------------------------------------------- - -def test_editors_with_company_no_pagination(fixtures): - """Single ReferenceField (editor → company): one aggregate, no count query.""" - - class Query(graphene.ObjectType): - editors = MongoengineConnectionField(nodes.EditorNode) - - result, count = _exec(graphene.Schema(query=Query), """ - query { - editors { - edges { - node { - firstName - company { name } - } - } - } - } - """) - - assert not result.errors - names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] - assert names == ["Penny", "Grant", "Dennis"] - assert count == 1 # 1 aggregate($lookup company) — count skipped - - -def test_articles_with_editor_and_company_no_pagination(fixtures): - """Two-level nesting (article → editor → company): still one aggregate.""" - - class Query(graphene.ObjectType): - articles = MongoengineConnectionField(nodes.ArticleNode) - - result, count = _exec(graphene.Schema(query=Query), """ - query { - articles { - edges { - node { - headline - editor { - firstName - company { name } - } - } - } - } - } - """) - - assert not result.errors - assert count == 1 # 1 aggregate($lookup editor, $lookup editor.company) - - -def test_articles_with_multiple_refs_no_pagination(fixtures): - """Two sibling ReferenceFields (editor → company, reporter) in one aggregate.""" - - class Query(graphene.ObjectType): - articles = MongoengineConnectionField(nodes.ArticleNode) - - result, count = _exec(graphene.Schema(query=Query), """ - query { - articles { - edges { - node { - headline - editor { - firstName - company { name } - } - reporter { firstName } - } - } - } - } - """) - - assert not result.errors - headlines = [e["node"]["headline"] for e in result.data["articles"]["edges"]] - assert set(headlines) == {"Hello", "World", "Bye"} - assert count == 1 # select_related("editor", "editor__company", "reporter") - - -def test_players_with_self_referential_no_pagination(fixtures): - """Self-referential ReferenceField (player → opponent): one aggregate.""" - - class Query(graphene.ObjectType): - players = MongoengineConnectionField(nodes.PlayerNode) - - result, count = _exec(graphene.Schema(query=Query), """ - query { - players { - edges { - node { - firstName - opponent { firstName } - } - } - } - } - """) - - assert not result.errors - magic = next( - e["node"] for e in result.data["players"]["edges"] - if e["node"]["firstName"] == "Magic" - ) - assert magic["opponent"]["firstName"] == "Michael" - assert count == 1 # select_related("opponent") — count skipped - - -# --------------------------------------------------------------------------- -# Pagination tests: count must equal 2 (1 count + 1 aggregate) -# --------------------------------------------------------------------------- - -def test_editors_paginated_first(fixtures): - """`first` triggers count + aggregate — still no per-editor company fetches.""" - - class Query(graphene.ObjectType): - editors = MongoengineConnectionField(nodes.EditorNode) - - result, count = _exec(graphene.Schema(query=Query), """ - query { - editors(first: 2) { - edges { - node { - firstName - company { name } - } - } - } - } - """) - - assert not result.errors - names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] - assert names == ["Penny", "Grant"] - assert count == 2 # 1 count + 1 aggregate($lookup company) - - -def test_editors_paginated_last(fixtures): - """`last` triggers count + aggregate.""" - - class Query(graphene.ObjectType): - editors = MongoengineConnectionField(nodes.EditorNode) - - result, count = _exec(graphene.Schema(query=Query), """ - query { - editors(last: 1) { - edges { - node { - firstName - company { name } - } - } - } - } - """) - - assert not result.errors - names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] - assert names == ["Dennis"] - assert count == 2 # 1 count + 1 aggregate - - -def test_editors_paginated_cursor_after(fixtures): - """`first` + `after` cursor triggers count + aggregate.""" - - class Query(graphene.ObjectType): - editors = MongoengineConnectionField(nodes.EditorNode) - - schema = graphene.Schema(query=Query) - cursor = offset_to_cursor(0) # cursor at position 0 → after it = from position 1 - - result, count = _exec(schema, f""" - query {{ - editors(first: 2, after: "{cursor}") {{ - edges {{ - node {{ - firstName - company {{ name }} - }} - }} - }} - }} - """) - - assert not result.errors - names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] - assert names == ["Grant", "Dennis"] - assert count == 2 # 1 count + 1 aggregate - - -def test_articles_paginated_first_with_editor(fixtures): - """`first` on articles with nested editor ref: count + one aggregate.""" - - class Query(graphene.ObjectType): - articles = MongoengineConnectionField(nodes.ArticleNode) - - result, count = _exec(graphene.Schema(query=Query), """ - query { - articles(first: 2) { - edges { - node { - headline - editor { - firstName - company { name } - } - } - } - } - } - """) - - assert not result.errors - assert len(result.data["articles"]["edges"]) == 2 - assert count == 2 # 1 count + 1 aggregate($lookup editor, $lookup editor.company) \ No newline at end of file diff --git a/tests/synchronous/test_types.py b/tests/synchronous/test_types.py index d8b65ade..c3ca00b4 100644 --- a/tests/synchronous/test_types.py +++ b/tests/synchronous/test_types.py @@ -200,7 +200,6 @@ def test_filter_list_types(): ListField(EmbeddedDocumentListField(...)) ListField(GenericEmbeddedDocumentField(...)) - ListField(GenericLazyReferenceField(...)) """ class ExamType(MongoengineObjectType): diff --git a/tests/synchronous/utils.py b/tests/synchronous/utils.py index 24a37f01..a24a16ec 100644 --- a/tests/synchronous/utils.py +++ b/tests/synchronous/utils.py @@ -1,3 +1,5 @@ +from mongoengine.context_managers import query_counter + from graphene_mongo import registry @@ -15,3 +17,11 @@ def inner(*args, **kwargs): return retval return inner + + +def execute_count(schema, query, **kwargs): + """Execute a GraphQL query and return (result, query_count).""" + with query_counter() as q: + result = schema.execute(query, **kwargs) + count = int(q) + return result, count From accaefd359b89a57e9d05ef743ac40f97ebf32e2 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sat, 23 May 2026 15:23:19 +0530 Subject: [PATCH 17/52] perf: skip count_documents when pageInfo is not requested Count query is only needed when `last` is used (tail-offset computation) or when `pageInfo` is explicitly queried (hasNextPage/hasPreviousPage). For first/after/before without pageInfo the count is now skipped, reducing those queries from 2 to 1. Also tighten all count >= 1 assertions to exact values across mutation, query, and relay_query tests, and add explicit tests for each count-query boundary (after+pageInfo, before+pageInfo, first-without-pageInfo). Fix redundant collection meta on Child/AnotherChild subclasses (removed to silence MongoEngine SyntaxWarning). --- graphene_mongo/asynchronous/fields.py | 9 ++- graphene_mongo/synchronous/fields.py | 21 ++--- tests/asynchronous/test_mutation.py | 4 +- tests/asynchronous/test_query.py | 4 +- tests/asynchronous/test_relay_query.py | 107 ++++++++++++++++++++++--- tests/models.py | 2 - tests/synchronous/test_mutation.py | 4 +- tests/synchronous/test_query.py | 4 +- tests/synchronous/test_relay_query.py | 107 ++++++++++++++++++++++--- 9 files changed, 218 insertions(+), 44 deletions(-) diff --git a/graphene_mongo/asynchronous/fields.py b/graphene_mongo/asynchronous/fields.py index 94f7b6bf..3e9cfa5b 100644 --- a/graphene_mongo/asynchronous/fields.py +++ b/graphene_mongo/asynchronous/fields.py @@ -289,10 +289,13 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non self.model, info, required_fields, **args ) else: - count = await self.model.aobjects(**args_copy).count() - if count != 0: + needs_count = last is not None or requires_page_info + if needs_count: + count = await self.model.aobjects(**args_copy).count() + if not needs_count or count != 0: skip, limit = find_skip_and_limit( - first=first, after=after, last=last, before=before, count=count + first=first, after=after, last=last, before=before, + count=count if needs_count else None, ) iterables = self.get_queryset( self.model, info, required_fields, skip, limit, **args diff --git a/graphene_mongo/synchronous/fields.py b/graphene_mongo/synchronous/fields.py index 5ce7b135..95933b26 100644 --- a/graphene_mongo/synchronous/fields.py +++ b/graphene_mongo/synchronous/fields.py @@ -489,17 +489,20 @@ def default_resolver(self, _root, info, required_fields=None, resolved=None, **a self.model, info, required_fields, **args ) else: - if PYMONGO_VERSION >= (3, 7): - if hasattr(self.model, "_meta") and "db_alias" in self.model._meta: - db = mongoengine.get_db(self.model._meta["db_alias"]) + needs_count = last is not None or requires_page_info + if needs_count: + if PYMONGO_VERSION >= (3, 7): + if hasattr(self.model, "_meta") and "db_alias" in self.model._meta: + db = mongoengine.get_db(self.model._meta["db_alias"]) + else: + db = mongoengine.get_db() + count = db[self.model._get_collection_name()].count_documents(args_copy) else: - db = mongoengine.get_db() - count = db[self.model._get_collection_name()].count_documents(args_copy) - else: - count = self.model.objects(args_copy).count() - if count != 0: + count = self.model.objects(args_copy).count() + if not needs_count or count != 0: skip, limit = find_skip_and_limit( - first=first, after=after, last=last, before=before, count=count + first=first, after=after, last=last, before=before, + count=count if needs_count else None, ) iterables = self.get_queryset( self.model, info, required_fields, skip, limit, **args diff --git a/tests/asynchronous/test_mutation.py b/tests/asynchronous/test_mutation.py index aa7f3bb9..838c8cb7 100644 --- a/tests/asynchronous/test_mutation.py +++ b/tests/asynchronous/test_mutation.py @@ -40,7 +40,7 @@ class Mutation(graphene.ObjectType): result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 + assert count == 1 # 1 insert async def test_should_update(fixtures): @@ -80,4 +80,4 @@ class Mutation(graphene.ObjectType): result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 \ No newline at end of file + assert count == 2 # 1 find (get) + 1 save \ No newline at end of file diff --git a/tests/asynchronous/test_query.py b/tests/asynchronous/test_query.py index 3ea34398..32e3f726 100644 --- a/tests/asynchronous/test_query.py +++ b/tests/asynchronous/test_query.py @@ -71,7 +71,7 @@ async def resolve_editors(self, *args, **kwargs): metadata = result.data["editor"].pop("metadata") assert json.loads(metadata) == expected_metadata assert result.data == expected - assert count >= 1 + assert count == 5 # 1 first editor + 1 company ref deref + 2 GridFS reads (files+chunks) + 1 all editors async def test_should_query_reporter(fixtures): @@ -194,7 +194,7 @@ async def resolve_all_players(self, *args, **kwargs): result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 + assert count == 2 # 1 select_related aggregate + 1 lazy deref for opponent (Magic's opponent is Michael) async def test_should_query_with_embedded_document(fixtures): diff --git a/tests/asynchronous/test_relay_query.py b/tests/asynchronous/test_relay_query.py index 492f2be9..eea2fa7c 100644 --- a/tests/asynchronous/test_relay_query.py +++ b/tests/asynchronous/test_relay_query.py @@ -203,7 +203,7 @@ class Query(graphene.ObjectType): result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 # 1 editors query; GridFS avatar reads may add extra queries + assert count == 3 # 1 editors find + 2 GridFS reads (files + chunks) for Penny's avatar async def test_should_filter_editors_by_id(fixtures): @@ -500,7 +500,7 @@ class Query(graphene.ObjectType): assert not result.errors assert result.data == expected - assert count == 2 # after cursor triggers pagination: 1 count + 1 find starting from the cursor offset + assert count == 1 # after cursor: no count query needed (last is None, pageInfo not requested); 1 find with skip async def test_should_before(fixtures): @@ -535,7 +535,7 @@ class Query(graphene.ObjectType): assert not result.errors assert result.data == expected - assert count == 2 # before cursor triggers pagination: 1 count + 1 find truncated before the cursor + assert count == 1 # before cursor: no count query needed (last is None, pageInfo not requested); 1 find with limit async def test_should_last_n(fixtures): @@ -570,6 +570,91 @@ class Query(graphene.ObjectType): assert count == 2 # last:2 triggers pagination: 1 count (to compute tail offset) + 1 find from the end +async def test_should_after_with_page_info(fixtures): + """after + pageInfo forces a count query (needed for hasPreviousPage/hasNextPage).""" + class Query(graphene.ObjectType): + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) + + query = """ + query { + players(after: "YXJyYXljb25uZWN0aW9uOjA=") { + edges { + cursor + node { firstName } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } + """ + schema = graphene.Schema(query=Query) + result, count = await execute_count(schema, query) + + assert not result.errors + edges = result.data["players"]["edges"] + assert [e["node"]["firstName"] for e in edges] == ["Magic", "Larry", "Chris"] + assert result.data["players"]["pageInfo"]["hasPreviousPage"] is True + assert count == 2 # pageInfo requested: count query issued even though last is not set + + +async def test_should_before_with_page_info(fixtures): + """before + pageInfo forces a count query (needed for hasNextPage).""" + class Query(graphene.ObjectType): + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) + + query = """ + query { + players(before: "YXJyYXljb25uZWN0aW9uOjI=") { + edges { + cursor + node { firstName } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } + """ + schema = graphene.Schema(query=Query) + result, count = await execute_count(schema, query) + + assert not result.errors + edges = result.data["players"]["edges"] + assert [e["node"]["firstName"] for e in edges] == ["Michael", "Magic"] + assert result.data["players"]["pageInfo"]["hasNextPage"] is True + assert result.data["players"]["pageInfo"]["hasPreviousPage"] is False + assert count == 2 # pageInfo requested: count query issued even though last is not set + + +async def test_should_first_without_page_info(fixtures): + """first without pageInfo skips the count query entirely.""" + class Query(graphene.ObjectType): + players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) + + query = """ + query { + players(first: 2) { + edges { + node { firstName } + } + } + } + """ + schema = graphene.Schema(query=Query) + result, count = await execute_count(schema, query) + + assert not result.errors + assert [e["node"]["firstName"] for e in result.data["players"]["edges"]] == ["Michael", "Magic"] + assert count == 1 # no pageInfo: count query skipped; 1 find with limit + + async def test_should_self_reference(fixtures): class Query(graphene.ObjectType): players = AsyncMongoengineConnectionField(nodes.PlayerAsyncNode) @@ -642,7 +727,7 @@ class Query(graphene.ObjectType): result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 # 1 players query + 1 sub-query per player for the nested players connection field + assert count == 1 # 1 players aggregate via select_related; nested players sub-connection resolved from pre-loaded list async def test_should_lazy_reference(fixtures): @@ -699,7 +784,7 @@ class Query(graphene.ObjectType): result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 # 1 parents query + extra queries to dereference each lazy beforeChild/afterChild relationship + assert count == 3 # 1 parents aggregate + 1 before_child select_related load + 1 after_child select_related load async def test_should_query_with_embedded_document(fixtures): @@ -817,7 +902,7 @@ class Query(graphene.ObjectType): result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 # custom queryset-based get_queryset; 1 aggregate expected but bound is loose for safety + assert count == 2 # get_queryset returns a QuerySet directly, bypassing select_related; 1 find + 1 editor lazy deref async def test_should_filter_mongoengine_queryset(fixtures): @@ -879,7 +964,7 @@ async def resolve_multiple_foos(self, *args, **kwargs): schema = graphene.Schema(query=Query) result, count = await execute_count(schema, query) assert not result.errors - assert count >= 1 # 1 foos query; bars is EmbeddedDocumentListField so no extra query, data is in the document + assert count == 1 # 1 foos find; bars is EmbeddedDocumentListField so no extra query, data is in the document async def test_should_filter_mongoengine_queryset_with_list(fixtures): @@ -965,7 +1050,7 @@ class Query(graphene.ObjectType): assert not result.errors assert result.data == expected - assert count >= 1 # 1 players find + paginated articles sub-queries per player (first:3 triggers count+find each) + assert count == 1 # 1 players aggregate with articles via select_related; first:3 pagination applied on pre-loaded list async def test_should_filter_mongoengine_queryset_by_id_and_other_fields( @@ -1126,7 +1211,7 @@ class Query(graphene.ObjectType): assert not result.errors names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] assert names == ["Penny", "Grant"] - assert count == 2 # first:2 triggers pagination: 1 count + 1 aggregate with $lookup for company + assert count == 1 # first:2 without pageInfo: count query skipped; 1 aggregate with $lookup for company async def test_editors_paginated_last(fixtures): @@ -1175,7 +1260,7 @@ class Query(graphene.ObjectType): assert not result.errors names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] assert names == ["Grant", "Dennis"] - assert count == 2 # first:2 with after cursor: 1 count + 1 aggregate with $lookup for company + assert count == 1 # first:2 with after cursor, no pageInfo: count query skipped; 1 aggregate with $lookup async def test_articles_paginated_first_with_editor(fixtures): @@ -1200,7 +1285,7 @@ class Query(graphene.ObjectType): assert not result.errors assert len(result.data["articles"]["edges"]) == 2 - assert count == 2 # first:2 triggers pagination: 1 count + 1 aggregate with $lookups for editor and editor→company + assert count == 1 # first:2 without pageInfo: count query skipped; 1 aggregate with $lookups for editor and editor→company # --------------------------------------------------------------------------- diff --git a/tests/models.py b/tests/models.py index 0e781df8..f3746c17 100644 --- a/tests/models.py +++ b/tests/models.py @@ -108,13 +108,11 @@ class CellTower(mongoengine.Document): class Child(Parent): - meta = {"collection": "test_parent"} baz = mongoengine.StringField() loc = mongoengine.PointField() class AnotherChild(Parent): - meta = {"collection": "test_parent"} qux = mongoengine.StringField() loc = mongoengine.PointField() diff --git a/tests/synchronous/test_mutation.py b/tests/synchronous/test_mutation.py index 0aa35c74..6c58371e 100644 --- a/tests/synchronous/test_mutation.py +++ b/tests/synchronous/test_mutation.py @@ -43,7 +43,7 @@ class Mutation(graphene.ObjectType): result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 + assert count == 1 # 1 insert def test_should_update(fixtures): @@ -83,4 +83,4 @@ class Mutation(graphene.ObjectType): result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 \ No newline at end of file + assert count == 2 # 1 find (get) + 1 save \ No newline at end of file diff --git a/tests/synchronous/test_query.py b/tests/synchronous/test_query.py index 673c4759..14b62858 100644 --- a/tests/synchronous/test_query.py +++ b/tests/synchronous/test_query.py @@ -72,7 +72,7 @@ def resolve_editors(self, *args, **kwargs): metadata = result.data["editor"].pop("metadata") assert json.loads(metadata) == expected_metadata assert result.data == expected - assert count >= 1 + assert count == 5 # 1 first editor + 1 company ref deref + 2 GridFS reads (files+chunks) + 1 all editors def test_should_query_reporter(fixtures): @@ -195,7 +195,7 @@ def resolve_all_players(self, *args, **kwargs): result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 + assert count == 2 # 1 select_related aggregate + 1 lazy deref for opponent (Magic's opponent is Michael) def test_should_query_with_embedded_document(fixtures): diff --git a/tests/synchronous/test_relay_query.py b/tests/synchronous/test_relay_query.py index b4ad9153..3b9107c6 100644 --- a/tests/synchronous/test_relay_query.py +++ b/tests/synchronous/test_relay_query.py @@ -205,7 +205,7 @@ class Query(graphene.ObjectType): result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 # 1 editors query; GridFS avatar reads may add extra queries + assert count == 3 # 1 editors find + 2 GridFS reads (files + chunks) for Penny's avatar def test_should_filter_editors_by_id(fixtures): @@ -505,7 +505,7 @@ class Query(graphene.ObjectType): assert not result.errors assert result.data == expected - assert count == 2 # after cursor triggers pagination: 1 count + 1 find starting from the cursor offset + assert count == 1 # after cursor: no count query needed (last is None, pageInfo not requested); 1 find with skip def test_should_before(fixtures): @@ -540,7 +540,7 @@ class Query(graphene.ObjectType): assert not result.errors assert result.data == expected - assert count == 2 # before cursor triggers pagination: 1 count + 1 find truncated before the cursor + assert count == 1 # before cursor: no count query needed (last is None, pageInfo not requested); 1 find with limit def test_should_last_n(fixtures): @@ -575,6 +575,91 @@ class Query(graphene.ObjectType): assert count == 2 # last:2 triggers pagination: 1 count (to compute tail offset) + 1 find from the end +def test_should_after_with_page_info(fixtures): + """after + pageInfo forces a count query (needed for hasPreviousPage/hasNextPage).""" + class Query(graphene.ObjectType): + players = MongoengineConnectionField(nodes.PlayerNode) + + query = """ + query { + players(after: "YXJyYXljb25uZWN0aW9uOjA=") { + edges { + cursor + node { firstName } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } + """ + schema = graphene.Schema(query=Query) + result, count = execute_count(schema, query) + + assert not result.errors + edges = result.data["players"]["edges"] + assert [e["node"]["firstName"] for e in edges] == ["Magic", "Larry", "Chris"] + assert result.data["players"]["pageInfo"]["hasPreviousPage"] is True + assert count == 2 # pageInfo requested: count query issued even though last is not set + + +def test_should_before_with_page_info(fixtures): + """before + pageInfo forces a count query (needed for hasNextPage).""" + class Query(graphene.ObjectType): + players = MongoengineConnectionField(nodes.PlayerNode) + + query = """ + query { + players(before: "YXJyYXljb25uZWN0aW9uOjI=") { + edges { + cursor + node { firstName } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } + """ + schema = graphene.Schema(query=Query) + result, count = execute_count(schema, query) + + assert not result.errors + edges = result.data["players"]["edges"] + assert [e["node"]["firstName"] for e in edges] == ["Michael", "Magic"] + assert result.data["players"]["pageInfo"]["hasNextPage"] is True + assert result.data["players"]["pageInfo"]["hasPreviousPage"] is False + assert count == 2 # pageInfo requested: count query issued even though last is not set + + +def test_should_first_without_page_info(fixtures): + """first without pageInfo skips the count query entirely.""" + class Query(graphene.ObjectType): + players = MongoengineConnectionField(nodes.PlayerNode) + + query = """ + query { + players(first: 2) { + edges { + node { firstName } + } + } + } + """ + schema = graphene.Schema(query=Query) + result, count = execute_count(schema, query) + + assert not result.errors + assert [e["node"]["firstName"] for e in result.data["players"]["edges"]] == ["Michael", "Magic"] + assert count == 1 # no pageInfo: count query skipped; 1 find with limit + + def test_should_self_reference(fixtures): class Query(graphene.ObjectType): players = MongoengineConnectionField(nodes.PlayerNode) @@ -647,7 +732,7 @@ class Query(graphene.ObjectType): result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 # 1 players query + 1 sub-query per player for the nested players connection field + assert count == 1 # 1 players aggregate via select_related; nested players sub-connection resolved from pre-loaded list def test_should_lazy_reference(fixtures): @@ -704,7 +789,7 @@ class Query(graphene.ObjectType): result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 # 1 parents query + extra queries to dereference each lazy beforeChild/afterChild relationship + assert count == 3 # 1 parents aggregate + 1 before_child select_related load + 1 after_child select_related load def test_should_query_with_embedded_document(fixtures): @@ -819,7 +904,7 @@ class Query(graphene.ObjectType): result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - assert count >= 1 # custom queryset-based get_queryset; 1 aggregate expected but bound is loose for safety + assert count == 2 # get_queryset returns a QuerySet directly, bypassing select_related; 1 find + 1 editor lazy deref def test_should_filter_mongoengine_queryset(fixtures): @@ -881,7 +966,7 @@ def resolve_multiple_foos(self, *args, **kwargs): schema = graphene.Schema(query=Query) result, count = execute_count(schema, query) assert not result.errors - assert count >= 1 # 1 foos query; bars is EmbeddedDocumentListField so no extra query, data is in the document + assert count == 1 # 1 foos find; bars is EmbeddedDocumentListField so no extra query, data is in the document def test_should_filter_mongoengine_queryset_with_list(fixtures): @@ -967,7 +1052,7 @@ class Query(graphene.ObjectType): assert not result.errors assert result.data == expected - assert count >= 1 # 1 players find + paginated articles sub-queries per player (first:3 triggers count+find each) + assert count == 1 # 1 players aggregate with articles via select_related; first:3 pagination applied on pre-loaded list def test_should_filter_mongoengine_queryset_by_id_and_other_fields(fixtures): @@ -1130,7 +1215,7 @@ class Query(graphene.ObjectType): assert not result.errors names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] assert names == ["Penny", "Grant"] - assert count == 2 # first:2 triggers pagination: 1 count + 1 aggregate with $lookup for company + assert count == 1 # first:2 without pageInfo: count query skipped; 1 aggregate with $lookup for company def test_editors_paginated_last(fixtures): @@ -1179,7 +1264,7 @@ class Query(graphene.ObjectType): assert not result.errors names = [e["node"]["firstName"] for e in result.data["editors"]["edges"]] assert names == ["Grant", "Dennis"] - assert count == 2 # first:2 with after cursor: 1 count + 1 aggregate with $lookup for company + assert count == 1 # first:2 with after cursor, no pageInfo: count query skipped; 1 aggregate with $lookup def test_articles_paginated_first_with_editor(fixtures): @@ -1204,7 +1289,7 @@ class Query(graphene.ObjectType): assert not result.errors assert len(result.data["articles"]["edges"]) == 2 - assert count == 2 # first:2 triggers pagination: 1 count + 1 aggregate with $lookups for editor and editor→company + assert count == 1 # first:2 without pageInfo: count query skipped; 1 aggregate with $lookups for editor and editor→company # --------------------------------------------------------------------------- From bfc2105525790df02eeb0e993de10c537fa929d8 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sat, 23 May 2026 15:42:43 +0530 Subject: [PATCH 18/52] refactor: extract shared connection field logic into base class Move all shared properties and resolver helpers from MongoengineConnectionField/AsyncMongoengineConnectionField into a new BaseMongoengineConnectionField in graphene_mongo/base/fields.py. Extracted helpers (_hydrate_args, _qs_accessor, _apply_select_related, _build_args_copy, _prepare_resolver_inputs, _collect_required_fields, _transform_qs_args) eliminate ~480 lines of duplication. The _qs_accessor hook lets async cleanly override model.aobjects without duplicating get_queryset. Also aligns sync has_previous_page to gate on requires_page_info, matching async behaviour. --- graphene_mongo/asynchronous/fields.py | 207 +++---------- graphene_mongo/base/fields.py | 391 +++++++++++++++++++++++++ graphene_mongo/synchronous/fields.py | 400 ++------------------------ 3 files changed, 455 insertions(+), 543 deletions(-) create mode 100644 graphene_mongo/base/fields.py diff --git a/graphene_mongo/asynchronous/fields.py b/graphene_mongo/asynchronous/fields.py index 3e9cfa5b..6fe05fab 100644 --- a/graphene_mongo/asynchronous/fields.py +++ b/graphene_mongo/asynchronous/fields.py @@ -4,18 +4,15 @@ from itertools import filterfalse from typing import Coroutine -import bson -from bson import DBRef, ObjectId import graphene +from bson import DBRef from graphene import Context from graphene.relay import ConnectionField -from graphene.utils.str_converters import to_snake_case from graphql import GraphQLResolveInfo from graphql_relay import cursor_to_offset, from_global_id import mongoengine from mongoengine import AsyncQuerySet, QuerySet from promise import Promise -import pymongo from pymongo.errors import OperationFailure from ..synchronous.fields import MongoengineConnectionField @@ -24,21 +21,11 @@ ExecutorEnum, connection_from_iterables, find_skip_and_limit, - get_document, - get_model_reference_fields, - get_query_fields, - get_related_field_filter_args, - get_select_related_paths, has_page_info, ) -PYMONGO_VERSION = tuple(pymongo.version_tuple[:2]) - class AsyncMongoengineConnectionField(MongoengineConnectionField): - def __init__(self, type, *args, **kwargs): - super(AsyncMongoengineConnectionField, self).__init__(type, *args, **kwargs) - @property def executor(self): return ExecutorEnum.ASYNC @@ -57,52 +44,19 @@ def type(self): return _type._meta.connection @property - def fields(self): - return super(AsyncMongoengineConnectionField, self).fields + def registry(self): + return getattr(self.node_type._meta, "registry", get_global_async_registry()) + + def _qs_accessor(self, model): + return model.aobjects def get_queryset( self, model, info, required_fields=None, skip=None, limit=None, **args ) -> AsyncQuerySet: if required_fields is None: required_fields = list() - if args: - reference_fields = get_model_reference_fields(self.model) - hydrated_references = {} - for arg_name, arg in args.copy().items(): - if arg_name in reference_fields and not isinstance( - arg, mongoengine.base.metaclasses.TopLevelDocumentMetaclass - ): - try: - reference_obj = reference_fields[arg_name].document_type( - pk=from_global_id(arg)[1] - ) - except TypeError: - reference_obj = reference_fields[arg_name].document_type(pk=arg) - hydrated_references[arg_name] = reference_obj - elif arg_name in self.model._fields_ordered and isinstance( - getattr(self.model, arg_name), - mongoengine.fields.GenericReferenceField, - ): - try: - reference_obj = get_document( - self.registry.get_type_for_model_string(from_global_id(arg)[0]) - )(pk=from_global_id(arg)[1]) - except TypeError: - reference_obj = get_document(arg["_cls"])(pk=arg["_ref"].id) - hydrated_references[arg_name] = reference_obj - elif "__near" in arg_name and isinstance( - getattr(self.model, arg_name.split("__")[0]), - mongoengine.fields.PointField, - ): - location = args.pop(arg_name, None) - hydrated_references[arg_name] = location["coordinates"] - if (arg_name.split("__")[0] + "__max_distance") not in args: - hydrated_references[arg_name.split("__")[0] + "__max_distance"] = 10000 - elif arg_name == "id": - hydrated_references["id"] = from_global_id(args.pop("id", None))[1] - args.update(hydrated_references) - + self._hydrate_args(args) if self._get_queryset: queryset_or_filters = self._get_queryset(model, info, **args) if isinstance(queryset_or_filters, mongoengine.AsyncQuerySet): @@ -111,28 +65,18 @@ def get_queryset( raise TypeError( "AsyncMongoengineConnectionField only accepts AsyncQuerySet in get_queryset(...)" ) - else: - args.update(queryset_or_filters) - queried_fields = get_query_fields(info) if isinstance(info, GraphQLResolveInfo) else {} - related = get_select_related_paths(model, queried_fields) - related_filter = get_related_field_filter_args(info, model) if isinstance(info, GraphQLResolveInfo) else {} - - qs = model.aobjects(**args).only(*required_fields).order_by(self.order_by) - if related: - qs = qs.select_related(*related) - for field_name, field_filter in related_filter.items(): - if field_name in related: - qs = qs.filter(**{f"{field_name}__{k}": v for k, v in field_filter.items()}) + args.update(queryset_or_filters) + qs = self._apply_select_related( + self._qs_accessor(model)(**args).only(*required_fields).order_by(self.order_by), + model, + info, + ) if limit is not None: return qs.skip(skip if skip else 0).limit(limit) elif skip is not None: return qs.skip(skip) return qs - @property - def registry(self): - return getattr(self.node_type._meta, "registry", get_global_async_registry()) - async def default_resolver(self, _root, info, required_fields=None, resolved=None, **args): if required_fields is None: required_fields = list() @@ -140,35 +84,13 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non for key, value in dict(args).items(): if value is None: del args[key] - if _root is not None and not resolved: - field_name = to_snake_case(info.field_name) - if not hasattr(_root, "_fields_ordered"): - if isinstance(getattr(_root, field_name, []), list): - args["pk__in"] = [r.id for r in getattr(_root, field_name, [])] - elif field_name in _root._fields_ordered and not ( - isinstance(_root._fields[field_name].field, mongoengine.EmbeddedDocumentField) - or isinstance( - _root._fields[field_name].field, - mongoengine.GenericEmbeddedDocumentField, - ) - ): - raw = getattr(_root, field_name, []) - if raw is not None: - first = next(iter(raw), None) - if isinstance(first, mongoengine.Document): - # Pre-loaded by select_related; the filter was already pushed into - # the $lookup sub-pipeline by get_queryset via filter(**related_filter). - # Return the pre-loaded (already filtered) list directly. - resolved = list(raw) - for k in [k for k in args if k != "id"]: - args.pop(k) - else: - args["pk__in"] = [r.id for r in raw] - _id = args.pop("id", None) + field_name, resolved = self._prepare_resolver_inputs(_root, info, args, resolved) + _id = args.pop("id", None) if _id is not None: args["pk"] = from_global_id(_id)[-1] + iterables = [] list_length = 0 skip = 0 @@ -209,7 +131,9 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non items = await _base_query.limit(limit) has_next_page = ( ( - len(await _base_query.skip(skip + limit).only("id").limit(1).to_list()) + len( + await _base_query.skip(skip + limit).only("id").limit(1).to_list() + ) != 0 ) if requires_page_info @@ -240,10 +164,10 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non args["pk__in"] = args["pk__in"][skip : skip + limit] elif skip: args["pk__in"] = args["pk__in"][skip:] - iterables = self.get_queryset(self.model, info, required_fields, **args) - iterables = await iterables.to_list() + iterables = await self.get_queryset( + self.model, info, required_fields, **args + ).to_list() else: - # If there is no ids to fetch, No need of DB call iterables = [] list_length = len(iterables) @@ -258,29 +182,12 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non or args or isinstance(getattr(_root, field_name, []), AsyncMongoengineConnectionField) ): - args_copy = args.copy() - for key in args.copy(): - if key not in self.model._fields_ordered: - args_copy.pop(key) - elif isinstance( - getattr(self.model, key), mongoengine.fields.ReferenceField - ) or isinstance( - getattr(self.model, key), - mongoengine.fields.GenericReferenceField, - ): - if not isinstance(args_copy[key], ObjectId): - _from_global_id = from_global_id(args_copy[key])[1] - if bson.objectid.ObjectId.is_valid(_from_global_id): - args_copy[key] = ObjectId(_from_global_id) - else: - args_copy[key] = _from_global_id - elif isinstance(getattr(self.model, key), mongoengine.fields.EnumField): - if getattr(args_copy[key], "value", None): - args_copy[key] = args_copy[key].value + args_copy = self._build_args_copy(args) if first is None and last is None and before is None and after is None: - iterables = self.get_queryset(self.model, info, required_fields, **args) - iterables = await iterables.to_list() + iterables = await self.get_queryset( + self.model, info, required_fields, **args + ).to_list() list_length = len(iterables) if isinstance(info, GraphQLResolveInfo): if not info.context: @@ -294,13 +201,15 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non count = await self.model.aobjects(**args_copy).count() if not needs_count or count != 0: skip, limit = find_skip_and_limit( - first=first, after=after, last=last, before=before, + first=first, + after=after, + last=last, + before=before, count=count if needs_count else None, ) - iterables = self.get_queryset( + iterables = await self.get_queryset( self.model, info, required_fields, skip, limit, **args - ) - iterables = await iterables.to_list() + ).to_list() list_length = len(iterables) if isinstance(info, GraphQLResolveInfo): if not info.context: @@ -310,7 +219,6 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non ) elif _root is not None: - field_name = to_snake_case(info.field_name) items = getattr(_root, field_name, []) count = len(items) skip, limit = find_skip_and_limit( @@ -322,8 +230,7 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non has_next_page = (skip + limit) < len(_base_query) if requires_page_info else False elif skip: items = items[skip:] - iterables = items - iterables = await iterables.to_list() + iterables = await items.to_list() list_length = len(iterables) if requires_page_info and count: @@ -352,16 +259,7 @@ async def chained_resolver(self, resolver, is_partial, root, info, **args): if value is None: del args[key] - required_fields = list() - - for field in self.required_fields: - if field in self.model._fields_ordered: - required_fields.append(field) - - for field in get_query_fields(info): - if to_snake_case(field) in self.model._fields_ordered: - required_fields.append(to_snake_case(field)) - + required_fields = self._collect_required_fields(info) args_copy = args.copy() if not bool(args) or not is_partial: @@ -395,51 +293,22 @@ def filter_connection(x): self.model, info, required_fields, **args_copy ) - # XXX: Filter nested args resolved = resolver(root, info, **args) if isinstance(resolved, Coroutine): resolved = await resolved if resolved is not None: - # if isinstance(resolved, Coroutine): - # resolved = await resolved if isinstance(resolved, list): if resolved == list(): return resolved elif not isinstance(resolved[0], DBRef): return resolved else: - return await self.default_resolver(root, info, required_fields, **args_copy) + return await self.default_resolver( + root, info, required_fields, **args_copy + ) elif isinstance(resolved, QuerySet): args.update(resolved._query) - args_copy = args.copy() - for arg_name, arg in args.copy().items(): - if "." in arg_name or arg_name not in self.model._fields_ordered + ( - "first", - "last", - "before", - "after", - ) + tuple(self.filter_args.keys()): - args_copy.pop(arg_name) - if arg_name == "_id" and isinstance(arg, dict): - operation = list(arg.keys())[0] - args_copy["pk" + operation.replace("$", "__")] = arg[operation] - if not isinstance(arg, ObjectId) and "." in arg_name: - if isinstance(arg, dict): - operation = list(arg.keys())[0] - args_copy[ - arg_name.replace(".", "__") + operation.replace("$", "__") - ] = arg[operation] - else: - args_copy[arg_name.replace(".", "__")] = arg - elif "." in arg_name and isinstance(arg, ObjectId): - args_copy[arg_name.replace(".", "__")] = arg - else: - operations = ["$lte", "$gte", "$ne", "$in"] - if isinstance(arg, dict) and any(op in arg for op in operations): - operation = list(arg.keys())[0] - args_copy[arg_name + operation.replace("$", "__")] = arg[operation] - del args_copy[arg_name] - + args_copy = self._transform_qs_args(args, args.copy()) return await self.default_resolver( root, info, required_fields, resolved=resolved, **args_copy ) diff --git a/graphene_mongo/base/fields.py b/graphene_mongo/base/fields.py new file mode 100644 index 00000000..c5d185f4 --- /dev/null +++ b/graphene_mongo/base/fields.py @@ -0,0 +1,391 @@ +from __future__ import absolute_import + +from collections import OrderedDict +from functools import reduce +from itertools import filterfalse + +import bson +import graphene +import mongoengine +from bson import ObjectId +from graphene.relay import ConnectionField +from graphene.types.argument import to_arguments +from graphene.types.dynamic import Dynamic +from graphene.types.structures import Structure +from graphene.types.utils import get_type +from graphene.utils.str_converters import to_snake_case +from graphql import GraphQLResolveInfo +from graphql_relay import from_global_id + +from .advanced_types import ( + FileFieldType, + MultiPolygonFieldType, + PointFieldInputType, + PointFieldType, + PolygonFieldType, +) +from .converter import MongoEngineConversionError, convert_mongoengine_field +from .registry import get_global_registry +from .utils import ( + ExecutorEnum, + get_document, + get_model_reference_fields, + get_query_fields, + get_related_field_filter_args, + get_select_related_paths, +) + + +class BaseMongoengineConnectionField(ConnectionField): + def __init__(self, type, *args, **kwargs): + get_queryset = kwargs.pop("get_queryset", None) + if get_queryset: + assert callable(get_queryset), ( + "Attribute `get_queryset` on {} must be callable.".format(self) + ) + self._get_queryset = get_queryset + super().__init__(type, *args, **kwargs) + + @property + def executor(self) -> ExecutorEnum: + raise NotImplementedError + + @property + def node_type(self): + return self.type._meta.node + + @property + def model(self): + return self.node_type._meta.model + + @property + def order_by(self): + return self.node_type._meta.order_by + + @property + def required_fields(self): + return tuple(set(self.node_type._meta.required_fields + self.node_type._meta.only_fields)) + + @property + def registry(self): + return getattr(self.node_type._meta, "registry", get_global_registry()) + + @property + def args(self): + _field_args = self.field_args + _advance_args = self.advance_args + _filter_args = self.filter_args + _extended_args = self.extended_args + if self._type._meta.non_filter_fields: + for _field in self._type._meta.non_filter_fields: + if _field in _field_args: + _field_args.pop(_field) + if _field in _advance_args: + _advance_args.pop(_field) + if _field in _filter_args: + _filter_args.pop(_field) + if _field in _extended_args: + _filter_args.pop(_field) + extra_args = dict( + dict(dict(_field_args, **_advance_args), **_filter_args), **_extended_args + ) + for key in list(self._base_args.keys()): + extra_args.pop(key, None) + return to_arguments(self._base_args or OrderedDict(), extra_args) + + @args.setter + def args(self, args): + self._base_args = args + + def _field_args(self, items): + def is_filterable(k): + if hasattr(self.fields[k].type, "_sdl"): + return False + if not hasattr(self.model, k): + return False + else: + field_ = self.fields[k] + type_ = field_.type + while hasattr(type_, "of_type"): + type_ = type_.of_type + if hasattr(type_, "_sdl") and "@key" in type_._sdl: + return False + if isinstance(getattr(self.model, k), property): + return False + try: + converted = convert_mongoengine_field( + getattr(self.model, k), self.registry, self.executor + ) + except MongoEngineConversionError: + return False + if isinstance(converted, (ConnectionField, Dynamic)): + return False + if callable(getattr(converted, "type", None)) and isinstance( + converted.type(), + ( + FileFieldType, + PointFieldType, + MultiPolygonFieldType, + graphene.Union, + PolygonFieldType, + ), + ): + return False + if isinstance(converted, graphene.List): + sub_type = getattr(converted, "_of_type", None) + if hasattr(sub_type, "of_type"): + sub_type = sub_type.of_type + if issubclass(sub_type, graphene.Union) or issubclass( + sub_type, graphene.ObjectType + ): + return False + if ( + hasattr(field_, "type") + and hasattr(converted, "type") + and converted.type != field_.type + ): + return False + return True + + def get_filter_type(_type): + if isinstance(_type, Structure): + return get_filter_type(_type.of_type) + return _type() + + return {k: get_filter_type(v.type) for k, v in items if is_filterable(k)} + + @property + def field_args(self): + return self._field_args(self.fields.items()) + + @property + def filter_args(self): + filter_args = dict() + if self._type._meta.filter_fields: + for field, filter_collection in self._type._meta.filter_fields.items(): + for each in filter_collection: + if str(self._type._meta.fields[field].type) in ( + "PointFieldType", + "PointFieldType!", + ): + if each == "max_distance": + filter_type = graphene.Int + else: + filter_type = PointFieldInputType + else: + filter_type = getattr( + graphene, + str(self._type._meta.fields[field].type).replace("!", ""), + ) + advanced_filter_types = { + "in": graphene.List(filter_type), + "nin": graphene.List(filter_type), + "all": graphene.List(filter_type), + } + filter_type = advanced_filter_types.get(each, filter_type) + filter_args[field + "__" + each] = graphene.Argument(type_=filter_type) + return filter_args + + @property + def advance_args(self): + def get_advance_field(r, kv): + field = kv[1] + mongo_field = getattr(self.model, kv[0], None) + if isinstance(mongo_field, mongoengine.PointField): + r.update({kv[0]: graphene.Argument(PointFieldInputType)}) + return r + if isinstance( + mongo_field, + (mongoengine.ReferenceField, mongoengine.GenericReferenceField), + ): + r.update({kv[0]: graphene.ID()}) + return r + if isinstance(mongo_field, mongoengine.GenericReferenceField): + r.update({kv[0]: graphene.ID()}) + return r + if callable(getattr(field, "get_type", None)): + _type = field.get_type() + if _type: + node = ( + _type.type._meta + if hasattr(_type.type, "_meta") + else _type.type._of_type._meta + ) + if "id" in node.fields and not issubclass( + node.model, (mongoengine.EmbeddedDocument,) + ): + r.update({kv[0]: node.fields["id"]._type.of_type()}) + return r + + return reduce(get_advance_field, self.fields.items(), {}) + + @property + def extended_args(self): + args = OrderedDict() + for k, each in self.fields.items(): + if hasattr(each.type, "_sdl"): + args.update({k: graphene.ID()}) + return args + + @property + def fields(self): + self._type = get_type(self._type) + return self._type._meta.fields + + # ── helpers ────────────────────────────────────────────────────────────── + + def _hydrate_args(self, args: dict) -> None: + """Hydrate reference/geo args in-place (global IDs → objects, coordinates).""" + reference_fields = get_model_reference_fields(self.model) + hydrated: dict = {} + for arg_name, arg in args.copy().items(): + if arg_name in reference_fields and not isinstance( + arg, mongoengine.base.metaclasses.TopLevelDocumentMetaclass + ): + try: + reference_obj = reference_fields[arg_name].document_type( + pk=from_global_id(arg)[1] + ) + except TypeError: + reference_obj = reference_fields[arg_name].document_type(pk=arg) + hydrated[arg_name] = reference_obj + elif arg_name in self.model._fields_ordered and isinstance( + getattr(self.model, arg_name), mongoengine.fields.GenericReferenceField + ): + try: + reference_obj = get_document( + self.registry.get_type_for_model_string(from_global_id(arg)[0]) + )(pk=from_global_id(arg)[1]) + except TypeError: + reference_obj = get_document(arg["_cls"])(pk=arg["_ref"].id) + hydrated[arg_name] = reference_obj + elif "__near" in arg_name and isinstance( + getattr(self.model, arg_name.split("__")[0]), mongoengine.fields.PointField + ): + location = args.pop(arg_name, None) + hydrated[arg_name] = location["coordinates"] + if (arg_name.split("__")[0] + "__max_distance") not in args: + hydrated[arg_name.split("__")[0] + "__max_distance"] = 10000 + elif arg_name == "id": + hydrated["id"] = from_global_id(args.pop("id", None))[1] + args.update(hydrated) + + def _qs_accessor(self, model): + """Return the QuerySet manager for this executor. Overridden in async.""" + return model.objects + + def _apply_select_related(self, qs, model, info): + """Apply select_related and related-field filters to a queryset.""" + queried_fields = get_query_fields(info) if isinstance(info, GraphQLResolveInfo) else {} + related = get_select_related_paths(model, queried_fields) + related_filter = ( + get_related_field_filter_args(info, model) + if isinstance(info, GraphQLResolveInfo) + else {} + ) + if related: + qs = qs.select_related(*related) + for field_name, field_filter in related_filter.items(): + if field_name in related: + qs = qs.filter( + **{f"{field_name}__{k}": v for k, v in field_filter.items()} + ) + return qs + + def _build_args_copy(self, args: dict) -> dict: + """Build a filtered args copy for count queries, normalising ObjectIds.""" + args_copy = args.copy() + for key in args.copy(): + if key not in self.model._fields_ordered: + args_copy.pop(key) + elif isinstance( + getattr(self.model, key), mongoengine.fields.ReferenceField + ) or isinstance( + getattr(self.model, key), mongoengine.fields.GenericReferenceField + ): + if not isinstance(args_copy[key], ObjectId): + _from_global_id = from_global_id(args_copy[key])[1] + args_copy[key] = ( + ObjectId(_from_global_id) + if bson.objectid.ObjectId.is_valid(_from_global_id) + else _from_global_id + ) + elif isinstance(getattr(self.model, key), mongoengine.fields.EnumField): + if getattr(args_copy[key], "value", None): + args_copy[key] = args_copy[key].value + return args_copy + + def _prepare_resolver_inputs(self, _root, info, args: dict, resolved): + """ + Pre-process _root to set args['pk__in'] or populate a pre-loaded resolved list. + Returns (field_name, resolved). + """ + field_name = to_snake_case(info.field_name) if _root is not None else "" + if _root is not None and not resolved: + if not hasattr(_root, "_fields_ordered"): + if isinstance(getattr(_root, field_name, []), list): + args["pk__in"] = [r.id for r in getattr(_root, field_name, [])] + elif field_name in _root._fields_ordered and not ( + isinstance( + _root._fields[field_name].field, mongoengine.EmbeddedDocumentField + ) + or isinstance( + _root._fields[field_name].field, + mongoengine.GenericEmbeddedDocumentField, + ) + ): + raw = getattr(_root, field_name, []) + if raw is not None: + first_item = next(iter(raw), None) + if isinstance(first_item, mongoengine.Document): + # Pre-loaded by select_related; filter already pushed into + # the $lookup sub-pipeline via filter(**related_filter). + resolved = list(raw) + for k in [k for k in args if k != "id"]: + args.pop(k) + else: + args["pk__in"] = [r.id for r in raw] + return field_name, resolved + + def _collect_required_fields(self, info) -> list: + """Collect required fields from meta and the current query selection.""" + required_fields = [ + f for f in self.required_fields if f in self.model._fields_ordered + ] + required_fields += [ + to_snake_case(f) + for f in get_query_fields(info) + if to_snake_case(f) in self.model._fields_ordered + ] + return required_fields + + def _transform_qs_args(self, args: dict, args_copy: dict) -> dict: + """Re-map a resolved QuerySet's _query dict into graphene-mongo style args.""" + for arg_name, arg in args.copy().items(): + if "." in arg_name or arg_name not in self.model._fields_ordered + ( + "first", + "last", + "before", + "after", + ) + tuple(self.filter_args.keys()): + args_copy.pop(arg_name, None) + if arg_name == "_id" and isinstance(arg, dict): + operation = list(arg.keys())[0] + args_copy["pk" + operation.replace("$", "__")] = arg[operation] + if not isinstance(arg, ObjectId) and "." in arg_name: + if isinstance(arg, dict): + operation = list(arg.keys())[0] + args_copy[ + arg_name.replace(".", "__") + operation.replace("$", "__") + ] = arg[operation] + else: + args_copy[arg_name.replace(".", "__")] = arg + elif "." in arg_name and isinstance(arg, ObjectId): + args_copy[arg_name.replace(".", "__")] = arg + else: + operations = ["$lte", "$gte", "$ne", "$in"] + if isinstance(arg, dict) and any(op in arg for op in operations): + operation = list(arg.keys())[0] + args_copy[arg_name + operation.replace("$", "__")] = arg[operation] + del args_copy[arg_name] + return args_copy diff --git a/graphene_mongo/synchronous/fields.py b/graphene_mongo/synchronous/fields.py index 95933b26..ed290a6b 100644 --- a/graphene_mongo/synchronous/fields.py +++ b/graphene_mongo/synchronous/fields.py @@ -1,62 +1,33 @@ from __future__ import absolute_import import logging -from collections import OrderedDict -from functools import partial, reduce +from functools import partial from itertools import filterfalse -import bson import graphene import mongoengine import pymongo -from bson import DBRef, ObjectId +from bson import DBRef from graphene import Context from graphene.relay import ConnectionField -from graphene.types.argument import to_arguments -from graphene.types.dynamic import Dynamic -from graphene.types.structures import Structure -from graphene.types.utils import get_type -from graphene.utils.str_converters import to_snake_case from graphql import GraphQLResolveInfo from graphql_relay import cursor_to_offset, from_global_id from mongoengine import QuerySet from promise import Promise from pymongo.errors import OperationFailure -from ..base.advanced_types import ( - FileFieldType, - MultiPolygonFieldType, - PointFieldInputType, - PointFieldType, - PolygonFieldType, -) -from ..base.converter import MongoEngineConversionError, convert_mongoengine_field -from ..base.registry import get_global_registry +from ..base.fields import BaseMongoengineConnectionField from ..base.utils import ( ExecutorEnum, connection_from_iterables, find_skip_and_limit, - get_document, - get_model_reference_fields, - get_query_fields, - get_related_field_filter_args, - get_select_related_paths, has_page_info, ) PYMONGO_VERSION = tuple(pymongo.version_tuple[:2]) -class MongoengineConnectionField(ConnectionField): - def __init__(self, type, *args, **kwargs): - get_queryset = kwargs.pop("get_queryset", None) - if get_queryset: - assert callable(get_queryset), ( - "Attribute `get_queryset` on {} must be callable.".format(self) - ) - self._get_queryset = get_queryset - super(MongoengineConnectionField, self).__init__(type, *args, **kwargs) - +class MongoengineConnectionField(BaseMongoengineConnectionField): @property def executor(self) -> ExecutorEnum: return ExecutorEnum.SYNC @@ -74,268 +45,23 @@ def type(self): ) return _type._meta.connection - @property - def node_type(self): - return self.type._meta.node - - @property - def model(self): - return self.node_type._meta.model - - @property - def order_by(self): - return self.node_type._meta.order_by - - @property - def required_fields(self): - return tuple(set(self.node_type._meta.required_fields + self.node_type._meta.only_fields)) - - @property - def registry(self): - return getattr(self.node_type._meta, "registry", get_global_registry()) - - @property - def args(self): - _field_args = self.field_args - _advance_args = self.advance_args - _filter_args = self.filter_args - _extended_args = self.extended_args - if self._type._meta.non_filter_fields: - for _field in self._type._meta.non_filter_fields: - if _field in _field_args: - _field_args.pop(_field) - if _field in _advance_args: - _advance_args.pop(_field) - if _field in _filter_args: - _filter_args.pop(_field) - if _field in _extended_args: - _filter_args.pop(_field) - extra_args = dict( - dict(dict(_field_args, **_advance_args), **_filter_args), **_extended_args - ) - - for key in list(self._base_args.keys()): - extra_args.pop(key, None) - return to_arguments(self._base_args or OrderedDict(), extra_args) - - @args.setter - def args(self, args): - self._base_args = args - - def _field_args(self, items): - def is_filterable(k): - """ - Remove complex columns from input args at this moment. - - Args: - k (str): field name. - Returns: - bool - """ - if hasattr(self.fields[k].type, "_sdl"): - return False - if not hasattr(self.model, k): - return False - else: - # else section is a patch for federated field error - field_ = self.fields[k] - type_ = field_.type - while hasattr(type_, "of_type"): - type_ = type_.of_type - if hasattr(type_, "_sdl") and "@key" in type_._sdl: - return False - if isinstance(getattr(self.model, k), property): - return False - try: - converted = convert_mongoengine_field( - getattr(self.model, k), self.registry, self.executor - ) - except MongoEngineConversionError: - return False - if isinstance(converted, (ConnectionField, Dynamic)): - return False - if callable(getattr(converted, "type", None)) and isinstance( - converted.type(), - ( - FileFieldType, - PointFieldType, - MultiPolygonFieldType, - graphene.Union, - PolygonFieldType, - ), - ): - return False - - if isinstance(converted, (graphene.List)): - sub_type = getattr(converted, "_of_type", None) - if hasattr(sub_type, "of_type"): # graphene.NonNull - sub_type = sub_type.of_type - if issubclass(sub_type, graphene.Union) or issubclass( - sub_type, graphene.ObjectType - ): - return False - - # below if condition: workaround for DB filterable field redefined as custom graphene type - if ( - hasattr(field_, "type") - and hasattr(converted, "type") - and converted.type != field_.type - ): - return False - return True - - def get_filter_type(_type): - """ - Returns the scalar type. - """ - if isinstance(_type, Structure): - return get_filter_type(_type.of_type) - return _type() - - return {k: get_filter_type(v.type) for k, v in items if is_filterable(k)} - - @property - def field_args(self): - return self._field_args(self.fields.items()) - - @property - def filter_args(self): - filter_args = dict() - if self._type._meta.filter_fields: - for field, filter_collection in self._type._meta.filter_fields.items(): - for each in filter_collection: - if str(self._type._meta.fields[field].type) in ( - "PointFieldType", - "PointFieldType!", - ): - if each == "max_distance": - filter_type = graphene.Int - else: - filter_type = PointFieldInputType - else: - filter_type = getattr( - graphene, - str(self._type._meta.fields[field].type).replace("!", ""), - ) - # handle special cases - advanced_filter_types = { - "in": graphene.List(filter_type), - "nin": graphene.List(filter_type), - "all": graphene.List(filter_type), - } - filter_type = advanced_filter_types.get(each, filter_type) - filter_args[field + "__" + each] = graphene.Argument(type_=filter_type) - return filter_args - - @property - def advance_args(self): - def get_advance_field(r, kv): - field = kv[1] - mongo_field = getattr(self.model, kv[0], None) - if isinstance(mongo_field, mongoengine.PointField): - r.update({kv[0]: graphene.Argument(PointFieldInputType)}) - return r - if isinstance( - mongo_field, - ( - mongoengine.ReferenceField, - mongoengine.GenericReferenceField, - ), - ): - r.update({kv[0]: graphene.ID()}) - return r - if isinstance(mongo_field, mongoengine.GenericReferenceField): - r.update({kv[0]: graphene.ID()}) - return r - if callable(getattr(field, "get_type", None)): - _type = field.get_type() - if _type: - node = ( - _type.type._meta - if hasattr(_type.type, "_meta") - else _type.type._of_type._meta - ) - if "id" in node.fields and not issubclass( - node.model, (mongoengine.EmbeddedDocument,) - ): - r.update({kv[0]: node.fields["id"]._type.of_type()}) - - return r - - return reduce(get_advance_field, self.fields.items(), {}) - - @property - def extended_args(self): - args = OrderedDict() - for k, each in self.fields.items(): - if hasattr(each.type, "_sdl"): - args.update({k: graphene.ID()}) - return args - - @property - def fields(self): - self._type = get_type(self._type) - return self._type._meta.fields - def get_queryset( self, model, info, required_fields=None, skip=None, limit=None, **args ) -> QuerySet: if required_fields is None: required_fields = list() - if args: - reference_fields = get_model_reference_fields(self.model) - hydrated_references = {} - for arg_name, arg in args.copy().items(): - if arg_name in reference_fields and not isinstance( - arg, mongoengine.base.metaclasses.TopLevelDocumentMetaclass - ): - try: - reference_obj = reference_fields[arg_name].document_type( - pk=from_global_id(arg)[1] - ) - except TypeError: - reference_obj = reference_fields[arg_name].document_type(pk=arg) - hydrated_references[arg_name] = reference_obj - elif arg_name in self.model._fields_ordered and isinstance( - getattr(self.model, arg_name), - mongoengine.fields.GenericReferenceField, - ): - try: - reference_obj = get_document( - self.registry.get_type_for_model_string(from_global_id(arg)[0]) - )(pk=from_global_id(arg)[1]) - except TypeError: - reference_obj = get_document(arg["_cls"])(pk=arg["_ref"].id) - hydrated_references[arg_name] = reference_obj - elif "__near" in arg_name and isinstance( - getattr(self.model, arg_name.split("__")[0]), - mongoengine.fields.PointField, - ): - location = args.pop(arg_name, None) - hydrated_references[arg_name] = location["coordinates"] - if (arg_name.split("__")[0] + "__max_distance") not in args: - hydrated_references[arg_name.split("__")[0] + "__max_distance"] = 10000 - elif arg_name == "id": - hydrated_references["id"] = from_global_id(args.pop("id", None))[1] - args.update(hydrated_references) - + self._hydrate_args(args) if self._get_queryset: queryset_or_filters = self._get_queryset(model, info, **args) if isinstance(queryset_or_filters, mongoengine.QuerySet): return queryset_or_filters - else: - args.update(queryset_or_filters) - queried_fields = get_query_fields(info) if isinstance(info, GraphQLResolveInfo) else {} - related = get_select_related_paths(model, queried_fields) - related_filter = get_related_field_filter_args(info, model) if isinstance(info, GraphQLResolveInfo) else {} - - qs = model.objects(**args).only(*required_fields).order_by(self.order_by) - if related: - qs = qs.select_related(*related) - for field_name, field_filter in related_filter.items(): - if field_name in related: - qs = qs.filter(**{f"{field_name}__{k}": v for k, v in field_filter.items()}) + args.update(queryset_or_filters) + qs = self._apply_select_related( + self._qs_accessor(model)(**args).only(*required_fields).order_by(self.order_by), + model, + info, + ) if limit is not None: return qs.skip(skip if skip else 0).limit(limit) elif skip is not None: @@ -349,35 +75,13 @@ def default_resolver(self, _root, info, required_fields=None, resolved=None, **a for key, value in dict(args).items(): if value is None: del args[key] - if _root is not None and not resolved: - field_name = to_snake_case(info.field_name) - if not hasattr(_root, "_fields_ordered"): - if isinstance(getattr(_root, field_name, []), list): - args["pk__in"] = [r.id for r in getattr(_root, field_name, [])] - elif field_name in _root._fields_ordered and not ( - isinstance(_root._fields[field_name].field, mongoengine.EmbeddedDocumentField) - or isinstance( - _root._fields[field_name].field, - mongoengine.GenericEmbeddedDocumentField, - ) - ): - raw = getattr(_root, field_name, []) - if raw is not None: - first = next(iter(raw), None) - if isinstance(first, mongoengine.Document): - # Pre-loaded by select_related; the filter was already pushed into - # the $lookup sub-pipeline by get_queryset via filter(**related_filter). - # Return the pre-loaded (already filtered) list directly. - resolved = list(raw) - for k in [k for k in args if k != "id"]: - args.pop(k) - else: - args["pk__in"] = [r.id for r in raw] - _id = args.pop("id", None) + field_name, resolved = self._prepare_resolver_inputs(_root, info, args, resolved) + _id = args.pop("id", None) if _id is not None: args["pk"] = from_global_id(_id)[-1] + iterables = [] list_length = 0 skip = 0 @@ -444,7 +148,6 @@ def default_resolver(self, _root, info, required_fields=None, resolved=None, **a args["pk__in"] = args["pk__in"][skip:] iterables = self.get_queryset(self.model, info, required_fields, **args) else: - # If there is no ids to fetch, No need of DB call iterables = [] list_length = len(iterables) @@ -459,25 +162,7 @@ def default_resolver(self, _root, info, required_fields=None, resolved=None, **a or args or isinstance(getattr(_root, field_name, []), MongoengineConnectionField) ): - args_copy = args.copy() - for key in args.copy(): - if key not in self.model._fields_ordered: - args_copy.pop(key) - elif isinstance( - getattr(self.model, key), mongoengine.fields.ReferenceField - ) or isinstance( - getattr(self.model, key), - mongoengine.fields.GenericReferenceField, - ): - if not isinstance(args_copy[key], ObjectId): - _from_global_id = from_global_id(args_copy[key])[1] - if bson.objectid.ObjectId.is_valid(_from_global_id): - args_copy[key] = ObjectId(_from_global_id) - else: - args_copy[key] = _from_global_id - elif isinstance(getattr(self.model, key), mongoengine.fields.EnumField): - if getattr(args_copy[key], "value", None): - args_copy[key] = args_copy[key].value + args_copy = self._build_args_copy(args) if first is None and last is None and before is None and after is None: iterables = self.get_queryset(self.model, info, required_fields, **args) @@ -496,12 +181,17 @@ def default_resolver(self, _root, info, required_fields=None, resolved=None, **a db = mongoengine.get_db(self.model._meta["db_alias"]) else: db = mongoengine.get_db() - count = db[self.model._get_collection_name()].count_documents(args_copy) + count = db[self.model._get_collection_name()].count_documents( + args_copy + ) else: count = self.model.objects(args_copy).count() if not needs_count or count != 0: skip, limit = find_skip_and_limit( - first=first, after=after, last=last, before=before, + first=first, + after=after, + last=last, + before=before, count=count if needs_count else None, ) iterables = self.get_queryset( @@ -516,7 +206,6 @@ def default_resolver(self, _root, info, required_fields=None, resolved=None, **a ) elif _root is not None: - field_name = to_snake_case(info.field_name) items = getattr(_root, field_name, []) count = len(items) skip, limit = find_skip_and_limit( @@ -531,13 +220,13 @@ def default_resolver(self, _root, info, required_fields=None, resolved=None, **a iterables = items list_length = len(iterables) - if count: + if requires_page_info and count: has_next_page = ( True if (0 if limit is None else limit) + (0 if skip is None else skip) < count else False ) - has_previous_page = True if skip else False + has_previous_page = True if requires_page_info and skip else False connection = connection_from_iterables( edges=iterables, @@ -557,16 +246,7 @@ def chained_resolver(self, resolver, is_partial, root, info, **args): if value is None: del args[key] - required_fields = list() - - for field in self.required_fields: - if field in self.model._fields_ordered: - required_fields.append(field) - - for field in get_query_fields(info): - if to_snake_case(field) in self.model._fields_ordered: - required_fields.append(to_snake_case(field)) - + required_fields = self._collect_required_fields(info) args_copy = args.copy() if not bool(args) or not is_partial: @@ -600,7 +280,6 @@ def filter_connection(x): self.model, info, required_fields, **args_copy ) - # XXX: Filter nested args resolved = resolver(root, info, **args) if resolved is not None: @@ -613,34 +292,7 @@ def filter_connection(x): return self.default_resolver(root, info, required_fields, **args_copy) elif isinstance(resolved, QuerySet): args.update(resolved._query) - args_copy = args.copy() - for arg_name, arg in args.copy().items(): - if "." in arg_name or arg_name not in self.model._fields_ordered + ( - "first", - "last", - "before", - "after", - ) + tuple(self.filter_args.keys()): - args_copy.pop(arg_name) - if arg_name == "_id" and isinstance(arg, dict): - operation = list(arg.keys())[0] - args_copy["pk" + operation.replace("$", "__")] = arg[operation] - if not isinstance(arg, ObjectId) and "." in arg_name: - if isinstance(arg, dict): - operation = list(arg.keys())[0] - args_copy[ - arg_name.replace(".", "__") + operation.replace("$", "__") - ] = arg[operation] - else: - args_copy[arg_name.replace(".", "__")] = arg - elif "." in arg_name and isinstance(arg, ObjectId): - args_copy[arg_name.replace(".", "__")] = arg - else: - operations = ["$lte", "$gte", "$ne", "$in"] - if isinstance(arg, dict) and any(op in arg for op in operations): - operation = list(arg.keys())[0] - args_copy[arg_name + operation.replace("$", "__")] = arg[operation] - del args_copy[arg_name] + args_copy = self._transform_qs_args(args, args.copy()) return self.default_resolver( root, info, required_fields, resolved=resolved, **args_copy ) From ae95f2b2535f45c8177ef065c6813124387b6e74 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sat, 23 May 2026 15:48:55 +0530 Subject: [PATCH 19/52] refactor: extract shared ObjectType factory logic into base/types.py Move construct_fields, construct_self_referenced_fields, and the create_graphene_generic_class factory into graphene_mongo/base/types.py. The unified factory accepts executor, registry factories, and connection field class as keyword params, eliminating ~130 lines of duplication between the sync and async type systems. sync/types.py and async/types.py now just call the factory with their specific params and set get_node (sync vs async) via classmethod attribute assignment to avoid triggering __init_subclass_with_meta__. --- graphene_mongo/asynchronous/types.py | 216 ++++----------------- graphene_mongo/base/types.py | 211 ++++++++++++++++++++ graphene_mongo/synchronous/types.py | 278 ++++----------------------- tests/asynchronous/test_types.py | 2 +- 4 files changed, 283 insertions(+), 424 deletions(-) create mode 100644 graphene_mongo/base/types.py diff --git a/graphene_mongo/asynchronous/types.py b/graphene_mongo/asynchronous/types.py index 3c441e99..d876b665 100644 --- a/graphene_mongo/asynchronous/types.py +++ b/graphene_mongo/asynchronous/types.py @@ -1,194 +1,44 @@ -import graphene -from graphene import InputObjectType -from graphene.relay import Connection, Node from graphene.types.interface import Interface, InterfaceOptions from graphene.types.objecttype import ObjectType, ObjectTypeOptions -from graphene.types.utils import yank_fields_from_attrs from graphene.utils.str_converters import to_snake_case -import mongoengine from .fields import AsyncMongoengineConnectionField -from ..base.registry import Registry, get_global_async_registry, get_inputs_async_registry -from ..synchronous.types import construct_fields, construct_self_referenced_fields -from ..base.utils import ExecutorEnum, get_query_fields, get_select_related_paths, is_valid_mongoengine_model +from ..base.registry import get_global_async_registry, get_inputs_async_registry +from ..base.types import create_graphene_generic_class as _create +from ..base.utils import ExecutorEnum, get_query_fields, get_select_related_paths def create_graphene_generic_class_async(object_type, option_type): - class AsyncMongoengineGenericObjectTypeOptions(option_type): - model = None - registry = None # type: Registry - connection = None - filter_fields = () - non_required_fields = () - order_by = None - - class AsyncGrapheneMongoengineGenericType(object_type): - @classmethod - def __init_subclass_with_meta__( - cls, - model=None, - registry=None, - skip_registry=False, - only_fields=(), - required_fields=(), - exclude_fields=(), - non_required_fields=(), - filter_fields=None, - non_filter_fields=(), - connection=None, - connection_class=None, - use_connection=None, - connection_field_class=None, - interfaces=(), - _meta=None, - order_by=None, - **options, - ): - assert is_valid_mongoengine_model(model), ( - "The attribute model in {}.Meta must be a valid Mongoengine Model. " - 'Received "{}" instead.' - ).format(cls.__name__, type(model)) - - if not registry: - # input objects shall be registred in a separated registry - if issubclass(cls, InputObjectType): - registry = get_inputs_async_registry() - else: - registry = get_global_async_registry() - - assert isinstance(registry, Registry), ( - "The attribute registry in {}.Meta needs to be an instance of " - 'Registry({}), received "{}".' - ).format(object_type, cls.__name__, registry) - converted_fields, self_referenced = construct_fields( - model, - registry, - only_fields, - exclude_fields, - non_required_fields, - ExecutorEnum.ASYNC, - ) - mongoengine_fields = yank_fields_from_attrs(converted_fields, _as=graphene.Field) - if use_connection is None and interfaces: - use_connection = any((issubclass(interface, Node) for interface in interfaces)) - - if use_connection and not connection: - # We create the connection automatically - if not connection_class: - connection_class = Connection - - connection = connection_class.create_type( - "{}Connection".format(options.get("name") or cls.__name__), node=cls - ) - - if connection is not None: - assert issubclass(connection, Connection), ( - "The attribute connection in {}.Meta must be of type Connection. " - 'Received "{}" instead.' - ).format(cls.__name__, type(connection)) - - if connection_field_class is not None: - assert issubclass(connection_field_class, graphene.ConnectionField), ( - "The attribute connection_field_class in {}.Meta must be of type graphene.ConnectionField. " - 'Received "{}" instead.' - ).format(cls.__name__, type(connection_field_class)) - else: - connection_field_class = AsyncMongoengineConnectionField - - if _meta: - assert isinstance(_meta, AsyncMongoengineGenericObjectTypeOptions), ( - "_meta must be an instance of AsyncMongoengineGenericObjectTypeOptions, " - "received {}" - ).format(_meta.__class__) - else: - _meta = AsyncMongoengineGenericObjectTypeOptions(option_type) - - _meta.model = model - _meta.registry = registry - _meta.fields = mongoengine_fields - _meta.filter_fields = filter_fields - _meta.non_filter_fields = non_filter_fields - _meta.connection = connection - _meta.connection_field_class = connection_field_class - # Save them for later - _meta.only_fields = only_fields - _meta.required_fields = required_fields - _meta.exclude_fields = exclude_fields - _meta.non_required_fields = non_required_fields - _meta.order_by = order_by - - super(AsyncGrapheneMongoengineGenericType, cls).__init_subclass_with_meta__( - _meta=_meta, interfaces=interfaces, **options - ) - - if not skip_registry: - registry.register(cls) - # Notes: Take care list of self-reference fields. - converted_fields = construct_self_referenced_fields( - self_referenced, registry, ExecutorEnum.ASYNC - ) - if converted_fields: - mongoengine_fields = yank_fields_from_attrs( - converted_fields, _as=graphene.Field - ) - cls._meta.fields.update(mongoengine_fields) - registry.register(cls) - - @classmethod - def rescan_fields(cls): - """Attempts to rescan fields and will insert any not converted initially""" - - converted_fields, self_referenced = construct_fields( - cls._meta.model, - cls._meta.registry, - cls._meta.only_fields, - cls._meta.exclude_fields, - cls._meta.non_required_fields, - ExecutorEnum.ASYNC, - ) - - mongoengine_fields = yank_fields_from_attrs(converted_fields, _as=graphene.Field) - - # The initial scan should take precedence - for field in mongoengine_fields: - if field not in cls._meta.fields: - cls._meta.fields.update({field: mongoengine_fields[field]}) - # Self-referenced fields can't change between scans! - - @classmethod - def is_type_of(cls, root, info): - if isinstance(root, cls): - return True - # XXX: Take care FileField - if isinstance(root, mongoengine.GridFSProxy): - return True - if not is_valid_mongoengine_model(type(root)): - raise Exception(('Received incompatible instance "{}".').format(root)) - return isinstance(root, cls._meta.model) - - @classmethod - async def get_node(cls, info, id): - required_fields = list() - for field in cls._meta.required_fields: - if field in cls._meta.model._fields_ordered: - required_fields.append(field) - queried_fields = get_query_fields(info) - if cls._meta.name in queried_fields: - queried_fields = queried_fields[cls._meta.name] - for field in queried_fields: - if to_snake_case(field) in cls._meta.model._fields_ordered: - required_fields.append(to_snake_case(field)) - required_fields = list(set(required_fields)) - related = get_select_related_paths(cls._meta.model, queried_fields) - qs = cls._meta.model.aobjects.only(*required_fields) - if related: - qs = qs.select_related(*related) - return await qs.get(pk=id) - - def resolve_id(self, info): - return str(self.id) - - return AsyncGrapheneMongoengineGenericType, AsyncMongoengineGenericObjectTypeOptions + GenericType, Options = _create( + object_type, + option_type, + executor=ExecutorEnum.ASYNC, + global_registry_factory=get_global_async_registry, + inputs_registry_factory=get_inputs_async_registry, + default_connection_field_class=AsyncMongoengineConnectionField, + ) + + @classmethod + async def get_node(cls, info, id): + required_fields = list() + for field in cls._meta.required_fields: + if field in cls._meta.model._fields_ordered: + required_fields.append(field) + queried_fields = get_query_fields(info) + if cls._meta.name in queried_fields: + queried_fields = queried_fields[cls._meta.name] + for field in queried_fields: + if to_snake_case(field) in cls._meta.model._fields_ordered: + required_fields.append(to_snake_case(field)) + required_fields = list(set(required_fields)) + related = get_select_related_paths(cls._meta.model, queried_fields) + qs = cls._meta.model.aobjects.only(*required_fields) + if related: + qs = qs.select_related(*related) + return await qs.get(pk=id) + + GenericType.get_node = get_node + return GenericType, Options ( diff --git a/graphene_mongo/base/types.py b/graphene_mongo/base/types.py new file mode 100644 index 00000000..7d2b1c37 --- /dev/null +++ b/graphene_mongo/base/types.py @@ -0,0 +1,211 @@ +from collections import OrderedDict + +import graphene +import mongoengine +from graphene.relay import Connection, Node +from graphene.types.inputobjecttype import InputObjectType +from graphene.types.utils import yank_fields_from_attrs + +from .converter import convert_mongoengine_field +from .registry import Registry +from .utils import ExecutorEnum, get_model_fields, is_valid_mongoengine_model + + +def construct_fields( + model, + registry, + only_fields, + exclude_fields, + non_required_fields, + executor: ExecutorEnum = ExecutorEnum.SYNC, +): + _model_fields = get_model_fields(model) + fields = OrderedDict() + self_referenced = OrderedDict() + for name, field in _model_fields.items(): + is_not_in_only = only_fields and name not in only_fields + is_excluded = name in exclude_fields + if is_not_in_only or is_excluded: + continue + if isinstance(field, mongoengine.ListField): + if not field.field: + continue + document_type_obj = field.field.__dict__.get("document_type_obj", None) + if ( + document_type_obj == model._class_name + or isinstance(document_type_obj, model) + or document_type_obj == model + ): + self_referenced[name] = field + continue + converted = convert_mongoengine_field(field, registry, executor) + if not converted: + continue + else: + if name in non_required_fields and "required" in converted.kwargs: + converted.kwargs["required"] = False + fields[name] = converted + + return fields, self_referenced + + +def construct_self_referenced_fields(self_referenced, registry, executor=ExecutorEnum.SYNC): + fields = OrderedDict() + for name, field in self_referenced.items(): + converted = convert_mongoengine_field(field, registry, executor) + if not converted: + continue + fields[name] = converted + return fields + + +def create_graphene_generic_class( + object_type, + option_type, + *, + executor, + global_registry_factory, + inputs_registry_factory, + default_connection_field_class, +): + class MongoengineGenericObjectTypeOptions(option_type): + model = None + registry = None # type: Registry + connection = None + filter_fields = () + non_required_fields = () + order_by = None + + class GrapheneMongoengineGenericType(object_type): + @classmethod + def __init_subclass_with_meta__( + cls, + model=None, + registry=None, + skip_registry=False, + only_fields=(), + required_fields=(), + exclude_fields=(), + non_required_fields=(), + filter_fields=None, + non_filter_fields=(), + connection=None, + connection_class=None, + use_connection=None, + connection_field_class=None, + interfaces=(), + _meta=None, + order_by=None, + **options, + ): + assert is_valid_mongoengine_model(model), ( + "The attribute model in {}.Meta must be a valid Mongoengine Model. " + 'Received "{}" instead.' + ).format(cls.__name__, type(model)) + + if not registry: + if issubclass(cls, InputObjectType): + registry = inputs_registry_factory() + else: + registry = global_registry_factory() + + assert isinstance(registry, Registry), ( + "The attribute registry in {}.Meta needs to be an instance of " + 'Registry({}), received "{}".' + ).format(object_type, cls.__name__, registry) + + converted_fields, self_referenced = construct_fields( + model, registry, only_fields, exclude_fields, non_required_fields, executor + ) + mongoengine_fields = yank_fields_from_attrs(converted_fields, _as=graphene.Field) + if use_connection is None and interfaces: + use_connection = any((issubclass(interface, Node) for interface in interfaces)) + + if use_connection and not connection: + if not connection_class: + connection_class = Connection + connection = connection_class.create_type( + "{}Connection".format(options.get("name") or cls.__name__), node=cls + ) + + if connection is not None: + assert issubclass(connection, Connection), ( + "The attribute connection in {}.Meta must be of type Connection. " + 'Received "{}" instead.' + ).format(cls.__name__, type(connection)) + + if connection_field_class is not None: + assert issubclass(connection_field_class, graphene.ConnectionField), ( + "The attribute connection_field_class in {}.Meta must be of type " + 'graphene.ConnectionField. Received "{}" instead.' + ).format(cls.__name__, type(connection_field_class)) + else: + connection_field_class = default_connection_field_class + + if _meta: + assert isinstance(_meta, MongoengineGenericObjectTypeOptions), ( + "_meta must be an instance of MongoengineGenericObjectTypeOptions, " + "received {}" + ).format(_meta.__class__) + else: + _meta = MongoengineGenericObjectTypeOptions(option_type) + + _meta.model = model + _meta.registry = registry + _meta.fields = mongoengine_fields + _meta.filter_fields = filter_fields + _meta.non_filter_fields = non_filter_fields + _meta.connection = connection + _meta.connection_field_class = connection_field_class + _meta.only_fields = only_fields + _meta.required_fields = required_fields + _meta.exclude_fields = exclude_fields + _meta.non_required_fields = non_required_fields + _meta.order_by = order_by + + super(GrapheneMongoengineGenericType, cls).__init_subclass_with_meta__( + _meta=_meta, interfaces=interfaces, **options + ) + + if not skip_registry: + registry.register(cls) + converted_fields = construct_self_referenced_fields( + self_referenced, registry, executor + ) + if converted_fields: + mongoengine_fields = yank_fields_from_attrs( + converted_fields, _as=graphene.Field + ) + cls._meta.fields.update(mongoengine_fields) + registry.register(cls) + + @classmethod + def rescan_fields(cls): + """Attempts to rescan fields and will insert any not converted initially""" + converted_fields, _ = construct_fields( + cls._meta.model, + cls._meta.registry, + cls._meta.only_fields, + cls._meta.exclude_fields, + cls._meta.non_required_fields, + executor, + ) + mongoengine_fields = yank_fields_from_attrs(converted_fields, _as=graphene.Field) + for field in mongoengine_fields: + if field not in cls._meta.fields: + cls._meta.fields.update({field: mongoengine_fields[field]}) + + @classmethod + def is_type_of(cls, root, info): + if isinstance(root, cls): + return True + if isinstance(root, mongoengine.GridFSProxy): + return True + if not is_valid_mongoengine_model(type(root)): + raise Exception(('Received incompatible instance "{}".').format(root)) + return isinstance(root, cls._meta.model) + + def resolve_id(self, info): + return str(self.id) + + return GrapheneMongoengineGenericType, MongoengineGenericObjectTypeOptions diff --git a/graphene_mongo/synchronous/types.py b/graphene_mongo/synchronous/types.py index 7ada54eb..ccfdcc50 100644 --- a/graphene_mongo/synchronous/types.py +++ b/graphene_mongo/synchronous/types.py @@ -1,251 +1,49 @@ -from collections import OrderedDict - -import graphene -import mongoengine -from graphene.relay import Connection, Node from graphene.types.inputobjecttype import InputObjectType, InputObjectTypeOptions from graphene.types.interface import Interface, InterfaceOptions from graphene.types.objecttype import ObjectType, ObjectTypeOptions -from graphene.types.utils import yank_fields_from_attrs from graphene.utils.str_converters import to_snake_case -from graphene_mongo import MongoengineConnectionField -from ..base.converter import convert_mongoengine_field -from ..base.registry import Registry, get_global_registry, get_inputs_registry -from ..base.utils import ExecutorEnum, get_model_fields, get_query_fields, get_select_related_paths, is_valid_mongoengine_model - - -def construct_fields( - model, - registry, - only_fields, - exclude_fields, - non_required_fields, - executor: ExecutorEnum = ExecutorEnum.SYNC, -): - """ - Args: - model (mongoengine.Document): - registry (.registry.Registry): - only_fields ([str]): - exclude_fields ([str]): - executor : ExecutorEnum - - Returns: - (OrderedDict, OrderedDict): converted fields and self reference fields. - - """ - _model_fields = get_model_fields(model) - fields = OrderedDict() - self_referenced = OrderedDict() - for name, field in _model_fields.items(): - is_not_in_only = only_fields and name not in only_fields - is_excluded = name in exclude_fields - if is_not_in_only or is_excluded: - # We skip this field if we specify required_fields and is not - # in there. Or when we exclude this field in exclude_fields - continue - if isinstance(field, mongoengine.ListField): - if not field.field: - continue - # Take care of list of self-reference. - document_type_obj = field.field.__dict__.get("document_type_obj", None) - if ( - document_type_obj == model._class_name - or isinstance(document_type_obj, model) - or document_type_obj == model - ): - self_referenced[name] = field - continue - converted = convert_mongoengine_field(field, registry, executor) - if not converted: - continue - else: - if name in non_required_fields and "required" in converted.kwargs: - converted.kwargs["required"] = False - fields[name] = converted - - return fields, self_referenced - - -def construct_self_referenced_fields(self_referenced, registry, executor=ExecutorEnum.SYNC): - fields = OrderedDict() - for name, field in self_referenced.items(): - converted = convert_mongoengine_field(field, registry, executor) - if not converted: - continue - fields[name] = converted - - return fields +from .fields import MongoengineConnectionField +from ..base.registry import get_global_registry, get_inputs_registry +from ..base.types import ( + construct_fields, + construct_self_referenced_fields, + create_graphene_generic_class as _create, +) +from ..base.utils import ExecutorEnum, get_query_fields, get_select_related_paths def create_graphene_generic_class(object_type, option_type): - class MongoengineGenericObjectTypeOptions(option_type): - model = None - registry = None # type: Registry - connection = None - filter_fields = () - non_required_fields = () - order_by = None - - class GrapheneMongoengineGenericType(object_type): - @classmethod - def __init_subclass_with_meta__( - cls, - model=None, - registry=None, - skip_registry=False, - only_fields=(), - required_fields=(), - exclude_fields=(), - non_required_fields=(), - filter_fields=None, - non_filter_fields=(), - connection=None, - connection_class=None, - use_connection=None, - connection_field_class=None, - interfaces=(), - _meta=None, - order_by=None, - **options, - ): - assert is_valid_mongoengine_model(model), ( - "The attribute model in {}.Meta must be a valid Mongoengine Model. " - 'Received "{}" instead.' - ).format(cls.__name__, type(model)) - - if not registry: - # input objects shall be registred in a separated registry - if issubclass(cls, InputObjectType): - registry = get_inputs_registry() - else: - registry = get_global_registry() - - assert isinstance(registry, Registry), ( - "The attribute registry in {}.Meta needs to be an instance of " - 'Registry({}), received "{}".' - ).format(object_type, cls.__name__, registry) - converted_fields, self_referenced = construct_fields( - model, registry, only_fields, exclude_fields, non_required_fields - ) - mongoengine_fields = yank_fields_from_attrs(converted_fields, _as=graphene.Field) - if use_connection is None and interfaces: - use_connection = any((issubclass(interface, Node) for interface in interfaces)) - - if use_connection and not connection: - # We create the connection automatically - if not connection_class: - connection_class = Connection - - connection = connection_class.create_type( - "{}Connection".format(options.get("name") or cls.__name__), node=cls - ) - - if connection is not None: - assert issubclass(connection, Connection), ( - "The attribute connection in {}.Meta must be of type Connection. " - 'Received "{}" instead.' - ).format(cls.__name__, type(connection)) - - if connection_field_class is not None: - assert issubclass(connection_field_class, graphene.ConnectionField), ( - "The attribute connection_field_class in {}.Meta must be of type graphene.ConnectionField. " - 'Received "{}" instead.' - ).format(cls.__name__, type(connection_field_class)) - else: - connection_field_class = MongoengineConnectionField - - if _meta: - assert isinstance(_meta, MongoengineGenericObjectTypeOptions), ( - "_meta must be an instance of MongoengineGenericObjectTypeOptions, received {}" - ).format(_meta.__class__) - else: - _meta = MongoengineGenericObjectTypeOptions(option_type) - - _meta.model = model - _meta.registry = registry - _meta.fields = mongoengine_fields - _meta.filter_fields = filter_fields - _meta.non_filter_fields = non_filter_fields - _meta.connection = connection - _meta.connection_field_class = connection_field_class - # Save them for later - _meta.only_fields = only_fields - _meta.required_fields = required_fields - _meta.exclude_fields = exclude_fields - _meta.non_required_fields = non_required_fields - _meta.order_by = order_by - - super(GrapheneMongoengineGenericType, cls).__init_subclass_with_meta__( - _meta=_meta, interfaces=interfaces, **options - ) - - if not skip_registry: - registry.register(cls) - # Notes: Take care list of self-reference fields. - converted_fields = construct_self_referenced_fields(self_referenced, registry) - if converted_fields: - mongoengine_fields = yank_fields_from_attrs( - converted_fields, _as=graphene.Field - ) - cls._meta.fields.update(mongoengine_fields) - registry.register(cls) - - @classmethod - def rescan_fields(cls): - """Attempts to rescan fields and will insert any not converted initially""" - - converted_fields, self_referenced = construct_fields( - cls._meta.model, - cls._meta.registry, - cls._meta.only_fields, - cls._meta.exclude_fields, - cls._meta.non_required_fields, - ) - - mongoengine_fields = yank_fields_from_attrs(converted_fields, _as=graphene.Field) - - # The initial scan should take precedence - for field in mongoengine_fields: - if field not in cls._meta.fields: - cls._meta.fields.update({field: mongoengine_fields[field]}) - # Self-referenced fields can't change between scans! - - @classmethod - def is_type_of(cls, root, info): - if isinstance(root, cls): - return True - # XXX: Take care FileField - if isinstance(root, mongoengine.GridFSProxy): - return True - if not is_valid_mongoengine_model(type(root)): - raise Exception(('Received incompatible instance "{}".').format(root)) - return isinstance(root, cls._meta.model) - - @classmethod - def get_node(cls, info, id): - required_fields = list() - for field in cls._meta.required_fields: - if field in cls._meta.model._fields_ordered: - required_fields.append(field) - queried_fields = get_query_fields(info) - if cls._meta.name in queried_fields: - queried_fields = queried_fields[cls._meta.name] - for field in queried_fields: - if to_snake_case(field) in cls._meta.model._fields_ordered: - required_fields.append(to_snake_case(field)) - required_fields = list(set(required_fields)) - related = get_select_related_paths(cls._meta.model, queried_fields) - qs = cls._meta.model.objects.only(*required_fields) - if related: - qs = qs.select_related(*related) - return qs.get(pk=id) - - def resolve_id(self, info): - return str(self.id) - - return GrapheneMongoengineGenericType, MongoengineGenericObjectTypeOptions + GenericType, Options = _create( + object_type, + option_type, + executor=ExecutorEnum.SYNC, + global_registry_factory=get_global_registry, + inputs_registry_factory=get_inputs_registry, + default_connection_field_class=MongoengineConnectionField, + ) + + @classmethod + def get_node(cls, info, id): + required_fields = list() + for field in cls._meta.required_fields: + if field in cls._meta.model._fields_ordered: + required_fields.append(field) + queried_fields = get_query_fields(info) + if cls._meta.name in queried_fields: + queried_fields = queried_fields[cls._meta.name] + for field in queried_fields: + if to_snake_case(field) in cls._meta.model._fields_ordered: + required_fields.append(to_snake_case(field)) + required_fields = list(set(required_fields)) + related = get_select_related_paths(cls._meta.model, queried_fields) + qs = cls._meta.model.objects.only(*required_fields) + if related: + qs = qs.select_related(*related) + return qs.get(pk=id) + + GenericType.get_node = get_node + return GenericType, Options MongoengineObjectType, MongoengineObjectTypeOptions = create_graphene_generic_class( diff --git a/tests/asynchronous/test_types.py b/tests/asynchronous/test_types.py index 2d988b45..0e87542f 100644 --- a/tests/asynchronous/test_types.py +++ b/tests/asynchronous/test_types.py @@ -174,7 +174,7 @@ class A(TypeSubclassWithBadOptions): class Meta: model = Article - assert "AsyncMongoengineGenericObjectTypeOptions" in str(einfo.value) + assert "MongoengineGenericObjectTypeOptions" in str(einfo.value) class TypeSubclass(AsyncMongoengineObjectType): class Meta: From ca5849a4c3c6410e3d537cccf6c3669597064fcc Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sat, 23 May 2026 15:50:41 +0530 Subject: [PATCH 20/52] chore: remove unused imports --- graphene_mongo/base/fields.py | 1 - graphene_mongo/synchronous/types.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/graphene_mongo/base/fields.py b/graphene_mongo/base/fields.py index c5d185f4..d20cb576 100644 --- a/graphene_mongo/base/fields.py +++ b/graphene_mongo/base/fields.py @@ -2,7 +2,6 @@ from collections import OrderedDict from functools import reduce -from itertools import filterfalse import bson import graphene diff --git a/graphene_mongo/synchronous/types.py b/graphene_mongo/synchronous/types.py index ccfdcc50..1d47bad5 100644 --- a/graphene_mongo/synchronous/types.py +++ b/graphene_mongo/synchronous/types.py @@ -6,8 +6,6 @@ from .fields import MongoengineConnectionField from ..base.registry import get_global_registry, get_inputs_registry from ..base.types import ( - construct_fields, - construct_self_referenced_fields, create_graphene_generic_class as _create, ) from ..base.utils import ExecutorEnum, get_query_fields, get_select_related_paths From f3794384580e200208721bf62927110dfa15579b Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sat, 23 May 2026 15:52:26 +0530 Subject: [PATCH 21/52] chore: remove dead sys.version_info >= (3, 6) guard in converter Project requires Python 3.10+, so the guard around EnumField registration was never needed at runtime. --- graphene_mongo/base/converter.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/graphene_mongo/base/converter.py b/graphene_mongo/base/converter.py index d1a2b9ef..030d863c 100644 --- a/graphene_mongo/base/converter.py +++ b/graphene_mongo/base/converter.py @@ -1,5 +1,4 @@ from functools import singledispatch -import sys import graphene from graphene.types.json import JSONString @@ -309,15 +308,13 @@ def dynamic_type(): return graphene.Dynamic(dynamic_type) -if sys.version_info >= (3, 6): - - @convert_mongoengine_field.register(mongoengine.EnumField) - def convert_field_to_enum(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): - if not registry.check_enum_already_exist(field._enum_cls): - registry.register_enum(field._enum_cls) - _type = registry.get_type_for_enum(field._enum_cls) - return graphene.Field( - _type, - description=get_field_description(field, registry), - required=get_field_is_required(field, registry), - ) +@convert_mongoengine_field.register(mongoengine.EnumField) +def convert_field_to_enum(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + if not registry.check_enum_already_exist(field._enum_cls): + registry.register_enum(field._enum_cls) + _type = registry.get_type_for_enum(field._enum_cls) + return graphene.Field( + _type, + description=get_field_description(field, registry), + required=get_field_is_required(field, registry), + ) From 53f04937235c5e0615a18bcffd1da91972e56955 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sat, 23 May 2026 21:01:40 +0530 Subject: [PATCH 22/52] chore: modernise codebase for Python 3.10+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove from __future__ imports (absolute_import, unicode_literals) — no-ops since Python 3 - Replace OrderedDict with plain dict in base/utils.py and base/types.py (dicts are insertion-ordered since Python 3.7) - Remove unused get_type_for_document() dead function from base/utils.py - Remove unused GraphQLResolveInfo import from base/utils.py - Remove unused LazyReference import from union_resolver.py - Rename UnionFieldResolver.resolver/resolver_async to reference_resolver/reference_resolver_async to match the naming convention used by ListFieldResolver and DynamicReferenceFieldResolver --- graphene_mongo/asynchronous/fields.py | 2 -- graphene_mongo/base/converter.py | 4 ++-- .../base/field_resolvers/union_resolver.py | 15 ++++++++------- graphene_mongo/base/fields.py | 2 -- graphene_mongo/base/types.py | 8 +++----- graphene_mongo/base/utils.py | 15 +-------------- graphene_mongo/synchronous/fields.py | 2 -- 7 files changed, 14 insertions(+), 34 deletions(-) diff --git a/graphene_mongo/asynchronous/fields.py b/graphene_mongo/asynchronous/fields.py index 6fe05fab..c6ed5fb7 100644 --- a/graphene_mongo/asynchronous/fields.py +++ b/graphene_mongo/asynchronous/fields.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import - from functools import partial from itertools import filterfalse from typing import Coroutine diff --git a/graphene_mongo/base/converter.py b/graphene_mongo/base/converter.py index 030d863c..dd0eb594 100644 --- a/graphene_mongo/base/converter.py +++ b/graphene_mongo/base/converter.py @@ -248,10 +248,10 @@ def convert_field_to_union(field, registry=None, executor: ExecutorEnum = Execut _union, resolver=get_field_resolver( field_resolver=field_resolver, - default_sync_resolver=UnionFieldResolver.resolver( + default_sync_resolver=UnionFieldResolver.reference_resolver( field=field, registry=registry, executor=executor ), - default_async_resolver=UnionFieldResolver.resolver_async( + default_async_resolver=UnionFieldResolver.reference_resolver_async( field=field, registry=registry, executor=executor ), executor=executor, diff --git a/graphene_mongo/base/field_resolvers/union_resolver.py b/graphene_mongo/base/field_resolvers/union_resolver.py index 5f8b9e91..3b7a32e5 100644 --- a/graphene_mongo/base/field_resolvers/union_resolver.py +++ b/graphene_mongo/base/field_resolvers/union_resolver.py @@ -5,7 +5,6 @@ from graphene.utils.str_converters import to_snake_case import mongoengine from mongoengine import Document -from mongoengine.base import LazyReference from graphene_mongo.base.utils import ExecutorEnum, get_document, get_queried_union_types @@ -54,10 +53,11 @@ def __reference_resolver_common( return document(id=document_id) @staticmethod - def resolver(field, registry, executor) -> Callable: + def reference_resolver(field, registry, executor) -> Callable: def resolver(root, *args, **kwargs) -> Optional[Document]: - resolver_fun = UnionFieldResolver.__reference_resolver_common - result = resolver_fun(field, registry, executor, root, *args, **kwargs) + result = UnionFieldResolver.__reference_resolver_common( + field, registry, executor, root, *args, **kwargs + ) if not isinstance(result, tuple): return result document, only_fields, pk = result @@ -66,10 +66,11 @@ def resolver(root, *args, **kwargs) -> Optional[Document]: return resolver @staticmethod - def resolver_async(field, registry, executor) -> Callable: + def reference_resolver_async(field, registry, executor) -> Callable: async def resolver(root, *args, **kwargs) -> Optional[Document]: - resolver_fun = UnionFieldResolver.__reference_resolver_common - result = resolver_fun(field, registry, executor, root, *args, **kwargs) + result = UnionFieldResolver.__reference_resolver_common( + field, registry, executor, root, *args, **kwargs + ) if not isinstance(result, tuple): return result model, only_fields, document_id = result diff --git a/graphene_mongo/base/fields.py b/graphene_mongo/base/fields.py index d20cb576..621e8657 100644 --- a/graphene_mongo/base/fields.py +++ b/graphene_mongo/base/fields.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import - from collections import OrderedDict from functools import reduce diff --git a/graphene_mongo/base/types.py b/graphene_mongo/base/types.py index 7d2b1c37..3e4e3797 100644 --- a/graphene_mongo/base/types.py +++ b/graphene_mongo/base/types.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - import graphene import mongoengine from graphene.relay import Connection, Node @@ -20,8 +18,8 @@ def construct_fields( executor: ExecutorEnum = ExecutorEnum.SYNC, ): _model_fields = get_model_fields(model) - fields = OrderedDict() - self_referenced = OrderedDict() + fields = dict() + self_referenced = dict() for name, field in _model_fields.items(): is_not_in_only = only_fields and name not in only_fields is_excluded = name in exclude_fields @@ -50,7 +48,7 @@ def construct_fields( def construct_self_referenced_fields(self_referenced, registry, executor=ExecutorEnum.SYNC): - fields = OrderedDict() + fields = dict() for name, field in self_referenced.items(): converted = convert_mongoengine_field(field, registry, executor) if not converted: diff --git a/graphene_mongo/base/utils.py b/graphene_mongo/base/utils.py index 924d0e38..2c9b1288 100644 --- a/graphene_mongo/base/utils.py +++ b/graphene_mongo/base/utils.py @@ -1,6 +1,3 @@ -from __future__ import unicode_literals - -from collections import OrderedDict import enum import inspect from typing import Callable, Optional @@ -15,7 +12,6 @@ GraphQLSkipDirective, VariableNode, ) -from graphql import GraphQLResolveInfo from graphql_relay.connection.array_connection import offset_to_cursor import mongoengine from mongoengine.base.common import _DocumentRegistry @@ -42,7 +38,7 @@ def get_model_fields(model, excluding=None): if attr_name in excluding: continue attributes[attr_name] = attr - return OrderedDict(sorted(attributes.items())) + return dict(sorted(attributes.items())) def get_model_reference_fields(model, excluding=None): @@ -64,15 +60,6 @@ def is_valid_mongoengine_model(model): ) -# noqa -def get_type_for_document(schema, document): - types = schema.types.values() - for _type in types: - type_document = hasattr(_type, "_meta") and getattr(_type._meta, "document", None) - if document == type_document: - return _type - - def get_field_description(field, registry=None): """ Common metadata includes verbose_name and help_text. diff --git a/graphene_mongo/synchronous/fields.py b/graphene_mongo/synchronous/fields.py index ed290a6b..d0e9f9d8 100644 --- a/graphene_mongo/synchronous/fields.py +++ b/graphene_mongo/synchronous/fields.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import - import logging from functools import partial from itertools import filterfalse From 3b66aecf55980117d9bf05cbc0c2bfacf7e8247e Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sat, 23 May 2026 21:29:13 +0530 Subject: [PATCH 23/52] docs: add comprehensive docstrings to all classes and functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every public and private class, method, and module-level function now has a docstring that describes its purpose, all input arguments (with types), and return values — making the library self-documenting for new contributors. Files covered: base/utils.py, base/fields.py, base/field_resolvers/*, synchronous/fields.py, synchronous/types.py, asynchronous/fields.py, asynchronous/types.py. --- graphene_mongo/asynchronous/fields.py | 122 ++++++++ graphene_mongo/asynchronous/types.py | 21 ++ graphene_mongo/base/advanced_types.py | 73 +++++ graphene_mongo/base/converter.py | 94 ++++++- .../dynamic_reference_field_resolver.py | 56 +++- .../field_resolvers/list_field_resolver.py | 128 +++++++++ .../base/field_resolvers/union_resolver.py | 55 ++++ graphene_mongo/base/fields.py | 265 +++++++++++++++++- graphene_mongo/base/registry.py | 117 +++++++- graphene_mongo/base/types.py | 149 +++++++++- graphene_mongo/base/utils.py | 115 ++++++++ graphene_mongo/synchronous/fields.py | 127 +++++++++ graphene_mongo/synchronous/types.py | 21 ++ 13 files changed, 1330 insertions(+), 13 deletions(-) diff --git a/graphene_mongo/asynchronous/fields.py b/graphene_mongo/asynchronous/fields.py index c6ed5fb7..a8b07874 100644 --- a/graphene_mongo/asynchronous/fields.py +++ b/graphene_mongo/asynchronous/fields.py @@ -24,12 +24,40 @@ class AsyncMongoengineConnectionField(MongoengineConnectionField): + """Relay ``ConnectionField`` for asynchronous MongoEngine queries. + + Extends :class:`~graphene_mongo.synchronous.fields.MongoengineConnectionField` + with ``async def`` overrides for ``get_queryset``, ``default_resolver``, + ``chained_resolver``, and ``connection_resolver``. Also overrides + :meth:`_qs_accessor` to return ``model.aobjects`` instead of ``model.objects``. + + Accepted in ``Meta.connection_field_class`` of + :class:`~graphene_mongo.asynchronous.types.AsyncMongoengineObjectType` subclasses. + """ + @property def executor(self): + """Return ``ExecutorEnum.ASYNC`` to indicate asynchronous execution. + + Returns: + ExecutorEnum: Always ``ExecutorEnum.ASYNC``. + """ return ExecutorEnum.ASYNC @property def type(self): + """Return the Relay connection type for this async field. + + Validates that the underlying graphene type is an + :class:`~graphene_mongo.asynchronous.types.AsyncMongoengineObjectType` and + that it has an associated connection class. + + Returns: + type: The connection class (e.g. ``ArticleTypeConnection``). + + Raises: + AssertionError: If the type is not an ``AsyncMongoengineObjectType`` or has no connection. + """ from .types import AsyncMongoengineObjectType _type = super(ConnectionField, self).type @@ -43,14 +71,53 @@ def type(self): @property def registry(self): + """Return the async type registry for this field's node type. + + Falls back to the global async registry if the node type has no explicit registry. + + Returns: + Registry: The active async type registry. + """ return getattr(self.node_type._meta, "registry", get_global_async_registry()) def _qs_accessor(self, model): + """Return the async QuerySet manager for the given model. + + Overrides the sync base to return ``model.aobjects`` so that + :meth:`get_queryset` issues non-blocking MongoDB calls. + + Args: + model: A MongoEngine ``Document`` subclass. + + Returns: + mongoengine.AsyncQuerySet: The ``model.aobjects`` manager. + """ return model.aobjects def get_queryset( self, model, info, required_fields=None, skip=None, limit=None, **args ) -> AsyncQuerySet: + """Build and return an asynchronous MongoEngine ``AsyncQuerySet``. + + Mirrors :meth:`~graphene_mongo.synchronous.fields.MongoengineConnectionField.get_queryset` + but uses ``model.aobjects`` via :meth:`_qs_accessor`. Rejects sync ``QuerySet`` + instances returned by a user-supplied ``get_queryset`` callback. + + Args: + model: MongoEngine ``Document`` class to query. + info: GraphQL resolve info object. + required_fields (list[str] | None): Fields to project with ``.only()``. + skip (int | None): Number of documents to skip; ``None`` means no skip. + limit (int | None): Maximum documents to return; ``None`` means no limit. + **args: Additional MongoEngine filter keyword arguments. + + Returns: + AsyncQuerySet: The constructed (and optionally paginated) async QuerySet. + + Raises: + TypeError: If a user-supplied ``get_queryset`` callback returns a sync + ``QuerySet`` instead of an ``AsyncQuerySet``. + """ if required_fields is None: required_fields = list() if args: @@ -76,6 +143,30 @@ def get_queryset( return qs async def default_resolver(self, _root, info, required_fields=None, resolved=None, **args): + """Resolve a connection field asynchronously, returning a Relay-compatible connection. + + Async counterpart of + :meth:`~graphene_mongo.synchronous.fields.MongoengineConnectionField.default_resolver`. + Awaits every database call and ``AsyncQuerySet`` operation. + + Handles the same three resolution scenarios as the sync version: + + 1. **Pre-resolved iterable** (``resolved`` is set): Applies pagination to the + provided ``AsyncQuerySet`` or list. + 2. **``pk__in`` shortcut**: Fetches only the listed primary keys. + 3. **Normal query**: Issues a counted or uncounted query using ``model.aobjects``. + + Args: + _root: The parent document instance, or ``None`` for top-level queries. + info: GraphQL resolve info object. + required_fields (list[str] | None): Fields to project with ``.only()``. + resolved: Pre-resolved ``AsyncQuerySet`` or list, or ``None``. + **args: MongoEngine filter and Relay pagination arguments. + + Returns: + Connection: A graphene Relay connection with ``edges``, ``pageInfo``, + ``iterable``, and ``list_length`` populated. + """ if required_fields is None: required_fields = list() args = args or {} @@ -253,6 +344,22 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non return connection async def chained_resolver(self, resolver, is_partial, root, info, **args): + """Chain a user resolver with the async default MongoEngine resolver. + + Async counterpart of + :meth:`~graphene_mongo.synchronous.fields.MongoengineConnectionField.chained_resolver`. + Awaits coroutines returned by the user resolver. + + Args: + resolver (callable): The field's user-supplied or parent resolver. + is_partial (bool): ``True`` when *resolver* is a ``functools.partial``. + root: The parent document instance. + info: GraphQL resolve info object. + **args: MongoEngine filter and Relay pagination arguments. + + Returns: + Connection | list | Any: The resolved value for this connection field. + """ for key, value in dict(args).items(): if value is None: del args[key] @@ -319,6 +426,21 @@ def filter_connection(x): @classmethod async def connection_resolver(cls, resolver, connection_type, root, info, **args): + """Async entry point called by graphene for every connection field resolution. + + Awaits the resolver coroutine and wraps the result via ``resolve_connection``. + Handles ``Promise``-based results for compatibility with mixed sync/async setups. + + Args: + resolver (callable): The async chained resolver produced by ``wrap_resolve``. + connection_type: The graphene connection type (or ``NonNull`` wrapper). + root: The parent document instance, or ``None`` for top-level queries. + info: GraphQL resolve info object. + **args: GraphQL field arguments. + + Returns: + Connection: The resolved Relay connection. + """ if root: for key, value in root.__dict__.items(): if value: diff --git a/graphene_mongo/asynchronous/types.py b/graphene_mongo/asynchronous/types.py index d876b665..fd8ad515 100644 --- a/graphene_mongo/asynchronous/types.py +++ b/graphene_mongo/asynchronous/types.py @@ -9,6 +9,27 @@ def create_graphene_generic_class_async(object_type, option_type): + """Create an async MongoengineObjectType base class and its options class. + + Thin wrapper around :func:`~graphene_mongo.base.types.create_graphene_generic_class` + that injects async-specific dependencies (async registry factories, async connection + field class, and an ``async def get_node`` classmethod). + + ``get_node`` is injected via attribute assignment rather than subclassing to avoid + triggering ``__init_subclass_with_meta__`` on the intermediate class. + + Args: + object_type: graphene base class to inherit from + (e.g. ``ObjectType``, ``Interface``). + option_type: Matching options class + (e.g. ``ObjectTypeOptions``, ``InterfaceOptions``). + + Returns: + tuple[type, type]: + ``(GenericType, Options)`` — the generated base class and its options class. + ``GenericType.get_node`` is an async classmethod that uses + ``await model.aobjects.only(*fields).get(pk=id)``. + """ GenericType, Options = _create( object_type, option_type, diff --git a/graphene_mongo/base/advanced_types.py b/graphene_mongo/base/advanced_types.py index a54b348e..e813f31d 100644 --- a/graphene_mongo/base/advanced_types.py +++ b/graphene_mongo/base/advanced_types.py @@ -6,6 +6,18 @@ @shareable # Support Graphene Federation v2 class FileFieldType(graphene.ObjectType): + """GraphQL ObjectType representing a MongoEngine FileField (GridFS blob). + + Exposes GridFS metadata and the raw file data encoded as a base64 string. + + Fields: + content_type (String): MIME type of the stored file. + md5 (String): MD5 checksum of the file contents. + chunk_size (Int): GridFS chunk size in bytes. + length (Int): Total file size in bytes. + data (String): Base64-encoded raw file contents. + """ + content_type = graphene.String() md5 = graphene.String() chunk_size = graphene.Int() @@ -14,22 +26,42 @@ class FileFieldType(graphene.ObjectType): @classmethod def _resolve_fs_field(cls, field, name, default_value=None): + """Fetch a named attribute from the GridFS proxy attached to *field*. + + Args: + field: The graphene field instance; must carry ``instance`` and + ``key`` attributes pointing to the parent document and field name. + name (str): Name of the GridFS proxy attribute to read. + default_value: Value returned when the attribute is absent on the proxy. + + Returns: + The attribute value, or *default_value* if not present. + """ v = getattr(field.instance, field.key) return getattr(v, name, default_value) def resolve_content_type(self, info): + """Resolve the MIME type of the stored file.""" return FileFieldType._resolve_fs_field(self, "content_type") def resolve_md5(self, info): + """Resolve the MD5 checksum of the stored file.""" return FileFieldType._resolve_fs_field(self, "md5") def resolve_chunk_size(self, info): + """Resolve the GridFS chunk size (defaults to 0 if unavailable).""" return FileFieldType._resolve_fs_field(self, "chunk_size", 0) def resolve_length(self, info): + """Resolve the total file size in bytes (defaults to 0 if unavailable).""" return FileFieldType._resolve_fs_field(self, "length", 0) def resolve_data(self, info): + """Read the raw file bytes from GridFS and return them base64-encoded. + + Returns: + str | None: UTF-8 base64 string, or ``None`` if the file is empty. + """ v = getattr(self.instance, self.key) data = v.read() if data is not None: @@ -39,29 +71,70 @@ def resolve_data(self, info): @shareable # Support Graphene Federation v2 class _CoordinatesTypeField(graphene.ObjectType): + """Internal base ObjectType for GeoJSON geometry types. + + Provides the ``type`` string field (e.g. ``"Point"``) shared by all + GeoJSON geometry shapes. Subclasses add the appropriate ``coordinates`` + field for their specific geometry. + """ + type = graphene.String() def resolve_type(self, info): + """Return the GeoJSON geometry type string stored in the raw dict.""" return self["type"] def resolve_coordinates(self, info): + """Return the raw coordinates from the GeoJSON dict.""" return self["coordinates"] class PointFieldType(_CoordinatesTypeField): + """GraphQL ObjectType for a MongoEngine PointField (GeoJSON Point). + + Fields: + type (String): Always ``"Point"``. + coordinates (List[Float]): ``[longitude, latitude]``. + """ + coordinates = graphene.List(graphene.Float) class PointFieldInputType(graphene.InputObjectType): + """GraphQL InputObjectType for filtering or mutating a MongoEngine PointField. + + Used as an argument type when querying by geographic point. + + Fields: + type (String): GeoJSON geometry type; defaults to ``"Point"``. + coordinates (List[Float]): Required ``[longitude, latitude]`` pair. + """ + type = graphene.String(default_value="Point") coordinates = graphene.List(graphene.Float, required=True) class PolygonFieldType(_CoordinatesTypeField): + """GraphQL ObjectType for a MongoEngine PolygonField (GeoJSON Polygon). + + Fields: + type (String): Always ``"Polygon"``. + coordinates (List[List[List[Float]]]): Outer ring + optional hole rings, + each a list of ``[longitude, latitude]`` pairs. + """ + coordinates = graphene.List(graphene.List(graphene.List(graphene.Float))) class MultiPolygonFieldType(_CoordinatesTypeField): + """GraphQL ObjectType for a MongoEngine MultiPolygonField (GeoJSON MultiPolygon). + + Fields: + type (String): Always ``"MultiPolygon"``. + coordinates (List[List[List[List[Float]]]]): A list of Polygon coordinate + arrays, each following the PolygonFieldType convention. + """ + coordinates = graphene.List( graphene.List( graphene.List( diff --git a/graphene_mongo/base/converter.py b/graphene_mongo/base/converter.py index dd0eb594..5180e731 100644 --- a/graphene_mongo/base/converter.py +++ b/graphene_mongo/base/converter.py @@ -21,11 +21,31 @@ class MongoEngineConversionError(Exception): - pass + """Raised when a MongoEngine field type has no registered graphene converter.""" @singledispatch def convert_mongoengine_field(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert a MongoEngine field instance into the corresponding graphene field. + + Dispatches via ``@singledispatch`` to a type-specific handler registered + below. All handlers share the same signature so callers do not need to know + the concrete field type. + + Args: + field: A MongoEngine field instance (e.g. ``StringField``, ``ReferenceField``). + registry (Registry | None): Active type registry used to resolve + referenced document types to their graphene equivalents. + executor (ExecutorEnum): ``SYNC`` or ``ASYNC`` — controls which resolver + variant is attached to relationship fields. + + Returns: + A graphene field instance: ``graphene.String``, ``graphene.Field``, + ``graphene.List``, ``graphene.Dynamic``, etc. + + Raises: + MongoEngineConversionError: If no handler is registered for *field*'s type. + """ raise MongoEngineConversionError( "Don't know how to convert the MongoEngine field %s (%s)" % (field, field.__class__) ) @@ -35,6 +55,7 @@ def convert_mongoengine_field(field, registry=None, executor: ExecutorEnum = Exe @convert_mongoengine_field.register(mongoengine.StringField) @convert_mongoengine_field.register(mongoengine.URLField) def convert_field_to_string(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert EmailField / StringField / URLField → graphene.String.""" return graphene.String( description=get_field_description(field, registry), required=get_field_is_required(field, registry), @@ -44,6 +65,7 @@ def convert_field_to_string(field, registry=None, executor: ExecutorEnum = Execu @convert_mongoengine_field.register(mongoengine.UUIDField) @convert_mongoengine_field.register(mongoengine.ObjectIdField) def convert_field_to_id(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert UUIDField / ObjectIdField → graphene.ID.""" return graphene.ID( description=get_field_description(field, registry), required=get_field_is_required(field, registry), @@ -53,6 +75,7 @@ def convert_field_to_id(field, registry=None, executor: ExecutorEnum = ExecutorE @convert_mongoengine_field.register(mongoengine.IntField) @convert_mongoengine_field.register(mongoengine.SequenceField) def convert_field_to_int(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert IntField / SequenceField → graphene.Int.""" return graphene.Int( description=get_field_description(field, registry), required=get_field_is_required(field, registry), @@ -61,6 +84,7 @@ def convert_field_to_int(field, registry=None, executor: ExecutorEnum = Executor @convert_mongoengine_field.register(mongoengine.BooleanField) def convert_field_to_boolean(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert BooleanField → graphene.Boolean.""" return graphene.Boolean( description=get_field_description(field, registry), required=get_field_is_required(field, registry), @@ -69,6 +93,7 @@ def convert_field_to_boolean(field, registry=None, executor: ExecutorEnum = Exec @convert_mongoengine_field.register(mongoengine.FloatField) def convert_field_to_float(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert FloatField → graphene.Float.""" return graphene.Float( description=get_field_description(field, registry), required=get_field_is_required(field, registry), @@ -78,6 +103,7 @@ def convert_field_to_float(field, registry=None, executor: ExecutorEnum = Execut @convert_mongoengine_field.register(mongoengine.Decimal128Field) @convert_mongoengine_field.register(mongoengine.DecimalField) def convert_field_to_decimal(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert Decimal128Field / DecimalField → graphene.Decimal.""" return graphene.Decimal( description=get_field_description(field, registry), required=get_field_is_required(field, registry), @@ -86,6 +112,7 @@ def convert_field_to_decimal(field, registry=None, executor: ExecutorEnum = Exec @convert_mongoengine_field.register(mongoengine.DateTimeField) def convert_field_to_datetime(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert DateTimeField → graphene.DateTime.""" return graphene.DateTime( description=get_field_description(field, registry), required=get_field_is_required(field, registry), @@ -94,6 +121,7 @@ def convert_field_to_datetime(field, registry=None, executor: ExecutorEnum = Exe @convert_mongoengine_field.register(mongoengine.DateField) def convert_field_to_date(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert DateField → graphene.Date.""" return graphene.Date( description=get_field_description(field, registry), required=get_field_is_required(field, registry), @@ -103,6 +131,7 @@ def convert_field_to_date(field, registry=None, executor: ExecutorEnum = Executo @convert_mongoengine_field.register(mongoengine.DictField) @convert_mongoengine_field.register(mongoengine.MapField) def convert_field_to_jsonstring(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert DictField / MapField → graphene JSONString (arbitrary JSON blob).""" return JSONString( description=get_field_description(field, registry), required=get_field_is_required(field, registry), @@ -111,6 +140,7 @@ def convert_field_to_jsonstring(field, registry=None, executor: ExecutorEnum = E @convert_mongoengine_field.register(mongoengine.PointField) def convert_point_to_field(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert PointField → graphene.Field(PointFieldType).""" return graphene.Field( advanced_types.PointFieldType, description=get_field_description(field, registry), @@ -120,6 +150,7 @@ def convert_point_to_field(field, registry=None, executor: ExecutorEnum = Execut @convert_mongoengine_field.register(mongoengine.PolygonField) def convert_polygon_to_field(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert PolygonField → graphene.Field(PolygonFieldType).""" return graphene.Field( advanced_types.PolygonFieldType, description=get_field_description(field, registry), @@ -129,6 +160,7 @@ def convert_polygon_to_field(field, registry=None, executor: ExecutorEnum = Exec @convert_mongoengine_field.register(mongoengine.MultiPolygonField) def convert_multipolygon_to_field(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert MultiPolygonField → graphene.Field(MultiPolygonFieldType).""" return graphene.Field( advanced_types.MultiPolygonFieldType, description=get_field_description(field, registry), @@ -138,6 +170,7 @@ def convert_multipolygon_to_field(field, registry=None, executor: ExecutorEnum = @convert_mongoengine_field.register(mongoengine.FileField) def convert_file_to_field(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert FileField → graphene.Field(FileFieldType).""" return graphene.Field( advanced_types.FileFieldType, description=get_field_description(field, registry), @@ -149,6 +182,20 @@ def convert_file_to_field(field, registry=None, executor: ExecutorEnum = Executo @convert_mongoengine_field.register(mongoengine.EmbeddedDocumentListField) @convert_mongoengine_field.register(mongoengine.GeoPointField) def convert_field_to_list(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert ListField / EmbeddedDocumentListField / GeoPointField → graphene.List. + + For lists of references a resolver is attached that lazily fetches the + referenced documents. For lists of embedded documents or scalars the list + type is inferred from the inner field's converted type. + + Args: + field: The MongoEngine list field instance. + registry (Registry | None): Active type registry. + executor (ExecutorEnum): Controls which resolver variant is attached. + + Returns: + graphene.List or a ConnectionField if the inner type is a Relay Node. + """ base_type = convert_mongoengine_field(field.field, registry=registry, executor=executor) if isinstance(base_type, graphene.Field): if isinstance(field.field, mongoengine.GenericReferenceField): @@ -197,6 +244,21 @@ def convert_field_to_list(field, registry=None, executor: ExecutorEnum = Executo @convert_mongoengine_field.register(mongoengine.GenericEmbeddedDocumentField) @convert_mongoengine_field.register(mongoengine.GenericReferenceField) def convert_field_to_union(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert GenericEmbeddedDocumentField / GenericReferenceField → graphene Union Field. + + Builds a dynamic graphene.Union type from the field's ``choices`` list, + then wraps it in a ``graphene.Field`` with an appropriate resolver that + identifies the concrete type at query time. + + Args: + field: The MongoEngine generic field instance. + registry (Registry | None): Active type registry. + executor (ExecutorEnum): Controls which resolver variant is attached. + + Returns: + graphene.Field wrapping the generated Union type, or ``None`` if + none of the choices have been registered yet. + """ _types = [] for choice in field.choices: if isinstance(field, mongoengine.GenericReferenceField): @@ -266,6 +328,22 @@ def convert_field_to_union(field, registry=None, executor: ExecutorEnum = Execut @convert_mongoengine_field.register(mongoengine.EmbeddedDocumentField) @convert_mongoengine_field.register(mongoengine.ReferenceField) def convert_field_to_dynamic(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert EmbeddedDocumentField / ReferenceField → graphene.Dynamic. + + Returns a ``graphene.Dynamic`` so that the target type is resolved lazily + at schema build time, allowing forward references between types that are + defined in any order. A reference-field resolver is attached for + ``ReferenceField`` to handle lazy de-referencing. + + Args: + field: The MongoEngine embedded or reference field instance. + registry (Registry | None): Active type registry. + executor (ExecutorEnum): Controls which resolver variant is attached. + + Returns: + graphene.Dynamic: Evaluates to a ``graphene.Field`` once the target + type is available in the registry. + """ model = field.document_type def dynamic_type(): @@ -310,6 +388,20 @@ def dynamic_type(): @convert_mongoengine_field.register(mongoengine.EnumField) def convert_field_to_enum(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert EnumField → graphene.Field wrapping a graphene.Enum. + + Registers the Python enum class with the registry on first encounter so + that the same graphene.Enum wrapper is reused for all fields sharing the + same enum class. + + Args: + field: The MongoEngine EnumField instance. + registry (Registry): Active type registry (must not be None). + executor (ExecutorEnum): Unused for scalar enum fields. + + Returns: + graphene.Field: Wraps the registered graphene.Enum type. + """ if not registry.check_enum_already_exist(field._enum_cls): registry.register_enum(field._enum_cls) _type = registry.get_type_for_enum(field._enum_cls) diff --git a/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py b/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py index 9a92b013..ec611b44 100644 --- a/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py +++ b/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py @@ -9,10 +9,38 @@ class DynamicReferenceFieldResolver: + """Resolver factory for MongoEngine ``ReferenceField`` and ``EmbeddedDocumentField``. + + Handles lazy de-referencing efficiently: if ``select_related`` has already + loaded the referenced document it is returned immediately without a DB round + trip. Otherwise a targeted query is issued fetching only the fields selected + in the current GraphQL query (plus any ``required_fields`` declared in Meta). + """ + @staticmethod def __reference_resolver_common( field, registry, executor: ExecutorEnum, root, *args, **kwargs ) -> Optional[Union[tuple[Document, set[str], ObjectId], Document]]: + """Shared pre-fetch logic for both sync and async resolvers. + + Reads the raw value from the parent document, determines which fields + need to be fetched, and either returns the already-loaded document or + a ``(document_class, fields_to_fetch, pk)`` tuple for the caller to query. + + Args: + field: The MongoEngine ``ReferenceField`` instance being resolved. + registry (Registry): Active type registry used to look up the target type. + executor (ExecutorEnum): ``SYNC`` or ``ASYNC``. + root: The parent MongoEngine document instance. + *args: GraphQL positional args; ``args[0]`` must be the resolve info. + **kwargs: GraphQL keyword args (unused here). + + Returns: + ``Document`` — if the reference was already fetched by ``select_related``. + ``tuple[type, set[str], ObjectId]`` — ``(document_class, fields_to_fetch, pk)`` + if a DB query is required. + ``None`` — if the field value is empty / unset. + """ document = root._data.get(field.name or field.db_name, None) if not document: return None @@ -42,6 +70,19 @@ def __reference_resolver_common( @staticmethod def reference_resolver(field, registry, executor) -> Callable: + """Return a synchronous resolver for a ``ReferenceField``. + + The returned resolver fetches the referenced document using + ``model.objects.only(*fields).get(pk=pk)``. + + Args: + field: The MongoEngine ``ReferenceField`` instance. + registry (Registry): Active type registry. + executor (ExecutorEnum): Should be ``ExecutorEnum.SYNC``. + + Returns: + callable: ``resolver(root, *args, **kwargs) → Document | None`` + """ def resolver(root, *args, **kwargs) -> Optional[Document]: result = DynamicReferenceFieldResolver.__reference_resolver_common( field, registry, executor, root, *args, **kwargs @@ -55,6 +96,19 @@ def resolver(root, *args, **kwargs) -> Optional[Document]: @staticmethod def reference_resolver_async(field, registry, executor) -> Callable: + """Return an asynchronous resolver for a ``ReferenceField``. + + The returned coroutine fetches the referenced document using + ``await model.aobjects.only(*fields).get(pk=pk)``. + + Args: + field: The MongoEngine ``ReferenceField`` instance. + registry (Registry): Active type registry. + executor (ExecutorEnum): Should be ``ExecutorEnum.ASYNC``. + + Returns: + callable: ``async resolver(root, *args, **kwargs) → Document | None`` + """ async def resolver(root, *args, **kwargs) -> Optional[Document]: result = DynamicReferenceFieldResolver.__reference_resolver_common( field, registry, executor, root, *args, **kwargs @@ -64,4 +118,4 @@ async def resolver(root, *args, **kwargs) -> Optional[Document]: model, only_fields, document_id = result return await model.aobjects.only(*only_fields).get(pk=document_id) - return resolver \ No newline at end of file + return resolver diff --git a/graphene_mongo/base/field_resolvers/list_field_resolver.py b/graphene_mongo/base/field_resolvers/list_field_resolver.py index 466f7847..9fe3c38e 100644 --- a/graphene_mongo/base/field_resolvers/list_field_resolver.py +++ b/graphene_mongo/base/field_resolvers/list_field_resolver.py @@ -14,6 +14,15 @@ class ListFieldResolver: + """Resolver factory for MongoEngine ``ListField`` containing references. + + Efficiently resolves lists of ``GenericReferenceField`` entries by grouping + references by their target document type, fetching each group in parallel + (sync: ``ThreadPoolExecutor``; async: ``asyncio.gather``), and reassembling + the results in the original order. Already-loaded ``Document`` instances + (e.g. from ``select_related``) are passed through without additional DB queries. + """ + @staticmethod def __get_reference_objects_common( registry, @@ -22,6 +31,23 @@ def __get_reference_objects_common( object_id_list: list[ObjectId], queried_fields: dict, ) -> tuple[Document, set[str], list[ObjectId]]: + """Resolve the document class, field selection set, and IDs for a list of references. + + Converts ``model`` to its MongoEngine document class, determines which fields are + queried (intersecting ``queried_fields`` with the document's field list), and appends + any ``required_fields`` declared in Meta. + + Args: + registry (Registry): Active type registry. + model (str | type): MongoEngine document class or its class name string. + executor (ExecutorEnum): ``SYNC`` or ``ASYNC``. + object_id_list (list[ObjectId]): Primary keys to fetch. + queried_fields (dict): Fields selected in the current GraphQL query. + + Returns: + tuple[type, set[str], list[ObjectId]]: + ``(document_class, fields_to_fetch, object_id_list)`` + """ from graphene_mongo.base.converter import convert_mongoengine_field document = get_document(model) @@ -51,6 +77,18 @@ def __get_reference_objects( object_id_list: list[ObjectId], queried_fields: dict, ): + """Synchronously fetch a batch of documents by primary key. + + Args: + registry (Registry): Active type registry. + model (str | type): MongoEngine document class or its class name string. + executor (ExecutorEnum): "SYNC". + object_id_list (list[ObjectId]): Primary keys to fetch. + queried_fields (dict): Fields selected in the current GraphQL query. + + Returns: + QuerySet: Filtered QuerySet of matching documents with ``only`` projection. + """ document, only_fields, document_ids = ListFieldResolver.__get_reference_objects_common( registry, model, executor, object_id_list, queried_fields ) @@ -64,6 +102,18 @@ async def __get_reference_objects_async( object_id_list: list[ObjectId], queried_fields: dict, ): + """Asynchronously fetch a batch of documents by primary key. + + Args: + registry (Registry): Active type registry. + model (str | type): MongoEngine document class or its class name string. + executor (ExecutorEnum): ``ASYNC``. + object_id_list (list[ObjectId]): Primary keys to fetch. + queried_fields (dict): Fields selected in the current GraphQL query. + + Returns: + list[Document]: Fetched documents with ``only`` projection applied. + """ document, only_fields, document_ids = ListFieldResolver.__get_reference_objects_common( registry, model, executor, object_id_list, queried_fields ) @@ -73,11 +123,32 @@ async def __get_reference_objects_async( @staticmethod def __get_non_querying_object(model, object_id_list) -> list[Document]: + """Return lightweight stub document instances without hitting the database. + + Used when the referenced type is not in the queried GraphQL fragment, so + only the primary key is needed (no fields to fetch). + + Args: + model (str | type): MongoEngine document class or its class name string. + object_id_list (list[ObjectId]): Primary keys to create stubs for. + + Returns: + list[Document]: Stub instances with ``pk`` set but no other fields loaded. + """ model = get_document(model) return [model(pk=each) for each in object_id_list] @staticmethod async def __get_non_querying_object_async(model, object_id_list) -> list[Document]: + """Async wrapper around :meth:`__get_non_querying_object`; returns stubs without DB I/O. + + Args: + model (str | type): MongoEngine document class or its class name string. + object_id_list (list[ObjectId]): Primary keys to create stubs for. + + Returns: + list[Document]: Stub instances with ``pk`` set but no other fields loaded. + """ return ListFieldResolver.__get_non_querying_object(model, object_id_list) @staticmethod @@ -86,6 +157,18 @@ def __build_results( to_resolve_object_ids: list[ObjectId], already_resolved: dict[ObjectId, Document] = None, ) -> list[Document]: + """Merge fetched batches and already-resolved documents back into original order. + + Args: + result (list[Document]): Batches of documents returned by each fetch call. + Each element is itself iterable (a QuerySet or list). + to_resolve_object_ids (list[ObjectId]): Original ordered list of primary keys. + already_resolved (dict[ObjectId, Document] | None): Documents already loaded + (e.g. from "select_related`) keyed by their primary key. + + Returns: + list[Document]: Documents ordered to match ``to_resolve_object_ids``. + """ result_object: dict[ObjectId, Document] = dict(already_resolved or {}) for items in result: for item in items: @@ -98,6 +181,25 @@ def __build_results( def __reference_resolver_common( field, registry, executor: ExecutorEnum, root, *args, **kwargs ) -> Optional[tuple[Union[list[Task], list[Document]], list[ObjectId]]]: + """Shared dispatch logic for both sync and async list-reference resolvers. + + Iterates over the raw field value, separating already-loaded ``Document`` + instances from "LazyReference" entries and raw ``_cls``/``_ref`` dicts. + Groups unresolved references by their document type, then either submits + them to a "ThreadPoolExecutor" (sync) or creates "asyncio" tasks (async). + + Args: + field: The MongoEngine ``ListField`` containing generic references. + registry (Registry): Active type registry. + executor (ExecutorEnum): ``SYNC`` or ``ASYNC`` — controls dispatch strategy. + root: The parent MongoEngine document instance. + *args: GraphQL positional args; ``args[0]`` must be the resolve info. + **kwargs: GraphQL keyword args (unused here). + + Returns: + ``tuple[list[Future | Task], list[ObjectId], dict[ObjectId, Document]]`` + for the caller to await / join, or ``None`` if the field is empty. + """ to_resolve = getattr(root, field.name or field.db_name) if not to_resolve: return None @@ -173,6 +275,19 @@ def __reference_resolver_common( @staticmethod def reference_resolver(field, registry, executor) -> Callable: + """Return a synchronous resolver for a list of generic references. + + Dispatches fetch jobs to a ``ThreadPoolExecutor``, then reassembles the + results in the original order via :meth:`__build_results`. + + Args: + field: The MongoEngine ``ListField`` containing generic references. + registry (Registry): Active type registry. + executor (ExecutorEnum): Should be ``ExecutorEnum.SYNC``. + + Returns: + callable: ``resolver(root, *args, **kwargs) → list[Document] | None`` + """ def resolver(root, *args, **kwargs) -> Optional[list[Document]]: resolver_result = ListFieldResolver.__reference_resolver_common( field, registry, executor, root, *args, **kwargs @@ -186,6 +301,19 @@ def resolver(root, *args, **kwargs) -> Optional[list[Document]]: @staticmethod def reference_resolver_async(field, registry, executor) -> Callable: + """Return an asynchronous resolver for a list of generic references. + + Awaits all ``asyncio`` tasks via ``asyncio.gather``, then reassembles the + results in the original order via :meth:`__build_results`. + + Args: + field: The MongoEngine ``ListField`` containing generic references. + registry (Registry): Active type registry. + executor (ExecutorEnum): Should be ``ExecutorEnum.ASYNC``. + + Returns: + callable: ``async resolver(root, *args, **kwargs) → list[Document] | None`` + """ async def resolver(root, *args, **kwargs) -> Optional[list[Document]]: resolver_result = ListFieldResolver.__reference_resolver_common( field, registry, executor, root, *args, **kwargs diff --git a/graphene_mongo/base/field_resolvers/union_resolver.py b/graphene_mongo/base/field_resolvers/union_resolver.py index 3b7a32e5..4f414427 100644 --- a/graphene_mongo/base/field_resolvers/union_resolver.py +++ b/graphene_mongo/base/field_resolvers/union_resolver.py @@ -10,10 +10,39 @@ class UnionFieldResolver: + """Resolver factory for MongoEngine ``GenericReferenceField``. + + Handles lazy de-referencing of generic references (fields that can point to + different document types). Identifies the concrete document type at resolve time, + then fetches only the fields requested in the current GraphQL query. + """ + @staticmethod def __reference_resolver_common( field, registry, executor: ExecutorEnum, root, *args, **kwargs ) -> Optional[Union[tuple[Document, set[str], ObjectId], Document]]: + """Shared pre-fetch logic for both sync and async union resolvers. + + Reads the raw generic reference from the parent document, identifies the + target document type, and determines which fields need to be fetched. Returns + the already-loaded document if it has been fetched, or a tuple for the caller + to query. + + Args: + field: The MongoEngine ``GenericReferenceField`` instance being resolved. + registry (Registry): Active type registry used to look up the target type. + executor (ExecutorEnum): ``SYNC`` or ``ASYNC``. + root: The parent MongoEngine document instance. + *args: GraphQL positional args; ``args[0]`` must be the resolve info. + **kwargs: GraphQL keyword args (unused here). + + Returns: + ``Document`` — if the reference was already fetched (e.g. via ``select_related``). + ``tuple[type, set[str], ObjectId]`` — ``(document_class, fields_to_fetch, pk)`` + if a DB query is required. + ``Document(id=pk)`` — a stub instance if the type is not in the queried union. + ``None`` — if the field value is empty / unset. + """ from graphene_mongo.base.converter import convert_mongoengine_field de_referenced = getattr(root, field.name or field.db_name) @@ -54,6 +83,19 @@ def __reference_resolver_common( @staticmethod def reference_resolver(field, registry, executor) -> Callable: + """Return a synchronous resolver for a ``GenericReferenceField``. + + The returned resolver fetches the referenced document using + ``model.objects.only(*fields).get(pk=pk)``. + + Args: + field: The MongoEngine ``GenericReferenceField`` instance. + registry (Registry): Active type registry. + executor (ExecutorEnum): Should be ``ExecutorEnum.SYNC``. + + Returns: + callable: ``resolver(root, *args, **kwargs) → Document | None`` + """ def resolver(root, *args, **kwargs) -> Optional[Document]: result = UnionFieldResolver.__reference_resolver_common( field, registry, executor, root, *args, **kwargs @@ -67,6 +109,19 @@ def resolver(root, *args, **kwargs) -> Optional[Document]: @staticmethod def reference_resolver_async(field, registry, executor) -> Callable: + """Return an asynchronous resolver for a ``GenericReferenceField``. + + The returned coroutine fetches the referenced document using + ``await model.aobjects.only(*fields).get(pk=pk)``. + + Args: + field: The MongoEngine ``GenericReferenceField`` instance. + registry (Registry): Active type registry. + executor (ExecutorEnum): Should be ``ExecutorEnum.ASYNC``. + + Returns: + callable: ``async resolver(root, *args, **kwargs) → Document | None`` + """ async def resolver(root, *args, **kwargs) -> Optional[Document]: result = UnionFieldResolver.__reference_resolver_common( field, registry, executor, root, *args, **kwargs diff --git a/graphene_mongo/base/fields.py b/graphene_mongo/base/fields.py index 621e8657..a6607ae4 100644 --- a/graphene_mongo/base/fields.py +++ b/graphene_mongo/base/fields.py @@ -34,7 +34,32 @@ class BaseMongoengineConnectionField(ConnectionField): + """Shared base class for sync and async MongoEngine connection fields. + + Provides all properties and helper methods that are identical between + :class:`~graphene_mongo.synchronous.fields.MongoengineConnectionField` (sync) + and :class:`~graphene_mongo.asynchronous.fields.AsyncMongoengineConnectionField` + (async). Subclasses must override :attr:`executor` and, optionally, + :meth:`_qs_accessor` (async overrides it to return ``model.aobjects`` instead + of ``model.objects``). + + The ``get_queryset``, ``default_resolver``, ``chained_resolver``, and + ``connection_resolver`` methods are implemented in the concrete subclasses + because they differ in whether they are ``async def`` and in how they call the + queryset managers. + """ + def __init__(self, type, *args, **kwargs): + """Initialise the connection field, optionally accepting a custom ``get_queryset``. + + Args: + type: The graphene ObjectType (or its connection) this field resolves to. + *args: Forwarded to :class:`graphene.relay.ConnectionField`. + **kwargs: Forwarded to :class:`graphene.relay.ConnectionField`. + Special key ``get_queryset`` (callable | None): When provided, called + during resolution to supply or override the MongoEngine QuerySet. + Must be callable; otherwise an ``AssertionError`` is raised. + """ get_queryset = kwargs.pop("get_queryset", None) if get_queryset: assert callable(get_queryset), ( @@ -45,30 +70,78 @@ def __init__(self, type, *args, **kwargs): @property def executor(self) -> ExecutorEnum: + """Return the executor variant (SYNC or ASYNC) for this field. + + Must be overridden by subclasses to return ``ExecutorEnum.SYNC`` or + ``ExecutorEnum.ASYNC``. + + Raises: + NotImplementedError: If not overridden in a subclass. + """ raise NotImplementedError @property def node_type(self): + """Return the graphene node type from the connection's ``_meta.node``. + + Returns: + type: The graphene ObjectType (e.g. ``ArticleType``) backing the connection. + """ return self.type._meta.node @property def model(self): + """Return the MongoEngine model class backing the node type. + + Returns: + type: A MongoEngine ``Document`` subclass. + """ return self.node_type._meta.model @property def order_by(self): + """Return the default ordering expression declared in the node type's Meta. + + Returns: + str | None: MongoEngine ordering string (e.g. ``"-created_at"``), or ``None``. + """ return self.node_type._meta.order_by @property def required_fields(self): + """Return the union of ``required_fields`` and ``only_fields`` from Meta. + + These fields are always fetched from MongoDB regardless of the GraphQL + query selection, ensuring relationships and computed properties work correctly. + + Returns: + tuple[str]: Deduplicated tuple of field names to always project. + """ return tuple(set(self.node_type._meta.required_fields + self.node_type._meta.only_fields)) @property def registry(self): + """Return the type registry associated with this field's node type. + + Falls back to the global sync registry if the node type has no explicit registry. + + Returns: + Registry: The active type registry. + """ return getattr(self.node_type._meta, "registry", get_global_registry()) @property def args(self): + """Build the complete set of GraphQL arguments for this connection field. + + Merges ``field_args``, ``advance_args``, ``filter_args``, and ``extended_args`` + into a single argument map, then strips any field names listed in + ``Meta.non_filter_fields`` and removes keys already present in ``_base_args`` + (the built-in Relay pagination args: ``first``, ``last``, ``before``, ``after``). + + Returns: + OrderedDict: Complete argument mapping passed to the generated GraphQL field. + """ _field_args = self.field_args _advance_args = self.advance_args _filter_args = self.filter_args @@ -92,9 +165,31 @@ def args(self): @args.setter def args(self, args): + """Store the base (Relay pagination) args before the extra field args are merged in. + + Args: + args: The base argument mapping supplied by graphene's ``ConnectionField``. + """ self._base_args = args def _field_args(self, items): + """Filter *items* down to the subset of fields that are usable as query arguments. + + A field is excluded when it: + - is backed by an SDL-annotated federation key (``@key`` directive) + - is a Python ``property`` on the model + - converts to a ``ConnectionField`` or ``Dynamic`` + - converts to a complex output type (``FileFieldType``, geo types, ``Union``) + - is a ``List`` whose element type is a ``Union`` or ``ObjectType`` + - has a mismatching type between the graphene field and converter output + + Args: + items: Iterable of ``(name, graphene_field)`` pairs (from ``self.fields.items()``). + + Returns: + dict[str, graphene scalar instance]: Filterable field names mapped to their + scalar type instance (suitable for use as a GraphQL argument type). + """ def is_filterable(k): if hasattr(self.fields[k].type, "_sdl"): return False @@ -153,10 +248,29 @@ def get_filter_type(_type): @property def field_args(self): + """Return filterable scalar arguments derived from the node type's fields. + + Delegates to :meth:`_field_args` over all fields declared on the graphene type. + + Returns: + dict[str, graphene scalar instance]: Field-level filter arguments. + """ return self._field_args(self.fields.items()) @property def filter_args(self): + """Build filter arguments from the ``Meta.filter_fields`` declaration. + + ``filter_fields`` is a dict of ``{field_name: [lookup, ...]}`` (e.g. + ``{"name": ["exact", "icontains"]}``). For each lookup a corresponding + ``graphene.Argument`` is generated, using ``graphene.List`` for + ``in`` / ``nin`` / ``all`` lookups and ``PointFieldInputType`` for geo + ``near`` queries. + + Returns: + dict[str, graphene.Argument]: Lookup-style filter argument mapping, + keyed by ``"field__lookup"`` (e.g. ``"name__icontains"``). + """ filter_args = dict() if self._type._meta.filter_fields: for field, filter_collection in self._type._meta.filter_fields.items(): @@ -185,6 +299,17 @@ def filter_args(self): @property def advance_args(self): + """Build advanced filter arguments for reference and geo fields. + + For ``PointField`` fields adds a ``PointFieldInputType`` argument. + For ``ReferenceField`` / ``GenericReferenceField`` fields adds a ``graphene.ID`` + argument so callers can filter by global ID. + For other ``Dynamic`` fields, if the resolved type has an ``id`` field and + is not an ``EmbeddedDocument``, adds its ID type as an argument. + + Returns: + dict[str, graphene argument]: Advanced argument mapping keyed by field name. + """ def get_advance_field(r, kv): field = kv[1] mongo_field = getattr(self.model, kv[0], None) @@ -218,6 +343,15 @@ def get_advance_field(r, kv): @property def extended_args(self): + """Build extra ``graphene.ID`` arguments for federation-annotated fields. + + Fields whose graphene type carries an ``_sdl`` attribute (i.e. fields + declared via ``graphene_federation``) are exposed as ``ID`` arguments so + they can still be used as filters. + + Returns: + dict[str, graphene.ID]: Mapping of field name → ``graphene.ID()`` instance. + """ args = OrderedDict() for k, each in self.fields.items(): if hasattr(each.type, "_sdl"): @@ -226,13 +360,36 @@ def extended_args(self): @property def fields(self): + """Return the resolved ``_meta.fields`` dict of the node type. + + Forces lazy type resolution (``get_type``) before accessing metadata so + that ``Dynamic`` / string-reference types are fully initialised. + + Returns: + dict[str, graphene.Field]: All fields declared on the graphene type. + """ self._type = get_type(self._type) return self._type._meta.fields # ── helpers ────────────────────────────────────────────────────────────── def _hydrate_args(self, args: dict) -> None: - """Hydrate reference/geo args in-place (global IDs → objects, coordinates).""" + """Hydrate reference and geo args in-place, converting GraphQL representations to MongoEngine objects. + + Performs three types of conversion: + + - ``ReferenceField`` args: Relay global ID strings are decoded via ``from_global_id`` + and used to construct a lightweight document stub (``DocumentClass(pk=...)``). + - ``GenericReferenceField`` args: The global ID is decoded to extract the type name + and PK; the document class is looked up in the registry. + - Geo ``__near`` args: The ``PointFieldInputType`` dict is converted to a coordinate + list; a default ``__max_distance`` of 10,000 is added if not already present. + - Plain ``id`` args: Decoded from global ID format and replaced in-place. + + Args: + args (dict): Mutable argument dict to update in-place; keys are field names + and values are the raw GraphQL argument values. + """ reference_fields = get_model_reference_fields(self.model) hydrated: dict = {} for arg_name, arg in args.copy().items(): @@ -268,11 +425,38 @@ def _hydrate_args(self, args: dict) -> None: args.update(hydrated) def _qs_accessor(self, model): - """Return the QuerySet manager for this executor. Overridden in async.""" + """Return the synchronous QuerySet manager for the given model. + + The async subclass overrides this to return ``model.aobjects`` so that + the entire ``get_queryset`` implementation can live in the base class with + only this single line differing between sync and async. + + Args: + model: A MongoEngine ``Document`` subclass. + + Returns: + mongoengine.QuerySet: The ``model.objects`` manager. + """ return model.objects def _apply_select_related(self, qs, model, info): - """Apply select_related and related-field filters to a queryset.""" + """Apply ``select_related`` and sub-field filters to a QuerySet. + + Walks the current GraphQL query selection (via :func:`get_query_fields`) to + determine which reference fields are being queried. For each, a + ``select_related`` path is added and any filter arguments declared on the + sub-field (e.g. ``articles(headline: "Hello")``) are pushed into the QS + via ``filter(articles__headline="Hello")``. + + Args: + qs: The base MongoEngine QuerySet to augment. + model: The MongoEngine ``Document`` class being queried. + info: The GraphQL resolve info (``GraphQLResolveInfo``); when *info* is not + a ``GraphQLResolveInfo`` instance (e.g. in tests) the step is skipped. + + Returns: + QuerySet: The augmented QuerySet (may be the same object if no paths found). + """ queried_fields = get_query_fields(info) if isinstance(info, GraphQLResolveInfo) else {} related = get_select_related_paths(model, queried_fields) related_filter = ( @@ -290,7 +474,19 @@ def _apply_select_related(self, qs, model, info): return qs def _build_args_copy(self, args: dict) -> dict: - """Build a filtered args copy for count queries, normalising ObjectIds.""" + """Build a filtered copy of *args* suitable for MongoDB count / filter queries. + + Strips keys that are not MongoEngine field names on this model, and converts + reference field values to ``ObjectId`` and enum field values to their raw Python + value so that MongoDB accepts them directly. + + Args: + args (dict): The current resolver argument dict (not mutated). + + Returns: + dict: A new dict containing only model-level field keys, with reference + values decoded to ``ObjectId`` and enum values unwrapped. + """ args_copy = args.copy() for key in args.copy(): if key not in self.model._fields_ordered: @@ -313,9 +509,28 @@ def _build_args_copy(self, args: dict) -> dict: return args_copy def _prepare_resolver_inputs(self, _root, info, args: dict, resolved): - """ - Pre-process _root to set args['pk__in'] or populate a pre-loaded resolved list. - Returns (field_name, resolved). + """Pre-process ``_root`` to populate ``args['pk__in']`` or a pre-loaded ``resolved`` list. + + When a parent document is present (``_root is not None``), inspects the + field value on the parent to determine the resolution strategy: + + - If the parent does not have ``_fields_ordered`` (non-Document root), treats + the field value as a plain Python list and sets ``args["pk__in"]`` to its IDs. + - If the field is a list of already-loaded ``Document`` instances (pre-fetched + by ``select_related``), populates ``resolved`` directly and clears non-``id`` + args to avoid a redundant DB query. + - Otherwise, sets ``args["pk__in"]`` from the raw reference list. + + Args: + _root: The parent resolver root object, or ``None`` for top-level queries. + info: GraphQL resolve info object (used to derive the field name). + args (dict): Mutable argument dict, updated in-place with ``pk__in`` if needed. + resolved: Pre-loaded iterable of documents, or ``None``. + + Returns: + tuple[str, list | None]: ``(field_name, resolved)`` where ``field_name`` is + the snake_case field name on the parent, and ``resolved`` is either the + pre-loaded list or ``None`` if a DB query is still required. """ field_name = to_snake_case(info.field_name) if _root is not None else "" if _root is not None and not resolved: @@ -345,7 +560,19 @@ def _prepare_resolver_inputs(self, _root, info, args: dict, resolved): return field_name, resolved def _collect_required_fields(self, info) -> list: - """Collect required fields from meta and the current query selection.""" + """Collect the set of MongoEngine fields that must be fetched for this query. + + Combines: + 1. ``required_fields`` from the node type's ``Meta`` (always fetched). + 2. All snake_case field names from the current GraphQL query selection + that map to actual MongoEngine fields on the model. + + Args: + info: GraphQL resolve info object; passed to :func:`get_query_fields`. + + Returns: + list[str]: Field names to pass to ``.only(...)`` on the QuerySet. + """ required_fields = [ f for f in self.required_fields if f in self.model._fields_ordered ] @@ -357,7 +584,27 @@ def _collect_required_fields(self, info) -> list: return required_fields def _transform_qs_args(self, args: dict, args_copy: dict) -> dict: - """Re-map a resolved QuerySet's _query dict into graphene-mongo style args.""" + """Re-map a resolved QuerySet's ``_query`` dict into graphene-mongo style args. + + When a custom resolver returns a ``QuerySet``, its internal ``_query`` dict + uses MongoDB wire format (dotted paths, ``$lte`` / ``$gte`` operators, etc.). + This method converts those entries into the ``__``-separated format that + graphene-mongo passes to :meth:`default_resolver`. + + Specifically: + - Keys with ``.`` are rewritten to use ``__`` separators. + - The special ``_id`` key (with ``$in`` / ``$lte`` etc.) is rewritten to ``pk__in`` etc. + - Standard comparison operators (``$lte``, ``$gte``, ``$ne``, ``$in``) inside + a field value dict are appended as ``field__lte``, ``field__gte``, etc. + - Keys not in the model's field list or the Relay args / filter args are stripped. + + Args: + args (dict): The merged ``_query`` dict (mutated by this method for operator lookups). + args_copy (dict): A pre-made copy of *args* that is returned as the result. + + Returns: + dict: The transformed ``args_copy`` ready for :meth:`default_resolver`. + """ for arg_name, arg in args.copy().items(): if "." in arg_name or arg_name not in self.model._fields_ordered + ( "first", diff --git a/graphene_mongo/base/registry.py b/graphene_mongo/base/registry.py index 3286c73b..1bbbab07 100644 --- a/graphene_mongo/base/registry.py +++ b/graphene_mongo/base/registry.py @@ -4,7 +4,23 @@ from graphene_mongo.base.utils import ExecutorEnum -class Registry(object): +class Registry: + """Central type registry mapping MongoEngine models to their GraphQL counterparts. + + Maintains four internal mappings: + + - model class → graphene ObjectType + - graphene type name (str) → MongoEngine model name (str) + - MongoEngine model name (str) → graphene ObjectType (Documents only) + - Python EnumMeta → graphene.Enum wrapper + + One Registry instance is shared across all types registered together, + which allows forward references to be resolved after all types are defined. + + Args: + executor (ExecutorEnum): Whether this registry serves SYNC or ASYNC fields. + """ + def __init__(self, executor: ExecutorEnum): self.executor = executor self._registry = {} @@ -13,6 +29,19 @@ def __init__(self, executor: ExecutorEnum): self._registry_enum = {} def register(self, cls): + """Register a MongoengineObjectType (or async variant) with this registry. + + After registration every previously registered type is rescanned so that + self-referential and forward-declared fields can be resolved now that + the new type is available. + + Args: + cls: A subclass of MongoengineObjectType or AsyncMongoengineObjectType. + + Raises: + AssertionError: If *cls* is not a recognised Mongoengine object type, + or if ``cls._meta.registry`` does not point to this Registry instance. + """ from ..synchronous.types import GrapheneMongoengineObjectTypes from ..asynchronous.types import AsyncGrapheneMongoengineObjectTypes @@ -34,6 +63,17 @@ def register(self, cls): cls.rescan_fields() def register_enum(self, cls): + """Register a Python Enum class and wrap it as a graphene Enum. + + Automatically appends ``"Enum"`` to the class name when not already + suffixed, keeping the GraphQL schema name unambiguous. + + Args: + cls (EnumMeta): The Python enum class to register. + + Raises: + AssertionError: If *cls* is not an ``EnumMeta`` instance. + """ from enum import EnumMeta assert isinstance(cls, EnumMeta), ( @@ -47,20 +87,67 @@ def register_enum(self, cls): self._registry_enum[cls] = Enum.from_enum(cls) def get_type_for_model(self, model): + """Return the graphene ObjectType registered for a MongoEngine model. + + Args: + model: A MongoEngine Document or EmbeddedDocument class. + + Returns: + The registered graphene type class, or ``None`` if not registered. + """ return self._registry.get(model) def get_type_for_model_string(self, model_string: str) -> str | None: + """Return the MongoEngine model name registered under a graphene type name. + + Args: + model_string (str): The graphene type name (e.g. ``"ArticleType"``). + + Returns: + str | None: The MongoEngine model class name, or ``None`` if not found. + """ return self._registry_string_map.get(model_string) def get_type_for_document_model(self, model): + """Return the graphene type class for a top-level MongoEngine Document. + + Unlike :meth:`get_type_for_model`, this method looks up by class name + string and only works for ``Document`` subclasses (not ``EmbeddedDocument``). + + Args: + model: A MongoEngine Document subclass. + + Returns: + The registered graphene type class, or ``None`` if not registered. + + Raises: + TypeError: If *model* is not a Document subclass. + """ if not issubclass(model, Document): raise TypeError(f"{model} is not a Document") return self._registry_document_string_map.get(model.__name__) def check_enum_already_exist(self, cls): + """Return whether an enum class has already been registered. + + Args: + cls (EnumMeta): The Python enum class to check. + + Returns: + bool: ``True`` if already registered, ``False`` otherwise. + """ return cls in self._registry_enum def get_type_for_enum(self, cls): + """Return the graphene Enum wrapper for a registered Python enum class. + + Args: + cls (EnumMeta): The Python enum class. + + Returns: + graphene.Enum | None: The wrapped graphene Enum, or ``None`` if + not yet registered. + """ return self._registry_enum.get(cls) @@ -71,6 +158,11 @@ def get_type_for_enum(self, cls): def get_inputs_registry(): + """Return (creating if necessary) the singleton sync Registry for InputObjectTypes. + + Returns: + Registry: Shared SYNC registry used for all InputObjectType registrations. + """ global inputs_registry if not inputs_registry: inputs_registry = Registry(executor=ExecutorEnum.SYNC) @@ -78,6 +170,11 @@ def get_inputs_registry(): def get_inputs_async_registry(): + """Return (creating if necessary) the singleton async Registry for InputObjectTypes. + + Returns: + Registry: Shared ASYNC registry used for all async InputObjectType registrations. + """ global async_inputs_registry if not async_inputs_registry: async_inputs_registry = Registry(executor=ExecutorEnum.ASYNC) @@ -85,6 +182,11 @@ def get_inputs_async_registry(): def get_global_registry(): + """Return (creating if necessary) the singleton sync Registry. + + Returns: + Registry: Shared SYNC registry used for all MongoengineObjectType registrations. + """ global registry if not registry: registry = Registry(executor=ExecutorEnum.SYNC) @@ -92,6 +194,11 @@ def get_global_registry(): def get_global_async_registry(): + """Return (creating if necessary) the singleton async Registry. + + Returns: + Registry: Shared ASYNC registry used for all AsyncMongoengineObjectType registrations. + """ global async_registry if not async_registry: async_registry = Registry(executor=ExecutorEnum.ASYNC) @@ -99,6 +206,10 @@ def get_global_async_registry(): def reset_global_registry(): + """Reset the sync global registry and inputs registry to ``None``. + + Called between tests to ensure a clean state. + """ global registry global inputs_registry registry = None @@ -106,6 +217,10 @@ def reset_global_registry(): def reset_global_async_registry(): + """Reset the async global registry and inputs registry to ``None``. + + Called between tests to ensure a clean state. + """ global async_registry global async_inputs_registry async_registry = None diff --git a/graphene_mongo/base/types.py b/graphene_mongo/base/types.py index 3e4e3797..17bb2f8f 100644 --- a/graphene_mongo/base/types.py +++ b/graphene_mongo/base/types.py @@ -17,6 +17,38 @@ def construct_fields( non_required_fields, executor: ExecutorEnum = ExecutorEnum.SYNC, ): + """Convert MongoEngine model fields to graphene field instances. + + Iterates over the model's fields in alphabetical order (from + :func:`~graphene_mongo.base.utils.get_model_fields`), applying the + ``only_fields`` / ``exclude_fields`` filters, and delegates each field to + :func:`~graphene_mongo.base.converter.convert_mongoengine_field`. + + Self-referential ``ListField`` entries (a list whose element type is the + owning model) are returned separately so they can be registered in a second + pass after the owning graphene type exists in the registry. + + Args: + model: MongoEngine ``Document`` or ``EmbeddedDocument`` class. + registry (Registry): Active type registry. + only_fields (tuple[str]): Whitelist of field names to include; + an empty tuple means *all* fields are included. + exclude_fields (tuple[str]): Field names to unconditionally skip. + non_required_fields (tuple[str]): Field names whose ``required`` flag + is forced to ``False`` in the generated graphene field. + executor (ExecutorEnum): ``SYNC`` or ``ASYNC`` — controls which resolver + variant is attached to relationship fields. + + Returns: + tuple[dict, dict]: + ``(converted_fields, self_referenced)`` + + - *converted_fields*: ``{name: graphene_field}`` for all immediately + usable fields. + - *self_referenced*: ``{name: mongoengine_field}`` for ``ListField`` + entries that reference the owning model (resolved in a second pass + by :func:`construct_self_referenced_fields`). + """ _model_fields = get_model_fields(model) fields = dict() self_referenced = dict() @@ -48,6 +80,20 @@ def construct_fields( def construct_self_referenced_fields(self_referenced, registry, executor=ExecutorEnum.SYNC): + """Convert self-referential ListFields after the owning type is registered. + + Called in a second pass because the graphene type must exist in the registry + before its own circular reference field can be resolved by the converter. + + Args: + self_referenced (dict): ``{name: mongoengine_field}`` mapping returned + by :func:`construct_fields` for self-referential ListFields. + registry (Registry): Active type registry (owning type already registered). + executor (ExecutorEnum): ``SYNC`` or ``ASYNC``. + + Returns: + dict: ``{name: graphene_field}`` for the successfully converted fields. + """ fields = dict() for name, field in self_referenced.items(): converted = convert_mongoengine_field(field, registry, executor) @@ -66,6 +112,46 @@ def create_graphene_generic_class( inputs_registry_factory, default_connection_field_class, ): + """Factory that produces a MongoEngine-aware graphene ObjectType base class. + + The returned class (``GrapheneMongoengineGenericType``) is the shared ancestor + for both ``MongoengineObjectType`` (sync) and ``AsyncMongoengineObjectType`` + (async). Callers inject the executor, registry factories, and default + connection field class so that the same factory body serves both variants + without duplication. + + The produced class exposes: + + - ``__init_subclass_with_meta__`` — wires up ``model``, ``registry``, + ``fields``, and ``connection`` when a user declares + ``class MyType(MongoengineObjectType): class Meta: model = Article``. + - ``rescan_fields()`` — re-converts fields that were unresolvable at first + registration (e.g. forward references to types defined later). + - ``is_type_of()`` — used by graphene to resolve abstract / union types. + - ``resolve_id()`` — returns ``str(self.id)`` for the Relay global ID. + + ``get_node`` is **not** defined here; it differs between sync (plain method) + and async (``async def``), and is injected by the caller via classmethod + attribute assignment after this factory returns. + + Args: + object_type: graphene base class to inherit from + (``ObjectType``, ``Interface``, or ``InputObjectType``). + option_type: Matching options class + (``ObjectTypeOptions``, ``InterfaceOptions``, etc.). + executor (ExecutorEnum): ``SYNC`` or ``ASYNC``. + global_registry_factory (callable): Zero-argument callable that returns + the singleton ``Registry`` for regular object types. + inputs_registry_factory (callable): Zero-argument callable that returns + the singleton ``Registry`` for ``InputObjectType`` registrations. + default_connection_field_class (type): ``ConnectionField`` subclass used + when ``Meta.connection_field_class`` is not specified. + + Returns: + tuple[type, type]: + ``(GrapheneMongoengineGenericType, MongoengineGenericObjectTypeOptions)`` + """ + class MongoengineGenericObjectTypeOptions(option_type): model = None registry = None # type: Registry @@ -96,6 +182,41 @@ def __init_subclass_with_meta__( order_by=None, **options, ): + """Wire up a user-defined MongoengineObjectType subclass. + + Called automatically by Python when the user writes + ``class MyType(MongoengineObjectType): class Meta: model = Article``. + + Args: + model: MongoEngine ``Document`` or ``EmbeddedDocument`` class + that backs this graphene type. Required. + registry (Registry | None): Explicit registry to use; when + omitted the appropriate singleton is chosen automatically. + skip_registry (bool): If ``True``, the type is not registered + after creation (useful for abstract base types). + only_fields (tuple[str]): Whitelist of model field names to expose. + required_fields (tuple[str]): Fields always fetched from MongoDB + regardless of the GraphQL query selection. + exclude_fields (tuple[str]): Model field names to hide. + non_required_fields (tuple[str]): Fields whose graphene ``required`` + flag is forced to ``False``. + filter_fields (dict | None): Lookup-style filter declarations, + e.g. ``{"name": ["exact", "icontains"]}``. + non_filter_fields (tuple[str]): Fields excluded from auto-generated + filter arguments. + connection (type | None): Explicit Relay connection class. + connection_class (type | None): Used to auto-create the connection. + use_connection (bool | None): Override connection auto-detection. + connection_field_class (type | None): ``ConnectionField`` subclass + for this type's connection field. + interfaces (tuple): graphene interfaces implemented by this type. + _meta: Pre-built options object; raises if wrong type. + order_by (str | None): Default MongoEngine ordering expression. + **options: Forwarded to the graphene base class. + + Raises: + AssertionError: On invalid model, registry, connection, or _meta. + """ assert is_valid_mongoengine_model(model), ( "The attribute model in {}.Meta must be a valid Mongoengine Model. " 'Received "{}" instead.' @@ -179,7 +300,13 @@ def __init_subclass_with_meta__( @classmethod def rescan_fields(cls): - """Attempts to rescan fields and will insert any not converted initially""" + """Re-convert model fields that could not be resolved at first registration. + + Useful when types are defined in any order and some fields reference + types that were not yet in the registry during the initial pass. + Only adds newly resolvable fields — existing fields are not replaced. + Self-referenced fields are intentionally excluded (they cannot change). + """ converted_fields, _ = construct_fields( cls._meta.model, cls._meta.registry, @@ -195,6 +322,21 @@ def rescan_fields(cls): @classmethod def is_type_of(cls, root, info): + """Determine whether *root* is an instance of this graphene type. + + Used by graphene when resolving abstract types and union members. + Accepts GridFSProxy objects (FileField values) as a special case. + + Args: + root: The resolved Python object to check. + info (GraphQLResolveInfo): GraphQL resolver context. + + Returns: + bool: ``True`` if *root* is compatible with this type. + + Raises: + Exception: If *root* is not a valid MongoEngine model instance. + """ if isinstance(root, cls): return True if isinstance(root, mongoengine.GridFSProxy): @@ -204,6 +346,11 @@ def is_type_of(cls, root, info): return isinstance(root, cls._meta.model) def resolve_id(self, info): + """Return the document's primary key as a string for the Relay ``id`` field. + + Returns: + str: ``str(self.id)`` + """ return str(self.id) return GrapheneMongoengineGenericType, MongoengineGenericObjectTypeOptions diff --git a/graphene_mongo/base/utils.py b/graphene_mongo/base/utils.py index 2c9b1288..69f31c46 100644 --- a/graphene_mongo/base/utils.py +++ b/graphene_mongo/base/utils.py @@ -19,11 +19,25 @@ class ExecutorEnum(enum.Enum): + """Enumeration distinguishing synchronous from asynchronous field execution. + + Used throughout the library to select the correct resolver variant and + QuerySet manager (``model.objects`` vs ``model.aobjects``). + """ + ASYNC = enum.auto() SYNC = enum.auto() def get_document(model): + """Look up a MongoEngine document class by name or class reference. + + Args: + model (str | type): Either a MongoEngine document class or its class name string. + + Returns: + type: The MongoEngine document class retrieved from the global document registry. + """ model_name = model if not isinstance(model, str): model_name = model.__name__ @@ -32,6 +46,16 @@ def get_document(model): def get_model_fields(model, excluding=None): + """Return all MongoEngine fields on *model* in alphabetical order. + + Args: + model: A MongoEngine ``Document`` or ``EmbeddedDocument`` class. + excluding (list[str] | None): Field names to omit from the result. + + Returns: + dict[str, mongoengine.BaseField]: Alphabetically sorted mapping of + field name → MongoEngine field instance. + """ excluding = excluding or [] attributes = dict() for attr_name, attr in model._fields.items(): @@ -42,6 +66,20 @@ def get_model_fields(model, excluding=None): def get_model_reference_fields(model, excluding=None): + """Return only the ``ReferenceField`` fields on *model*. + + Used by :meth:`~graphene_mongo.base.fields.BaseMongoengineConnectionField._hydrate_args` + to identify which query arguments represent references that need to be decoded + from Relay global IDs to MongoEngine document stubs. + + Args: + model: A MongoEngine ``Document`` or ``EmbeddedDocument`` class. + excluding (list[str] | None): Field names to omit from the result. + + Returns: + dict[str, mongoengine.ReferenceField]: Mapping of field name → ``ReferenceField`` + for all reference fields on the model. + """ excluding = excluding or [] attributes = dict() for attr_name, attr in model._fields.items(): @@ -55,6 +93,15 @@ def get_model_reference_fields(model, excluding=None): def is_valid_mongoengine_model(model): + """Return ``True`` if *model* is a MongoEngine ``Document`` or ``EmbeddedDocument`` class. + + Args: + model: Any Python object to check. + + Returns: + bool: ``True`` when *model* is a class that subclasses ``Document`` or + ``EmbeddedDocument``; ``False`` otherwise. + """ return inspect.isclass(model) and ( issubclass(model, mongoengine.Document) or issubclass(model, mongoengine.EmbeddedDocument) ) @@ -93,6 +140,20 @@ def get_field_is_required(field, registry=None): def get_node_from_global_id(node, info, global_id): + """Resolve a Relay global ID to the corresponding MongoEngine document. + + Walks the node's interface list looking for a ``Node`` interface and delegates + to its ``get_node_from_global_id`` implementation. Falls back to + ``Node.get_node_from_global_id`` if the node has no ``_meta.interfaces``. + + Args: + node: A graphene ObjectType class implementing the Relay ``Node`` interface. + info: GraphQL resolve info object. + global_id (str): The Relay-encoded global ID (e.g. ``"QXJ0aWNsZTox"``). + + Returns: + Document | None: The fetched MongoEngine document, or ``None`` if not found. + """ try: for interface in node._meta.interfaces: if issubclass(interface, Node): @@ -329,6 +390,21 @@ def has_page_info(info): def ast_to_dict(node, include_loc=False): + """Recursively convert a GraphQL AST node to a plain Python dict. + + Only ``FieldNode`` instances are expanded; all other node types (scalars, + lists, etc.) are returned as-is. This simplified representation is used by + :func:`collect_query_fields` and friends to traverse the selection set without + importing every AST node type. + + Args: + node: A GraphQL AST node or any Python value. + include_loc (bool): When ``True``, a ``"loc"`` key with ``start``/``end`` + positions is included for each ``FieldNode``. + + Returns: + dict | list | Any: The converted representation. + """ if isinstance(node, FieldNode): d = {"kind": node.__class__.__name__} if hasattr(node, "keys"): @@ -347,6 +423,27 @@ def ast_to_dict(node, include_loc=False): def find_skip_and_limit(first, last, after, before, count=None): + """Compute MongoDB ``skip`` and ``limit`` values from Relay cursor-pagination args. + + Implements the Relay cursor connection spec + (https://relay.dev/graphql/connections.htm) for ``first`` / ``last`` / + ``before`` / ``after`` pagination. + + Args: + first (int | None): Return the first N edges after *after*. + last (int | None): Return the last N edges before *before*. + after (int | None): 0-based offset cursor; edges after this position. + before (int | None): 0-based offset cursor; edges before this position. + count (int | None): Total number of matching documents. **Required** when + *last* is not ``None``; a ``ValueError`` is raised otherwise. + + Returns: + tuple[int, int | None]: ``(skip, limit)`` where ``skip`` is the number of + documents to skip and ``limit`` is the page size (``None`` means no limit). + + Raises: + ValueError: When *last* is provided but *count* is ``None``. + """ skip = 0 limit = None @@ -396,6 +493,24 @@ def connection_from_iterables( edge_type, pageinfo_type, ): + """Build a Relay connection object from a list of resolved edge nodes. + + Constructs cursor strings for each edge using the node's position offset, + then assembles the connection with ``pageInfo`` populated. + + Args: + edges (Iterable): The resolved document instances to wrap as edges. + start_offset (int | None): The 0-based index of the first item in *edges* + within the full result set (used to compute cursors). + has_previous_page (bool): Whether there are items before this page. + has_next_page (bool): Whether there are items after this page. + connection_type (type): The graphene connection class to instantiate. + edge_type (type): The graphene edge class to instantiate for each node. + pageinfo_type (type): The graphene ``PageInfo`` class. + + Returns: + connection_type: A fully populated graphene Relay connection instance. + """ edges_items = [ edge_type( node=node, diff --git a/graphene_mongo/synchronous/fields.py b/graphene_mongo/synchronous/fields.py index d0e9f9d8..d1aee1f4 100644 --- a/graphene_mongo/synchronous/fields.py +++ b/graphene_mongo/synchronous/fields.py @@ -26,12 +26,39 @@ class MongoengineConnectionField(BaseMongoengineConnectionField): + """Relay ``ConnectionField`` for synchronous MongoEngine queries. + + Extends :class:`~graphene_mongo.base.fields.BaseMongoengineConnectionField` with + sync-specific implementations of ``get_queryset``, ``default_resolver``, + ``chained_resolver``, and ``connection_resolver``. + + Accepted in ``Meta.connection_field_class`` of + :class:`~graphene_mongo.synchronous.types.MongoengineObjectType` subclasses. + """ + @property def executor(self) -> ExecutorEnum: + """Return ``ExecutorEnum.SYNC`` to indicate synchronous execution. + + Returns: + ExecutorEnum: Always ``ExecutorEnum.SYNC``. + """ return ExecutorEnum.SYNC @property def type(self): + """Return the Relay connection type for this field. + + Validates that the underlying graphene type is a + :class:`~graphene_mongo.synchronous.types.MongoengineObjectType` and that + it has an associated connection class. + + Returns: + type: The connection class (e.g. ``ArticleTypeConnection``). + + Raises: + AssertionError: If the type is not a ``MongoengineObjectType`` or has no connection. + """ from .types import MongoengineObjectType _type = super(ConnectionField, self).type @@ -46,6 +73,23 @@ def type(self): def get_queryset( self, model, info, required_fields=None, skip=None, limit=None, **args ) -> QuerySet: + """Build and return a synchronous MongoEngine ``QuerySet``. + + Hydrates reference and geo arguments, delegates to a user-supplied + ``get_queryset`` callback if provided, applies ``select_related``, projects to + ``required_fields``, and applies ``skip`` / ``limit`` pagination. + + Args: + model: MongoEngine ``Document`` class to query. + info: GraphQL resolve info object. + required_fields (list[str] | None): Fields to project with ``.only()``. + skip (int | None): Number of documents to skip; ``None`` means no skip. + limit (int | None): Maximum documents to return; ``None`` means no limit. + **args: Additional MongoEngine filter keyword arguments. + + Returns: + QuerySet: The constructed (and optionally paginated) QuerySet. + """ if required_fields is None: required_fields = list() if args: @@ -67,6 +111,33 @@ def get_queryset( return qs def default_resolver(self, _root, info, required_fields=None, resolved=None, **args): + """Resolve a connection field synchronously, returning a Relay-compatible connection. + + Handles three resolution scenarios: + + 1. **Pre-resolved iterable** (``resolved`` is set): Applies pagination directly + to the provided QuerySet or list. + 2. **``pk__in`` shortcut**: When a parent document's relation IDs were captured + in ``args["pk__in"]``, fetches only those documents. + 3. **Normal query**: Issues a counted or uncounted query against the model, + optionally using the user-supplied ``get_queryset`` callback, and applies + Relay cursor pagination. + + Page-info computation is gated on ``has_page_info(info)`` to avoid unnecessary + ``count_documents`` calls when the client doesn't request ``pageInfo``. + + Args: + _root: The parent document instance, or ``None`` for top-level queries. + info: GraphQL resolve info object. + required_fields (list[str] | None): Fields to project with ``.only()``. + resolved: Pre-resolved iterable (QuerySet or list), or ``None``. + **args: MongoEngine filter and Relay pagination arguments + (``first``, ``last``, ``before``, ``after``, etc.). + + Returns: + Connection: A graphene Relay connection with ``edges``, ``pageInfo``, + ``iterable``, and ``list_length`` populated. + """ if required_fields is None: required_fields = list() args = args or {} @@ -240,6 +311,32 @@ def default_resolver(self, _root, info, required_fields=None, resolved=None, **a return connection def chained_resolver(self, resolver, is_partial, root, info, **args): + """Chain a user resolver with the default MongoEngine resolver. + + Calls the supplied *resolver* first. Depending on the return value: + + - ``None`` → falls through to :meth:`default_resolver`. + - ``list`` (non-empty, non-``DBRef``) → returned as-is. + - ``list`` of ``DBRef`` → re-queries via :meth:`default_resolver`. + - ``QuerySet`` → its ``_query`` dict is merged into args and forwarded to + :meth:`default_resolver` as ``resolved``. + - ``Promise`` → unwrapped and its value is returned. + - Any other value → returned as-is. + + Before calling *resolver*, the queryset context (``info.context.queryset``) + is populated for external consumers. + + Args: + resolver (callable): The field's user-supplied or parent resolver. + is_partial (bool): ``True`` when *resolver* is a ``functools.partial`` + (i.e. a custom resolver was provided). + root: The parent document instance. + info: GraphQL resolve info object. + **args: MongoEngine filter and Relay pagination arguments. + + Returns: + Connection | list | Any: The resolved value for this connection field. + """ for key, value in dict(args).items(): if value is None: del args[key] @@ -303,6 +400,22 @@ def filter_connection(x): @classmethod def connection_resolver(cls, resolver, connection_type, root, info, **args): + """Entry point called by graphene for every connection field resolution. + + Decodes any Relay global ID values on the ``root`` object before delegating + to the chained resolver. Wraps the resolver result in a ``Promise`` chain + when the result is thenable (for compatibility with async-in-sync setups). + + Args: + resolver (callable): The chained resolver produced by :meth:`wrap_resolve`. + connection_type: The graphene connection type (or ``NonNull`` wrapper). + root: The parent document instance, or ``None`` for top-level queries. + info: GraphQL resolve info object. + **args: GraphQL field arguments. + + Returns: + Connection | Promise: The resolved Relay connection. + """ if root: for key, value in root.__dict__.items(): if value: @@ -323,6 +436,20 @@ def connection_resolver(cls, resolver, connection_type, root, info, **args): return on_resolve(iterable) def wrap_resolve(self, parent_resolver): + """Wrap the field's resolver to go through :meth:`chained_resolver`. + + Called by graphene when building the schema. Composes the user resolver + (or graphene's default attribute resolver) with :meth:`chained_resolver` + and :meth:`connection_resolver` so the full resolution pipeline is applied. + + Args: + parent_resolver (callable): The resolver provided by graphene (default + attribute resolver or the one set on the field). + + Returns: + callable: A partial that calls ``connection_resolver(chained_resolver(...), ...)`` + for every incoming GraphQL request. + """ super_resolver = self.resolver or parent_resolver resolver = partial( self.chained_resolver, super_resolver, isinstance(super_resolver, partial) diff --git a/graphene_mongo/synchronous/types.py b/graphene_mongo/synchronous/types.py index 1d47bad5..5e423904 100644 --- a/graphene_mongo/synchronous/types.py +++ b/graphene_mongo/synchronous/types.py @@ -12,6 +12,27 @@ def create_graphene_generic_class(object_type, option_type): + """Create a sync MongoengineObjectType base class and its options class. + + Thin wrapper around :func:`~graphene_mongo.base.types.create_graphene_generic_class` + that injects sync-specific dependencies (registry factories, connection field class, + and a synchronous ``get_node`` classmethod). + + ``get_node`` is injected via attribute assignment rather than subclassing to avoid + triggering ``__init_subclass_with_meta__`` on the intermediate class. + + Args: + object_type: graphene base class to inherit from + (e.g. ``ObjectType``, ``Interface``, ``InputObjectType``). + option_type: Matching options class + (e.g. ``ObjectTypeOptions``, ``InterfaceOptions``). + + Returns: + tuple[type, type]: + ``(GenericType, Options)`` — the generated base class and its options class. + ``GenericType.get_node`` is a sync classmethod that uses + ``model.objects.only(*fields).get(pk=id)``. + """ GenericType, Options = _create( object_type, option_type, From dfe89be7a1a6a7fa0fb979c17f28b3e1125ef7ad Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sat, 23 May 2026 21:34:07 +0530 Subject: [PATCH 24/52] docs: remove RST markup from docstrings to fix warnings --- graphene_mongo/asynchronous/fields.py | 88 ++++---- graphene_mongo/asynchronous/types.py | 18 +- graphene_mongo/base/advanced_types.py | 24 +-- graphene_mongo/base/converter.py | 22 +- .../dynamic_reference_field_resolver.py | 40 ++-- .../field_resolvers/list_field_resolver.py | 68 +++--- .../base/field_resolvers/union_resolver.py | 36 ++-- graphene_mongo/base/fields.py | 194 +++++++++--------- graphene_mongo/base/registry.py | 26 +-- graphene_mongo/base/types.py | 88 ++++---- graphene_mongo/base/utils.py | 62 +++--- graphene_mongo/synchronous/fields.py | 96 ++++----- graphene_mongo/synchronous/types.py | 18 +- 13 files changed, 390 insertions(+), 390 deletions(-) diff --git a/graphene_mongo/asynchronous/fields.py b/graphene_mongo/asynchronous/fields.py index a8b07874..b34649ec 100644 --- a/graphene_mongo/asynchronous/fields.py +++ b/graphene_mongo/asynchronous/fields.py @@ -24,23 +24,23 @@ class AsyncMongoengineConnectionField(MongoengineConnectionField): - """Relay ``ConnectionField`` for asynchronous MongoEngine queries. + """Relay ConnectionField for asynchronous MongoEngine queries. - Extends :class:`~graphene_mongo.synchronous.fields.MongoengineConnectionField` - with ``async def`` overrides for ``get_queryset``, ``default_resolver``, - ``chained_resolver``, and ``connection_resolver``. Also overrides - :meth:`_qs_accessor` to return ``model.aobjects`` instead of ``model.objects``. + Extends MongoengineConnectionField + with async def overrides for get_queryset, default_resolver, + chained_resolver, and connection_resolver. Also overrides + _qs_accessor to return model.aobjects instead of model.objects. - Accepted in ``Meta.connection_field_class`` of - :class:`~graphene_mongo.asynchronous.types.AsyncMongoengineObjectType` subclasses. + Accepted in Meta.connection_field_class of + AsyncMongoengineObjectType subclasses. """ @property def executor(self): - """Return ``ExecutorEnum.ASYNC`` to indicate asynchronous execution. + """Return ExecutorEnum.ASYNC to indicate asynchronous execution. Returns: - ExecutorEnum: Always ``ExecutorEnum.ASYNC``. + ExecutorEnum: Always ExecutorEnum.ASYNC. """ return ExecutorEnum.ASYNC @@ -49,14 +49,14 @@ def type(self): """Return the Relay connection type for this async field. Validates that the underlying graphene type is an - :class:`~graphene_mongo.asynchronous.types.AsyncMongoengineObjectType` and + AsyncMongoengineObjectType and that it has an associated connection class. Returns: - type: The connection class (e.g. ``ArticleTypeConnection``). + type: The connection class (e.g. ArticleTypeConnection). Raises: - AssertionError: If the type is not an ``AsyncMongoengineObjectType`` or has no connection. + AssertionError: If the type is not an AsyncMongoengineObjectType or has no connection. """ from .types import AsyncMongoengineObjectType @@ -83,40 +83,40 @@ def registry(self): def _qs_accessor(self, model): """Return the async QuerySet manager for the given model. - Overrides the sync base to return ``model.aobjects`` so that - :meth:`get_queryset` issues non-blocking MongoDB calls. + Overrides the sync base to return model.aobjects so that + get_queryset issues non-blocking MongoDB calls. Args: - model: A MongoEngine ``Document`` subclass. + model: A MongoEngine Document subclass. Returns: - mongoengine.AsyncQuerySet: The ``model.aobjects`` manager. + mongoengine.AsyncQuerySet: The model.aobjects manager. """ return model.aobjects def get_queryset( self, model, info, required_fields=None, skip=None, limit=None, **args ) -> AsyncQuerySet: - """Build and return an asynchronous MongoEngine ``AsyncQuerySet``. + """Build and return an asynchronous MongoEngine AsyncQuerySet. - Mirrors :meth:`~graphene_mongo.synchronous.fields.MongoengineConnectionField.get_queryset` - but uses ``model.aobjects`` via :meth:`_qs_accessor`. Rejects sync ``QuerySet`` - instances returned by a user-supplied ``get_queryset`` callback. + Mirrors get_queryset + but uses model.aobjects via _qs_accessor. Rejects sync QuerySet + instances returned by a user-supplied get_queryset callback. Args: - model: MongoEngine ``Document`` class to query. + model: MongoEngine Document class to query. info: GraphQL resolve info object. - required_fields (list[str] | None): Fields to project with ``.only()``. - skip (int | None): Number of documents to skip; ``None`` means no skip. - limit (int | None): Maximum documents to return; ``None`` means no limit. + required_fields (list[str] | None): Fields to project with .only(). + skip (int | None): Number of documents to skip; None means no skip. + limit (int | None): Maximum documents to return; None means no limit. **args: Additional MongoEngine filter keyword arguments. Returns: AsyncQuerySet: The constructed (and optionally paginated) async QuerySet. Raises: - TypeError: If a user-supplied ``get_queryset`` callback returns a sync - ``QuerySet`` instead of an ``AsyncQuerySet``. + TypeError: If a user-supplied get_queryset callback returns a sync + QuerySet instead of an AsyncQuerySet. """ if required_fields is None: required_fields = list() @@ -146,26 +146,26 @@ async def default_resolver(self, _root, info, required_fields=None, resolved=Non """Resolve a connection field asynchronously, returning a Relay-compatible connection. Async counterpart of - :meth:`~graphene_mongo.synchronous.fields.MongoengineConnectionField.default_resolver`. - Awaits every database call and ``AsyncQuerySet`` operation. + default_resolver. + Awaits every database call and AsyncQuerySet operation. Handles the same three resolution scenarios as the sync version: - 1. **Pre-resolved iterable** (``resolved`` is set): Applies pagination to the - provided ``AsyncQuerySet`` or list. - 2. **``pk__in`` shortcut**: Fetches only the listed primary keys. - 3. **Normal query**: Issues a counted or uncounted query using ``model.aobjects``. + 1. **Pre-resolved iterable** (resolved is set): Applies pagination to the + provided AsyncQuerySet or list. + 2. **pk__in shortcut**: Fetches only the listed primary keys. + 3. **Normal query**: Issues a counted or uncounted query using model.aobjects. Args: - _root: The parent document instance, or ``None`` for top-level queries. + _root: The parent document instance, or None for top-level queries. info: GraphQL resolve info object. - required_fields (list[str] | None): Fields to project with ``.only()``. - resolved: Pre-resolved ``AsyncQuerySet`` or list, or ``None``. + required_fields (list[str] | None): Fields to project with .only(). + resolved: Pre-resolved AsyncQuerySet or list, or None. **args: MongoEngine filter and Relay pagination arguments. Returns: - Connection: A graphene Relay connection with ``edges``, ``pageInfo``, - ``iterable``, and ``list_length`` populated. + Connection: A graphene Relay connection with edges, pageInfo, + iterable, and list_length populated. """ if required_fields is None: required_fields = list() @@ -347,12 +347,12 @@ async def chained_resolver(self, resolver, is_partial, root, info, **args): """Chain a user resolver with the async default MongoEngine resolver. Async counterpart of - :meth:`~graphene_mongo.synchronous.fields.MongoengineConnectionField.chained_resolver`. + chained_resolver. Awaits coroutines returned by the user resolver. Args: resolver (callable): The field's user-supplied or parent resolver. - is_partial (bool): ``True`` when *resolver* is a ``functools.partial``. + is_partial (bool): True when *resolver* is a functools.partial. root: The parent document instance. info: GraphQL resolve info object. **args: MongoEngine filter and Relay pagination arguments. @@ -428,13 +428,13 @@ def filter_connection(x): async def connection_resolver(cls, resolver, connection_type, root, info, **args): """Async entry point called by graphene for every connection field resolution. - Awaits the resolver coroutine and wraps the result via ``resolve_connection``. - Handles ``Promise``-based results for compatibility with mixed sync/async setups. + Awaits the resolver coroutine and wraps the result via resolve_connection. + Handles Promise-based results for compatibility with mixed sync/async setups. Args: - resolver (callable): The async chained resolver produced by ``wrap_resolve``. - connection_type: The graphene connection type (or ``NonNull`` wrapper). - root: The parent document instance, or ``None`` for top-level queries. + resolver (callable): The async chained resolver produced by wrap_resolve. + connection_type: The graphene connection type (or NonNull wrapper). + root: The parent document instance, or None for top-level queries. info: GraphQL resolve info object. **args: GraphQL field arguments. diff --git a/graphene_mongo/asynchronous/types.py b/graphene_mongo/asynchronous/types.py index fd8ad515..45272a1e 100644 --- a/graphene_mongo/asynchronous/types.py +++ b/graphene_mongo/asynchronous/types.py @@ -11,24 +11,24 @@ def create_graphene_generic_class_async(object_type, option_type): """Create an async MongoengineObjectType base class and its options class. - Thin wrapper around :func:`~graphene_mongo.base.types.create_graphene_generic_class` + Thin wrapper around create_graphene_generic_class that injects async-specific dependencies (async registry factories, async connection - field class, and an ``async def get_node`` classmethod). + field class, and an async def get_node classmethod). - ``get_node`` is injected via attribute assignment rather than subclassing to avoid - triggering ``__init_subclass_with_meta__`` on the intermediate class. + get_node is injected via attribute assignment rather than subclassing to avoid + triggering __init_subclass_with_meta__ on the intermediate class. Args: object_type: graphene base class to inherit from - (e.g. ``ObjectType``, ``Interface``). + (e.g. ObjectType, Interface). option_type: Matching options class - (e.g. ``ObjectTypeOptions``, ``InterfaceOptions``). + (e.g. ObjectTypeOptions, InterfaceOptions). Returns: tuple[type, type]: - ``(GenericType, Options)`` — the generated base class and its options class. - ``GenericType.get_node`` is an async classmethod that uses - ``await model.aobjects.only(*fields).get(pk=id)``. + (GenericType, Options) — the generated base class and its options class. + GenericType.get_node is an async classmethod that uses + await model.aobjects.only(*fields).get(pk=id). """ GenericType, Options = _create( object_type, diff --git a/graphene_mongo/base/advanced_types.py b/graphene_mongo/base/advanced_types.py index e813f31d..e9ec85c6 100644 --- a/graphene_mongo/base/advanced_types.py +++ b/graphene_mongo/base/advanced_types.py @@ -29,8 +29,8 @@ def _resolve_fs_field(cls, field, name, default_value=None): """Fetch a named attribute from the GridFS proxy attached to *field*. Args: - field: The graphene field instance; must carry ``instance`` and - ``key`` attributes pointing to the parent document and field name. + field: The graphene field instance; must carry instance and + key attributes pointing to the parent document and field name. name (str): Name of the GridFS proxy attribute to read. default_value: Value returned when the attribute is absent on the proxy. @@ -60,7 +60,7 @@ def resolve_data(self, info): """Read the raw file bytes from GridFS and return them base64-encoded. Returns: - str | None: UTF-8 base64 string, or ``None`` if the file is empty. + str | None: UTF-8 base64 string, or None if the file is empty. """ v = getattr(self.instance, self.key) data = v.read() @@ -73,8 +73,8 @@ def resolve_data(self, info): class _CoordinatesTypeField(graphene.ObjectType): """Internal base ObjectType for GeoJSON geometry types. - Provides the ``type`` string field (e.g. ``"Point"``) shared by all - GeoJSON geometry shapes. Subclasses add the appropriate ``coordinates`` + Provides the type string field (e.g. "Point") shared by all + GeoJSON geometry shapes. Subclasses add the appropriate coordinates field for their specific geometry. """ @@ -93,8 +93,8 @@ class PointFieldType(_CoordinatesTypeField): """GraphQL ObjectType for a MongoEngine PointField (GeoJSON Point). Fields: - type (String): Always ``"Point"``. - coordinates (List[Float]): ``[longitude, latitude]``. + type (String): Always "Point". + coordinates (List[Float]): [longitude, latitude]. """ coordinates = graphene.List(graphene.Float) @@ -106,8 +106,8 @@ class PointFieldInputType(graphene.InputObjectType): Used as an argument type when querying by geographic point. Fields: - type (String): GeoJSON geometry type; defaults to ``"Point"``. - coordinates (List[Float]): Required ``[longitude, latitude]`` pair. + type (String): GeoJSON geometry type; defaults to "Point". + coordinates (List[Float]): Required [longitude, latitude] pair. """ type = graphene.String(default_value="Point") @@ -118,9 +118,9 @@ class PolygonFieldType(_CoordinatesTypeField): """GraphQL ObjectType for a MongoEngine PolygonField (GeoJSON Polygon). Fields: - type (String): Always ``"Polygon"``. + type (String): Always "Polygon". coordinates (List[List[List[Float]]]): Outer ring + optional hole rings, - each a list of ``[longitude, latitude]`` pairs. + each a list of [longitude, latitude] pairs. """ coordinates = graphene.List(graphene.List(graphene.List(graphene.Float))) @@ -130,7 +130,7 @@ class MultiPolygonFieldType(_CoordinatesTypeField): """GraphQL ObjectType for a MongoEngine MultiPolygonField (GeoJSON MultiPolygon). Fields: - type (String): Always ``"MultiPolygon"``. + type (String): Always "MultiPolygon". coordinates (List[List[List[List[Float]]]]): A list of Polygon coordinate arrays, each following the PolygonFieldType convention. """ diff --git a/graphene_mongo/base/converter.py b/graphene_mongo/base/converter.py index 5180e731..d9b5a510 100644 --- a/graphene_mongo/base/converter.py +++ b/graphene_mongo/base/converter.py @@ -28,20 +28,20 @@ class MongoEngineConversionError(Exception): def convert_mongoengine_field(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): """Convert a MongoEngine field instance into the corresponding graphene field. - Dispatches via ``@singledispatch`` to a type-specific handler registered + Dispatches via @singledispatch to a type-specific handler registered below. All handlers share the same signature so callers do not need to know the concrete field type. Args: - field: A MongoEngine field instance (e.g. ``StringField``, ``ReferenceField``). + field: A MongoEngine field instance (e.g. StringField, ReferenceField). registry (Registry | None): Active type registry used to resolve referenced document types to their graphene equivalents. - executor (ExecutorEnum): ``SYNC`` or ``ASYNC`` — controls which resolver + executor (ExecutorEnum): SYNC or ASYNC — controls which resolver variant is attached to relationship fields. Returns: - A graphene field instance: ``graphene.String``, ``graphene.Field``, - ``graphene.List``, ``graphene.Dynamic``, etc. + A graphene field instance: graphene.String, graphene.Field, + graphene.List, graphene.Dynamic, etc. Raises: MongoEngineConversionError: If no handler is registered for *field*'s type. @@ -246,8 +246,8 @@ def convert_field_to_list(field, registry=None, executor: ExecutorEnum = Executo def convert_field_to_union(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): """Convert GenericEmbeddedDocumentField / GenericReferenceField → graphene Union Field. - Builds a dynamic graphene.Union type from the field's ``choices`` list, - then wraps it in a ``graphene.Field`` with an appropriate resolver that + Builds a dynamic graphene.Union type from the field's choices list, + then wraps it in a graphene.Field with an appropriate resolver that identifies the concrete type at query time. Args: @@ -256,7 +256,7 @@ def convert_field_to_union(field, registry=None, executor: ExecutorEnum = Execut executor (ExecutorEnum): Controls which resolver variant is attached. Returns: - graphene.Field wrapping the generated Union type, or ``None`` if + graphene.Field wrapping the generated Union type, or None if none of the choices have been registered yet. """ _types = [] @@ -330,10 +330,10 @@ def convert_field_to_union(field, registry=None, executor: ExecutorEnum = Execut def convert_field_to_dynamic(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): """Convert EmbeddedDocumentField / ReferenceField → graphene.Dynamic. - Returns a ``graphene.Dynamic`` so that the target type is resolved lazily + Returns a graphene.Dynamic so that the target type is resolved lazily at schema build time, allowing forward references between types that are defined in any order. A reference-field resolver is attached for - ``ReferenceField`` to handle lazy de-referencing. + ReferenceField to handle lazy de-referencing. Args: field: The MongoEngine embedded or reference field instance. @@ -341,7 +341,7 @@ def convert_field_to_dynamic(field, registry=None, executor: ExecutorEnum = Exec executor (ExecutorEnum): Controls which resolver variant is attached. Returns: - graphene.Dynamic: Evaluates to a ``graphene.Field`` once the target + graphene.Dynamic: Evaluates to a graphene.Field once the target type is available in the registry. """ model = field.document_type diff --git a/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py b/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py index ec611b44..46c0fa32 100644 --- a/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py +++ b/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py @@ -9,12 +9,12 @@ class DynamicReferenceFieldResolver: - """Resolver factory for MongoEngine ``ReferenceField`` and ``EmbeddedDocumentField``. + """Resolver factory for MongoEngine ReferenceField and EmbeddedDocumentField. - Handles lazy de-referencing efficiently: if ``select_related`` has already + Handles lazy de-referencing efficiently: if select_related has already loaded the referenced document it is returned immediately without a DB round trip. Otherwise a targeted query is issued fetching only the fields selected - in the current GraphQL query (plus any ``required_fields`` declared in Meta). + in the current GraphQL query (plus any required_fields declared in Meta). """ @staticmethod @@ -25,21 +25,21 @@ def __reference_resolver_common( Reads the raw value from the parent document, determines which fields need to be fetched, and either returns the already-loaded document or - a ``(document_class, fields_to_fetch, pk)`` tuple for the caller to query. + a (document_class, fields_to_fetch, pk) tuple for the caller to query. Args: - field: The MongoEngine ``ReferenceField`` instance being resolved. + field: The MongoEngine ReferenceField instance being resolved. registry (Registry): Active type registry used to look up the target type. - executor (ExecutorEnum): ``SYNC`` or ``ASYNC``. + executor (ExecutorEnum): SYNC or ASYNC. root: The parent MongoEngine document instance. - *args: GraphQL positional args; ``args[0]`` must be the resolve info. + *args: GraphQL positional args; args[0] must be the resolve info. **kwargs: GraphQL keyword args (unused here). Returns: - ``Document`` — if the reference was already fetched by ``select_related``. - ``tuple[type, set[str], ObjectId]`` — ``(document_class, fields_to_fetch, pk)`` + Document — if the reference was already fetched by select_related. + tuple[type, set[str], ObjectId] — (document_class, fields_to_fetch, pk) if a DB query is required. - ``None`` — if the field value is empty / unset. + None — if the field value is empty / unset. """ document = root._data.get(field.name or field.db_name, None) if not document: @@ -70,18 +70,18 @@ def __reference_resolver_common( @staticmethod def reference_resolver(field, registry, executor) -> Callable: - """Return a synchronous resolver for a ``ReferenceField``. + """Return a synchronous resolver for a ReferenceField. The returned resolver fetches the referenced document using - ``model.objects.only(*fields).get(pk=pk)``. + model.objects.only(*fields).get(pk=pk). Args: - field: The MongoEngine ``ReferenceField`` instance. + field: The MongoEngine ReferenceField instance. registry (Registry): Active type registry. - executor (ExecutorEnum): Should be ``ExecutorEnum.SYNC``. + executor (ExecutorEnum): Should be ExecutorEnum.SYNC. Returns: - callable: ``resolver(root, *args, **kwargs) → Document | None`` + callable: resolver(root, *args, **kwargs) → Document | None """ def resolver(root, *args, **kwargs) -> Optional[Document]: result = DynamicReferenceFieldResolver.__reference_resolver_common( @@ -96,18 +96,18 @@ def resolver(root, *args, **kwargs) -> Optional[Document]: @staticmethod def reference_resolver_async(field, registry, executor) -> Callable: - """Return an asynchronous resolver for a ``ReferenceField``. + """Return an asynchronous resolver for a ReferenceField. The returned coroutine fetches the referenced document using - ``await model.aobjects.only(*fields).get(pk=pk)``. + await model.aobjects.only(*fields).get(pk=pk). Args: - field: The MongoEngine ``ReferenceField`` instance. + field: The MongoEngine ReferenceField instance. registry (Registry): Active type registry. - executor (ExecutorEnum): Should be ``ExecutorEnum.ASYNC``. + executor (ExecutorEnum): Should be ExecutorEnum.ASYNC. Returns: - callable: ``async resolver(root, *args, **kwargs) → Document | None`` + callable: async resolver(root, *args, **kwargs) → Document | None """ async def resolver(root, *args, **kwargs) -> Optional[Document]: result = DynamicReferenceFieldResolver.__reference_resolver_common( diff --git a/graphene_mongo/base/field_resolvers/list_field_resolver.py b/graphene_mongo/base/field_resolvers/list_field_resolver.py index 9fe3c38e..2b8a4baf 100644 --- a/graphene_mongo/base/field_resolvers/list_field_resolver.py +++ b/graphene_mongo/base/field_resolvers/list_field_resolver.py @@ -14,13 +14,13 @@ class ListFieldResolver: - """Resolver factory for MongoEngine ``ListField`` containing references. + """Resolver factory for MongoEngine ListField containing references. - Efficiently resolves lists of ``GenericReferenceField`` entries by grouping + Efficiently resolves lists of GenericReferenceField entries by grouping references by their target document type, fetching each group in parallel - (sync: ``ThreadPoolExecutor``; async: ``asyncio.gather``), and reassembling - the results in the original order. Already-loaded ``Document`` instances - (e.g. from ``select_related``) are passed through without additional DB queries. + (sync: ThreadPoolExecutor; async: asyncio.gather), and reassembling + the results in the original order. Already-loaded Document instances + (e.g. from select_related) are passed through without additional DB queries. """ @staticmethod @@ -33,20 +33,20 @@ def __get_reference_objects_common( ) -> tuple[Document, set[str], list[ObjectId]]: """Resolve the document class, field selection set, and IDs for a list of references. - Converts ``model`` to its MongoEngine document class, determines which fields are - queried (intersecting ``queried_fields`` with the document's field list), and appends - any ``required_fields`` declared in Meta. + Converts model to its MongoEngine document class, determines which fields are + queried (intersecting queried_fields with the document's field list), and appends + any required_fields declared in Meta. Args: registry (Registry): Active type registry. model (str | type): MongoEngine document class or its class name string. - executor (ExecutorEnum): ``SYNC`` or ``ASYNC``. + executor (ExecutorEnum): SYNC or ASYNC. object_id_list (list[ObjectId]): Primary keys to fetch. queried_fields (dict): Fields selected in the current GraphQL query. Returns: tuple[type, set[str], list[ObjectId]]: - ``(document_class, fields_to_fetch, object_id_list)`` + (document_class, fields_to_fetch, object_id_list) """ from graphene_mongo.base.converter import convert_mongoengine_field @@ -87,7 +87,7 @@ def __get_reference_objects( queried_fields (dict): Fields selected in the current GraphQL query. Returns: - QuerySet: Filtered QuerySet of matching documents with ``only`` projection. + QuerySet: Filtered QuerySet of matching documents with only projection. """ document, only_fields, document_ids = ListFieldResolver.__get_reference_objects_common( registry, model, executor, object_id_list, queried_fields @@ -107,12 +107,12 @@ async def __get_reference_objects_async( Args: registry (Registry): Active type registry. model (str | type): MongoEngine document class or its class name string. - executor (ExecutorEnum): ``ASYNC``. + executor (ExecutorEnum): ASYNC. object_id_list (list[ObjectId]): Primary keys to fetch. queried_fields (dict): Fields selected in the current GraphQL query. Returns: - list[Document]: Fetched documents with ``only`` projection applied. + list[Document]: Fetched documents with only projection applied. """ document, only_fields, document_ids = ListFieldResolver.__get_reference_objects_common( registry, model, executor, object_id_list, queried_fields @@ -133,21 +133,21 @@ def __get_non_querying_object(model, object_id_list) -> list[Document]: object_id_list (list[ObjectId]): Primary keys to create stubs for. Returns: - list[Document]: Stub instances with ``pk`` set but no other fields loaded. + list[Document]: Stub instances with pk set but no other fields loaded. """ model = get_document(model) return [model(pk=each) for each in object_id_list] @staticmethod async def __get_non_querying_object_async(model, object_id_list) -> list[Document]: - """Async wrapper around :meth:`__get_non_querying_object`; returns stubs without DB I/O. + """Async wrapper around __get_non_querying_object; returns stubs without DB I/O. Args: model (str | type): MongoEngine document class or its class name string. object_id_list (list[ObjectId]): Primary keys to create stubs for. Returns: - list[Document]: Stub instances with ``pk`` set but no other fields loaded. + list[Document]: Stub instances with pk set but no other fields loaded. """ return ListFieldResolver.__get_non_querying_object(model, object_id_list) @@ -167,7 +167,7 @@ def __build_results( (e.g. from "select_related`) keyed by their primary key. Returns: - list[Document]: Documents ordered to match ``to_resolve_object_ids``. + list[Document]: Documents ordered to match to_resolve_object_ids. """ result_object: dict[ObjectId, Document] = dict(already_resolved or {}) for items in result: @@ -183,22 +183,22 @@ def __reference_resolver_common( ) -> Optional[tuple[Union[list[Task], list[Document]], list[ObjectId]]]: """Shared dispatch logic for both sync and async list-reference resolvers. - Iterates over the raw field value, separating already-loaded ``Document`` - instances from "LazyReference" entries and raw ``_cls``/``_ref`` dicts. + Iterates over the raw field value, separating already-loaded Document + instances from "LazyReference" entries and raw _cls/_ref dicts. Groups unresolved references by their document type, then either submits them to a "ThreadPoolExecutor" (sync) or creates "asyncio" tasks (async). Args: - field: The MongoEngine ``ListField`` containing generic references. + field: The MongoEngine ListField containing generic references. registry (Registry): Active type registry. - executor (ExecutorEnum): ``SYNC`` or ``ASYNC`` — controls dispatch strategy. + executor (ExecutorEnum): SYNC or ASYNC — controls dispatch strategy. root: The parent MongoEngine document instance. - *args: GraphQL positional args; ``args[0]`` must be the resolve info. + *args: GraphQL positional args; args[0] must be the resolve info. **kwargs: GraphQL keyword args (unused here). Returns: - ``tuple[list[Future | Task], list[ObjectId], dict[ObjectId, Document]]`` - for the caller to await / join, or ``None`` if the field is empty. + tuple[list[Future | Task], list[ObjectId], dict[ObjectId, Document]] + for the caller to await / join, or None if the field is empty. """ to_resolve = getattr(root, field.name or field.db_name) if not to_resolve: @@ -277,16 +277,16 @@ def __reference_resolver_common( def reference_resolver(field, registry, executor) -> Callable: """Return a synchronous resolver for a list of generic references. - Dispatches fetch jobs to a ``ThreadPoolExecutor``, then reassembles the - results in the original order via :meth:`__build_results`. + Dispatches fetch jobs to a ThreadPoolExecutor, then reassembles the + results in the original order via __build_results. Args: - field: The MongoEngine ``ListField`` containing generic references. + field: The MongoEngine ListField containing generic references. registry (Registry): Active type registry. - executor (ExecutorEnum): Should be ``ExecutorEnum.SYNC``. + executor (ExecutorEnum): Should be ExecutorEnum.SYNC. Returns: - callable: ``resolver(root, *args, **kwargs) → list[Document] | None`` + callable: resolver(root, *args, **kwargs) → list[Document] | None """ def resolver(root, *args, **kwargs) -> Optional[list[Document]]: resolver_result = ListFieldResolver.__reference_resolver_common( @@ -303,16 +303,16 @@ def resolver(root, *args, **kwargs) -> Optional[list[Document]]: def reference_resolver_async(field, registry, executor) -> Callable: """Return an asynchronous resolver for a list of generic references. - Awaits all ``asyncio`` tasks via ``asyncio.gather``, then reassembles the - results in the original order via :meth:`__build_results`. + Awaits all asyncio tasks via asyncio.gather, then reassembles the + results in the original order via __build_results. Args: - field: The MongoEngine ``ListField`` containing generic references. + field: The MongoEngine ListField containing generic references. registry (Registry): Active type registry. - executor (ExecutorEnum): Should be ``ExecutorEnum.ASYNC``. + executor (ExecutorEnum): Should be ExecutorEnum.ASYNC. Returns: - callable: ``async resolver(root, *args, **kwargs) → list[Document] | None`` + callable: async resolver(root, *args, **kwargs) → list[Document] | None """ async def resolver(root, *args, **kwargs) -> Optional[list[Document]]: resolver_result = ListFieldResolver.__reference_resolver_common( diff --git a/graphene_mongo/base/field_resolvers/union_resolver.py b/graphene_mongo/base/field_resolvers/union_resolver.py index 4f414427..9b7b98ac 100644 --- a/graphene_mongo/base/field_resolvers/union_resolver.py +++ b/graphene_mongo/base/field_resolvers/union_resolver.py @@ -10,7 +10,7 @@ class UnionFieldResolver: - """Resolver factory for MongoEngine ``GenericReferenceField``. + """Resolver factory for MongoEngine GenericReferenceField. Handles lazy de-referencing of generic references (fields that can point to different document types). Identifies the concrete document type at resolve time, @@ -29,19 +29,19 @@ def __reference_resolver_common( to query. Args: - field: The MongoEngine ``GenericReferenceField`` instance being resolved. + field: The MongoEngine GenericReferenceField instance being resolved. registry (Registry): Active type registry used to look up the target type. - executor (ExecutorEnum): ``SYNC`` or ``ASYNC``. + executor (ExecutorEnum): SYNC or ASYNC. root: The parent MongoEngine document instance. - *args: GraphQL positional args; ``args[0]`` must be the resolve info. + *args: GraphQL positional args; args[0] must be the resolve info. **kwargs: GraphQL keyword args (unused here). Returns: - ``Document`` — if the reference was already fetched (e.g. via ``select_related``). - ``tuple[type, set[str], ObjectId]`` — ``(document_class, fields_to_fetch, pk)`` + Document — if the reference was already fetched (e.g. via select_related). + tuple[type, set[str], ObjectId] — (document_class, fields_to_fetch, pk) if a DB query is required. - ``Document(id=pk)`` — a stub instance if the type is not in the queried union. - ``None`` — if the field value is empty / unset. + Document(id=pk) — a stub instance if the type is not in the queried union. + None — if the field value is empty / unset. """ from graphene_mongo.base.converter import convert_mongoengine_field @@ -83,18 +83,18 @@ def __reference_resolver_common( @staticmethod def reference_resolver(field, registry, executor) -> Callable: - """Return a synchronous resolver for a ``GenericReferenceField``. + """Return a synchronous resolver for a GenericReferenceField. The returned resolver fetches the referenced document using - ``model.objects.only(*fields).get(pk=pk)``. + model.objects.only(*fields).get(pk=pk). Args: - field: The MongoEngine ``GenericReferenceField`` instance. + field: The MongoEngine GenericReferenceField instance. registry (Registry): Active type registry. - executor (ExecutorEnum): Should be ``ExecutorEnum.SYNC``. + executor (ExecutorEnum): Should be ExecutorEnum.SYNC. Returns: - callable: ``resolver(root, *args, **kwargs) → Document | None`` + callable: resolver(root, *args, **kwargs) → Document | None """ def resolver(root, *args, **kwargs) -> Optional[Document]: result = UnionFieldResolver.__reference_resolver_common( @@ -109,18 +109,18 @@ def resolver(root, *args, **kwargs) -> Optional[Document]: @staticmethod def reference_resolver_async(field, registry, executor) -> Callable: - """Return an asynchronous resolver for a ``GenericReferenceField``. + """Return an asynchronous resolver for a GenericReferenceField. The returned coroutine fetches the referenced document using - ``await model.aobjects.only(*fields).get(pk=pk)``. + await model.aobjects.only(*fields).get(pk=pk). Args: - field: The MongoEngine ``GenericReferenceField`` instance. + field: The MongoEngine GenericReferenceField instance. registry (Registry): Active type registry. - executor (ExecutorEnum): Should be ``ExecutorEnum.ASYNC``. + executor (ExecutorEnum): Should be ExecutorEnum.ASYNC. Returns: - callable: ``async resolver(root, *args, **kwargs) → Document | None`` + callable: async resolver(root, *args, **kwargs) → Document | None """ async def resolver(root, *args, **kwargs) -> Optional[Document]: result = UnionFieldResolver.__reference_resolver_common( diff --git a/graphene_mongo/base/fields.py b/graphene_mongo/base/fields.py index a6607ae4..f885f975 100644 --- a/graphene_mongo/base/fields.py +++ b/graphene_mongo/base/fields.py @@ -37,28 +37,28 @@ class BaseMongoengineConnectionField(ConnectionField): """Shared base class for sync and async MongoEngine connection fields. Provides all properties and helper methods that are identical between - :class:`~graphene_mongo.synchronous.fields.MongoengineConnectionField` (sync) - and :class:`~graphene_mongo.asynchronous.fields.AsyncMongoengineConnectionField` - (async). Subclasses must override :attr:`executor` and, optionally, - :meth:`_qs_accessor` (async overrides it to return ``model.aobjects`` instead - of ``model.objects``). - - The ``get_queryset``, ``default_resolver``, ``chained_resolver``, and - ``connection_resolver`` methods are implemented in the concrete subclasses - because they differ in whether they are ``async def`` and in how they call the + MongoengineConnectionField (sync) + and AsyncMongoengineConnectionField + (async). Subclasses must override executor and, optionally, + _qs_accessor (async overrides it to return model.aobjects instead + of model.objects). + + The get_queryset, default_resolver, chained_resolver, and + connection_resolver methods are implemented in the concrete subclasses + because they differ in whether they are async def and in how they call the queryset managers. """ def __init__(self, type, *args, **kwargs): - """Initialise the connection field, optionally accepting a custom ``get_queryset``. + """Initialise the connection field, optionally accepting a custom get_queryset. Args: type: The graphene ObjectType (or its connection) this field resolves to. - *args: Forwarded to :class:`graphene.relay.ConnectionField`. - **kwargs: Forwarded to :class:`graphene.relay.ConnectionField`. - Special key ``get_queryset`` (callable | None): When provided, called + *args: Forwarded to ConnectionField. + **kwargs: Forwarded to ConnectionField. + Special key get_queryset (callable | None): When provided, called during resolution to supply or override the MongoEngine QuerySet. - Must be callable; otherwise an ``AssertionError`` is raised. + Must be callable; otherwise an AssertionError is raised. """ get_queryset = kwargs.pop("get_queryset", None) if get_queryset: @@ -72,8 +72,8 @@ def __init__(self, type, *args, **kwargs): def executor(self) -> ExecutorEnum: """Return the executor variant (SYNC or ASYNC) for this field. - Must be overridden by subclasses to return ``ExecutorEnum.SYNC`` or - ``ExecutorEnum.ASYNC``. + Must be overridden by subclasses to return ExecutorEnum.SYNC or + ExecutorEnum.ASYNC. Raises: NotImplementedError: If not overridden in a subclass. @@ -82,10 +82,10 @@ def executor(self) -> ExecutorEnum: @property def node_type(self): - """Return the graphene node type from the connection's ``_meta.node``. + """Return the graphene node type from the connection's _meta.node. Returns: - type: The graphene ObjectType (e.g. ``ArticleType``) backing the connection. + type: The graphene ObjectType (e.g. ArticleType) backing the connection. """ return self.type._meta.node @@ -94,7 +94,7 @@ def model(self): """Return the MongoEngine model class backing the node type. Returns: - type: A MongoEngine ``Document`` subclass. + type: A MongoEngine Document subclass. """ return self.node_type._meta.model @@ -103,13 +103,13 @@ def order_by(self): """Return the default ordering expression declared in the node type's Meta. Returns: - str | None: MongoEngine ordering string (e.g. ``"-created_at"``), or ``None``. + str | None: MongoEngine ordering string (e.g. "-created_at"), or None. """ return self.node_type._meta.order_by @property def required_fields(self): - """Return the union of ``required_fields`` and ``only_fields`` from Meta. + """Return the union of required_fields and only_fields from Meta. These fields are always fetched from MongoDB regardless of the GraphQL query selection, ensuring relationships and computed properties work correctly. @@ -134,10 +134,10 @@ def registry(self): def args(self): """Build the complete set of GraphQL arguments for this connection field. - Merges ``field_args``, ``advance_args``, ``filter_args``, and ``extended_args`` + Merges field_args, advance_args, filter_args, and extended_args into a single argument map, then strips any field names listed in - ``Meta.non_filter_fields`` and removes keys already present in ``_base_args`` - (the built-in Relay pagination args: ``first``, ``last``, ``before``, ``after``). + Meta.non_filter_fields and removes keys already present in _base_args + (the built-in Relay pagination args: first, last, before, after). Returns: OrderedDict: Complete argument mapping passed to the generated GraphQL field. @@ -168,7 +168,7 @@ def args(self, args): """Store the base (Relay pagination) args before the extra field args are merged in. Args: - args: The base argument mapping supplied by graphene's ``ConnectionField``. + args: The base argument mapping supplied by graphene's ConnectionField. """ self._base_args = args @@ -176,15 +176,15 @@ def _field_args(self, items): """Filter *items* down to the subset of fields that are usable as query arguments. A field is excluded when it: - - is backed by an SDL-annotated federation key (``@key`` directive) - - is a Python ``property`` on the model - - converts to a ``ConnectionField`` or ``Dynamic`` - - converts to a complex output type (``FileFieldType``, geo types, ``Union``) - - is a ``List`` whose element type is a ``Union`` or ``ObjectType`` + - is backed by an SDL-annotated federation key (@key directive) + - is a Python property on the model + - converts to a ConnectionField or Dynamic + - converts to a complex output type (FileFieldType, geo types, Union) + - is a List whose element type is a Union or ObjectType - has a mismatching type between the graphene field and converter output Args: - items: Iterable of ``(name, graphene_field)`` pairs (from ``self.fields.items()``). + items: Iterable of (name, graphene_field) pairs (from self.fields.items()). Returns: dict[str, graphene scalar instance]: Filterable field names mapped to their @@ -250,7 +250,7 @@ def get_filter_type(_type): def field_args(self): """Return filterable scalar arguments derived from the node type's fields. - Delegates to :meth:`_field_args` over all fields declared on the graphene type. + Delegates to _field_args over all fields declared on the graphene type. Returns: dict[str, graphene scalar instance]: Field-level filter arguments. @@ -259,17 +259,17 @@ def field_args(self): @property def filter_args(self): - """Build filter arguments from the ``Meta.filter_fields`` declaration. + """Build filter arguments from the Meta.filter_fields declaration. - ``filter_fields`` is a dict of ``{field_name: [lookup, ...]}`` (e.g. - ``{"name": ["exact", "icontains"]}``). For each lookup a corresponding - ``graphene.Argument`` is generated, using ``graphene.List`` for - ``in`` / ``nin`` / ``all`` lookups and ``PointFieldInputType`` for geo - ``near`` queries. + filter_fields is a dict of {field_name: [lookup, ...]} (e.g. + {"name": ["exact", "icontains"]}). For each lookup a corresponding + graphene.Argument is generated, using graphene.List for + in / nin / all lookups and PointFieldInputType for geo + near queries. Returns: dict[str, graphene.Argument]: Lookup-style filter argument mapping, - keyed by ``"field__lookup"`` (e.g. ``"name__icontains"``). + keyed by "field__lookup" (e.g. "name__icontains"). """ filter_args = dict() if self._type._meta.filter_fields: @@ -301,11 +301,11 @@ def filter_args(self): def advance_args(self): """Build advanced filter arguments for reference and geo fields. - For ``PointField`` fields adds a ``PointFieldInputType`` argument. - For ``ReferenceField`` / ``GenericReferenceField`` fields adds a ``graphene.ID`` + For PointField fields adds a PointFieldInputType argument. + For ReferenceField / GenericReferenceField fields adds a graphene.ID argument so callers can filter by global ID. - For other ``Dynamic`` fields, if the resolved type has an ``id`` field and - is not an ``EmbeddedDocument``, adds its ID type as an argument. + For other Dynamic fields, if the resolved type has an id field and + is not an EmbeddedDocument, adds its ID type as an argument. Returns: dict[str, graphene argument]: Advanced argument mapping keyed by field name. @@ -343,14 +343,14 @@ def get_advance_field(r, kv): @property def extended_args(self): - """Build extra ``graphene.ID`` arguments for federation-annotated fields. + """Build extra graphene.ID arguments for federation-annotated fields. - Fields whose graphene type carries an ``_sdl`` attribute (i.e. fields - declared via ``graphene_federation``) are exposed as ``ID`` arguments so + Fields whose graphene type carries an _sdl attribute (i.e. fields + declared via graphene_federation) are exposed as ID arguments so they can still be used as filters. Returns: - dict[str, graphene.ID]: Mapping of field name → ``graphene.ID()`` instance. + dict[str, graphene.ID]: Mapping of field name → graphene.ID() instance. """ args = OrderedDict() for k, each in self.fields.items(): @@ -360,10 +360,10 @@ def extended_args(self): @property def fields(self): - """Return the resolved ``_meta.fields`` dict of the node type. + """Return the resolved _meta.fields dict of the node type. - Forces lazy type resolution (``get_type``) before accessing metadata so - that ``Dynamic`` / string-reference types are fully initialised. + Forces lazy type resolution (get_type) before accessing metadata so + that Dynamic / string-reference types are fully initialised. Returns: dict[str, graphene.Field]: All fields declared on the graphene type. @@ -378,13 +378,13 @@ def _hydrate_args(self, args: dict) -> None: Performs three types of conversion: - - ``ReferenceField`` args: Relay global ID strings are decoded via ``from_global_id`` - and used to construct a lightweight document stub (``DocumentClass(pk=...)``). - - ``GenericReferenceField`` args: The global ID is decoded to extract the type name + - ReferenceField args: Relay global ID strings are decoded via from_global_id + and used to construct a lightweight document stub (DocumentClass(pk=...)). + - GenericReferenceField args: The global ID is decoded to extract the type name and PK; the document class is looked up in the registry. - - Geo ``__near`` args: The ``PointFieldInputType`` dict is converted to a coordinate - list; a default ``__max_distance`` of 10,000 is added if not already present. - - Plain ``id`` args: Decoded from global ID format and replaced in-place. + - Geo __near args: The PointFieldInputType dict is converted to a coordinate + list; a default __max_distance of 10,000 is added if not already present. + - Plain id args: Decoded from global ID format and replaced in-place. Args: args (dict): Mutable argument dict to update in-place; keys are field names @@ -427,32 +427,32 @@ def _hydrate_args(self, args: dict) -> None: def _qs_accessor(self, model): """Return the synchronous QuerySet manager for the given model. - The async subclass overrides this to return ``model.aobjects`` so that - the entire ``get_queryset`` implementation can live in the base class with + The async subclass overrides this to return model.aobjects so that + the entire get_queryset implementation can live in the base class with only this single line differing between sync and async. Args: - model: A MongoEngine ``Document`` subclass. + model: A MongoEngine Document subclass. Returns: - mongoengine.QuerySet: The ``model.objects`` manager. + mongoengine.QuerySet: The model.objects manager. """ return model.objects def _apply_select_related(self, qs, model, info): - """Apply ``select_related`` and sub-field filters to a QuerySet. + """Apply select_related and sub-field filters to a QuerySet. - Walks the current GraphQL query selection (via :func:`get_query_fields`) to + Walks the current GraphQL query selection (via get_query_fields) to determine which reference fields are being queried. For each, a - ``select_related`` path is added and any filter arguments declared on the - sub-field (e.g. ``articles(headline: "Hello")``) are pushed into the QS - via ``filter(articles__headline="Hello")``. + select_related path is added and any filter arguments declared on the + sub-field (e.g. articles(headline: "Hello")) are pushed into the QS + via filter(articles__headline="Hello"). Args: qs: The base MongoEngine QuerySet to augment. - model: The MongoEngine ``Document`` class being queried. - info: The GraphQL resolve info (``GraphQLResolveInfo``); when *info* is not - a ``GraphQLResolveInfo`` instance (e.g. in tests) the step is skipped. + model: The MongoEngine Document class being queried. + info: The GraphQL resolve info (GraphQLResolveInfo); when *info* is not + a GraphQLResolveInfo instance (e.g. in tests) the step is skipped. Returns: QuerySet: The augmented QuerySet (may be the same object if no paths found). @@ -477,7 +477,7 @@ def _build_args_copy(self, args: dict) -> dict: """Build a filtered copy of *args* suitable for MongoDB count / filter queries. Strips keys that are not MongoEngine field names on this model, and converts - reference field values to ``ObjectId`` and enum field values to their raw Python + reference field values to ObjectId and enum field values to their raw Python value so that MongoDB accepts them directly. Args: @@ -485,7 +485,7 @@ def _build_args_copy(self, args: dict) -> dict: Returns: dict: A new dict containing only model-level field keys, with reference - values decoded to ``ObjectId`` and enum values unwrapped. + values decoded to ObjectId and enum values unwrapped. """ args_copy = args.copy() for key in args.copy(): @@ -509,28 +509,28 @@ def _build_args_copy(self, args: dict) -> dict: return args_copy def _prepare_resolver_inputs(self, _root, info, args: dict, resolved): - """Pre-process ``_root`` to populate ``args['pk__in']`` or a pre-loaded ``resolved`` list. + """Pre-process _root to populate args['pk__in'] or a pre-loaded resolved list. - When a parent document is present (``_root is not None``), inspects the + When a parent document is present (_root is not None), inspects the field value on the parent to determine the resolution strategy: - - If the parent does not have ``_fields_ordered`` (non-Document root), treats - the field value as a plain Python list and sets ``args["pk__in"]`` to its IDs. - - If the field is a list of already-loaded ``Document`` instances (pre-fetched - by ``select_related``), populates ``resolved`` directly and clears non-``id`` + - If the parent does not have _fields_ordered (non-Document root), treats + the field value as a plain Python list and sets args["pk__in"] to its IDs. + - If the field is a list of already-loaded Document instances (pre-fetched + by select_related), populates resolved directly and clears non-id args to avoid a redundant DB query. - - Otherwise, sets ``args["pk__in"]`` from the raw reference list. + - Otherwise, sets args["pk__in"] from the raw reference list. Args: - _root: The parent resolver root object, or ``None`` for top-level queries. + _root: The parent resolver root object, or None for top-level queries. info: GraphQL resolve info object (used to derive the field name). - args (dict): Mutable argument dict, updated in-place with ``pk__in`` if needed. - resolved: Pre-loaded iterable of documents, or ``None``. + args (dict): Mutable argument dict, updated in-place with pk__in if needed. + resolved: Pre-loaded iterable of documents, or None. Returns: - tuple[str, list | None]: ``(field_name, resolved)`` where ``field_name`` is - the snake_case field name on the parent, and ``resolved`` is either the - pre-loaded list or ``None`` if a DB query is still required. + tuple[str, list | None]: (field_name, resolved) where field_name is + the snake_case field name on the parent, and resolved is either the + pre-loaded list or None if a DB query is still required. """ field_name = to_snake_case(info.field_name) if _root is not None else "" if _root is not None and not resolved: @@ -563,15 +563,15 @@ def _collect_required_fields(self, info) -> list: """Collect the set of MongoEngine fields that must be fetched for this query. Combines: - 1. ``required_fields`` from the node type's ``Meta`` (always fetched). + 1. required_fields from the node type's Meta (always fetched). 2. All snake_case field names from the current GraphQL query selection that map to actual MongoEngine fields on the model. Args: - info: GraphQL resolve info object; passed to :func:`get_query_fields`. + info: GraphQL resolve info object; passed to get_query_fields. Returns: - list[str]: Field names to pass to ``.only(...)`` on the QuerySet. + list[str]: Field names to pass to .only(...) on the QuerySet. """ required_fields = [ f for f in self.required_fields if f in self.model._fields_ordered @@ -584,26 +584,26 @@ def _collect_required_fields(self, info) -> list: return required_fields def _transform_qs_args(self, args: dict, args_copy: dict) -> dict: - """Re-map a resolved QuerySet's ``_query`` dict into graphene-mongo style args. + """Re-map a resolved QuerySet's _query dict into graphene-mongo style args. - When a custom resolver returns a ``QuerySet``, its internal ``_query`` dict - uses MongoDB wire format (dotted paths, ``$lte`` / ``$gte`` operators, etc.). - This method converts those entries into the ``__``-separated format that - graphene-mongo passes to :meth:`default_resolver`. + When a custom resolver returns a QuerySet, its internal _query dict + uses MongoDB wire format (dotted paths, $lte / $gte operators, etc.). + This method converts those entries into the __-separated format that + graphene-mongo passes to default_resolver. Specifically: - - Keys with ``.`` are rewritten to use ``__`` separators. - - The special ``_id`` key (with ``$in`` / ``$lte`` etc.) is rewritten to ``pk__in`` etc. - - Standard comparison operators (``$lte``, ``$gte``, ``$ne``, ``$in``) inside - a field value dict are appended as ``field__lte``, ``field__gte``, etc. + - Keys with . are rewritten to use __ separators. + - The special _id key (with $in / $lte etc.) is rewritten to pk__in etc. + - Standard comparison operators ($lte, $gte, $ne, $in) inside + a field value dict are appended as field__lte, field__gte, etc. - Keys not in the model's field list or the Relay args / filter args are stripped. Args: - args (dict): The merged ``_query`` dict (mutated by this method for operator lookups). + args (dict): The merged _query dict (mutated by this method for operator lookups). args_copy (dict): A pre-made copy of *args* that is returned as the result. Returns: - dict: The transformed ``args_copy`` ready for :meth:`default_resolver`. + dict: The transformed args_copy ready for default_resolver. """ for arg_name, arg in args.copy().items(): if "." in arg_name or arg_name not in self.model._fields_ordered + ( diff --git a/graphene_mongo/base/registry.py b/graphene_mongo/base/registry.py index 1bbbab07..3cbc8db8 100644 --- a/graphene_mongo/base/registry.py +++ b/graphene_mongo/base/registry.py @@ -40,7 +40,7 @@ def register(self, cls): Raises: AssertionError: If *cls* is not a recognised Mongoengine object type, - or if ``cls._meta.registry`` does not point to this Registry instance. + or if cls._meta.registry does not point to this Registry instance. """ from ..synchronous.types import GrapheneMongoengineObjectTypes from ..asynchronous.types import AsyncGrapheneMongoengineObjectTypes @@ -65,14 +65,14 @@ def register(self, cls): def register_enum(self, cls): """Register a Python Enum class and wrap it as a graphene Enum. - Automatically appends ``"Enum"`` to the class name when not already + Automatically appends "Enum" to the class name when not already suffixed, keeping the GraphQL schema name unambiguous. Args: cls (EnumMeta): The Python enum class to register. Raises: - AssertionError: If *cls* is not an ``EnumMeta`` instance. + AssertionError: If *cls* is not an EnumMeta instance. """ from enum import EnumMeta @@ -93,7 +93,7 @@ def get_type_for_model(self, model): model: A MongoEngine Document or EmbeddedDocument class. Returns: - The registered graphene type class, or ``None`` if not registered. + The registered graphene type class, or None if not registered. """ return self._registry.get(model) @@ -101,24 +101,24 @@ def get_type_for_model_string(self, model_string: str) -> str | None: """Return the MongoEngine model name registered under a graphene type name. Args: - model_string (str): The graphene type name (e.g. ``"ArticleType"``). + model_string (str): The graphene type name (e.g. "ArticleType"). Returns: - str | None: The MongoEngine model class name, or ``None`` if not found. + str | None: The MongoEngine model class name, or None if not found. """ return self._registry_string_map.get(model_string) def get_type_for_document_model(self, model): """Return the graphene type class for a top-level MongoEngine Document. - Unlike :meth:`get_type_for_model`, this method looks up by class name - string and only works for ``Document`` subclasses (not ``EmbeddedDocument``). + Unlike get_type_for_model, this method looks up by class name + string and only works for Document subclasses (not EmbeddedDocument). Args: model: A MongoEngine Document subclass. Returns: - The registered graphene type class, or ``None`` if not registered. + The registered graphene type class, or None if not registered. Raises: TypeError: If *model* is not a Document subclass. @@ -134,7 +134,7 @@ def check_enum_already_exist(self, cls): cls (EnumMeta): The Python enum class to check. Returns: - bool: ``True`` if already registered, ``False`` otherwise. + bool: True if already registered, False otherwise. """ return cls in self._registry_enum @@ -145,7 +145,7 @@ def get_type_for_enum(self, cls): cls (EnumMeta): The Python enum class. Returns: - graphene.Enum | None: The wrapped graphene Enum, or ``None`` if + graphene.Enum | None: The wrapped graphene Enum, or None if not yet registered. """ return self._registry_enum.get(cls) @@ -206,7 +206,7 @@ def get_global_async_registry(): def reset_global_registry(): - """Reset the sync global registry and inputs registry to ``None``. + """Reset the sync global registry and inputs registry to None. Called between tests to ensure a clean state. """ @@ -217,7 +217,7 @@ def reset_global_registry(): def reset_global_async_registry(): - """Reset the async global registry and inputs registry to ``None``. + """Reset the async global registry and inputs registry to None. Called between tests to ensure a clean state. """ diff --git a/graphene_mongo/base/types.py b/graphene_mongo/base/types.py index 17bb2f8f..cdd084c7 100644 --- a/graphene_mongo/base/types.py +++ b/graphene_mongo/base/types.py @@ -20,34 +20,34 @@ def construct_fields( """Convert MongoEngine model fields to graphene field instances. Iterates over the model's fields in alphabetical order (from - :func:`~graphene_mongo.base.utils.get_model_fields`), applying the - ``only_fields`` / ``exclude_fields`` filters, and delegates each field to - :func:`~graphene_mongo.base.converter.convert_mongoengine_field`. + get_model_fields), applying the + only_fields / exclude_fields filters, and delegates each field to + convert_mongoengine_field. - Self-referential ``ListField`` entries (a list whose element type is the + Self-referential ListField entries (a list whose element type is the owning model) are returned separately so they can be registered in a second pass after the owning graphene type exists in the registry. Args: - model: MongoEngine ``Document`` or ``EmbeddedDocument`` class. + model: MongoEngine Document or EmbeddedDocument class. registry (Registry): Active type registry. only_fields (tuple[str]): Whitelist of field names to include; an empty tuple means *all* fields are included. exclude_fields (tuple[str]): Field names to unconditionally skip. - non_required_fields (tuple[str]): Field names whose ``required`` flag - is forced to ``False`` in the generated graphene field. - executor (ExecutorEnum): ``SYNC`` or ``ASYNC`` — controls which resolver + non_required_fields (tuple[str]): Field names whose required flag + is forced to False in the generated graphene field. + executor (ExecutorEnum): SYNC or ASYNC — controls which resolver variant is attached to relationship fields. Returns: tuple[dict, dict]: - ``(converted_fields, self_referenced)`` + (converted_fields, self_referenced) - - *converted_fields*: ``{name: graphene_field}`` for all immediately + - *converted_fields*: {name: graphene_field} for all immediately usable fields. - - *self_referenced*: ``{name: mongoengine_field}`` for ``ListField`` + - *self_referenced*: {name: mongoengine_field} for ListField entries that reference the owning model (resolved in a second pass - by :func:`construct_self_referenced_fields`). + by construct_self_referenced_fields). """ _model_fields = get_model_fields(model) fields = dict() @@ -86,13 +86,13 @@ def construct_self_referenced_fields(self_referenced, registry, executor=Executo before its own circular reference field can be resolved by the converter. Args: - self_referenced (dict): ``{name: mongoengine_field}`` mapping returned - by :func:`construct_fields` for self-referential ListFields. + self_referenced (dict): {name: mongoengine_field} mapping returned + by construct_fields for self-referential ListFields. registry (Registry): Active type registry (owning type already registered). - executor (ExecutorEnum): ``SYNC`` or ``ASYNC``. + executor (ExecutorEnum): SYNC or ASYNC. Returns: - dict: ``{name: graphene_field}`` for the successfully converted fields. + dict: {name: graphene_field} for the successfully converted fields. """ fields = dict() for name, field in self_referenced.items(): @@ -114,42 +114,42 @@ def create_graphene_generic_class( ): """Factory that produces a MongoEngine-aware graphene ObjectType base class. - The returned class (``GrapheneMongoengineGenericType``) is the shared ancestor - for both ``MongoengineObjectType`` (sync) and ``AsyncMongoengineObjectType`` + The returned class (GrapheneMongoengineGenericType) is the shared ancestor + for both MongoengineObjectType (sync) and AsyncMongoengineObjectType (async). Callers inject the executor, registry factories, and default connection field class so that the same factory body serves both variants without duplication. The produced class exposes: - - ``__init_subclass_with_meta__`` — wires up ``model``, ``registry``, - ``fields``, and ``connection`` when a user declares - ``class MyType(MongoengineObjectType): class Meta: model = Article``. - - ``rescan_fields()`` — re-converts fields that were unresolvable at first + - __init_subclass_with_meta__ — wires up model, registry, + fields, and connection when a user declares + class MyType(MongoengineObjectType): class Meta: model = Article. + - rescan_fields() — re-converts fields that were unresolvable at first registration (e.g. forward references to types defined later). - - ``is_type_of()`` — used by graphene to resolve abstract / union types. - - ``resolve_id()`` — returns ``str(self.id)`` for the Relay global ID. + - is_type_of() — used by graphene to resolve abstract / union types. + - resolve_id() — returns str(self.id) for the Relay global ID. - ``get_node`` is **not** defined here; it differs between sync (plain method) - and async (``async def``), and is injected by the caller via classmethod + get_node is **not** defined here; it differs between sync (plain method) + and async (async def), and is injected by the caller via classmethod attribute assignment after this factory returns. Args: object_type: graphene base class to inherit from - (``ObjectType``, ``Interface``, or ``InputObjectType``). + (ObjectType, Interface, or InputObjectType). option_type: Matching options class - (``ObjectTypeOptions``, ``InterfaceOptions``, etc.). - executor (ExecutorEnum): ``SYNC`` or ``ASYNC``. + (ObjectTypeOptions, InterfaceOptions, etc.). + executor (ExecutorEnum): SYNC or ASYNC. global_registry_factory (callable): Zero-argument callable that returns - the singleton ``Registry`` for regular object types. + the singleton Registry for regular object types. inputs_registry_factory (callable): Zero-argument callable that returns - the singleton ``Registry`` for ``InputObjectType`` registrations. - default_connection_field_class (type): ``ConnectionField`` subclass used - when ``Meta.connection_field_class`` is not specified. + the singleton Registry for InputObjectType registrations. + default_connection_field_class (type): ConnectionField subclass used + when Meta.connection_field_class is not specified. Returns: tuple[type, type]: - ``(GrapheneMongoengineGenericType, MongoengineGenericObjectTypeOptions)`` + (GrapheneMongoengineGenericType, MongoengineGenericObjectTypeOptions) """ class MongoengineGenericObjectTypeOptions(option_type): @@ -185,29 +185,29 @@ def __init_subclass_with_meta__( """Wire up a user-defined MongoengineObjectType subclass. Called automatically by Python when the user writes - ``class MyType(MongoengineObjectType): class Meta: model = Article``. + class MyType(MongoengineObjectType): class Meta: model = Article. Args: - model: MongoEngine ``Document`` or ``EmbeddedDocument`` class + model: MongoEngine Document or EmbeddedDocument class that backs this graphene type. Required. registry (Registry | None): Explicit registry to use; when omitted the appropriate singleton is chosen automatically. - skip_registry (bool): If ``True``, the type is not registered + skip_registry (bool): If True, the type is not registered after creation (useful for abstract base types). only_fields (tuple[str]): Whitelist of model field names to expose. required_fields (tuple[str]): Fields always fetched from MongoDB regardless of the GraphQL query selection. exclude_fields (tuple[str]): Model field names to hide. - non_required_fields (tuple[str]): Fields whose graphene ``required`` - flag is forced to ``False``. + non_required_fields (tuple[str]): Fields whose graphene required + flag is forced to False. filter_fields (dict | None): Lookup-style filter declarations, - e.g. ``{"name": ["exact", "icontains"]}``. + e.g. {"name": ["exact", "icontains"]}. non_filter_fields (tuple[str]): Fields excluded from auto-generated filter arguments. connection (type | None): Explicit Relay connection class. connection_class (type | None): Used to auto-create the connection. use_connection (bool | None): Override connection auto-detection. - connection_field_class (type | None): ``ConnectionField`` subclass + connection_field_class (type | None): ConnectionField subclass for this type's connection field. interfaces (tuple): graphene interfaces implemented by this type. _meta: Pre-built options object; raises if wrong type. @@ -332,7 +332,7 @@ def is_type_of(cls, root, info): info (GraphQLResolveInfo): GraphQL resolver context. Returns: - bool: ``True`` if *root* is compatible with this type. + bool: True if *root* is compatible with this type. Raises: Exception: If *root* is not a valid MongoEngine model instance. @@ -346,10 +346,10 @@ def is_type_of(cls, root, info): return isinstance(root, cls._meta.model) def resolve_id(self, info): - """Return the document's primary key as a string for the Relay ``id`` field. + """Return the document's primary key as a string for the Relay id field. Returns: - str: ``str(self.id)`` + str: str(self.id) """ return str(self.id) diff --git a/graphene_mongo/base/utils.py b/graphene_mongo/base/utils.py index 69f31c46..72105b36 100644 --- a/graphene_mongo/base/utils.py +++ b/graphene_mongo/base/utils.py @@ -22,7 +22,7 @@ class ExecutorEnum(enum.Enum): """Enumeration distinguishing synchronous from asynchronous field execution. Used throughout the library to select the correct resolver variant and - QuerySet manager (``model.objects`` vs ``model.aobjects``). + QuerySet manager (model.objects vs model.aobjects). """ ASYNC = enum.auto() @@ -49,7 +49,7 @@ def get_model_fields(model, excluding=None): """Return all MongoEngine fields on *model* in alphabetical order. Args: - model: A MongoEngine ``Document`` or ``EmbeddedDocument`` class. + model: A MongoEngine Document or EmbeddedDocument class. excluding (list[str] | None): Field names to omit from the result. Returns: @@ -66,18 +66,18 @@ def get_model_fields(model, excluding=None): def get_model_reference_fields(model, excluding=None): - """Return only the ``ReferenceField`` fields on *model*. + """Return only the ReferenceField fields on *model*. - Used by :meth:`~graphene_mongo.base.fields.BaseMongoengineConnectionField._hydrate_args` + Used by _hydrate_args to identify which query arguments represent references that need to be decoded from Relay global IDs to MongoEngine document stubs. Args: - model: A MongoEngine ``Document`` or ``EmbeddedDocument`` class. + model: A MongoEngine Document or EmbeddedDocument class. excluding (list[str] | None): Field names to omit from the result. Returns: - dict[str, mongoengine.ReferenceField]: Mapping of field name → ``ReferenceField`` + dict[str, mongoengine.ReferenceField]: Mapping of field name → ReferenceField for all reference fields on the model. """ excluding = excluding or [] @@ -93,14 +93,14 @@ def get_model_reference_fields(model, excluding=None): def is_valid_mongoengine_model(model): - """Return ``True`` if *model* is a MongoEngine ``Document`` or ``EmbeddedDocument`` class. + """Return True if *model* is a MongoEngine Document or EmbeddedDocument class. Args: model: Any Python object to check. Returns: - bool: ``True`` when *model* is a class that subclasses ``Document`` or - ``EmbeddedDocument``; ``False`` otherwise. + bool: True when *model* is a class that subclasses Document or + EmbeddedDocument; False otherwise. """ return inspect.isclass(model) and ( issubclass(model, mongoengine.Document) or issubclass(model, mongoengine.EmbeddedDocument) @@ -142,17 +142,17 @@ def get_field_is_required(field, registry=None): def get_node_from_global_id(node, info, global_id): """Resolve a Relay global ID to the corresponding MongoEngine document. - Walks the node's interface list looking for a ``Node`` interface and delegates - to its ``get_node_from_global_id`` implementation. Falls back to - ``Node.get_node_from_global_id`` if the node has no ``_meta.interfaces``. + Walks the node's interface list looking for a Node interface and delegates + to its get_node_from_global_id implementation. Falls back to + Node.get_node_from_global_id if the node has no _meta.interfaces. Args: - node: A graphene ObjectType class implementing the Relay ``Node`` interface. + node: A graphene ObjectType class implementing the Relay Node interface. info: GraphQL resolve info object. - global_id (str): The Relay-encoded global ID (e.g. ``"QXJ0aWNsZTox"``). + global_id (str): The Relay-encoded global ID (e.g. "QXJ0aWNsZTox"). Returns: - Document | None: The fetched MongoEngine document, or ``None`` if not found. + Document | None: The fetched MongoEngine document, or None if not found. """ try: for interface in node._meta.interfaces: @@ -263,8 +263,8 @@ def get_query_fields(info): def get_select_related_paths(model, queried_fields, prefix=""): """Recursively build select_related paths for queried reference fields. - Returns ``__``-separated paths (e.g. ``["editor", "editor__company"]``) - suitable for ``QuerySet.select_related(*paths)``. + Returns __-separated paths (e.g. ["editor", "editor__company"]) + suitable for QuerySet.select_related(*paths). """ paths = [] if not queried_fields or not hasattr(queried_fields, "items"): @@ -392,15 +392,15 @@ def has_page_info(info): def ast_to_dict(node, include_loc=False): """Recursively convert a GraphQL AST node to a plain Python dict. - Only ``FieldNode`` instances are expanded; all other node types (scalars, + Only FieldNode instances are expanded; all other node types (scalars, lists, etc.) are returned as-is. This simplified representation is used by - :func:`collect_query_fields` and friends to traverse the selection set without + collect_query_fields and friends to traverse the selection set without importing every AST node type. Args: node: A GraphQL AST node or any Python value. - include_loc (bool): When ``True``, a ``"loc"`` key with ``start``/``end`` - positions is included for each ``FieldNode``. + include_loc (bool): When True, a "loc" key with start/end + positions is included for each FieldNode. Returns: dict | list | Any: The converted representation. @@ -423,11 +423,11 @@ def ast_to_dict(node, include_loc=False): def find_skip_and_limit(first, last, after, before, count=None): - """Compute MongoDB ``skip`` and ``limit`` values from Relay cursor-pagination args. + """Compute MongoDB skip and limit values from Relay cursor-pagination args. Implements the Relay cursor connection spec - (https://relay.dev/graphql/connections.htm) for ``first`` / ``last`` / - ``before`` / ``after`` pagination. + (https://relay.dev/graphql/connections.htm) for first / last / + before / after pagination. Args: first (int | None): Return the first N edges after *after*. @@ -435,14 +435,14 @@ def find_skip_and_limit(first, last, after, before, count=None): after (int | None): 0-based offset cursor; edges after this position. before (int | None): 0-based offset cursor; edges before this position. count (int | None): Total number of matching documents. **Required** when - *last* is not ``None``; a ``ValueError`` is raised otherwise. + *last* is not None; a ValueError is raised otherwise. Returns: - tuple[int, int | None]: ``(skip, limit)`` where ``skip`` is the number of - documents to skip and ``limit`` is the page size (``None`` means no limit). + tuple[int, int | None]: (skip, limit) where skip is the number of + documents to skip and limit is the page size (None means no limit). Raises: - ValueError: When *last* is provided but *count* is ``None``. + ValueError: When *last* is provided but *count* is None. """ skip = 0 limit = None @@ -496,7 +496,7 @@ def connection_from_iterables( """Build a Relay connection object from a list of resolved edge nodes. Constructs cursor strings for each edge using the node's position offset, - then assembles the connection with ``pageInfo`` populated. + then assembles the connection with pageInfo populated. Args: edges (Iterable): The resolved document instances to wrap as edges. @@ -506,7 +506,7 @@ def connection_from_iterables( has_next_page (bool): Whether there are items after this page. connection_type (type): The graphene connection class to instantiate. edge_type (type): The graphene edge class to instantiate for each node. - pageinfo_type (type): The graphene ``PageInfo`` class. + pageinfo_type (type): The graphene PageInfo class. Returns: connection_type: A fully populated graphene Relay connection instance. @@ -536,7 +536,7 @@ def connection_from_iterables( def get_related_field_filter_args(info, model) -> dict: """ Walk the GraphQL AST to find reference/list-of-reference sub-fields that carry - filter arguments (e.g. ``articles(headline: "Hello")``). + filter arguments (e.g. articles(headline: "Hello")). Returns a dict suitable for passing into the parent queryset with __ syntax: {"articles": {"headline": "Hello"}} diff --git a/graphene_mongo/synchronous/fields.py b/graphene_mongo/synchronous/fields.py index d1aee1f4..6a37f580 100644 --- a/graphene_mongo/synchronous/fields.py +++ b/graphene_mongo/synchronous/fields.py @@ -26,22 +26,22 @@ class MongoengineConnectionField(BaseMongoengineConnectionField): - """Relay ``ConnectionField`` for synchronous MongoEngine queries. + """Relay ConnectionField for synchronous MongoEngine queries. - Extends :class:`~graphene_mongo.base.fields.BaseMongoengineConnectionField` with - sync-specific implementations of ``get_queryset``, ``default_resolver``, - ``chained_resolver``, and ``connection_resolver``. + Extends BaseMongoengineConnectionField with + sync-specific implementations of get_queryset, default_resolver, + chained_resolver, and connection_resolver. - Accepted in ``Meta.connection_field_class`` of - :class:`~graphene_mongo.synchronous.types.MongoengineObjectType` subclasses. + Accepted in Meta.connection_field_class of + MongoengineObjectType subclasses. """ @property def executor(self) -> ExecutorEnum: - """Return ``ExecutorEnum.SYNC`` to indicate synchronous execution. + """Return ExecutorEnum.SYNC to indicate synchronous execution. Returns: - ExecutorEnum: Always ``ExecutorEnum.SYNC``. + ExecutorEnum: Always ExecutorEnum.SYNC. """ return ExecutorEnum.SYNC @@ -50,14 +50,14 @@ def type(self): """Return the Relay connection type for this field. Validates that the underlying graphene type is a - :class:`~graphene_mongo.synchronous.types.MongoengineObjectType` and that + MongoengineObjectType and that it has an associated connection class. Returns: - type: The connection class (e.g. ``ArticleTypeConnection``). + type: The connection class (e.g., ArticleTypeConnection). Raises: - AssertionError: If the type is not a ``MongoengineObjectType`` or has no connection. + AssertionError: If the type is not a MongoengineObjectType or has no connection. """ from .types import MongoengineObjectType @@ -73,18 +73,18 @@ def type(self): def get_queryset( self, model, info, required_fields=None, skip=None, limit=None, **args ) -> QuerySet: - """Build and return a synchronous MongoEngine ``QuerySet``. + """Build and return a synchronous MongoEngine QuerySet. Hydrates reference and geo arguments, delegates to a user-supplied - ``get_queryset`` callback if provided, applies ``select_related``, projects to - ``required_fields``, and applies ``skip`` / ``limit`` pagination. + get_queryset callback if provided, applies select_related, projects to + required_fields, and applies skip / limit pagination. Args: - model: MongoEngine ``Document`` class to query. + model: MongoEngine Document class to query. info: GraphQL resolve info object. - required_fields (list[str] | None): Fields to project with ``.only()``. - skip (int | None): Number of documents to skip; ``None`` means no skip. - limit (int | None): Maximum documents to return; ``None`` means no limit. + required_fields (list[str] | None): Fields to project with .only(). + skip (int | None): Number of documents to skip; None means no skip. + limit (int | None): Maximum documents to return; None means no limit. **args: Additional MongoEngine filter keyword arguments. Returns: @@ -115,28 +115,28 @@ def default_resolver(self, _root, info, required_fields=None, resolved=None, **a Handles three resolution scenarios: - 1. **Pre-resolved iterable** (``resolved`` is set): Applies pagination directly + 1. **Pre-resolved iterable** (resolved is set): Applies pagination directly to the provided QuerySet or list. - 2. **``pk__in`` shortcut**: When a parent document's relation IDs were captured - in ``args["pk__in"]``, fetches only those documents. + 2. **pk__in shortcut**: When a parent document's relation IDs were captured + in args[ "pk__in"], fetches only those documents. 3. **Normal query**: Issues a counted or uncounted query against the model, - optionally using the user-supplied ``get_queryset`` callback, and applies + optionally using the user-supplied get_queryset callback, and applies Relay cursor pagination. - Page-info computation is gated on ``has_page_info(info)`` to avoid unnecessary - ``count_documents`` calls when the client doesn't request ``pageInfo``. + Page-info computation is gated on has_page_info(info) to avoid unnecessary + count_documents calls when the client doesn't request pageInfo. Args: - _root: The parent document instance, or ``None`` for top-level queries. + _root: The parent document instance, or None for top-level queries. info: GraphQL resolve info object. - required_fields (list[str] | None): Fields to project with ``.only()``. - resolved: Pre-resolved iterable (QuerySet or list), or ``None``. + required_fields (list[str] | None): Fields to project with .only(). + resolved: Pre-resolved iterable (QuerySet or list), or None. **args: MongoEngine filter and Relay pagination arguments - (``first``, ``last``, ``before``, ``after``, etc.). + (first, last, before, after, etc.). Returns: - Connection: A graphene Relay connection with ``edges``, ``pageInfo``, - ``iterable``, and ``list_length`` populated. + Connection: A graphene Relay connection with edges, pageInfo, + iterable, and list_length populated. """ if required_fields is None: required_fields = list() @@ -315,21 +315,21 @@ def chained_resolver(self, resolver, is_partial, root, info, **args): Calls the supplied *resolver* first. Depending on the return value: - - ``None`` → falls through to :meth:`default_resolver`. - - ``list`` (non-empty, non-``DBRef``) → returned as-is. - - ``list`` of ``DBRef`` → re-queries via :meth:`default_resolver`. - - ``QuerySet`` → its ``_query`` dict is merged into args and forwarded to - :meth:`default_resolver` as ``resolved``. - - ``Promise`` → unwrapped and its value is returned. + - None → falls through to default_resolver. + - list (non-empty, non-DBRef) → returned as-is. + - list of DBRef → re-queries via default_resolver. + - QuerySet → its _query dict is merged into args and forwarded to + default_resolver as resolved. + - Promise → unwrapped and its value is returned. - Any other value → returned as-is. - Before calling *resolver*, the queryset context (``info.context.queryset``) + Before calling *resolver*, the queryset context (info.context.queryset) is populated for external consumers. Args: resolver (callable): The field's user-supplied or parent resolver. - is_partial (bool): ``True`` when *resolver* is a ``functools.partial`` - (i.e. a custom resolver was provided). + is_partial (bool): True when *resolver* is a functools.partial + (i.e., a custom resolver was provided). root: The parent document instance. info: GraphQL resolve info object. **args: MongoEngine filter and Relay pagination arguments. @@ -402,14 +402,14 @@ def filter_connection(x): def connection_resolver(cls, resolver, connection_type, root, info, **args): """Entry point called by graphene for every connection field resolution. - Decodes any Relay global ID values on the ``root`` object before delegating - to the chained resolver. Wraps the resolver result in a ``Promise`` chain + Decodes any Relay global ID values on the root object before delegating + to the chained resolver. Wraps the resolver result in a Promise chain when the result is thenable (for compatibility with async-in-sync setups). Args: - resolver (callable): The chained resolver produced by :meth:`wrap_resolve`. - connection_type: The graphene connection type (or ``NonNull`` wrapper). - root: The parent document instance, or ``None`` for top-level queries. + resolver (callable): The chained resolver produced by wrap_resolve. + connection_type: The graphene connection type (or NonNull wrapper). + root: The parent document instance, or None for top-level queries. info: GraphQL resolve info object. **args: GraphQL field arguments. @@ -436,18 +436,18 @@ def connection_resolver(cls, resolver, connection_type, root, info, **args): return on_resolve(iterable) def wrap_resolve(self, parent_resolver): - """Wrap the field's resolver to go through :meth:`chained_resolver`. + """Wrap the field's resolver to go through chained_resolver. Called by graphene when building the schema. Composes the user resolver - (or graphene's default attribute resolver) with :meth:`chained_resolver` - and :meth:`connection_resolver` so the full resolution pipeline is applied. + (or graphene's default attribute resolver) with chained_resolver + and connection_resolver so the full resolution pipeline is applied. Args: parent_resolver (callable): The resolver provided by graphene (default attribute resolver or the one set on the field). Returns: - callable: A partial that calls ``connection_resolver(chained_resolver(...), ...)`` + callable: A partial that calls connection_resolver(chained_resolver)...), ...) for every incoming GraphQL request. """ super_resolver = self.resolver or parent_resolver diff --git a/graphene_mongo/synchronous/types.py b/graphene_mongo/synchronous/types.py index 5e423904..29688ad4 100644 --- a/graphene_mongo/synchronous/types.py +++ b/graphene_mongo/synchronous/types.py @@ -14,24 +14,24 @@ def create_graphene_generic_class(object_type, option_type): """Create a sync MongoengineObjectType base class and its options class. - Thin wrapper around :func:`~graphene_mongo.base.types.create_graphene_generic_class` + Thin wrapper around create_graphene_generic_class that injects sync-specific dependencies (registry factories, connection field class, - and a synchronous ``get_node`` classmethod). + and a synchronous get_node classmethod). - ``get_node`` is injected via attribute assignment rather than subclassing to avoid - triggering ``__init_subclass_with_meta__`` on the intermediate class. + get_node is injected via attribute assignment rather than subclassing to avoid + triggering __init_subclass_with_meta__ on the intermediate class. Args: object_type: graphene base class to inherit from - (e.g. ``ObjectType``, ``Interface``, ``InputObjectType``). + (e.g. ObjectType, Interface, InputObjectType). option_type: Matching options class - (e.g. ``ObjectTypeOptions``, ``InterfaceOptions``). + (e.g. ObjectTypeOptions, InterfaceOptions). Returns: tuple[type, type]: - ``(GenericType, Options)`` — the generated base class and its options class. - ``GenericType.get_node`` is a sync classmethod that uses - ``model.objects.only(*fields).get(pk=id)``. + (GenericType, Options) — the generated base class and its options class. + GenericType.get_node is a sync classmethod that uses + model.objects.only(*fields).get(pk=id). """ GenericType, Options = _create( object_type, From 236ab7885ece12e631d0ef507df7d407cf58e991 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sat, 23 May 2026 21:49:46 +0530 Subject: [PATCH 25/52] chore: suppress false-positive classmethod warning and fix docstring grammar Add noinspection comment for @classmethod applied outside a class body in the async factory function. Minor docstring grammar corrections (e.g. comma, spacing). --- graphene_mongo/asynchronous/fields.py | 2 +- graphene_mongo/asynchronous/types.py | 11 ++++++----- graphene_mongo/base/field_resolvers/union_resolver.py | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/graphene_mongo/asynchronous/fields.py b/graphene_mongo/asynchronous/fields.py index b34649ec..1dcdd16a 100644 --- a/graphene_mongo/asynchronous/fields.py +++ b/graphene_mongo/asynchronous/fields.py @@ -53,7 +53,7 @@ def type(self): that it has an associated connection class. Returns: - type: The connection class (e.g. ArticleTypeConnection). + type: The connection class (e.g., ArticleTypeConnection). Raises: AssertionError: If the type is not an AsyncMongoengineObjectType or has no connection. diff --git a/graphene_mongo/asynchronous/types.py b/graphene_mongo/asynchronous/types.py index 45272a1e..46dc7a8d 100644 --- a/graphene_mongo/asynchronous/types.py +++ b/graphene_mongo/asynchronous/types.py @@ -13,21 +13,21 @@ def create_graphene_generic_class_async(object_type, option_type): Thin wrapper around create_graphene_generic_class that injects async-specific dependencies (async registry factories, async connection - field class, and an async def get_node classmethod). + field class, and an async def get_node class method). get_node is injected via attribute assignment rather than subclassing to avoid triggering __init_subclass_with_meta__ on the intermediate class. Args: object_type: graphene base class to inherit from - (e.g. ObjectType, Interface). + (e.g., ObjectType, Interface). option_type: Matching options class - (e.g. ObjectTypeOptions, InterfaceOptions). + (e.g., ObjectTypeOptions, InterfaceOptions). Returns: tuple[type, type]: - (GenericType, Options) — the generated base class and its options class. - GenericType.get_node is an async classmethod that uses + (GenericType, Options) — the generated base class and its option's class. + GenericType.get_node is an async class method that uses await model.aobjects.only(*fields).get(pk=id). """ GenericType, Options = _create( @@ -39,6 +39,7 @@ def create_graphene_generic_class_async(object_type, option_type): default_connection_field_class=AsyncMongoengineConnectionField, ) + # noinspection PyDecorator @classmethod async def get_node(cls, info, id): required_fields = list() diff --git a/graphene_mongo/base/field_resolvers/union_resolver.py b/graphene_mongo/base/field_resolvers/union_resolver.py index 9b7b98ac..74d6a295 100644 --- a/graphene_mongo/base/field_resolvers/union_resolver.py +++ b/graphene_mongo/base/field_resolvers/union_resolver.py @@ -40,7 +40,7 @@ def __reference_resolver_common( Document — if the reference was already fetched (e.g. via select_related). tuple[type, set[str], ObjectId] — (document_class, fields_to_fetch, pk) if a DB query is required. - Document(id=pk) — a stub instance if the type is not in the queried union. + Document (id=pk) — a stub instance if the type is not in the queried union. None — if the field value is empty / unset. """ from graphene_mongo.base.converter import convert_mongoengine_field From ce031ac5d2fb91a8f004749c625d3d8e6873d57c Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sat, 23 May 2026 22:00:18 +0530 Subject: [PATCH 26/52] docs: add Meta options and field type mapping tables to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reference sections were missing from the README: - Meta options table covering all class Meta attributes with types and descriptions - MongoEngine → GraphQL field type mapping table --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e0e8dad1..aaec44fb 100644 --- a/README.md +++ b/README.md @@ -107,22 +107,66 @@ query = ''' result = await schema.execute_async(query) ``` +## Meta Options + +All options are declared inside the nested `Meta` class of an ObjectType. + +| Option | Type | Description | +|--------------------------|------------------|-------------------------------------------------------------------------| +| `model` | `Document` class | **Required.** The MongoEngine document to expose. | +| `interfaces` | `tuple` | e.g. `(Node,)` — enables Relay cursor pagination. | +| `only_fields` | `tuple[str]` | Whitelist of field names to expose. | +| `exclude_fields` | `tuple[str]` | Field names to hide from the schema. | +| `required_fields` | `tuple[str]` | Fields always fetched from the DB regardless of query selection. | +| `non_required_fields` | `tuple[str]` | Force these graphene fields to be non-required. | +| `filter_fields` | `dict` | Lookup-style filter arguments, e.g. `{"name": ["exact", "icontains"]}`. | +| `non_filter_fields` | `tuple[str]` | Fields excluded from auto-generated filter arguments. | +| `order_by` | `str` | Default MongoEngine ordering expression, e.g. `"-created_at"`. | +| `registry` | `Registry` | Explicit type registry (useful for isolating types in tests). | +| `connection_field_class` | `type` | Override the connection field class for this type. | + +## Field Type Mapping + +MongoEngine fields are converted to GraphQL types automatically: + +| MongoEngine | GraphQL | +|-----------------------------------------|--------------------------------------------------------------------| +| `StringField`, `EmailField`, `URLField` | `String` | +| `IntField`, `SequenceField` | `Int` | +| `FloatField` | `Float` | +| `BooleanField` | `Boolean` | +| `DateTimeField` | `DateTime` | +| `DateField` | `Date` | +| `DecimalField`, `Decimal128Field` | `Decimal` | +| `UUIDField`, `ObjectIdField` | `ID` | +| `DictField`, `MapField` | `JSONString` | +| `FileField` | `FileFieldType` (`contentType`, `md5`, `length`, `data` as base64) | +| `PointField` | `PointFieldType` (`type`, `coordinates`) | +| `PolygonField` | `PolygonFieldType` | +| `MultiPolygonField` | `MultiPolygonFieldType` | +| `ReferenceField` | Resolved graphene type (lazy, via `Dynamic`) | +| `EmbeddedDocumentField` | Resolved graphene type (lazy, via `Dynamic`) | +| `ListField(ReferenceField(...))` | `List` or `ConnectionField` if the target is a Relay `Node` | +| `GenericReferenceField` | `Union` of registered `choices` | +| `EnumField` | `graphene.Enum` (auto-registered) | + To learn more check out the following [examples](examples/): * [Flask MongoEngine example](examples/flask_mongoengine) * [Django MongoEngine example](examples/django_mongoengine) * [Falcon MongoEngine example](examples/falcon_mongoengine) +* [FastAPI MongoEngine example](examples/fastapi_mongoengine) ## Contributing After cloning this repo, ensure dependencies are installed by running: ```sh -pip install -r requirements.txt +uv sync ``` After developing, the full test suite can be evaluated by running: ```sh -make test +uv run make test ``` From 580fcb646bf7f017cafb816334e1fc435747b543 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 07:57:34 +0530 Subject: [PATCH 27/52] style: fix indentation in convert_field_to_list --- graphene_mongo/base/converter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_mongo/base/converter.py b/graphene_mongo/base/converter.py index d9b5a510..898dde50 100644 --- a/graphene_mongo/base/converter.py +++ b/graphene_mongo/base/converter.py @@ -230,7 +230,7 @@ def convert_field_to_list(field, registry=None, executor: ExecutorEnum = Executo # Non-relationship field relations = (mongoengine.ReferenceField, mongoengine.EmbeddedDocumentField) if not isinstance(base_type, (graphene.List, graphene.NonNull)) and not isinstance( - field.field, relations + field.field, relations ): base_type = type(base_type) From 1a4065c2b020a6b2d364d7db39043055eb0fc630 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 08:20:23 +0530 Subject: [PATCH 28/52] refactor: remove ListFieldResolver and UnionFieldResolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit select_related pre-fetches GenericReferenceField and ListField(GenericReferenceField) at the queryset level, so the ThreadPoolExecutor / asyncio.gather in ListFieldResolver and the per-document fetch in UnionFieldResolver never ran in practice. DynamicReferenceFieldResolver is kept: select_related only reaches references directly off the root queryset; nested ReferenceField (e.g. editor → company) and references inside custom get_queryset return LazyReference objects that graphene cannot serialize without this fallback. 179/179 tests pass. --- graphene_mongo/base/converter.py | 29 +- .../base/field_resolvers/__init__.py | 8 +- .../dynamic_reference_field_resolver.py | 63 +--- .../field_resolvers/list_field_resolver.py | 329 ------------------ .../base/field_resolvers/union_resolver.py | 134 ------- 5 files changed, 14 insertions(+), 549 deletions(-) delete mode 100644 graphene_mongo/base/field_resolvers/list_field_resolver.py delete mode 100644 graphene_mongo/base/field_resolvers/union_resolver.py diff --git a/graphene_mongo/base/converter.py b/graphene_mongo/base/converter.py index 898dde50..976549d6 100644 --- a/graphene_mongo/base/converter.py +++ b/graphene_mongo/base/converter.py @@ -6,11 +6,7 @@ import mongoengine from . import advanced_types -from .field_resolvers import ( - DynamicReferenceFieldResolver, - ListFieldResolver, - UnionFieldResolver, -) +from .field_resolvers import DynamicReferenceFieldResolver from .utils import ( ExecutorEnum, get_field_description, @@ -203,15 +199,6 @@ def convert_field_to_list(field, registry=None, executor: ExecutorEnum = Executo base_type._type, description=get_field_description(field, registry), required=get_field_is_required(field, registry), - resolver=get_field_resolver( - default_sync_resolver=ListFieldResolver.reference_resolver( - field=field, registry=registry, executor=executor - ), - default_async_resolver=ListFieldResolver.reference_resolver_async( - field=field, registry=registry, executor=executor - ), - executor=executor, - ), ) return graphene.List( base_type._type, @@ -308,16 +295,7 @@ def convert_field_to_union(field, registry=None, executor: ExecutorEnum = Execut field_resolver = resolver_function return graphene.Field( _union, - resolver=get_field_resolver( - field_resolver=field_resolver, - default_sync_resolver=UnionFieldResolver.reference_resolver( - field=field, registry=registry, executor=executor - ), - default_async_resolver=UnionFieldResolver.reference_resolver_async( - field=field, registry=registry, executor=executor - ), - executor=executor, - ), + resolver=field_resolver, description=get_field_description(field, registry), required=required, ) @@ -332,8 +310,7 @@ def convert_field_to_dynamic(field, registry=None, executor: ExecutorEnum = Exec Returns a graphene.Dynamic so that the target type is resolved lazily at schema build time, allowing forward references between types that are - defined in any order. A reference-field resolver is attached for - ReferenceField to handle lazy de-referencing. + defined in any order. Args: field: The MongoEngine embedded or reference field instance. diff --git a/graphene_mongo/base/field_resolvers/__init__.py b/graphene_mongo/base/field_resolvers/__init__.py index c2ae54cb..95b53fe2 100644 --- a/graphene_mongo/base/field_resolvers/__init__.py +++ b/graphene_mongo/base/field_resolvers/__init__.py @@ -1,9 +1,3 @@ from .dynamic_reference_field_resolver import DynamicReferenceFieldResolver -from .list_field_resolver import ListFieldResolver -from .union_resolver import UnionFieldResolver -__all__ = [ - "DynamicReferenceFieldResolver", - "ListFieldResolver", - "UnionFieldResolver", -] \ No newline at end of file +__all__ = ["DynamicReferenceFieldResolver"] \ No newline at end of file diff --git a/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py b/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py index 46c0fa32..f8864fb3 100644 --- a/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py +++ b/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py @@ -9,38 +9,19 @@ class DynamicReferenceFieldResolver: - """Resolver factory for MongoEngine ReferenceField and EmbeddedDocumentField. + """Fallback resolver for ReferenceField when select_related has not pre-loaded the document. - Handles lazy de-referencing efficiently: if select_related has already - loaded the referenced document it is returned immediately without a DB round - trip. Otherwise a targeted query is issued fetching only the fields selected - in the current GraphQL query (plus any required_fields declared in Meta). + select_related only resolves references that are directly reachable from the root + queryset. Nested references (e.g. editor.company) and references inside custom + get_queryset implementations may return LazyReference objects instead of loaded + Documents. This resolver detects that case and issues a targeted query fetching + only the fields selected in the current GraphQL query. """ @staticmethod def __reference_resolver_common( field, registry, executor: ExecutorEnum, root, *args, **kwargs ) -> Optional[Union[tuple[Document, set[str], ObjectId], Document]]: - """Shared pre-fetch logic for both sync and async resolvers. - - Reads the raw value from the parent document, determines which fields - need to be fetched, and either returns the already-loaded document or - a (document_class, fields_to_fetch, pk) tuple for the caller to query. - - Args: - field: The MongoEngine ReferenceField instance being resolved. - registry (Registry): Active type registry used to look up the target type. - executor (ExecutorEnum): SYNC or ASYNC. - root: The parent MongoEngine document instance. - *args: GraphQL positional args; args[0] must be the resolve info. - **kwargs: GraphQL keyword args (unused here). - - Returns: - Document — if the reference was already fetched by select_related. - tuple[type, set[str], ObjectId] — (document_class, fields_to_fetch, pk) - if a DB query is required. - None — if the field value is empty / unset. - """ document = root._data.get(field.name or field.db_name, None) if not document: return None @@ -59,7 +40,7 @@ def __reference_resolver_common( fields_to_fetch = set(list(_type._meta.required_fields) + queried_fields) if isinstance(document, field.document_type): - return document # Already fetched by select_related + return document document_id = ( document.id @@ -70,19 +51,7 @@ def __reference_resolver_common( @staticmethod def reference_resolver(field, registry, executor) -> Callable: - """Return a synchronous resolver for a ReferenceField. - - The returned resolver fetches the referenced document using - model.objects.only(*fields).get(pk=pk). - - Args: - field: The MongoEngine ReferenceField instance. - registry (Registry): Active type registry. - executor (ExecutorEnum): Should be ExecutorEnum.SYNC. - - Returns: - callable: resolver(root, *args, **kwargs) → Document | None - """ + """Return a synchronous resolver for a ReferenceField.""" def resolver(root, *args, **kwargs) -> Optional[Document]: result = DynamicReferenceFieldResolver.__reference_resolver_common( field, registry, executor, root, *args, **kwargs @@ -96,19 +65,7 @@ def resolver(root, *args, **kwargs) -> Optional[Document]: @staticmethod def reference_resolver_async(field, registry, executor) -> Callable: - """Return an asynchronous resolver for a ReferenceField. - - The returned coroutine fetches the referenced document using - await model.aobjects.only(*fields).get(pk=pk). - - Args: - field: The MongoEngine ReferenceField instance. - registry (Registry): Active type registry. - executor (ExecutorEnum): Should be ExecutorEnum.ASYNC. - - Returns: - callable: async resolver(root, *args, **kwargs) → Document | None - """ + """Return an asynchronous resolver for a ReferenceField.""" async def resolver(root, *args, **kwargs) -> Optional[Document]: result = DynamicReferenceFieldResolver.__reference_resolver_common( field, registry, executor, root, *args, **kwargs @@ -118,4 +75,4 @@ async def resolver(root, *args, **kwargs) -> Optional[Document]: model, only_fields, document_id = result return await model.aobjects.only(*only_fields).get(pk=document_id) - return resolver + return resolver \ No newline at end of file diff --git a/graphene_mongo/base/field_resolvers/list_field_resolver.py b/graphene_mongo/base/field_resolvers/list_field_resolver.py deleted file mode 100644 index 2b8a4baf..00000000 --- a/graphene_mongo/base/field_resolvers/list_field_resolver.py +++ /dev/null @@ -1,329 +0,0 @@ -import asyncio -from asyncio import Future, Task -from collections.abc import Callable -from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Optional, Union - -import mongoengine -from bson import ObjectId -from graphene.utils.str_converters import to_snake_case -from mongoengine import Document -from mongoengine.base import LazyReference - -from graphene_mongo.base.utils import ExecutorEnum, get_queried_union_types, get_document - - -class ListFieldResolver: - """Resolver factory for MongoEngine ListField containing references. - - Efficiently resolves lists of GenericReferenceField entries by grouping - references by their target document type, fetching each group in parallel - (sync: ThreadPoolExecutor; async: asyncio.gather), and reassembling - the results in the original order. Already-loaded Document instances - (e.g. from select_related) are passed through without additional DB queries. - """ - - @staticmethod - def __get_reference_objects_common( - registry, - model, - executor: ExecutorEnum, - object_id_list: list[ObjectId], - queried_fields: dict, - ) -> tuple[Document, set[str], list[ObjectId]]: - """Resolve the document class, field selection set, and IDs for a list of references. - - Converts model to its MongoEngine document class, determines which fields are - queried (intersecting queried_fields with the document's field list), and appends - any required_fields declared in Meta. - - Args: - registry (Registry): Active type registry. - model (str | type): MongoEngine document class or its class name string. - executor (ExecutorEnum): SYNC or ASYNC. - object_id_list (list[ObjectId]): Primary keys to fetch. - queried_fields (dict): Fields selected in the current GraphQL query. - - Returns: - tuple[type, set[str], list[ObjectId]]: - (document_class, fields_to_fetch, object_id_list) - """ - from graphene_mongo.base.converter import convert_mongoengine_field - - document = get_document(model) - document_field = mongoengine.ReferenceField(document) - document_field = convert_mongoengine_field(document_field, registry, executor) - document_field_type = document_field.get_type().type - _queried_fields = list() - filter_args = list() - if document_field_type._meta.filter_fields: - for key, values in document_field_type._meta.filter_fields.items(): - for each in values: - filter_args.append(key + "__" + each) - for each in queried_fields: - item = to_snake_case(each) - if item in document._fields_ordered + tuple(filter_args): - _queried_fields.append(item) - - only_fields = set(list(document_field_type._meta.required_fields) + _queried_fields) - return document, only_fields, object_id_list - - # ======================= DB CALLS ======================= - @staticmethod - def __get_reference_objects( - registry, - model, - executor: ExecutorEnum, - object_id_list: list[ObjectId], - queried_fields: dict, - ): - """Synchronously fetch a batch of documents by primary key. - - Args: - registry (Registry): Active type registry. - model (str | type): MongoEngine document class or its class name string. - executor (ExecutorEnum): "SYNC". - object_id_list (list[ObjectId]): Primary keys to fetch. - queried_fields (dict): Fields selected in the current GraphQL query. - - Returns: - QuerySet: Filtered QuerySet of matching documents with only projection. - """ - document, only_fields, document_ids = ListFieldResolver.__get_reference_objects_common( - registry, model, executor, object_id_list, queried_fields - ) - return document.objects().only(*only_fields).filter(pk__in=document_ids) - - @staticmethod - async def __get_reference_objects_async( - registry, - model, - executor: ExecutorEnum, - object_id_list: list[ObjectId], - queried_fields: dict, - ): - """Asynchronously fetch a batch of documents by primary key. - - Args: - registry (Registry): Active type registry. - model (str | type): MongoEngine document class or its class name string. - executor (ExecutorEnum): ASYNC. - object_id_list (list[ObjectId]): Primary keys to fetch. - queried_fields (dict): Fields selected in the current GraphQL query. - - Returns: - list[Document]: Fetched documents with only projection applied. - """ - document, only_fields, document_ids = ListFieldResolver.__get_reference_objects_common( - registry, model, executor, object_id_list, queried_fields - ) - return await document.aobjects.only(*only_fields).filter(pk__in=document_ids).to_list() - - # ======================= DB CALLS: END ======================= - - @staticmethod - def __get_non_querying_object(model, object_id_list) -> list[Document]: - """Return lightweight stub document instances without hitting the database. - - Used when the referenced type is not in the queried GraphQL fragment, so - only the primary key is needed (no fields to fetch). - - Args: - model (str | type): MongoEngine document class or its class name string. - object_id_list (list[ObjectId]): Primary keys to create stubs for. - - Returns: - list[Document]: Stub instances with pk set but no other fields loaded. - """ - model = get_document(model) - return [model(pk=each) for each in object_id_list] - - @staticmethod - async def __get_non_querying_object_async(model, object_id_list) -> list[Document]: - """Async wrapper around __get_non_querying_object; returns stubs without DB I/O. - - Args: - model (str | type): MongoEngine document class or its class name string. - object_id_list (list[ObjectId]): Primary keys to create stubs for. - - Returns: - list[Document]: Stub instances with pk set but no other fields loaded. - """ - return ListFieldResolver.__get_non_querying_object(model, object_id_list) - - @staticmethod - def __build_results( - result: list[Document], - to_resolve_object_ids: list[ObjectId], - already_resolved: dict[ObjectId, Document] = None, - ) -> list[Document]: - """Merge fetched batches and already-resolved documents back into original order. - - Args: - result (list[Document]): Batches of documents returned by each fetch call. - Each element is itself iterable (a QuerySet or list). - to_resolve_object_ids (list[ObjectId]): Original ordered list of primary keys. - already_resolved (dict[ObjectId, Document] | None): Documents already loaded - (e.g. from "select_related`) keyed by their primary key. - - Returns: - list[Document]: Documents ordered to match to_resolve_object_ids. - """ - result_object: dict[ObjectId, Document] = dict(already_resolved or {}) - for items in result: - for item in items: - result_object[item.id] = item - return [result_object[each] for each in to_resolve_object_ids] - - # ======================= Main Logic ======================= - - @staticmethod - def __reference_resolver_common( - field, registry, executor: ExecutorEnum, root, *args, **kwargs - ) -> Optional[tuple[Union[list[Task], list[Document]], list[ObjectId]]]: - """Shared dispatch logic for both sync and async list-reference resolvers. - - Iterates over the raw field value, separating already-loaded Document - instances from "LazyReference" entries and raw _cls/_ref dicts. - Groups unresolved references by their document type, then either submits - them to a "ThreadPoolExecutor" (sync) or creates "asyncio" tasks (async). - - Args: - field: The MongoEngine ListField containing generic references. - registry (Registry): Active type registry. - executor (ExecutorEnum): SYNC or ASYNC — controls dispatch strategy. - root: The parent MongoEngine document instance. - *args: GraphQL positional args; args[0] must be the resolve info. - **kwargs: GraphQL keyword args (unused here). - - Returns: - tuple[list[Future | Task], list[ObjectId], dict[ObjectId, Document]] - for the caller to await / join, or None if the field is empty. - """ - to_resolve = getattr(root, field.name or field.db_name) - if not to_resolve: - return None - - choice_to_resolve = dict() - registry_string_map = registry._registry_string_map - querying_union_types = get_queried_union_types( - info=args[0], valid_gql_types=registry_string_map.keys() - ) - to_resolve_models = dict() - for each, queried_fields in querying_union_types.items(): - to_resolve_models[registry.get_type_for_model_string(each)] = queried_fields - already_resolved: dict[ObjectId, Document] = {} - to_resolve_object_ids: list[ObjectId] = list() - for each in to_resolve: - if isinstance(each, Document): - already_resolved[each.pk] = each - to_resolve_object_ids.append(each.pk) - elif isinstance(each, LazyReference): - to_resolve_object_ids.append(each.pk) - model = each.document_type._class_name - if model not in choice_to_resolve: - choice_to_resolve[model] = list() - choice_to_resolve[model].append(each.pk) - else: - to_resolve_object_ids.append(each["_ref"].id) - if each["_cls"] not in choice_to_resolve: - choice_to_resolve[each["_cls"]] = list() - choice_to_resolve[each["_cls"]].append(each["_ref"].id) - - if executor == ExecutorEnum.SYNC: - pool = ThreadPoolExecutor(5) - futures: list[Future] = list() - for model, object_id_list in choice_to_resolve.items(): - if model in to_resolve_models: - queried_fields = to_resolve_models[model] - futures.append( - pool.submit( - ListFieldResolver.__get_reference_objects, - *(registry, model, executor, object_id_list, queried_fields), - ) - ) - else: - futures.append( - pool.submit( - ListFieldResolver.__get_non_querying_object, - *(model, object_id_list), - ) - ) - result = [future.result() for future in as_completed(futures)] - return result, to_resolve_object_ids, already_resolved - else: - loop = asyncio.get_event_loop() - tasks: list[Task] = [] - for model, object_id_list in choice_to_resolve.items(): - if model in to_resolve_models: - queried_fields = to_resolve_models[model] - task = loop.create_task( - ListFieldResolver.__get_reference_objects_async( - registry, - model, - executor, - object_id_list, - queried_fields, - ) - ) - else: - task = loop.create_task( - ListFieldResolver.__get_non_querying_object_async(model, object_id_list) - ) - tasks.append(task) - return tasks, to_resolve_object_ids, already_resolved - - @staticmethod - def reference_resolver(field, registry, executor) -> Callable: - """Return a synchronous resolver for a list of generic references. - - Dispatches fetch jobs to a ThreadPoolExecutor, then reassembles the - results in the original order via __build_results. - - Args: - field: The MongoEngine ListField containing generic references. - registry (Registry): Active type registry. - executor (ExecutorEnum): Should be ExecutorEnum.SYNC. - - Returns: - callable: resolver(root, *args, **kwargs) → list[Document] | None - """ - def resolver(root, *args, **kwargs) -> Optional[list[Document]]: - resolver_result = ListFieldResolver.__reference_resolver_common( - field, registry, executor, root, *args, **kwargs - ) - if not isinstance(resolver_result, tuple): - return resolver_result - result, to_resolve_object_ids, already_resolved = resolver_result - return ListFieldResolver.__build_results(result, to_resolve_object_ids, already_resolved) - - return resolver - - @staticmethod - def reference_resolver_async(field, registry, executor) -> Callable: - """Return an asynchronous resolver for a list of generic references. - - Awaits all asyncio tasks via asyncio.gather, then reassembles the - results in the original order via __build_results. - - Args: - field: The MongoEngine ListField containing generic references. - registry (Registry): Active type registry. - executor (ExecutorEnum): Should be ExecutorEnum.ASYNC. - - Returns: - callable: async resolver(root, *args, **kwargs) → list[Document] | None - """ - async def resolver(root, *args, **kwargs) -> Optional[list[Document]]: - resolver_result = ListFieldResolver.__reference_resolver_common( - field, registry, executor, root, *args, **kwargs - ) - if not isinstance(resolver_result, tuple): - return resolver_result - tasks, to_resolve_object_ids, already_resolved = resolver_result - result: list[Document] = await asyncio.gather(*tasks) - return ListFieldResolver.__build_results(result, to_resolve_object_ids, already_resolved) - - return resolver - - # ======================= Main Logic: END ======================= diff --git a/graphene_mongo/base/field_resolvers/union_resolver.py b/graphene_mongo/base/field_resolvers/union_resolver.py deleted file mode 100644 index 74d6a295..00000000 --- a/graphene_mongo/base/field_resolvers/union_resolver.py +++ /dev/null @@ -1,134 +0,0 @@ -from collections.abc import Callable -from typing import Optional, Union - -from bson import ObjectId -from graphene.utils.str_converters import to_snake_case -import mongoengine -from mongoengine import Document - -from graphene_mongo.base.utils import ExecutorEnum, get_document, get_queried_union_types - - -class UnionFieldResolver: - """Resolver factory for MongoEngine GenericReferenceField. - - Handles lazy de-referencing of generic references (fields that can point to - different document types). Identifies the concrete document type at resolve time, - then fetches only the fields requested in the current GraphQL query. - """ - - @staticmethod - def __reference_resolver_common( - field, registry, executor: ExecutorEnum, root, *args, **kwargs - ) -> Optional[Union[tuple[Document, set[str], ObjectId], Document]]: - """Shared pre-fetch logic for both sync and async union resolvers. - - Reads the raw generic reference from the parent document, identifies the - target document type, and determines which fields need to be fetched. Returns - the already-loaded document if it has been fetched, or a tuple for the caller - to query. - - Args: - field: The MongoEngine GenericReferenceField instance being resolved. - registry (Registry): Active type registry used to look up the target type. - executor (ExecutorEnum): SYNC or ASYNC. - root: The parent MongoEngine document instance. - *args: GraphQL positional args; args[0] must be the resolve info. - **kwargs: GraphQL keyword args (unused here). - - Returns: - Document — if the reference was already fetched (e.g. via select_related). - tuple[type, set[str], ObjectId] — (document_class, fields_to_fetch, pk) - if a DB query is required. - Document (id=pk) — a stub instance if the type is not in the queried union. - None — if the field value is empty / unset. - """ - from graphene_mongo.base.converter import convert_mongoengine_field - - de_referenced = getattr(root, field.name or field.db_name) - if not de_referenced: - return None - - if isinstance(de_referenced, Document): - return de_referenced - - document = get_document(de_referenced.document_type) - document_id = de_referenced.id - document_field = mongoengine.ReferenceField(document) - document_field = convert_mongoengine_field(document_field, registry, executor=executor) - _type = document_field.get_type().type - filter_args = list() - if _type._meta.filter_fields: - for key, values in _type._meta.filter_fields.items(): - for each in values: - filter_args.append(key + "__" + each) - - registry_string_map = registry._registry_string_map - querying_union_types = get_queried_union_types( - info=args[0], valid_gql_types=registry_string_map.keys() - ) - - if _type.__name__ in querying_union_types: - queried_fields = list() - for each in querying_union_types[_type._meta.name].keys(): - item = to_snake_case(each) - if item in document._fields_ordered + tuple(filter_args): - queried_fields.append(item) - - only_fields = set(list(_type._meta.required_fields) + queried_fields) - - return document, only_fields, document_id - - return document(id=document_id) - - @staticmethod - def reference_resolver(field, registry, executor) -> Callable: - """Return a synchronous resolver for a GenericReferenceField. - - The returned resolver fetches the referenced document using - model.objects.only(*fields).get(pk=pk). - - Args: - field: The MongoEngine GenericReferenceField instance. - registry (Registry): Active type registry. - executor (ExecutorEnum): Should be ExecutorEnum.SYNC. - - Returns: - callable: resolver(root, *args, **kwargs) → Document | None - """ - def resolver(root, *args, **kwargs) -> Optional[Document]: - result = UnionFieldResolver.__reference_resolver_common( - field, registry, executor, root, *args, **kwargs - ) - if not isinstance(result, tuple): - return result - document, only_fields, pk = result - return document.objects.only(*only_fields).get(pk=pk) - - return resolver - - @staticmethod - def reference_resolver_async(field, registry, executor) -> Callable: - """Return an asynchronous resolver for a GenericReferenceField. - - The returned coroutine fetches the referenced document using - await model.aobjects.only(*fields).get(pk=pk). - - Args: - field: The MongoEngine GenericReferenceField instance. - registry (Registry): Active type registry. - executor (ExecutorEnum): Should be ExecutorEnum.ASYNC. - - Returns: - callable: async resolver(root, *args, **kwargs) → Document | None - """ - async def resolver(root, *args, **kwargs) -> Optional[Document]: - result = UnionFieldResolver.__reference_resolver_common( - field, registry, executor, root, *args, **kwargs - ) - if not isinstance(result, tuple): - return result - model, only_fields, document_id = result - return await model.aobjects.only(*only_fields).get(pk=document_id) - - return resolver From ce8f335bba70c3c214a3a6cb91726b83446f91d5 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 09:08:48 +0530 Subject: [PATCH 29/52] =?UTF-8?q?refactor:=20remove=20DynamicReferenceFiel?= =?UTF-8?q?dResolver=20=E2=80=94=20select=5Frelated=20handles=20all=20pre-?= =?UTF-8?q?fetching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three field resolvers are now removed. The mongoengine pipeline_builder fix (passing embedded_list_path=full_path when recursing into ListField items) enables select_related("list_field__nested_ref") to correctly hydrate nested references inside list elements via $map rather than writing a _missing_reference marker via $addFields on an array. Additional fixes: - get_select_related_paths: unwrap Relay edges→node when recursing into connection sub-fields so nested refs (e.g. beforeChild→parent) are visible - get_queryset: apply _apply_select_related even when a custom get_queryset callback returns a QuerySet/AsyncQuerySet directly - Update stale count assertions to reflect that all referenced documents are now pre-fetched in a single aggregation (no lazy deref extra queries) --- graphene_mongo/asynchronous/fields.py | 2 +- graphene_mongo/base/converter.py | 13 +--- .../base/field_resolvers/__init__.py | 3 - .../dynamic_reference_field_resolver.py | 78 ------------------- graphene_mongo/base/utils.py | 5 +- graphene_mongo/synchronous/fields.py | 2 +- tests/asynchronous/test_query.py | 8 +- tests/asynchronous/test_relay_query.py | 4 +- tests/synchronous/test_query.py | 8 +- tests/synchronous/test_relay_query.py | 4 +- 10 files changed, 19 insertions(+), 108 deletions(-) delete mode 100644 graphene_mongo/base/field_resolvers/__init__.py delete mode 100644 graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py diff --git a/graphene_mongo/asynchronous/fields.py b/graphene_mongo/asynchronous/fields.py index 1dcdd16a..f3edefc7 100644 --- a/graphene_mongo/asynchronous/fields.py +++ b/graphene_mongo/asynchronous/fields.py @@ -125,7 +125,7 @@ def get_queryset( if self._get_queryset: queryset_or_filters = self._get_queryset(model, info, **args) if isinstance(queryset_or_filters, mongoengine.AsyncQuerySet): - return queryset_or_filters + return self._apply_select_related(queryset_or_filters, model, info) elif isinstance(queryset_or_filters, mongoengine.QuerySet): raise TypeError( "AsyncMongoengineConnectionField only accepts AsyncQuerySet in get_queryset(...)" diff --git a/graphene_mongo/base/converter.py b/graphene_mongo/base/converter.py index 976549d6..b6b76d8b 100644 --- a/graphene_mongo/base/converter.py +++ b/graphene_mongo/base/converter.py @@ -6,12 +6,10 @@ import mongoengine from . import advanced_types -from .field_resolvers import DynamicReferenceFieldResolver from .utils import ( ExecutorEnum, get_field_description, get_field_is_required, - get_field_resolver, get_document, ) @@ -346,16 +344,7 @@ def dynamic_type(): field_resolver = resolver_function return graphene.Field( _type, - resolver=get_field_resolver( - field_resolver=field_resolver, - default_sync_resolver=DynamicReferenceFieldResolver.reference_resolver( - field=field, registry=registry, executor=executor - ), - default_async_resolver=DynamicReferenceFieldResolver.reference_resolver_async( - field=field, registry=registry, executor=executor - ), - executor=executor, - ), + resolver=field_resolver, description=get_field_description(field, registry), required=required, ) diff --git a/graphene_mongo/base/field_resolvers/__init__.py b/graphene_mongo/base/field_resolvers/__init__.py deleted file mode 100644 index 95b53fe2..00000000 --- a/graphene_mongo/base/field_resolvers/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .dynamic_reference_field_resolver import DynamicReferenceFieldResolver - -__all__ = ["DynamicReferenceFieldResolver"] \ No newline at end of file diff --git a/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py b/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py deleted file mode 100644 index f8864fb3..00000000 --- a/graphene_mongo/base/field_resolvers/dynamic_reference_field_resolver.py +++ /dev/null @@ -1,78 +0,0 @@ -from collections.abc import Callable -from typing import Optional, Union - -from bson import ObjectId -from graphene.utils.str_converters import to_snake_case -from mongoengine import Document, ReferenceField - -from graphene_mongo.base.utils import ExecutorEnum, get_query_fields - - -class DynamicReferenceFieldResolver: - """Fallback resolver for ReferenceField when select_related has not pre-loaded the document. - - select_related only resolves references that are directly reachable from the root - queryset. Nested references (e.g. editor.company) and references inside custom - get_queryset implementations may return LazyReference objects instead of loaded - Documents. This resolver detects that case and issues a targeted query fetching - only the fields selected in the current GraphQL query. - """ - - @staticmethod - def __reference_resolver_common( - field, registry, executor: ExecutorEnum, root, *args, **kwargs - ) -> Optional[Union[tuple[Document, set[str], ObjectId], Document]]: - document = root._data.get(field.name or field.db_name, None) - if not document: - return None - - queried_fields = list() - _type = registry.get_type_for_model(field.document_type) - filter_args = list() - if _type._meta.filter_fields: - for key, values in _type._meta.filter_fields.items(): - for each in values: - filter_args.append(key + "__" + each) - for each in get_query_fields(args[0]).keys(): - item = to_snake_case(each) - if item in field.document_type._fields_ordered + tuple(filter_args): - queried_fields.append(item) - - fields_to_fetch = set(list(_type._meta.required_fields) + queried_fields) - if isinstance(document, field.document_type): - return document - - document_id = ( - document.id - if isinstance(field, ReferenceField) - else getattr(root, field.name or field.db_name) - ) - return field.document_type, fields_to_fetch, document_id - - @staticmethod - def reference_resolver(field, registry, executor) -> Callable: - """Return a synchronous resolver for a ReferenceField.""" - def resolver(root, *args, **kwargs) -> Optional[Document]: - result = DynamicReferenceFieldResolver.__reference_resolver_common( - field, registry, executor, root, *args, **kwargs - ) - if not isinstance(result, tuple): - return result - document, only_fields, pk = result - return document.objects.only(*only_fields).get(pk=pk) - - return resolver - - @staticmethod - def reference_resolver_async(field, registry, executor) -> Callable: - """Return an asynchronous resolver for a ReferenceField.""" - async def resolver(root, *args, **kwargs) -> Optional[Document]: - result = DynamicReferenceFieldResolver.__reference_resolver_common( - field, registry, executor, root, *args, **kwargs - ) - if not isinstance(result, tuple): - return result - model, only_fields, document_id = result - return await model.aobjects.only(*only_fields).get(pk=document_id) - - return resolver \ No newline at end of file diff --git a/graphene_mongo/base/utils.py b/graphene_mongo/base/utils.py index 72105b36..62fc4aed 100644 --- a/graphene_mongo/base/utils.py +++ b/graphene_mongo/base/utils.py @@ -279,7 +279,10 @@ def get_select_related_paths(model, queried_fields, prefix=""): path = f"{prefix}__{snake}" if prefix else snake paths.append(path) if sub_fields and hasattr(inner, "document_type"): - paths += get_select_related_paths(inner.document_type, sub_fields, prefix=path) + # Unwrap Relay edges→node wrapper so nested fields (e.g. beforeChild→parent) + # are visible to the recursion even when the sub-field is a connection. + effective = sub_fields.get("edges", {}).get("node") or sub_fields + paths += get_select_related_paths(inner.document_type, effective, prefix=path) return paths diff --git a/graphene_mongo/synchronous/fields.py b/graphene_mongo/synchronous/fields.py index 6a37f580..3a76a492 100644 --- a/graphene_mongo/synchronous/fields.py +++ b/graphene_mongo/synchronous/fields.py @@ -97,7 +97,7 @@ def get_queryset( if self._get_queryset: queryset_or_filters = self._get_queryset(model, info, **args) if isinstance(queryset_or_filters, mongoengine.QuerySet): - return queryset_or_filters + return self._apply_select_related(queryset_or_filters, model, info) args.update(queryset_or_filters) qs = self._apply_select_related( self._qs_accessor(model)(**args).only(*required_fields).order_by(self.order_by), diff --git a/tests/asynchronous/test_query.py b/tests/asynchronous/test_query.py index 32e3f726..300ff113 100644 --- a/tests/asynchronous/test_query.py +++ b/tests/asynchronous/test_query.py @@ -15,7 +15,7 @@ class Query(graphene.ObjectType): editors = graphene.List(async_types.EditorAsyncType) async def resolve_editor(self, *args, **kwargs): - return await models.Editor.aobjects.select_related().first() + return await models.Editor.aobjects.select_related("company").first() async def resolve_editors(self, *args, **kwargs): return await models.Editor.aobjects.all().to_list() @@ -71,7 +71,7 @@ async def resolve_editors(self, *args, **kwargs): metadata = result.data["editor"].pop("metadata") assert json.loads(metadata) == expected_metadata assert result.data == expected - assert count == 5 # 1 first editor + 1 company ref deref + 2 GridFS reads (files+chunks) + 1 all editors + assert count == 4 # 1 first editor (company pre-fetched via select_related) + 2 GridFS reads (files+chunks) + 1 all editors async def test_should_query_reporter(fixtures): @@ -155,7 +155,7 @@ class Query(graphene.ObjectType): all_players = graphene.List(async_types.PlayerAsyncType) async def resolve_all_players(self, *args, **kwargs): - return await models.Player.aobjects.select_related("players").to_list() + return await models.Player.aobjects.select_related("players", "opponent").to_list() query = """ query PlayersQuery { @@ -194,7 +194,7 @@ async def resolve_all_players(self, *args, **kwargs): result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected - assert count == 2 # 1 select_related aggregate + 1 lazy deref for opponent (Magic's opponent is Michael) + assert count == 1 # 1 select_related aggregate (opponent + players all pre-fetched via select_related) async def test_should_query_with_embedded_document(fixtures): diff --git a/tests/asynchronous/test_relay_query.py b/tests/asynchronous/test_relay_query.py index eea2fa7c..c2d7b842 100644 --- a/tests/asynchronous/test_relay_query.py +++ b/tests/asynchronous/test_relay_query.py @@ -784,7 +784,7 @@ class Query(graphene.ObjectType): result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected - assert count == 3 # 1 parents aggregate + 1 before_child select_related load + 1 after_child select_related load + assert count == 1 # 1 aggregate (before_child, before_child.parent, after_child, after_child.parent all pre-fetched) async def test_should_query_with_embedded_document(fixtures): @@ -902,7 +902,7 @@ class Query(graphene.ObjectType): result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected - assert count == 2 # get_queryset returns a QuerySet directly, bypassing select_related; 1 find + 1 editor lazy deref + assert count == 1 # get_queryset returns a QuerySet; select_related applied to it pre-fetches editor in the same aggregate async def test_should_filter_mongoengine_queryset(fixtures): diff --git a/tests/synchronous/test_query.py b/tests/synchronous/test_query.py index 14b62858..ce6ce36b 100644 --- a/tests/synchronous/test_query.py +++ b/tests/synchronous/test_query.py @@ -16,7 +16,7 @@ class Query(graphene.ObjectType): editors = graphene.List(types.EditorType) def resolve_editor(self, *args, **kwargs): - return models.Editor.objects.first() + return models.Editor.objects.select_related("company").first() def resolve_editors(self, *args, **kwargs): return list(models.Editor.objects.all()) @@ -72,7 +72,7 @@ def resolve_editors(self, *args, **kwargs): metadata = result.data["editor"].pop("metadata") assert json.loads(metadata) == expected_metadata assert result.data == expected - assert count == 5 # 1 first editor + 1 company ref deref + 2 GridFS reads (files+chunks) + 1 all editors + assert count == 4 # 1 first editor (company pre-fetched via select_related) + 2 GridFS reads (files+chunks) + 1 all editors def test_should_query_reporter(fixtures): @@ -156,7 +156,7 @@ class Query(graphene.ObjectType): all_players = graphene.List(types.PlayerType) def resolve_all_players(self, *args, **kwargs): - return models.Player.objects.select_related("players").all() + return models.Player.objects.select_related("players", "opponent").all() query = """ query PlayersQuery { @@ -195,7 +195,7 @@ def resolve_all_players(self, *args, **kwargs): result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - assert count == 2 # 1 select_related aggregate + 1 lazy deref for opponent (Magic's opponent is Michael) + assert count == 1 # 1 select_related aggregate (opponent + players all pre-fetched via select_related) def test_should_query_with_embedded_document(fixtures): diff --git a/tests/synchronous/test_relay_query.py b/tests/synchronous/test_relay_query.py index 3b9107c6..cfee0ad1 100644 --- a/tests/synchronous/test_relay_query.py +++ b/tests/synchronous/test_relay_query.py @@ -789,7 +789,7 @@ class Query(graphene.ObjectType): result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - assert count == 3 # 1 parents aggregate + 1 before_child select_related load + 1 after_child select_related load + assert count == 1 # 1 aggregate (before_child, before_child.parent, after_child, after_child.parent all pre-fetched) def test_should_query_with_embedded_document(fixtures): @@ -904,7 +904,7 @@ class Query(graphene.ObjectType): result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - assert count == 2 # get_queryset returns a QuerySet directly, bypassing select_related; 1 find + 1 editor lazy deref + assert count == 1 # get_queryset returns a QuerySet; select_related applied to it pre-fetches editor in the same aggregate def test_should_filter_mongoengine_queryset(fixtures): From 66b0f534254da240dfe9546952d21a1541c2da76 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 09:14:35 +0530 Subject: [PATCH 30/52] docs: add How It Works section explaining query-driven pre-fetching --- README.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/README.md b/README.md index aaec44fb..57fc4189 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,85 @@ MongoEngine fields are converted to GraphQL types automatically: | `GenericReferenceField` | `Union` of registered `choices` | | `EnumField` | `graphene.Enum` (auto-registered) | +## How It Works + +### Automatic Query-Driven Pre-fetching + +The central design principle is: **fetch exactly what the GraphQL client asked for, in as few MongoDB round-trips as +possible.** + +When a connection field resolves, graphene-mongo inspects the incoming GraphQL selection set before the query runs. It +walks every field the client requested and collects the MongoEngine reference paths that need to be resolved — including +nested references (e.g. `article → editor → company`). These paths are passed directly to MongoEngine's +`select_related`, which compiles them into a single MongoDB aggregation pipeline using `$lookup` stages. + +```graphql +query { + articles { + edges { + node { + headline + editor { + firstName + company { name } + } + } + } + } +} +``` + +The library detects that `editor` and `editor.company` are referenced fields, then issues: + +```python +Article.aobjects.select_related("editor", "editor__company") +``` + +This becomes **one** aggregation with two `$lookup` stages — no N+1, no lazy deref, no hidden thread pools. + +### What `select_related` covers + +| Field type | Example | Behaviour | +|-----------------------------|-----------------------|----------------------------------------------------------------------------| +| `ReferenceField` | `article.editor` | Pre-fetched; nested refs also recursed (e.g. `editor__company`) | +| `ListField(ReferenceField)` | `parent.before_child` | List hydrated; nested refs inside each element also pre-fetched via `$map` | +| `EmbeddedDocumentField` | `professor.metadata` | Always co-located in the document — no extra query | +| `GenericReferenceField` | `item.content` | Union resolved; choices pre-fetched | + +### Sync vs Async + +Both execution modes share the same pre-fetching logic. The difference is in the QuerySet manager used: + +- **Sync** (`MongoengineObjectType`) — uses `model.objects`, resolvers are plain functions. +- **Async** (`AsyncMongoengineObjectType`) — uses `model.aobjects`, resolvers are `async def`. The pipeline_builder + compiles everything into a single `aggregate()` call. + +### Custom `get_queryset` + +You can supply a `get_queryset` callback on a connection field to apply custom filters. The library applies +`select_related` on top of whatever queryset or filter dict you return, so pre-fetching still works: + +```python +def get_queryset(model, info, **args): + return model.objects(published=True) # filters only — select_related added automatically + + +articles = MongoengineConnectionField(ArticleNode, get_queryset=get_queryset) +``` + +If you return a raw `QuerySet` or `AsyncQuerySet`, `select_related` is applied to it before execution. If you return a +dict, it is used as filter kwargs. + +### Custom resolvers on ObjectTypes + +If you write a resolver directly on a `Query` class, you are fully in control — the framework does not add any resolvers +on top of yours. Use `select_related` explicitly for whatever your query needs: + +```python +async def resolve_reporter(self, info): + return await Reporter.aobjects.select_related("articles").first() +``` + To learn more check out the following [examples](examples/): * [Flask MongoEngine example](examples/flask_mongoengine) From 21c58afab7570f1d26fbff4eb95add0b63a88116 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 09:16:18 +0530 Subject: [PATCH 31/52] docs: clarify that get_queryset returning a QuerySet also gets select_related applied --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 57fc4189..1171675e 100644 --- a/README.md +++ b/README.md @@ -216,8 +216,9 @@ def get_queryset(model, info, **args): articles = MongoengineConnectionField(ArticleNode, get_queryset=get_queryset) ``` -If you return a raw `QuerySet` or `AsyncQuerySet`, `select_related` is applied to it before execution. If you return a -dict, it is used as filter kwargs. +If you return a `QuerySet` or `AsyncQuerySet`, `select_related` is applied to it automatically — your filters are +preserved and the referenced fields the client asked for are pre-fetched on top, all in one aggregation. If you return +a dict, it is used as filter kwargs and the same pre-fetching applies. ### Custom resolvers on ObjectTypes From 4633c8325a672eb9df830c94cd21f6830e32bbe6 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 09:19:33 +0530 Subject: [PATCH 32/52] feat: apply select_related to QuerySet returned from custom connection resolver When a resolver on the Query class returns a QuerySet to a connection field, chained_resolver now applies _apply_select_related before passing it to default_resolver. This means returning a queryset from any custom resolver automatically gets query-driven pre-fetching, consistent with get_queryset. --- graphene_mongo/asynchronous/fields.py | 1 + graphene_mongo/synchronous/fields.py | 1 + 2 files changed, 2 insertions(+) diff --git a/graphene_mongo/asynchronous/fields.py b/graphene_mongo/asynchronous/fields.py index f3edefc7..00c1dc9f 100644 --- a/graphene_mongo/asynchronous/fields.py +++ b/graphene_mongo/asynchronous/fields.py @@ -414,6 +414,7 @@ def filter_connection(x): elif isinstance(resolved, QuerySet): args.update(resolved._query) args_copy = self._transform_qs_args(args, args.copy()) + resolved = self._apply_select_related(resolved, self.model, info) return await self.default_resolver( root, info, required_fields, resolved=resolved, **args_copy ) diff --git a/graphene_mongo/synchronous/fields.py b/graphene_mongo/synchronous/fields.py index 3a76a492..8a196c5c 100644 --- a/graphene_mongo/synchronous/fields.py +++ b/graphene_mongo/synchronous/fields.py @@ -388,6 +388,7 @@ def filter_connection(x): elif isinstance(resolved, QuerySet): args.update(resolved._query) args_copy = self._transform_qs_args(args, args.copy()) + resolved = self._apply_select_related(resolved, self.model, info) return self.default_resolver( root, info, required_fields, resolved=resolved, **args_copy ) From f48a49d78c03a29bd9bd4fd1a3dca02c5d1406f1 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 20:27:26 +0530 Subject: [PATCH 33/52] feat: add OpenTelemetry tracing, select_related pre-fetching, and async support - Add built-in OpenTelemetry span emission for connection fields and Node lookups - Implement automatic select_related pre-fetching driven by GraphQL selection set - Add async Motor-backed QuerySet support via AsyncMongoengineObjectType - Add telemetry.py wiring to all four example applications - Expand test coverage for converter, telemetry, and relay queries - Update pyproject.toml with telemetry optional extra and dev dependencies --- README.md | 125 ++- docs/async_tutorial.rst | 246 ------ docs/conf.py | 433 +---------- docs/fields.rst | 202 ----- docs/index.rst | 99 +-- docs/requirements.txt | 4 +- docs/tutorial.rst | 216 ------ examples/django_mongoengine/README.md | 115 ++- examples/django_mongoengine/bike/apps.py | 4 + examples/django_mongoengine/bike/tests.py | 1 - examples/django_mongoengine/pyproject.toml | 9 + examples/django_mongoengine/telemetry.py | 42 + examples/falcon_mongoengine/README.md | 128 +-- examples/falcon_mongoengine/api.py | 1 - examples/falcon_mongoengine/app.py | 2 + examples/falcon_mongoengine/pyproject.toml | 9 + examples/falcon_mongoengine/telemetry.py | 42 + examples/falcon_mongoengine/tests/tests.py | 1 - examples/fastapi_mongoengine/README.md | 101 +++ examples/fastapi_mongoengine/app.py | 2 + examples/fastapi_mongoengine/database.py | 1 - examples/fastapi_mongoengine/pyproject.toml | 9 + examples/fastapi_mongoengine/telemetry.py | 42 + examples/flask_mongoengine/README.md | 137 ++-- examples/flask_mongoengine/app.py | 2 + examples/flask_mongoengine/pyproject.toml | 9 + examples/flask_mongoengine/telemetry.py | 42 + graphene_mongo/__init__.py | 5 +- graphene_mongo/asynchronous/fields.py | 35 +- graphene_mongo/asynchronous/types.py | 34 +- graphene_mongo/base/telemetry.py | 133 ++++ graphene_mongo/synchronous/fields.py | 35 +- graphene_mongo/synchronous/types.py | 34 +- pyproject.toml | 13 +- tests/asynchronous/test_converter.py | 253 ++++++ tests/asynchronous/test_relay_query.py | 39 +- tests/asynchronous/test_types.py | 1 - tests/synchronous/test_converter.py | 253 ++++++ tests/synchronous/test_fields.py | 3 - tests/synchronous/test_mutation.py | 1 - tests/synchronous/test_query.py | 1 - tests/synchronous/test_relay_query.py | 20 +- tests/synchronous/test_utils.py | 44 +- tests/test_converter.py | 257 +----- tests/test_telemetry.py | 327 ++++++++ tests/test_telemetry_exporter.py | 199 +++++ uv.lock | 818 +++++++++++++++++++- 47 files changed, 2872 insertions(+), 1657 deletions(-) delete mode 100644 docs/async_tutorial.rst delete mode 100644 docs/fields.rst delete mode 100644 docs/tutorial.rst create mode 100644 examples/django_mongoengine/telemetry.py create mode 100644 examples/falcon_mongoengine/telemetry.py create mode 100644 examples/fastapi_mongoengine/README.md create mode 100644 examples/fastapi_mongoengine/telemetry.py create mode 100644 examples/flask_mongoengine/telemetry.py create mode 100644 graphene_mongo/base/telemetry.py create mode 100644 tests/asynchronous/test_converter.py create mode 100644 tests/synchronous/test_converter.py create mode 100644 tests/test_telemetry.py create mode 100644 tests/test_telemetry_exporter.py diff --git a/README.md b/README.md index 1171675e..88e3fa15 100644 --- a/README.md +++ b/README.md @@ -220,16 +220,135 @@ If you return a `QuerySet` or `AsyncQuerySet`, `select_related` is applied to it preserved and the referenced fields the client asked for are pre-fetched on top, all in one aggregation. If you return a dict, it is used as filter kwargs and the same pre-fetching applies. +### `Node.Field()` and `get_node` + +The default `get_node` on both `MongoengineObjectType` and `AsyncMongoengineObjectType` already applies +`select_related` automatically — it inspects the GraphQL selection set and pre-fetches only the referenced +fields the client asked for, in a single aggregation. + +If you override `get_node` for custom filtering or access control, you must replicate this yourself or +referenced fields will be unhydrated: + +```python +from graphene_mongo import get_query_fields, get_select_related_paths + + +class ReporterNode(AsyncMongoengineObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + @classmethod + async def get_node(cls, info, id): + # ⚠ Always derive and apply select_related when overriding get_node + queried = get_query_fields(info) + paths = get_select_related_paths(cls._meta.model, queried) + qs = cls._meta.model.aobjects.filter(pk=id) + if paths: + qs = qs.select_related(*paths) + return await qs.first() +``` + +Omitting `select_related` here will cause referenced fields to be unhydrated — they will resolve to `None` +or raise an error depending on whether async lazy dereferencing is supported. + ### Custom resolvers on ObjectTypes -If you write a resolver directly on a `Query` class, you are fully in control — the framework does not add any resolvers -on top of yours. Use `select_related` explicitly for whatever your query needs: +If you write a resolver directly on a `Query` class for a `graphene.Field` (single document), you are fully +in control — return the document directly. Hard-coding `select_related` paths works but over-fetches when the +client doesn't request those fields and silently breaks when new reference fields are added to the model. +Use `get_query_fields` + `get_select_related_paths` instead so pre-fetching adapts automatically: ```python +from graphene_mongo import get_query_fields, get_select_related_paths + + +# ✗ hard-coded — over-fetches, breaks silently when model changes +async def resolve_reporter(self, info): + return await Reporter.aobjects.select_related("articles", "company").first() + + +# ✓ query-driven — fetches only what the client asked for async def resolve_reporter(self, info): - return await Reporter.aobjects.select_related("articles").first() + queried = get_query_fields(info) + paths = get_select_related_paths(Reporter, queried) + return await Reporter.aobjects.select_related(*paths).first() +``` + +### Pre-fetching in `graphene.List` resolvers + +Connection fields apply `select_related` automatically. If you use `graphene.List` or write a single-document +resolver outside the connection pipeline, you are responsible for calling `select_related` yourself. +Two utilities are exported to help: + +```python +from graphene_mongo import get_query_fields, get_select_related_paths + +# Derive the paths the client actually queried +queried = get_query_fields(info) # {"editor": {"firstName": {}}, ...} +paths = get_select_related_paths(Reporter, queried) # ["editor", "editor__company"] + +# Apply only what the client asked for +qs = Reporter.aobjects.filter(active=True).select_related(*paths) +``` + +`get_query_fields` returns the nested selection-set dict from the GraphQL AST. +`get_select_related_paths` walks that dict against the MongoEngine model and returns `__`-separated paths +suitable for `QuerySet.select_related`. + +## OpenTelemetry Tracing + +graphene-mongo has built-in OpenTelemetry support. Install the optional extra to activate it: + +```sh +pip install graphene-mongo[telemetry] +``` + +When `opentelemetry-api` is installed, the library emits spans automatically — no code changes required +in your resolvers or schema. When it is not installed, the library runs with zero overhead (a single +boolean check per resolution). + +### Span hierarchy + +``` +POST /graphql ← framework HTTP span (FastAPI / Flask / Falcon / Django) + └─ graphql articles ← graphene-mongo (connection field resolution) + └─ mongodb.aggregate ← opentelemetry-instrumentation-pymongo (automatic) + └─ graphql node ReporterType ← graphene-mongo (Node.Field / get_node lookup) + └─ mongodb.aggregate ``` +### Attributes set on each span + +| Attribute | Value | +|-----------------------------|-------------------------------------------------| +| `graphql.field.name` | The field name being resolved | +| `graphql.field.parent_type` | The parent GraphQL type name | +| `graphql.operation.type` | `query`, `mutation`, or `subscription` | +| `graphql.operation.name` | The named operation (if provided by the client) | +| `graphql.pagination.first` | Value of `first` argument (connection fields) | +| `graphql.pagination.last` | Value of `last` argument (connection fields) | +| `graphql.node.id` | The Relay global ID (node lookups only) | + +Spans are marked `ERROR` and the exception is recorded (with full stacktrace) if an unhandled exception +propagates out of the resolver. MongoDB-level spans are produced automatically by +`opentelemetry-instrumentation-pymongo` and appear as children. + +### Wiring up a backend + +Each framework example in this repo includes a ready-to-use `telemetry.py` with +`setup_telemetry()` that creates a `TracerProvider`, attaches a `BatchSpanProcessor` with an +OTLP exporter, instruments pymongo, and instruments the framework. Call it once at app startup +and set `OTEL_EXPORTER_OTLP_ENDPOINT` to point at your collector (Jaeger, Datadog Agent, +Grafana Tempo, etc.). + +Full wiring instructions and sample span output for each framework: + +- [FastAPI example](examples/fastapi_mongoengine/README.md) +- [Flask example](examples/flask_mongoengine/README.md) +- [Falcon example](examples/falcon_mongoengine/README.md) +- [Django example](examples/django_mongoengine/README.md) + To learn more check out the following [examples](examples/): * [Flask MongoEngine example](examples/flask_mongoengine) diff --git a/docs/async_tutorial.rst b/docs/async_tutorial.rst deleted file mode 100644 index a38d221b..00000000 --- a/docs/async_tutorial.rst +++ /dev/null @@ -1,246 +0,0 @@ -Async Tutorial — FastAPI -======================== - -graphene-mongo ships a first-class async API built on Motor (asyncio MongoDB driver). -This tutorial shows how to wire it up with FastAPI. - -The full source is in -`examples/fastapi_mongoengine `__. - -For the synchronous Flask tutorial see :doc:`tutorial`. - -How the Async API Works ------------------------ - -The async API mirrors the sync API but uses: - -- ``AsyncMongoengineObjectType`` instead of ``MongoengineObjectType`` -- ``AsyncMongoengineConnectionField`` instead of ``MongoengineConnectionField`` -- ``schema.execute_async()`` instead of ``schema.execute()`` -- ``mongoengine.async_connect()`` in addition to ``mongoengine.connect()`` - -Internally, ``AsyncMongoengineConnectionField`` calls ``select_related()`` on -the queryset, resolving all referenced documents in a **single** MongoDB -``$aggregate`` pipeline — no N+1 queries. - -Setup ------ - -.. code:: bash - - mkdir fastapi_graphene_mongo && cd fastapi_graphene_mongo - uv init - uv add fastapi "uvicorn[standard]" graphene-mongo mongoengine mongomock - -Models ------- - -.. code:: python - - # models.py - import mongoengine - - class Author(mongoengine.Document): - meta = {"collection": "authors"} - name = mongoengine.StringField(required=True) - birth_year = mongoengine.IntField() - nationality = mongoengine.StringField() - - class Book(mongoengine.Document): - meta = {"collection": "books"} - title = mongoengine.StringField(required=True) - published_year = mongoengine.IntField() - genre = mongoengine.StringField() - author = mongoengine.ReferenceField(Author) - tags = mongoengine.ListField(mongoengine.StringField()) - -Schema ------- - -Use ``AsyncMongoengineObjectType`` and ``AsyncMongoengineConnectionField``: - -.. code:: python - - # schema.py - import graphene - from graphene.relay import Node - from graphene_mongo import AsyncMongoengineConnectionField, AsyncMongoengineObjectType - from models import Author as AuthorModel, Book as BookModel - - class AuthorType(AsyncMongoengineObjectType): - class Meta: - model = AuthorModel - interfaces = (Node,) - filter_fields = { - "name": ["exact", "icontains", "istartswith"], - "nationality": ["exact"], - } - - class BookType(AsyncMongoengineObjectType): - class Meta: - model = BookModel - interfaces = (Node,) - filter_fields = { - "title": ["exact", "icontains"], - "genre": ["exact"], - "published_year": ["exact", "gte", "lte"], - } - - class Query(graphene.ObjectType): - node = Node.Field() - books = AsyncMongoengineConnectionField(BookType) - authors = AsyncMongoengineConnectionField(AuthorType) - - schema = graphene.Schema(query=Query, types=[AuthorType, BookType]) - -Async Mutations ---------------- - -Mutation ``mutate`` methods can be ``async def`` — use ``await`` to call -``aobjects`` (Motor-backed queryset) and ``asave()`` / ``adelete()``: - -.. code:: python - - class CreateBook(graphene.Mutation): - class Arguments: - title = graphene.String(required=True) - genre = graphene.String() - author_id = graphene.ID() - - book = graphene.Field(BookType) - - async def mutate(self, info, title, genre=None, author_id=None): - from graphql_relay import from_global_id - author = None - if author_id: - author = await AuthorModel.aobjects.get(pk=from_global_id(author_id)[1]) - book = BookModel(title=title, genre=genre, author=author) - await book.asave() - return CreateBook(book=book) - - class DeleteBook(graphene.Mutation): - class Arguments: - id = graphene.ID(required=True) - - success = graphene.Boolean() - - async def mutate(self, info, id): - from graphql_relay import from_global_id - try: - book = await BookModel.aobjects.get(pk=from_global_id(id)[1]) - await book.adelete() - return DeleteBook(success=True) - except BookModel.DoesNotExist: - return DeleteBook(success=False) - - class Mutation(graphene.ObjectType): - create_book = CreateBook.Field() - delete_book = DeleteBook.Field() - -FastAPI Integration -------------------- - -FastAPI uses a lifespan context manager for startup/shutdown. Both -``mongoengine.connect()`` (for sync operations) and -``mongoengine.async_connect()`` (for Motor) are needed: - -.. code:: python - - # app.py - from contextlib import asynccontextmanager - import mongoengine - from fastapi import FastAPI, Request - from fastapi.responses import JSONResponse - from schema import schema - - @asynccontextmanager - async def lifespan(app: FastAPI): - mongoengine.connect("library_db") - await mongoengine.async_connect("library_db") - yield - mongoengine.disconnect() - - app = FastAPI(title="Library API", lifespan=lifespan) - - @app.post("/graphql") - async def graphql(request: Request): - body = await request.json() - result = await schema.execute_async( - body["query"], - variable_values=body.get("variables"), - operation_name=body.get("operationName"), - ) - errors = [{"message": str(e)} for e in result.errors] if result.errors else None - return JSONResponse({"data": result.data, "errors": errors}) - -Running -------- - -.. code:: bash - - uv run uvicorn app:app --reload - -Example Queries ---------------- - -Fetch all books with their author (resolved in **one** aggregation query): - -.. code:: graphql - - { - books { - edges { - node { - title - genre - author { name nationality } - } - } - } - } - -Filter and paginate: - -.. code:: graphql - - { - books(genre: "Dystopian", first: 5) { - edges { node { title publishedYear } } - pageInfo { hasNextPage endCursor } - } - } - -Create a book: - -.. code:: graphql - - mutation { - createBook(title: "Brave New World", genre: "Dystopian") { - book { id title } - } - } - -N+1 Elimination ---------------- - -``AsyncMongoengineConnectionField`` automatically calls ``select_related()`` -based on which fields are present in the query. For the query above, -``author`` is pre-fetched in the same aggregation as the books — no separate -per-book query. This applies to deeply nested references too: - -.. code:: graphql - - # one aggregate query regardless of depth - { - books { - edges { - node { - title - author { name } # pre-fetched via $lookup - } - } - } - } - -Without pagination args (``first`` / ``last`` / ``before`` / ``after``), the -``count()`` call is also skipped, reducing the total to **one** DB query. \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 51777b48..792fd316 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,440 +1,33 @@ import os +import sphinx_graphene_theme -on_rtd = os.environ.get("READTHEDOCS", None) == "True" - -# -*- coding: utf-8 -*- -# -# Graphene documentation build configuration file, created by -# sphinx-quickstart on Sun Sep 11 18:30:51 2016. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' +project = "Graphene-Mongo" +copyright = "2024, graphene-mongo contributors" +author = "graphene-mongo contributors" +version = "0.5" +release = "0.5.0" -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.intersphinx", - "sphinx.ext.todo", - "sphinx.ext.coverage", "sphinx.ext.viewcode", ] -if not on_rtd: - extensions += ["sphinx.ext.githubpages"] -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] +on_rtd = os.environ.get("READTHEDOCS") == "True" +if not on_rtd: + extensions.append("sphinx.ext.githubpages") -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] source_suffix = ".rst" - -# The encoding of source files. -# -# source_encoding = 'utf-8-sig' - -# The master toctree document. master_doc = "index" - -# General information about the project. -project = "Graphene Mongo" -copyright = "2024, graphene-mongo contributors" -author = "graphene-mongo contributors" - -# The short X.Y version. -version = "0.5" -# The full version, including alpha/beta/rc tags. -release = "0.5.0" - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# -# today = '' -# -# Else, today_fmt is used as the format for a strftime call. -# -# today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -# -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# -# show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -# keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -# html_theme = 'alabaster' -# if on_rtd: -# html_theme = 'sphinx_rtd_theme' -import sphinx_graphene_theme - html_theme = "sphinx_graphene_theme" - html_theme_path = [sphinx_graphene_theme.get_html_theme_path()] - - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = [] - -# The name for this set of Sphinx documents. -# " v documentation" by default. -# -# html_title = u'Graphene v1.0.dev' - -# A shorter title for the navigation bar. Default is the same as html_title. -# -# html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# -# html_logo = None - -# The name of an image file (relative to this directory) to use as a favicon of -# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -# -# html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -# -# html_extra_path = [] - -# If not None, a 'Last updated on:' timestamp is inserted at every page -# bottom, using the given strftime format. -# The empty string is equivalent to '%b %d, %Y'. -# -# html_last_updated_fmt = None - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# -# html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# -# html_additional_pages = {} - -# If false, no module index is generated. -# -# html_domain_indices = True - -# If false, no index is generated. -# -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# -# html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -# -# html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -# -# html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' -# -# html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# 'ja' uses this config value. -# 'zh' user can custom change `jieba` dictionary path. -# -# html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -# -# html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = "Graphenedoc" - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [(master_doc, "Graphene.tex", "Graphene Documentation", "Syrus Akbary", "manual")] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# -# latex_use_parts = False - -# If true, show page references after internal links. -# -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# -# latex_appendices = [] - -# It false, will not define \strong, \code, itleref, \crossref ... but only -# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added -# packages. -# -# latex_keep_old_macro_names = True - -# If false, no module index is generated. -# -# latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [(master_doc, "graphene_django", "Graphene Django Documentation", [author], 1)] - -# If true, show URL addresses after external links. -# -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - "Graphene-Django", - "Graphene Django Documentation", - author, - "Graphene Django", - "One line description of project.", - "Miscellaneous", - ) -] - -# Documents to append as an appendix to all manuals. -# -# texinfo_appendices = [] - -# If false, no module index is generated. -# -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# -# texinfo_no_detailmenu = False - - -# -- Options for Epub output ---------------------------------------------- - -# Bibliographic Dublin Core info. -epub_title = project -epub_author = author -epub_publisher = author -epub_copyright = copyright - -# The basename for the epub file. It defaults to the project name. -# epub_basename = project - -# The HTML theme for the epub output. Since the default themes are not -# optimized for small screen space, using the same theme for HTML and epub -# output is usually not wise. This defaults to 'epub', a theme designed to save -# visual space. -# -# epub_theme = 'epub' - -# The language of the text. It defaults to the language option -# or 'en' if the language is not set. -# -# epub_language = '' - -# The scheme of the identifier. Typical schemes are ISBN or URL. -# epub_scheme = '' - -# The unique identifier of the text. This can be a ISBN number -# or the project homepage. -# -# epub_identifier = '' - -# A unique identification for the text. -# -# epub_uid = '' - -# A tuple containing the cover image and cover page html template filenames. -# -# epub_cover = () - -# A sequence of (type, uri, title) tuples for the guide element of content.opf. -# -# epub_guide = () - -# HTML files that should be inserted before the pages created by sphinx. -# The format is a list of tuples containing the path and title. -# -# epub_pre_files = [] - -# HTML files that should be inserted after the pages created by sphinx. -# The format is a list of tuples containing the path and title. -# -# epub_post_files = [] - -# A list of files that should not be packed into the epub file. -epub_exclude_files = ["search.html"] - -# The depth of the table of contents in toc.ncx. -# -# epub_tocdepth = 3 - -# Allow duplicate toc entries. -# -# epub_tocdup = True - -# Choose between 'default' and 'includehidden'. -# -# epub_tocscope = 'default' - -# Fix unsupported image types using the Pillow. -# -# epub_fix_images = False - -# Scale large images. -# -# epub_max_image_width = 0 - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# -# epub_show_urls = 'inline' - -# If false, no index is generated. -# -# epub_use_index = True - +intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {"https://docs.python.org/": None} +html_context = { + "rtd_versions_url": "https://readthedocs.org/projects/graphene-mongo/versions/", +} \ No newline at end of file diff --git a/docs/fields.rst b/docs/fields.rst deleted file mode 100644 index 1aa91be4..00000000 --- a/docs/fields.rst +++ /dev/null @@ -1,202 +0,0 @@ -Supported Fields -================ - -All standard Mongoengine fields are automatically converted to the appropriate -Graphene scalar or type when you define a ``MongoengineObjectType`` or -``AsyncMongoengineObjectType``. - -Scalar Fields -------------- - -+-------------------------------+--------------------------+ -| Mongoengine Field | GraphQL / Graphene Type | -+===============================+==========================+ -| ``BooleanField`` | ``Boolean`` | -+-------------------------------+--------------------------+ -| ``DateTimeField`` | ``DateTime`` | -+-------------------------------+--------------------------+ -| ``DecimalField`` | ``Float`` | -+-------------------------------+--------------------------+ -| ``DictField`` | ``JSONString`` | -+-------------------------------+--------------------------+ -| ``EmailField`` | ``String`` | -+-------------------------------+--------------------------+ -| ``FloatField`` | ``Float`` | -+-------------------------------+--------------------------+ -| ``IntField`` | ``Int`` | -+-------------------------------+--------------------------+ -| ``ObjectIdField`` | ``ID`` | -+-------------------------------+--------------------------+ -| ``SequenceField`` | ``Int`` | -+-------------------------------+--------------------------+ -| ``StringField`` | ``String`` | -+-------------------------------+--------------------------+ -| ``URLField`` | ``String`` | -+-------------------------------+--------------------------+ -| ``UUIDField`` | ``String`` | -+-------------------------------+--------------------------+ - -Reference Fields ----------------- - -ReferenceField -~~~~~~~~~~~~~~ - -Resolves to the related document type. When queried through -``MongoengineConnectionField`` or ``AsyncMongoengineConnectionField``, -the referenced document is pre-fetched via ``select_related()`` — -no extra query per document. - -.. code:: python - - class Author(mongoengine.Document): - name = mongoengine.StringField() - - class Book(mongoengine.Document): - title = mongoengine.StringField() - author = mongoengine.ReferenceField(Author) - - # query: author resolved in one aggregate, not N+1 - { books { edges { node { title author { name } } } } } - -GenericReferenceField -~~~~~~~~~~~~~~~~~~~~~ - -A field that can reference documents of different types. Rendered as a -GraphQL union type automatically from the ``choices`` list: - -.. code:: python - - class Article(mongoengine.Document): ... - class Video(mongoengine.Document): ... - - class Feed(mongoengine.Document): - item = mongoengine.GenericReferenceField(choices=[Article, Video]) - -Embedded Documents ------------------- - -EmbeddedDocumentField -~~~~~~~~~~~~~~~~~~~~~ - -Nested sub-document, rendered as a nested GraphQL object: - -.. code:: python - - class Address(mongoengine.EmbeddedDocument): - street = mongoengine.StringField() - city = mongoengine.StringField() - - class Person(mongoengine.Document): - address = mongoengine.EmbeddedDocumentField(Address) - -EmbeddedDocumentListField -~~~~~~~~~~~~~~~~~~~~~~~~~ - -List of embedded sub-documents, rendered as a Relay connection: - -.. code:: python - - class Task(mongoengine.EmbeddedDocument): - name = mongoengine.StringField() - - class Employee(mongoengine.Document): - tasks = mongoengine.ListField(mongoengine.EmbeddedDocumentField(Task)) - -List Fields ------------ - -ListField -~~~~~~~~~ - -Rendered as a GraphQL list. When the inner field is a ``ReferenceField``, -it becomes a list of the related type: - -.. code:: python - - class Employee(mongoengine.Document): - roles = mongoengine.ListField(mongoengine.ReferenceField(Role)) - tags = mongoengine.ListField(mongoengine.StringField()) - -MapField -~~~~~~~~ - -Rendered as ``JSONString``. - -Geo Fields ----------- - -+-------------------------------+----------------------------------+ -| Mongoengine Field | Notes | -+===============================+==================================+ -| ``PointField`` | ``[longitude, latitude]`` | -+-------------------------------+----------------------------------+ -| ``PolygonField`` | GeoJSON polygon | -+-------------------------------+----------------------------------+ -| ``MultiPolygonField`` | GeoJSON multi-polygon | -+-------------------------------+----------------------------------+ - -File Fields ------------ - -FileField -~~~~~~~~~ - -GridFS file field. Rendered as a ``FileFieldType`` with ``data``, -``contentType``, ``length``, and ``chunkSize`` sub-fields: - -.. code:: graphql - - { editors { edges { node { avatar { contentType length } } } } } - -Advanced --------- - -Self-Referential Fields -~~~~~~~~~~~~~~~~~~~~~~~ - -A document can reference itself. graphene-mongo handles the circular -dependency automatically: - -.. code:: python - - class Employee(mongoengine.Document): - name = mongoengine.StringField() - leader = mongoengine.ReferenceField("self") - reports = mongoengine.ListField(mongoengine.ReferenceField("self")) - -Inheritance -~~~~~~~~~~~ - -Mongoengine document inheritance is supported. Child types are registered -separately and resolved as GraphQL union types when queried through a -``GenericReferenceField``: - -.. code:: python - - class Animal(mongoengine.Document): - name = mongoengine.StringField() - meta = {"allow_inheritance": True} - - class Dog(Animal): - breed = mongoengine.StringField() - -filter_fields -~~~~~~~~~~~~~ - -All connection fields support ``filter_fields`` for inline filtering: - -.. code:: python - - class BookType(MongoengineObjectType): - class Meta: - model = Book - interfaces = (Node,) - filter_fields = { - "title": ["exact", "icontains", "istartswith"], - "published_year": ["exact", "gte", "lte"], - } - -Supported operators: ``exact``, ``iexact``, ``contains``, ``icontains``, -``startswith``, ``istartswith``, ``in``, ``nin``, ``lt``, ``lte``, ``gt``, -``gte``, ``ne``. \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index e905a027..8aeead33 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,90 +1,23 @@ Graphene-Mongo ============== -A `Mongoengine `__ integration for -`Graphene `__. Supports both synchronous and -async (Motor/asyncio) execution. +graphene-mongo is a `MongoEngine `__ integration for +`Graphene `__ that lets you expose MongoDB documents as a +fully-featured GraphQL API with minimal boilerplate. -Features --------- +It uses a *code-first* approach — you write Python, not GraphQL SDL. MongoEngine document +fields are mapped to GraphQL scalar types automatically. You get Relay cursor pagination, +filtering, and N+1-free pre-fetching out of the box. -- Automatic GraphQL type generation from Mongoengine models -- Relay-compatible connection fields with pagination and filtering -- **Sync** (``MongoengineObjectType``, ``MongoengineConnectionField``) and - **Async** (``AsyncMongoengineObjectType``, ``AsyncMongoengineConnectionField``) APIs -- ``select_related`` — eliminates N+1 queries by pre-fetching referenced documents - in a single MongoDB ``$aggregate`` pipeline -- Works with Flask, Falcon, FastAPI, Django, or any Python web framework -- Full support for mutations, embedded documents, reference fields, and inheritance - -Installation ------------- - -.. code:: bash - - # with uv (recommended) - uv add graphene-mongo - - # with pip - pip install graphene-mongo - -Quick Start ------------ - -**Synchronous (Flask / Django / Falcon)** - -.. code:: python - - import graphene - from graphene.relay import Node - from graphene_mongo import MongoengineObjectType, MongoengineConnectionField - from mongoengine import Document, StringField, connect - - connect("mydb") - - class Article(Document): - title = StringField() - - class ArticleType(MongoengineObjectType): - class Meta: - model = Article - interfaces = (Node,) - - class Query(graphene.ObjectType): - node = Node.Field() - articles = MongoengineConnectionField(ArticleType) - - schema = graphene.Schema(query=Query) - -**Asynchronous (FastAPI / async frameworks)** - -.. code:: python - - import graphene - import mongoengine - from graphene.relay import Node - from graphene_mongo import AsyncMongoengineObjectType, AsyncMongoengineConnectionField - - class ArticleType(AsyncMongoengineObjectType): - class Meta: - model = Article - interfaces = (Node,) - - class Query(graphene.ObjectType): - node = Node.Field() - articles = AsyncMongoengineConnectionField(ArticleType) - - schema = graphene.Schema(query=Query) - - # execute - result = await schema.execute_async("{ articles { edges { node { title } } } }") - -Contents --------- +Contents: .. toctree:: - :maxdepth: 2 - - tutorial - async_tutorial - fields \ No newline at end of file + :maxdepth: 2 + + installation + quickstart/index + types/index + prefetching/index + relay/index + telemetry/index + examples/index \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt index 666a8c9d..570b0b2f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -# Docs template -http://graphene-python.org/sphinx_graphene_theme.zip +sphinx>=7.0 +sphinx-autobuild \ No newline at end of file diff --git a/docs/tutorial.rst b/docs/tutorial.rst deleted file mode 100644 index bfcde8b8..00000000 --- a/docs/tutorial.rst +++ /dev/null @@ -1,216 +0,0 @@ -Mongoengine + Flask Tutorial -============================ - -This tutorial walks through building a GraphQL API with graphene-mongo and Flask. -The full source is in -`examples/flask_mongoengine `__. - -For an async example using FastAPI see :doc:`async_tutorial`. - -Setup ------ - -.. code:: bash - - mkdir flask_graphene_mongo && cd flask_graphene_mongo - uv init - uv add Flask graphene-mongo mongoengine mongomock - -Defining Models ---------------- - -.. code:: python - - # models.py - from datetime import datetime - import mongoengine - - class Department(mongoengine.Document): - meta = {"collection": "department"} - name = mongoengine.StringField(required=True) - - class Role(mongoengine.Document): - meta = {"collection": "role"} - name = mongoengine.StringField(required=True) - - class Task(mongoengine.EmbeddedDocument): - name = mongoengine.StringField() - deadline = mongoengine.DateTimeField() - - class Employee(mongoengine.Document): - meta = {"collection": "employee"} - name = mongoengine.StringField(required=True) - hired_on = mongoengine.DateTimeField(default=datetime.now) - department = mongoengine.ReferenceField(Department) - roles = mongoengine.ListField(mongoengine.ReferenceField(Role)) - leader = mongoengine.ReferenceField("self") - tasks = mongoengine.ListField(mongoengine.EmbeddedDocumentField(Task)) - -Schema ------- - -``MongoengineObjectType`` converts a Mongoengine Document into a Graphene type. -Adding the ``Node`` interface enables Relay-compatible pagination and global IDs. - -.. code:: python - - # schema.py - import graphene - from graphene.relay import Node - from graphene_mongo import MongoengineConnectionField, MongoengineObjectType - from models import Department as DepartmentModel - from models import Employee as EmployeeModel - from models import Role as RoleModel - from models import Task as TaskModel - - class Department(MongoengineObjectType): - class Meta: - model = DepartmentModel - interfaces = (Node,) - - class Role(MongoengineObjectType): - class Meta: - model = RoleModel - interfaces = (Node,) - filter_fields = {"name": ["exact", "icontains", "istartswith"]} - - class Task(MongoengineObjectType): - class Meta: - model = TaskModel - interfaces = (Node,) - - class Employee(MongoengineObjectType): - class Meta: - model = EmployeeModel - interfaces = (Node,) - filter_fields = {"name": ["exact", "icontains", "istartswith"]} - - class Query(graphene.ObjectType): - node = Node.Field() - all_employees = MongoengineConnectionField(Employee) - all_roles = MongoengineConnectionField(Role) - - schema = graphene.Schema(query=Query, types=[Department, Employee, Role, Task]) - -Filtering and Pagination -~~~~~~~~~~~~~~~~~~~~~~~~ - -``filter_fields`` enables field-level filtering directly in the query: - -.. code:: graphql - - # filter by name - { allEmployees(name: "Peter") { edges { node { name } } } } - - # pagination - { allEmployees(first: 5) { edges { node { name } } } } - - # cursor-based pagination - { allEmployees(first: 5, after: "cursor==") { edges { node { name } } pageInfo { hasNextPage endCursor } } } - -Mutations ---------- - -.. code:: python - - # mutations.py - import graphene - from models import Employee, Department, Role - - class CreateEmployee(graphene.Mutation): - class Arguments: - name = graphene.String(required=True) - department_id = graphene.ID() - - employee = graphene.Field(lambda: EmployeeType) - - def mutate(self, info, name, department_id=None): - from graphql_relay import from_global_id - dept = None - if department_id: - dept = Department.objects.get(pk=from_global_id(department_id)[1]) - emp = Employee(name=name, department=dept).save() - return CreateEmployee(employee=emp) - - class Mutation(graphene.ObjectType): - create_employee = CreateEmployee.Field() - - schema = graphene.Schema(query=Query, mutation=Mutation) - -Flask App ---------- - -Flask 3.x supports ``async def`` views natively, so no extra adapter is needed: - -.. code:: python - - # app.py - from database import init_db - from flask import Flask, jsonify, request - from schema import schema - - app = Flask(__name__) - - @app.post("/graphql") - async def graphql_view(): - body = request.get_json() - result = await schema.execute_async( - body["query"], - variable_values=body.get("variables"), - operation_name=body.get("operationName"), - ) - errors = [{"message": str(e)} for e in result.errors] if result.errors else None - return jsonify({"data": result.data, "errors": errors}) - - if __name__ == "__main__": - init_db() - app.run() - -Seed Data ---------- - -.. code:: python - - # database.py - import mongoengine - from models import Department, Employee, Role, Task - from datetime import datetime - - mongoengine.connect("graphene-mongo-example", host="mongomock://localhost") - - def init_db(): - engineering = Department(name="Engineering").save() - hr = Department(name="Human Resources").save() - - manager = Role(name="manager").save() - engineer = Role(name="engineer").save() - - peter = Employee( - name="Peter", department=engineering, roles=[engineer], - tasks=[Task(name="Fix bug", deadline=datetime(2025, 1, 1))] - ).save() - Employee(name="Roy", department=engineering, roles=[engineer], leader=peter).save() - Employee(name="Tracy", department=hr, roles=[manager]).save() - -Running -------- - -.. code:: bash - - uv run python app.py - -Then query at ``http://localhost:5000/graphql``: - -.. code:: graphql - - { - allEmployees { - edges { - node { - name - department { name } - roles { edges { node { name } } } - } - } - } - } \ No newline at end of file diff --git a/examples/django_mongoengine/README.md b/examples/django_mongoengine/README.md index 32964231..5fca0bff 100644 --- a/examples/django_mongoengine/README.md +++ b/examples/django_mongoengine/README.md @@ -1,56 +1,107 @@ +# Django + MongoEngine Example -Example Django+MongoEngine Project -================================ +GraphQL API for a bike shop — `Bike` and `Shop` documents — built with +Django, graphene-django, graphene-mongo sync types, and Relay cursor pagination. -This example project demos integration between Graphene, Django and MongoEngine. - -Getting started ---------------- - -First you'll need to get the source of the project. Do this by cloning the -whole Graphene repository: +## Getting started ```bash -# Get the example project code -git clone git@github.com:abawchen/graphene-mongo.git +git clone https://github.com/graphql-python/graphene-mongo.git cd graphene-mongo/examples/django_mongoengine +uv sync ``` -Create a virtual environment. +## Run ```bash -# Create a virtualenv in which we can install the dependencies -virtualenv env -source env/bin/activate +uv run python manage.py migrate +uv run python manage.py runserver ``` -Now we can install our dependencies: +Open the playground at [http://localhost:8000/graphql](http://localhost:8000/graphql). + +## Sample queries + +```graphql +# List all bikes +query { + bikes { + edges { + node { + id + name + year + brand + speed + } + } + } +} + +# Create a bike +mutation { + createBike(name: "Trail Blazer", year: 2024, brand: "Trek", speed: 21) { + bike { id name year brand } + } +} + +# Update a bike +mutation { + updateBike(id: "", speed: 24) { + bike { id name speed } + } +} + +# Delete a bike +mutation { + deleteBike(id: "") { + ok + } +} +``` + +## Run tests ```bash -pip install -r requirements.txt +uv run pytest -v ``` -Run the following command: +## OpenTelemetry tracing + +`telemetry.py` is already included and called from `BikeConfig.ready()` in `bike/apps.py`. +Install the extras and point the app at your collector: -```python -python manage.py migrate +```bash +uv pip install "graphene-mongo[telemetry]" \ + opentelemetry-instrumentation-django \ + opentelemetry-instrumentation-pymongo \ + opentelemetry-exporter-otlp ``` -Setup a mongodb connection and create a database. -See the mongoengine connection details in the *settings.py* file +```bash +OTEL_SERVICE_NAME=bike-shop-api \ +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ +uv run python manage.py runserver +``` -Start the server: +### What you get -```python -python manage.py runserver +``` +GET /graphql ← Django HTTP span + └─ graphql bikes ← graphene-mongo field_span + └─ mongodb.aggregate ← pymongo auto-instrumentation + └─ graphql node BikeType ← graphene-mongo node_span + └─ mongodb.aggregate ``` -Now head on over to -[http://127.0.0.1:8000/graphql](http://127.0.0.1:8000/graphql) -and run some queries! +Each span carries: -For tests run: +| Attribute | Example value | +|---|---| +| `graphql.field.name` | `bikes` | +| `graphql.field.parent_type` | `Query` | +| `graphql.operation.type` | `query` | +| `graphql.operation.name` | `ListBikes` | +| `graphql.node.id` | `QmlrZVR5cGU6NjY...` | -```python -pytest -v -``` +Errors set `StatusCode.ERROR` and attach the full stacktrace as an `exception` event. \ No newline at end of file diff --git a/examples/django_mongoengine/bike/apps.py b/examples/django_mongoengine/bike/apps.py index 4809c9c9..72ee9fa3 100644 --- a/examples/django_mongoengine/bike/apps.py +++ b/examples/django_mongoengine/bike/apps.py @@ -3,3 +3,7 @@ class BikeConfig(AppConfig): name = "bike" + + def ready(self): + from telemetry import setup_telemetry + setup_telemetry() diff --git a/examples/django_mongoengine/bike/tests.py b/examples/django_mongoengine/bike/tests.py index 335fa126..c2d94ae8 100644 --- a/examples/django_mongoengine/bike/tests.py +++ b/examples/django_mongoengine/bike/tests.py @@ -3,7 +3,6 @@ from django.test import RequestFactory from graphene.test import Client from .schema import schema -from .fixtures import fixtures_data def test_bikes_first_item_query(fixtures_data): diff --git a/examples/django_mongoengine/pyproject.toml b/examples/django_mongoengine/pyproject.toml index 44dd5120..c59efbec 100644 --- a/examples/django_mongoengine/pyproject.toml +++ b/examples/django_mongoengine/pyproject.toml @@ -11,6 +11,15 @@ dependencies = [ "pytest-django>=4.8", ] +[project.optional-dependencies] +telemetry = [ + "opentelemetry-api>=1.0.0", + "opentelemetry-sdk>=1.0.0", + "opentelemetry-exporter-otlp>=1.0.0", + "opentelemetry-instrumentation-django>=0.40b0", + "opentelemetry-instrumentation-pymongo>=0.40b0", +] + [tool.uv.sources] graphene-mongo = { path = "../.." } mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } diff --git a/examples/django_mongoengine/telemetry.py b/examples/django_mongoengine/telemetry.py new file mode 100644 index 00000000..1dc3e4b4 --- /dev/null +++ b/examples/django_mongoengine/telemetry.py @@ -0,0 +1,42 @@ +"""Optional OpenTelemetry setup for the Django example. + +Install the extras to activate: + + pip install graphene-mongo[telemetry] \ + opentelemetry-instrumentation-django \ + opentelemetry-instrumentation-pymongo \ + opentelemetry-exporter-otlp + +Then call setup_telemetry() inside BikeConfig.ready() in apps.py. +Point OTEL_EXPORTER_OTLP_ENDPOINT at your collector (default: localhost:4317). +""" + +import os + + +def setup_telemetry(): + """Instrument Django + pymongo and export spans via OTLP. + + Silently does nothing when opentelemetry packages are not installed. + """ + try: + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.instrumentation.django import DjangoInstrumentor + from opentelemetry.instrumentation.pymongo import PymongoInstrumentor + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + except ImportError: + return + + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + service_name = os.getenv("OTEL_SERVICE_NAME", "graphene-mongo-django") + + provider = TracerProvider() + provider.add_span_processor( + BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) + ) + trace.set_tracer_provider(provider) + + PymongoInstrumentor().instrument() + DjangoInstrumentor().instrument() \ No newline at end of file diff --git a/examples/falcon_mongoengine/README.md b/examples/falcon_mongoengine/README.md index 98e30fce..79fb86ae 100644 --- a/examples/falcon_mongoengine/README.md +++ b/examples/falcon_mongoengine/README.md @@ -1,81 +1,105 @@ +# Falcon + MongoEngine Example -Example Falcon+MongoEngine Project -================================ +GraphQL API for a bookmarks manager — `Category` and `Bookmark` documents — +built with Falcon ASGI, graphene-mongo sync types, and Relay cursor pagination. -This example project demos integration between Graphene, Falcon and MongoEngine. - -Getting started ---------------- - -First you'll need to get the source of the project. Do this by cloning the -whole Graphene repository: +## Getting started ```bash -# Get the example project code -git clone git@github.com:abawchen/graphene-mongo.git +git clone https://github.com/graphql-python/graphene-mongo.git cd graphene-mongo/examples/falcon_mongoengine +uv sync ``` -Create a virtual environment. +## Run ```bash -# Create a virtualenv in which we can install the dependencies -virtualenv env -source env/bin/activate +uv run uvicorn app:app --reload --port 9000 ``` -Now we can install our dependencies: +Send queries via HTTP: ```bash -pip install -r requirements.txt +curl -X POST http://localhost:9000/graphql \ + -H "Content-Type: application/json" \ + -d '{"query": "{ categories { edges { node { name color } } } }"}' ``` -Setup a mongodb connection and create a database. -See the mongoengine connection details in the *app.py* file - -Start the server: +## Sample queries -On windows: -``` -waitress-serve --port=9000 falcon_mongoengine.app:app +```graphql +# List categories +query { + categories { + edges { + node { name color } + } + } +} + +# List bookmarks with pagination and their category +query { + bookmarks(first: 10) { + pageInfo { hasNextPage endCursor } + edges { + node { + name + url + tags + category { name color } + } + } + } +} + +# Filter by category name +query { + categories(name: "Travel") { + edges { node { name color } } + } +} ``` -On Linux: -``` -gunicorn -b 0.0.0.0:9000 falcon_mongoengine.app:app +## Run tests + +```bash +uv run pytest -v ``` -Now head on over to -[http://127.0.0.1:9000/graphql?query=](http://127.0.0.1:9000/graphql?query=) -and run some queries! +## OpenTelemetry tracing -Example: +`telemetry.py` is already included. Install the extras and point the app at +your collector: +```bash +uv pip install "graphene-mongo[telemetry]" \ + opentelemetry-instrumentation-falcon \ + opentelemetry-instrumentation-pymongo \ + opentelemetry-exporter-otlp ``` -http://127.0.0.1:9000/graphql?query=query - { - categories(first: 1, name: "Travel") - { - edges { node { name color } } - } - } + +```bash +OTEL_SERVICE_NAME=bookmarks-api \ +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ +uv run uvicorn app:app --reload --port 9000 ``` +### What you get + ``` -http://127.0.0.1:9000/graphql?query=query - { - bookmarks(first: 10) - { - pageInfo { startCursor endCursor hasNextPage hasPreviousPage } - edges { - node { name url category { name color } tags } - } - } - } +POST /graphql ← Falcon HTTP span + └─ graphql bookmarks ← graphene-mongo field_span + └─ mongodb.aggregate ← pymongo auto-instrumentation ``` -For tests run: +Each span carries: -```python -pytest -v -``` +| Attribute | Example value | +|---|---| +| `graphql.field.name` | `bookmarks` | +| `graphql.field.parent_type` | `Query` | +| `graphql.operation.type` | `query` | +| `graphql.operation.name` | `ListBookmarks` | +| `graphql.pagination.first` | `10` | + +Errors set `StatusCode.ERROR` and attach the full stacktrace as an `exception` event. \ No newline at end of file diff --git a/examples/falcon_mongoengine/api.py b/examples/falcon_mongoengine/api.py index eb8733b7..1ded7362 100644 --- a/examples/falcon_mongoengine/api.py +++ b/examples/falcon_mongoengine/api.py @@ -1,4 +1,3 @@ -import falcon from schema import schema diff --git a/examples/falcon_mongoengine/app.py b/examples/falcon_mongoengine/app.py index 911e396d..402bd3ff 100644 --- a/examples/falcon_mongoengine/app.py +++ b/examples/falcon_mongoengine/app.py @@ -2,12 +2,14 @@ import falcon.asgi from api import GraphQLResource +from telemetry import setup_telemetry class MongoLifespan: async def process_startup(self, scope, event): mongoengine.connect("bookmarks_db") await mongoengine.async_connect("bookmarks_db") + setup_telemetry() async def process_shutdown(self, scope, event): mongoengine.disconnect() diff --git a/examples/falcon_mongoengine/pyproject.toml b/examples/falcon_mongoengine/pyproject.toml index f9d8c634..3c131289 100644 --- a/examples/falcon_mongoengine/pyproject.toml +++ b/examples/falcon_mongoengine/pyproject.toml @@ -11,6 +11,15 @@ dependencies = [ "httpx>=0.27.0", ] +[project.optional-dependencies] +telemetry = [ + "opentelemetry-api>=1.0.0", + "opentelemetry-sdk>=1.0.0", + "opentelemetry-exporter-otlp>=1.0.0", + "opentelemetry-instrumentation-falcon>=0.40b0", + "opentelemetry-instrumentation-pymongo>=0.40b0", +] + [tool.uv.sources] graphene-mongo = { path = "../.." } mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } diff --git a/examples/falcon_mongoengine/telemetry.py b/examples/falcon_mongoengine/telemetry.py new file mode 100644 index 00000000..ab80bfb9 --- /dev/null +++ b/examples/falcon_mongoengine/telemetry.py @@ -0,0 +1,42 @@ +"""Optional OpenTelemetry setup for the Falcon example. + +Install the extras to activate: + + pip install graphene-mongo[telemetry] \ + opentelemetry-instrumentation-falcon \ + opentelemetry-instrumentation-pymongo \ + opentelemetry-exporter-otlp + +Then call setup_telemetry() inside MongoLifespan.process_startup(). +Point OTEL_EXPORTER_OTLP_ENDPOINT at your collector (default: localhost:4317). +""" + +import os + + +def setup_telemetry(): + """Instrument Falcon + pymongo and export spans via OTLP. + + Silently does nothing when opentelemetry packages are not installed. + """ + try: + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.instrumentation.falcon import FalconInstrumentor + from opentelemetry.instrumentation.pymongo import PymongoInstrumentor + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + except ImportError: + return + + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + service_name = os.getenv("OTEL_SERVICE_NAME", "graphene-mongo-falcon") + + provider = TracerProvider() + provider.add_span_processor( + BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) + ) + trace.set_tracer_provider(provider) + + PymongoInstrumentor().instrument() + FalconInstrumentor().instrument() \ No newline at end of file diff --git a/examples/falcon_mongoengine/tests/tests.py b/examples/falcon_mongoengine/tests/tests.py index 80e0def7..5895bbf0 100644 --- a/examples/falcon_mongoengine/tests/tests.py +++ b/examples/falcon_mongoengine/tests/tests.py @@ -2,7 +2,6 @@ from graphene.test import Client from examples.falcon_mongoengine.schema import schema -from .fixtures import fixtures_data mongoengine.connect("graphene-mongo-test", host="mongomock://localhost", alias="default") diff --git a/examples/fastapi_mongoengine/README.md b/examples/fastapi_mongoengine/README.md new file mode 100644 index 00000000..4768f065 --- /dev/null +++ b/examples/fastapi_mongoengine/README.md @@ -0,0 +1,101 @@ +# FastAPI + MongoEngine Example + +GraphQL API for a library — `Author` and `Book` documents — built with +FastAPI, graphene-mongo async types, and Relay cursor pagination. + +## Getting started + +```bash +git clone https://github.com/graphql-python/graphene-mongo.git +cd graphene-mongo/examples/fastapi_mongoengine +uv sync +``` + +## Run + +```bash +uv run uvicorn app:app --reload +``` + +Open the playground at [http://localhost:8000/graphql](http://localhost:8000/graphql). + +## Sample queries + +```graphql +# List books with their authors +query { + books { + edges { + node { + title + genre + publishedYear + author { name nationality } + } + } + } +} + +# Filter by genre, paginate +query { + books(genre: "Fiction", first: 5) { + pageInfo { hasNextPage endCursor } + edges { + node { title publishedYear } + } + } +} + +# Create a book +mutation { + createBook(title: "Dune", publishedYear: 1965, genre: "Science Fiction") { + book { id title } + } +} +``` + +## Run tests + +```bash +uv run pytest -v +``` + +## OpenTelemetry tracing + +`telemetry.py` is already included. Install the extras and point the app at +your collector: + +```bash +uv pip install "graphene-mongo[telemetry]" \ + opentelemetry-instrumentation-fastapi \ + opentelemetry-instrumentation-pymongo \ + opentelemetry-exporter-otlp +``` + +```bash +OTEL_SERVICE_NAME=library-api \ +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ +uv run uvicorn app:app --reload +``` + +### What you get + +``` +POST /graphql ← FastAPI HTTP span + └─ graphql books ← graphene-mongo field_span + └─ mongodb.aggregate ← pymongo auto-instrumentation + └─ graphql node AuthorType ← graphene-mongo node_span + └─ mongodb.aggregate +``` + +Each span carries: + +| Attribute | Example value | +|---|---| +| `graphql.field.name` | `books` | +| `graphql.field.parent_type` | `Query` | +| `graphql.operation.type` | `query` | +| `graphql.operation.name` | `ListBooks` | +| `graphql.pagination.first` | `5` | + +Errors set `StatusCode.ERROR` and attach the full stacktrace as an `exception` event. \ No newline at end of file diff --git a/examples/fastapi_mongoengine/app.py b/examples/fastapi_mongoengine/app.py index 33ccea4a..63a40161 100644 --- a/examples/fastapi_mongoengine/app.py +++ b/examples/fastapi_mongoengine/app.py @@ -6,6 +6,7 @@ from database import init_db from schema import schema +from telemetry import setup_telemetry GRAPHQL_PLAYGROUND = """ @@ -36,6 +37,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Library GraphQL API", lifespan=lifespan) +setup_telemetry(app) @app.get("/graphql", response_class=HTMLResponse) diff --git a/examples/fastapi_mongoengine/database.py b/examples/fastapi_mongoengine/database.py index ac66aed4..57f75410 100644 --- a/examples/fastapi_mongoengine/database.py +++ b/examples/fastapi_mongoengine/database.py @@ -1,4 +1,3 @@ -import mongoengine from models import Author, Book diff --git a/examples/fastapi_mongoengine/pyproject.toml b/examples/fastapi_mongoengine/pyproject.toml index 75e51ca9..53a2efa7 100644 --- a/examples/fastapi_mongoengine/pyproject.toml +++ b/examples/fastapi_mongoengine/pyproject.toml @@ -12,6 +12,15 @@ dependencies = [ "pytest-asyncio>=0.23.0", ] +[project.optional-dependencies] +telemetry = [ + "opentelemetry-api>=1.0.0", + "opentelemetry-sdk>=1.0.0", + "opentelemetry-exporter-otlp>=1.0.0", + "opentelemetry-instrumentation-fastapi>=0.40b0", + "opentelemetry-instrumentation-pymongo>=0.40b0", +] + [tool.uv.sources] graphene-mongo = { path = "../.." } mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } diff --git a/examples/fastapi_mongoengine/telemetry.py b/examples/fastapi_mongoengine/telemetry.py new file mode 100644 index 00000000..46d98b55 --- /dev/null +++ b/examples/fastapi_mongoengine/telemetry.py @@ -0,0 +1,42 @@ +"""Optional OpenTelemetry setup for the FastAPI example. + +Install the extras to activate: + + pip install graphene-mongo[telemetry] \ + opentelemetry-instrumentation-fastapi \ + opentelemetry-instrumentation-pymongo \ + opentelemetry-exporter-otlp + +Then call setup_telemetry(app) inside the lifespan before yielding. +Point OTEL_EXPORTER_OTLP_ENDPOINT at your collector (default: localhost:4317). +""" + +import os + + +def setup_telemetry(app): + """Instrument FastAPI + pymongo and export spans via OTLP. + + Silently does nothing when opentelemetry packages are not installed. + """ + try: + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + from opentelemetry.instrumentation.pymongo import PymongoInstrumentor + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + except ImportError: + return + + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + service_name = os.getenv("OTEL_SERVICE_NAME", "graphene-mongo-fastapi") + + provider = TracerProvider() + provider.add_span_processor( + BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) + ) + trace.set_tracer_provider(provider) + + PymongoInstrumentor().instrument() + FastAPIInstrumentor.instrument_app(app) \ No newline at end of file diff --git a/examples/flask_mongoengine/README.md b/examples/flask_mongoengine/README.md index b4731ff5..9dfbafa6 100644 --- a/examples/flask_mongoengine/README.md +++ b/examples/flask_mongoengine/README.md @@ -1,87 +1,96 @@ +# Flask + MongoEngine Example -Example Flask+MongoEngine Project -================================ +GraphQL API for an HR domain — `Department`, `Employee`, `Role`, and `Task` documents — +built with Flask, graphene-mongo sync types, and Relay cursor pagination. -This example project demos integration between Graphene, Flask and MongoEngine. -The project contains three models, which are `Department`, `Employee` and `Role`. - -Getting started ---------------- - -First you'll need to get the source of the project. Do this by cloning the -whole Graphene repository: +## Getting started ```bash -# Get the example project code -git clone git@github.com:abawchen/graphene-mongo.git +git clone https://github.com/graphql-python/graphene-mongo.git cd graphene-mongo/examples/flask_mongoengine +uv sync ``` -It is good idea (but not required) to create a virtual environment -for this project. We'll do this using -[virtualenv](http://docs.python-guide.org/en/latest/dev/virtualenvs/) -to keep things simple, -but you may also find something like -[virtualenvwrapper](https://virtualenvwrapper.readthedocs.org/en/latest/) -to be useful: +## Run ```bash -# Create a virtualenv in which we can install the dependencies -virtualenv env -source env/bin/activate +uv run python app.py +``` + +Open the playground at [http://localhost:5000/graphql](http://localhost:5000/graphql). + +## Sample queries + +```graphql +# List all employees with their department and roles +query { + allEmployees { + edges { + node { + id + name + department { id name } + roles { + edges { node { id name } } + } + tasks { + edges { node { name deadline } } + } + } + } + } +} + +# Filter employees by department name +query { + allEmployees(department: "Engineering") { + edges { + node { name } + } + } +} ``` -Now we can install our dependencies: +## Run tests ```bash -pip install -r requirements.txt +uv run pytest -v ``` -Now the following command will setup the database, and start the server: +## OpenTelemetry tracing + +`telemetry.py` is already included. Install the extras and point the app at +your collector: ```bash -python app.py +uv pip install "graphene-mongo[telemetry]" \ + opentelemetry-instrumentation-flask \ + opentelemetry-instrumentation-pymongo \ + opentelemetry-exporter-otlp ``` +```bash +OTEL_SERVICE_NAME=hr-api \ +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ +uv run python app.py +``` -Now head on over to -[http://127.0.0.1:5000/graphql](http://127.0.0.1:5000/graphql) -and run some queries! +### What you get -Sample query: ``` -{ - allEmployees { - edges { - node { - id, - name, - department { - id, - name - }, - roles { - edges { - node { - id, - name - } - } - }, - leader { - id, - name - } - tasks { - edges { - node { - name, - deadline - } - } - } - } - } - } -} +POST /graphql ← Flask HTTP span + └─ graphql allEmployees ← graphene-mongo field_span + └─ mongodb.aggregate ← pymongo auto-instrumentation ``` + +Each span carries: + +| Attribute | Example value | +|-----------------------------|-----------------| +| `graphql.field.name` | `allEmployees` | +| `graphql.field.parent_type` | `Query` | +| `graphql.operation.type` | `query` | +| `graphql.operation.name` | `ListEmployees` | +| `graphql.pagination.first` | `10` | + +Errors set `StatusCode.ERROR` and attach the full stacktrace as an `exception` event. \ No newline at end of file diff --git a/examples/flask_mongoengine/app.py b/examples/flask_mongoengine/app.py index 0e6a2011..1e94a99f 100644 --- a/examples/flask_mongoengine/app.py +++ b/examples/flask_mongoengine/app.py @@ -1,8 +1,10 @@ from database import init_db from flask import Flask, jsonify, request from schema import schema +from telemetry import setup_telemetry app = Flask(__name__) +setup_telemetry(app) @app.post("/graphql") diff --git a/examples/flask_mongoengine/pyproject.toml b/examples/flask_mongoengine/pyproject.toml index c3e0a710..6b2b45c7 100644 --- a/examples/flask_mongoengine/pyproject.toml +++ b/examples/flask_mongoengine/pyproject.toml @@ -9,6 +9,15 @@ dependencies = [ "mongomock>=4.1.2", ] +[project.optional-dependencies] +telemetry = [ + "opentelemetry-api>=1.0.0", + "opentelemetry-sdk>=1.0.0", + "opentelemetry-exporter-otlp>=1.0.0", + "opentelemetry-instrumentation-flask>=0.40b0", + "opentelemetry-instrumentation-pymongo>=0.40b0", +] + [tool.uv.sources] graphene-mongo = { path = "../.." } mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } diff --git a/examples/flask_mongoengine/telemetry.py b/examples/flask_mongoengine/telemetry.py new file mode 100644 index 00000000..8f5e7c01 --- /dev/null +++ b/examples/flask_mongoengine/telemetry.py @@ -0,0 +1,42 @@ +"""Optional OpenTelemetry setup for the Flask example. + +Install the extras to activate: + + pip install graphene-mongo[telemetry] \ + opentelemetry-instrumentation-flask \ + opentelemetry-instrumentation-pymongo \ + opentelemetry-exporter-otlp + +Then call setup_telemetry(app) before app.run(). +Point OTEL_EXPORTER_OTLP_ENDPOINT at your collector (default: localhost:4317). +""" + +import os + + +def setup_telemetry(app): + """Instrument Flask + pymongo and export spans via OTLP. + + Silently does nothing when opentelemetry packages are not installed. + """ + try: + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.instrumentation.flask import FlaskInstrumentor + from opentelemetry.instrumentation.pymongo import PymongoInstrumentor + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + except ImportError: + return + + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + service_name = os.getenv("OTEL_SERVICE_NAME", "graphene-mongo-flask") + + provider = TracerProvider() + provider.add_span_processor( + BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) + ) + trace.set_tracer_provider(provider) + + PymongoInstrumentor().instrument() + FlaskInstrumentor().instrument_app(app) \ No newline at end of file diff --git a/graphene_mongo/__init__.py b/graphene_mongo/__init__.py index 04c421ce..3e6a2118 100644 --- a/graphene_mongo/__init__.py +++ b/graphene_mongo/__init__.py @@ -2,7 +2,8 @@ from .asynchronous.fields import AsyncMongoengineConnectionField from .synchronous.types import MongoengineInputType, MongoengineInterfaceType, MongoengineObjectType from .asynchronous.types import AsyncMongoengineObjectType -from .base import registry, advanced_types +from .base import registry, advanced_types # noqa: F401 +from .base.utils import get_query_fields, get_select_related_paths __version__ = "0.5.0" @@ -14,4 +15,6 @@ "MongoengineInterfaceType", "MongoengineConnectionField", "AsyncMongoengineConnectionField", + "get_query_fields", + "get_select_related_paths", ] \ No newline at end of file diff --git a/graphene_mongo/asynchronous/fields.py b/graphene_mongo/asynchronous/fields.py index 00c1dc9f..a26f1adc 100644 --- a/graphene_mongo/asynchronous/fields.py +++ b/graphene_mongo/asynchronous/fields.py @@ -3,7 +3,6 @@ from typing import Coroutine import graphene -from bson import DBRef from graphene import Context from graphene.relay import ConnectionField from graphql import GraphQLResolveInfo @@ -15,6 +14,7 @@ from ..synchronous.fields import MongoengineConnectionField from ..base.registry import get_global_async_registry +from ..base.telemetry import field_span from ..base.utils import ( ExecutorEnum, connection_from_iterables, @@ -403,15 +403,8 @@ def filter_connection(x): resolved = await resolved if resolved is not None: if isinstance(resolved, list): - if resolved == list(): - return resolved - elif not isinstance(resolved[0], DBRef): - return resolved - else: - return await self.default_resolver( - root, info, required_fields, **args_copy - ) - elif isinstance(resolved, QuerySet): + return resolved + elif isinstance(resolved, (QuerySet, AsyncQuerySet)): args.update(resolved._query) args_copy = self._transform_qs_args(args, args.copy()) resolved = self._apply_select_related(resolved, self.model, info) @@ -421,7 +414,12 @@ def filter_connection(x): elif isinstance(resolved, Promise): return resolved.value else: - return await resolved + raise TypeError( + f"Resolver for connection field '{self.name}' returned " + f"{type(resolved).__name__!r}, which is not supported. " + "Return an AsyncQuerySet (with select_related applied for any " + "referenced fields the client queried) or a list." + ) return await self.default_resolver(root, info, required_fields, **args) @@ -450,11 +448,12 @@ async def connection_resolver(cls, resolver, connection_type, root, info, **args except Exception: pass - iterable = await resolver(root=root, info=info, **args) + with field_span(info, args): + iterable = await resolver(root=root, info=info, **args) - if isinstance(connection_type, graphene.NonNull): - connection_type = connection_type.of_type - on_resolve = partial(cls.resolve_connection, connection_type, args) - if Promise.is_thenable(iterable): - iterable = Promise.resolve(iterable).then(on_resolve).value - return on_resolve(iterable) + if isinstance(connection_type, graphene.NonNull): + connection_type = connection_type.of_type + on_resolve = partial(cls.resolve_connection, connection_type, args) + if Promise.is_thenable(iterable): + iterable = Promise.resolve(iterable).then(on_resolve).value + return on_resolve(iterable) diff --git a/graphene_mongo/asynchronous/types.py b/graphene_mongo/asynchronous/types.py index 46dc7a8d..98f280d8 100644 --- a/graphene_mongo/asynchronous/types.py +++ b/graphene_mongo/asynchronous/types.py @@ -5,6 +5,7 @@ from .fields import AsyncMongoengineConnectionField from ..base.registry import get_global_async_registry, get_inputs_async_registry from ..base.types import create_graphene_generic_class as _create +from ..base.telemetry import node_span from ..base.utils import ExecutorEnum, get_query_fields, get_select_related_paths @@ -42,22 +43,23 @@ def create_graphene_generic_class_async(object_type, option_type): # noinspection PyDecorator @classmethod async def get_node(cls, info, id): - required_fields = list() - for field in cls._meta.required_fields: - if field in cls._meta.model._fields_ordered: - required_fields.append(field) - queried_fields = get_query_fields(info) - if cls._meta.name in queried_fields: - queried_fields = queried_fields[cls._meta.name] - for field in queried_fields: - if to_snake_case(field) in cls._meta.model._fields_ordered: - required_fields.append(to_snake_case(field)) - required_fields = list(set(required_fields)) - related = get_select_related_paths(cls._meta.model, queried_fields) - qs = cls._meta.model.aobjects.only(*required_fields) - if related: - qs = qs.select_related(*related) - return await qs.get(pk=id) + with node_span(info, cls._meta.name, id): + required_fields = list() + for field in cls._meta.required_fields: + if field in cls._meta.model._fields_ordered: + required_fields.append(field) + queried_fields = get_query_fields(info) + if cls._meta.name in queried_fields: + queried_fields = queried_fields[cls._meta.name] + for field in queried_fields: + if to_snake_case(field) in cls._meta.model._fields_ordered: + required_fields.append(to_snake_case(field)) + required_fields = list(set(required_fields)) + related = get_select_related_paths(cls._meta.model, queried_fields) + qs = cls._meta.model.aobjects.only(*required_fields) + if related: + qs = qs.select_related(*related) + return await qs.get(pk=id) GenericType.get_node = get_node return GenericType, Options diff --git a/graphene_mongo/base/telemetry.py b/graphene_mongo/base/telemetry.py new file mode 100644 index 00000000..992cedee --- /dev/null +++ b/graphene_mongo/base/telemetry.py @@ -0,0 +1,133 @@ +"""Optional OpenTelemetry tracing for graphene-mongo. + +Gracefully degrades to a no-op when "opentelemetry-api` is not installed — +no import errors, no performance overhead beyond a single boolean check. + +Span hierarchy produced per connection field resolution: + + graphql ← graphene-mongo (this module) + └─ mongodb.aggregate ← opentelemetry-instrumentation-pymongo (automatic) + +Install the optional dependency to activate: + + pip install graphene-mongo[telemetry] +""" + +from contextlib import contextmanager, nullcontext + +try: + from opentelemetry import trace + from opentelemetry.trace import SpanKind, StatusCode + + _OTEL_AVAILABLE = True +except ImportError: + _OTEL_AVAILABLE = False + +_tracer = None + + +def _get_tracer(): + global _tracer + if _tracer is None: + _tracer = trace.get_tracer("graphene_mongo") + return _tracer + + +def _set_graphql_attributes(span, info, args): + """Populate standard GraphQL semantic attributes on *span*.""" + span.set_attribute("graphql.field.name", info.field_name) + span.set_attribute("graphql.field.parent_type", info.parent_type.name) + + if info.operation: + op_type = getattr(info.operation, "operation", None) + if op_type is not None: + span.set_attribute("graphql.operation.type", op_type.value) + op_name = getattr(info.operation, "name", None) + if op_name is not None: + span.set_attribute("graphql.operation.name", op_name.value) + + for key in ("first", "last"): + val = args.get(key) + if val is not None: + span.set_attribute(f"graphql.pagination.{key}", int(val)) + + +@contextmanager +def field_span(info, args): + """Context manager that wraps a connection field resolution in an OTEL span. + + Creates a child span named ``graphql `` under whatever span is + currently active (e.g., an HTTP server span). All MongoDB commands issued + inside the block become grandchildren via opentelemetry-instrumentation-pymongo. + + Marks the span as "ERROR" and records the exception if one propagates out. + No-op when opentelemetry-api is not installed. + + Args: + info: GraphQL resolve info object. + args (dict): The raw GraphQL field arguments (first, last, before, after, …). + + Yields: + opentelemetry.trace.Span | None + """ + if not _OTEL_AVAILABLE: + yield None + return + + with _get_tracer().start_as_current_span( + f"graphql {info.field_name}", + kind=SpanKind.INTERNAL, + record_exception=False, + set_status_on_exception=False, + ) as span: + _set_graphql_attributes(span, info, args) + try: + yield span + except Exception as exc: + span.set_status(StatusCode.ERROR, str(exc)) + span.record_exception(exc) + raise + + +@contextmanager +def node_span(info, type_name, node_id): + """Context manager that wraps a ``get_node`` lookup in an OTEL span. + + Creates a child span named ``graphql node `` so Node.Field() + lookups are traceable separately from connection field resolutions. + + Args: + info: GraphQL resolve info object. + type_name (str): The GraphQL type name (e.g. ``"ReporterNode"``). + node_id (str): The Relay global ID being looked up. + + Yields: + opentelemetry.trace.Span | None + """ + if not _OTEL_AVAILABLE: + yield None + return + + with _get_tracer().start_as_current_span( + f"graphql node {type_name}", + kind=SpanKind.INTERNAL, + record_exception=False, + set_status_on_exception=False, + ) as span: + span.set_attribute("graphql.field.parent_type", type_name) + span.set_attribute("graphql.node.id", str(node_id)) + + if info.operation: + op_type = getattr(info.operation, "operation", None) + if op_type is not None: + span.set_attribute("graphql.operation.type", op_type.value) + op_name = getattr(info.operation, "name", None) + if op_name is not None: + span.set_attribute("graphql.operation.name", op_name.value) + + try: + yield span + except Exception as exc: + span.set_status(StatusCode.ERROR, str(exc)) + span.record_exception(exc) + raise \ No newline at end of file diff --git a/graphene_mongo/synchronous/fields.py b/graphene_mongo/synchronous/fields.py index 8a196c5c..68d98414 100644 --- a/graphene_mongo/synchronous/fields.py +++ b/graphene_mongo/synchronous/fields.py @@ -5,7 +5,6 @@ import graphene import mongoengine import pymongo -from bson import DBRef from graphene import Context from graphene.relay import ConnectionField from graphql import GraphQLResolveInfo @@ -15,6 +14,7 @@ from pymongo.errors import OperationFailure from ..base.fields import BaseMongoengineConnectionField +from ..base.telemetry import field_span from ..base.utils import ( ExecutorEnum, connection_from_iterables, @@ -316,8 +316,7 @@ def chained_resolver(self, resolver, is_partial, root, info, **args): Calls the supplied *resolver* first. Depending on the return value: - None → falls through to default_resolver. - - list (non-empty, non-DBRef) → returned as-is. - - list of DBRef → re-queries via default_resolver. + - list (embedded documents from attribute resolver) → returned as-is. - QuerySet → its _query dict is merged into args and forwarded to default_resolver as resolved. - Promise → unwrapped and its value is returned. @@ -379,12 +378,7 @@ def filter_connection(x): if resolved is not None: if isinstance(resolved, list): - if resolved == list(): - return resolved - elif not isinstance(resolved[0], DBRef): - return resolved - else: - return self.default_resolver(root, info, required_fields, **args_copy) + return resolved elif isinstance(resolved, QuerySet): args.update(resolved._query) args_copy = self._transform_qs_args(args, args.copy()) @@ -395,7 +389,12 @@ def filter_connection(x): elif isinstance(resolved, Promise): return resolved.value else: - return resolved + raise TypeError( + f"Resolver for connection field '{self.name}' returned " + f"{type(resolved).__name__!r}, which is not supported. " + "Return a QuerySet (with select_related applied for any " + "referenced fields the client queried) or a list." + ) return self.default_resolver(root, info, required_fields, **args) @@ -424,17 +423,19 @@ def connection_resolver(cls, resolver, connection_type, root, info, **args): setattr(root, key, from_global_id(value)[1]) except Exception as error: logging.debug("Exception Occurred: ", exc_info=error) - iterable = resolver(root, info, **args) - if isinstance(connection_type, graphene.NonNull): - connection_type = connection_type.of_type + with field_span(info, args): + iterable = resolver(root, info, **args) + + if isinstance(connection_type, graphene.NonNull): + connection_type = connection_type.of_type - on_resolve = partial(cls.resolve_connection, connection_type, args) + on_resolve = partial(cls.resolve_connection, connection_type, args) - if Promise.is_thenable(iterable): - return Promise.resolve(iterable).then(on_resolve) + if Promise.is_thenable(iterable): + return Promise.resolve(iterable).then(on_resolve) - return on_resolve(iterable) + return on_resolve(iterable) def wrap_resolve(self, parent_resolver): """Wrap the field's resolver to go through chained_resolver. diff --git a/graphene_mongo/synchronous/types.py b/graphene_mongo/synchronous/types.py index 29688ad4..6db5b4de 100644 --- a/graphene_mongo/synchronous/types.py +++ b/graphene_mongo/synchronous/types.py @@ -8,6 +8,7 @@ from ..base.types import ( create_graphene_generic_class as _create, ) +from ..base.telemetry import node_span from ..base.utils import ExecutorEnum, get_query_fields, get_select_related_paths @@ -44,22 +45,23 @@ def create_graphene_generic_class(object_type, option_type): @classmethod def get_node(cls, info, id): - required_fields = list() - for field in cls._meta.required_fields: - if field in cls._meta.model._fields_ordered: - required_fields.append(field) - queried_fields = get_query_fields(info) - if cls._meta.name in queried_fields: - queried_fields = queried_fields[cls._meta.name] - for field in queried_fields: - if to_snake_case(field) in cls._meta.model._fields_ordered: - required_fields.append(to_snake_case(field)) - required_fields = list(set(required_fields)) - related = get_select_related_paths(cls._meta.model, queried_fields) - qs = cls._meta.model.objects.only(*required_fields) - if related: - qs = qs.select_related(*related) - return qs.get(pk=id) + with node_span(info, cls._meta.name, id): + required_fields = list() + for field in cls._meta.required_fields: + if field in cls._meta.model._fields_ordered: + required_fields.append(field) + queried_fields = get_query_fields(info) + if cls._meta.name in queried_fields: + queried_fields = queried_fields[cls._meta.name] + for field in queried_fields: + if to_snake_case(field) in cls._meta.model._fields_ordered: + required_fields.append(to_snake_case(field)) + required_fields = list(set(required_fields)) + related = get_select_related_paths(cls._meta.model, queried_fields) + qs = cls._meta.model.objects.only(*required_fields) + if related: + qs = qs.select_related(*related) + return qs.get(pk=id) GenericType.get_node = get_node return GenericType, Options diff --git a/pyproject.toml b/pyproject.toml index 997f3470..2c9a63b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,11 @@ dependencies = [ "mongoengine", ] +[project.optional-dependencies] +telemetry = [ + "opentelemetry-api>=1.0.0", +] + [project.urls] Homepage = "https://github.com/graphql-python/graphene-mongo" Repository = "https://github.com/graphql-python/graphene-mongo" @@ -38,6 +43,10 @@ Repository = "https://github.com/graphql-python/graphene-mongo" mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } [dependency-groups] +dev = [ + "sphinx>=8.1.3", + "sphinx-autobuild>=2024.10.3", +] test = [ "pytest", "mongomock>=4.1.2", @@ -45,6 +54,8 @@ test = [ "pytest-cov", "pytest-asyncio", "ruff", + "opentelemetry-api>=1.0.0", + "opentelemetry-sdk>=1.0.0", ] [build-system] @@ -66,4 +77,4 @@ minversion = "8.0" addopts = "-v -s" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" -asyncio_default_test_loop_scope = "session" \ No newline at end of file +asyncio_default_test_loop_scope = "session" diff --git a/tests/asynchronous/test_converter.py b/tests/asynchronous/test_converter.py new file mode 100644 index 00000000..84308a56 --- /dev/null +++ b/tests/asynchronous/test_converter.py @@ -0,0 +1,253 @@ +import graphene + +from graphene_mongo import registry +from graphene_mongo.base.converter import convert_mongoengine_field +from graphene_mongo.asynchronous.fields import AsyncMongoengineConnectionField +from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType + +from ..models import ( + Article, + Editor, + EmbeddedArticle, + EmbeddedFoo, + Player, + ProfessorMetadata, + ProfessorVector, + Publisher, + Reporter, +) + + +def test_should_reference_convert_dynamic(): + class E(AsyncMongoengineObjectType): + class Meta: + model = Editor + interfaces = (graphene.Node,) + + dynamic_field = convert_mongoengine_field(EmbeddedArticle._fields["editor"], E._meta.registry) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert isinstance(graphene_type, graphene.Field) + assert graphene_type.type == E + + +def test_should_lazy_reference_convert_dynamic(): + class P(AsyncMongoengineObjectType): + class Meta: + model = Publisher + interfaces = (graphene.Node,) + + dynamic_field = convert_mongoengine_field(Editor._fields["company"], P._meta.registry) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert isinstance(graphene_type, graphene.Field) + assert graphene_type.type == P + + +def test_should_embedded_convert_dynamic(): + class PM(AsyncMongoengineObjectType): + class Meta: + model = ProfessorMetadata + interfaces = (graphene.Node,) + + dynamic_field = convert_mongoengine_field( + ProfessorVector._fields["metadata"], PM._meta.registry + ) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert isinstance(graphene_type, graphene.Field) + assert graphene_type.type == PM + + +def test_should_convert_none(): + registry.reset_global_async_registry() + dynamic_field = convert_mongoengine_field( + EmbeddedArticle._fields["editor"], registry.get_global_async_registry() + ) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert graphene_type is None + + +def test_should_convert_none_lazily(): + registry.reset_global_async_registry() + dynamic_field = convert_mongoengine_field( + Editor._fields["company"], registry.get_global_async_registry() + ) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert graphene_type is None + + +def test_should_list_of_reference_convert_list(): + class A(AsyncMongoengineObjectType): + class Meta: + model = Article + + graphene_field = convert_mongoengine_field(Reporter._fields["articles"], A._meta.registry) + assert isinstance(graphene_field, graphene.List) + dynamic_field = graphene_field.get_type() + assert dynamic_field._of_type == A + + +def test_should_list_of_generic_reference_covert_list(): + class A(AsyncMongoengineObjectType): + class Meta: + model = Article + + class E(AsyncMongoengineObjectType): + class Meta: + model = Editor + + class R(AsyncMongoengineObjectType): + class Meta: + model = Reporter + + generic_references_field = convert_mongoengine_field( + Reporter._fields["generic_references"], registry.get_global_async_registry() + ) + assert isinstance(generic_references_field, graphene.List) + field = generic_references_field.get_type() + assert field._of_type._meta.types == (A, E) + + +def test_should_list_of_embedded_convert_list(): + class E(AsyncMongoengineObjectType): + class Meta: + model = EmbeddedArticle + + graphene_field = convert_mongoengine_field( + Reporter._fields["embedded_articles"], E._meta.registry + ) + assert isinstance(graphene_field, graphene.List) + dynamic_field = graphene_field.get_type() + assert dynamic_field._of_type == E + + +def test_should_embedded_list_convert_list(): + class E(AsyncMongoengineObjectType): + class Meta: + model = EmbeddedArticle + + graphene_field = convert_mongoengine_field( + Reporter._fields["embedded_list_articles"], E._meta.registry + ) + assert isinstance(graphene_field, graphene.List) + dynamic_field = graphene_field.get_type() + assert dynamic_field._of_type == E + + +def test_should_self_reference_convert_dynamic(): + class P(AsyncMongoengineObjectType): + class Meta: + model = Player + interfaces = (graphene.Node,) + + dynamic_field = convert_mongoengine_field(Player._fields["opponent"], P._meta.registry) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert isinstance(graphene_type, graphene.Field) + assert graphene_type.type == P + + graphene_field = convert_mongoengine_field(Player._fields["players"], P._meta.registry) + assert isinstance(graphene_field, AsyncMongoengineConnectionField) + + +def test_should_list_of_self_reference_convert_list(): + class A(AsyncMongoengineObjectType): + class Meta: + model = Article + + class P(AsyncMongoengineObjectType): + class Meta: + model = Player + + graphene_field = convert_mongoengine_field(Player._fields["players"], P._meta.registry) + assert isinstance(graphene_field, graphene.List) + dynamic_field = graphene_field.get_type() + assert dynamic_field._of_type == P + + +def test_should_description_convert_common_metadata(): + class A(AsyncMongoengineObjectType): + class Meta: + model = Article + + headline_field = convert_mongoengine_field(Article._fields["headline"], A._meta.registry) + assert headline_field.kwargs["description"] == "The article headline." + + pubDate_field = convert_mongoengine_field(Article._fields["pub_date"], A._meta.registry) + assert pubDate_field.kwargs["description"] == "Publication Date\nThe date of first press." + + firstName_field = convert_mongoengine_field(Editor._fields["first_name"], A._meta.registry) + assert firstName_field.kwargs["description"] == "Editor's first name.\n(fname)" + + metadata_field = convert_mongoengine_field(Editor._fields["metadata"], A._meta.registry) + assert metadata_field.kwargs["description"] == "Arbitrary metadata." + + +def test_should_description_convert_reference_metadata(): + class A(AsyncMongoengineObjectType): + class Meta: + model = Article + + class E(AsyncMongoengineObjectType): + class Meta: + model = Editor + + editor_field = convert_mongoengine_field(Article._fields["editor"], A._meta.registry).get_type() + assert editor_field.description == "An Editor of a publication." + + +def test_should_generic_reference_convert_union(): + class A(AsyncMongoengineObjectType): + class Meta: + model = Article + + class E(AsyncMongoengineObjectType): + class Meta: + model = Editor + + class R(AsyncMongoengineObjectType): + class Meta: + model = Reporter + + generic_reference_field = convert_mongoengine_field( + Reporter._fields["generic_reference"], registry.get_global_async_registry() + ) + assert isinstance(generic_reference_field, graphene.Field) + if not Reporter._fields["generic_reference"].required: + assert isinstance(generic_reference_field.type(), graphene.Union) + assert generic_reference_field.type()._meta.types == (A, E) + else: + assert issubclass(generic_reference_field.type.of_type, graphene.Union) + assert generic_reference_field.type.of_type._meta.types == (A, E) + + +def test_should_generic_embedded_document_convert_union(): + class D(AsyncMongoengineObjectType): + class Meta: + model = EmbeddedArticle + + class F(AsyncMongoengineObjectType): + class Meta: + model = EmbeddedFoo + + class A(AsyncMongoengineObjectType): + class Meta: + model = Article + + class E(AsyncMongoengineObjectType): + class Meta: + model = Editor + + class R(AsyncMongoengineObjectType): + class Meta: + model = Reporter + + generic_embedded_document = convert_mongoengine_field( + Reporter._fields["generic_embedded_document"], registry.get_global_async_registry() + ) + assert isinstance(generic_embedded_document, graphene.Field) + assert isinstance(generic_embedded_document.type(), graphene.Union) + assert generic_embedded_document.type()._meta.types == (D, F) diff --git a/tests/asynchronous/test_relay_query.py b/tests/asynchronous/test_relay_query.py index c2d7b842..87f76be5 100644 --- a/tests/asynchronous/test_relay_query.py +++ b/tests/asynchronous/test_relay_query.py @@ -9,7 +9,7 @@ from .. import models from . import nodes -from graphene_mongo import AsyncMongoengineConnectionField, AsyncMongoengineObjectType +from graphene_mongo import AsyncMongoengineConnectionField from .utils import execute_count @@ -1456,3 +1456,40 @@ class Query(graphene.ObjectType): # select_related joins via $lookup in the same aggregate — no separate find assert len(cap.projected_fields_for("test_article")) == 0 + + +async def test_connection_field_resolver_returns_document_raises(fixtures): + """An async connection field resolver that returns a single document raises TypeError.""" + + class Query(graphene.ObjectType): + articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) + + async def resolve_articles(self, info): + return await models.Article.aobjects.first() + + schema = graphene.Schema(query=Query) + result = await schema.execute_async("{ articles { edges { node { headline } } } }") + assert result.errors + assert "Article" in str(result.errors[0]) + assert "not supported" in str(result.errors[0]) + + +async def test_connection_field_resolver_returns_async_queryset(fixtures): + """An AsyncQuerySet returned from a custom resolver is handled correctly with select_related.""" + + class Query(graphene.ObjectType): + articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) + + async def resolve_articles(self, info): + return models.Article.aobjects.filter(headline="Hello") + + schema = graphene.Schema(query=Query) + result, count = await execute_count( + schema, "{ articles { edges { node { headline editor { firstName } } } } }" + ) + assert not result.errors + assert result.data["articles"]["edges"][0]["node"]["headline"] == "Hello" + assert result.data["articles"]["edges"][0]["node"]["editor"]["firstName"] == "Penny" + assert count == 1 # AsyncQuerySet goes through select_related — single aggregation + + diff --git a/tests/asynchronous/test_types.py b/tests/asynchronous/test_types.py index 0e87542f..1bc6d4b7 100644 --- a/tests/asynchronous/test_types.py +++ b/tests/asynchronous/test_types.py @@ -1,4 +1,3 @@ -import graphene from graphene import Field, Int, Interface, ObjectType from graphene.relay import Node, is_node from pytest import raises diff --git a/tests/synchronous/test_converter.py b/tests/synchronous/test_converter.py new file mode 100644 index 00000000..2bab05c1 --- /dev/null +++ b/tests/synchronous/test_converter.py @@ -0,0 +1,253 @@ +import graphene + +from graphene_mongo import registry +from graphene_mongo.base.converter import convert_mongoengine_field +from graphene_mongo.synchronous.fields import MongoengineConnectionField +from graphene_mongo.synchronous.types import MongoengineObjectType + +from ..models import ( + Article, + Editor, + EmbeddedArticle, + EmbeddedFoo, + Player, + ProfessorMetadata, + ProfessorVector, + Publisher, + Reporter, +) + + +def test_should_reference_convert_dynamic(): + class E(MongoengineObjectType): + class Meta: + model = Editor + interfaces = (graphene.Node,) + + dynamic_field = convert_mongoengine_field(EmbeddedArticle._fields["editor"], E._meta.registry) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert isinstance(graphene_type, graphene.Field) + assert graphene_type.type == E + + +def test_should_lazy_reference_convert_dynamic(): + class P(MongoengineObjectType): + class Meta: + model = Publisher + interfaces = (graphene.Node,) + + dynamic_field = convert_mongoengine_field(Editor._fields["company"], P._meta.registry) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert isinstance(graphene_type, graphene.Field) + assert graphene_type.type == P + + +def test_should_embedded_convert_dynamic(): + class PM(MongoengineObjectType): + class Meta: + model = ProfessorMetadata + interfaces = (graphene.Node,) + + dynamic_field = convert_mongoengine_field( + ProfessorVector._fields["metadata"], PM._meta.registry + ) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert isinstance(graphene_type, graphene.Field) + assert graphene_type.type == PM + + +def test_should_convert_none(): + registry.reset_global_registry() + dynamic_field = convert_mongoengine_field( + EmbeddedArticle._fields["editor"], registry.get_global_registry() + ) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert graphene_type is None + + +def test_should_convert_none_lazily(): + registry.reset_global_registry() + dynamic_field = convert_mongoengine_field( + Editor._fields["company"], registry.get_global_registry() + ) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert graphene_type is None + + +def test_should_list_of_reference_convert_list(): + class A(MongoengineObjectType): + class Meta: + model = Article + + graphene_field = convert_mongoengine_field(Reporter._fields["articles"], A._meta.registry) + assert isinstance(graphene_field, graphene.List) + dynamic_field = graphene_field.get_type() + assert dynamic_field._of_type == A + + +def test_should_list_of_generic_reference_covert_list(): + class A(MongoengineObjectType): + class Meta: + model = Article + + class E(MongoengineObjectType): + class Meta: + model = Editor + + class R(MongoengineObjectType): + class Meta: + model = Reporter + + generic_references_field = convert_mongoengine_field( + Reporter._fields["generic_references"], registry.get_global_registry() + ) + assert isinstance(generic_references_field, graphene.List) + field = generic_references_field.get_type() + assert field._of_type._meta.types == (A, E) + + +def test_should_list_of_embedded_convert_list(): + class E(MongoengineObjectType): + class Meta: + model = EmbeddedArticle + + graphene_field = convert_mongoengine_field( + Reporter._fields["embedded_articles"], E._meta.registry + ) + assert isinstance(graphene_field, graphene.List) + dynamic_field = graphene_field.get_type() + assert dynamic_field._of_type == E + + +def test_should_embedded_list_convert_list(): + class E(MongoengineObjectType): + class Meta: + model = EmbeddedArticle + + graphene_field = convert_mongoengine_field( + Reporter._fields["embedded_list_articles"], E._meta.registry + ) + assert isinstance(graphene_field, graphene.List) + dynamic_field = graphene_field.get_type() + assert dynamic_field._of_type == E + + +def test_should_self_reference_convert_dynamic(): + class P(MongoengineObjectType): + class Meta: + model = Player + interfaces = (graphene.Node,) + + dynamic_field = convert_mongoengine_field(Player._fields["opponent"], P._meta.registry) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert isinstance(graphene_type, graphene.Field) + assert graphene_type.type == P + + graphene_field = convert_mongoengine_field(Player._fields["players"], P._meta.registry) + assert isinstance(graphene_field, MongoengineConnectionField) + + +def test_should_list_of_self_reference_convert_list(): + class A(MongoengineObjectType): + class Meta: + model = Article + + class P(MongoengineObjectType): + class Meta: + model = Player + + graphene_field = convert_mongoengine_field(Player._fields["players"], P._meta.registry) + assert isinstance(graphene_field, graphene.List) + dynamic_field = graphene_field.get_type() + assert dynamic_field._of_type == P + + +def test_should_description_convert_common_metadata(): + class A(MongoengineObjectType): + class Meta: + model = Article + + headline_field = convert_mongoengine_field(Article._fields["headline"], A._meta.registry) + assert headline_field.kwargs["description"] == "The article headline." + + pubDate_field = convert_mongoengine_field(Article._fields["pub_date"], A._meta.registry) + assert pubDate_field.kwargs["description"] == "Publication Date\nThe date of first press." + + firstName_field = convert_mongoengine_field(Editor._fields["first_name"], A._meta.registry) + assert firstName_field.kwargs["description"] == "Editor's first name.\n(fname)" + + metadata_field = convert_mongoengine_field(Editor._fields["metadata"], A._meta.registry) + assert metadata_field.kwargs["description"] == "Arbitrary metadata." + + +def test_should_description_convert_reference_metadata(): + class A(MongoengineObjectType): + class Meta: + model = Article + + class E(MongoengineObjectType): + class Meta: + model = Editor + + editor_field = convert_mongoengine_field(Article._fields["editor"], A._meta.registry).get_type() + assert editor_field.description == "An Editor of a publication." + + +def test_should_generic_reference_convert_union(): + class A(MongoengineObjectType): + class Meta: + model = Article + + class E(MongoengineObjectType): + class Meta: + model = Editor + + class R(MongoengineObjectType): + class Meta: + model = Reporter + + generic_reference_field = convert_mongoengine_field( + Reporter._fields["generic_reference"], registry.get_global_registry() + ) + assert isinstance(generic_reference_field, graphene.Field) + if not Reporter._fields["generic_reference"].required: + assert isinstance(generic_reference_field.type(), graphene.Union) + assert generic_reference_field.type()._meta.types == (A, E) + else: + assert issubclass(generic_reference_field.type.of_type, graphene.Union) + assert generic_reference_field.type.of_type._meta.types == (A, E) + + +def test_should_generic_embedded_document_convert_union(): + class D(MongoengineObjectType): + class Meta: + model = EmbeddedArticle + + class F(MongoengineObjectType): + class Meta: + model = EmbeddedFoo + + class A(MongoengineObjectType): + class Meta: + model = Article + + class E(MongoengineObjectType): + class Meta: + model = Editor + + class R(MongoengineObjectType): + class Meta: + model = Reporter + + generic_embedded_document = convert_mongoengine_field( + Reporter._fields["generic_embedded_document"], registry.get_global_registry() + ) + assert isinstance(generic_embedded_document, graphene.Field) + assert isinstance(generic_embedded_document.type(), graphene.Union) + assert generic_embedded_document.type()._meta.types == (D, F) \ No newline at end of file diff --git a/tests/synchronous/test_fields.py b/tests/synchronous/test_fields.py index 7fe8c558..d2c604d7 100644 --- a/tests/synchronous/test_fields.py +++ b/tests/synchronous/test_fields.py @@ -1,8 +1,5 @@ -import pytest -from mongoengine.context_managers import query_counter from . import nodes -from graphene_mongo import AsyncMongoengineConnectionField from graphene_mongo.synchronous.fields import MongoengineConnectionField diff --git a/tests/synchronous/test_mutation.py b/tests/synchronous/test_mutation.py index 6c58371e..c37b1808 100644 --- a/tests/synchronous/test_mutation.py +++ b/tests/synchronous/test_mutation.py @@ -1,5 +1,4 @@ import graphene -import pytest from graphene.relay import Node diff --git a/tests/synchronous/test_query.py b/tests/synchronous/test_query.py index ce6ce36b..57b2bcef 100644 --- a/tests/synchronous/test_query.py +++ b/tests/synchronous/test_query.py @@ -3,7 +3,6 @@ import os import graphene -import pytest from .. import models from .. import types diff --git a/tests/synchronous/test_relay_query.py b/tests/synchronous/test_relay_query.py index cfee0ad1..8834c5ca 100644 --- a/tests/synchronous/test_relay_query.py +++ b/tests/synchronous/test_relay_query.py @@ -3,7 +3,6 @@ import os import graphene -import pytest from graphene.relay import Node from graphql_relay.connection.array_connection import offset_to_cursor from graphql_relay.node.node import to_global_id @@ -11,7 +10,6 @@ from .. import models from . import nodes from graphene_mongo.synchronous.fields import MongoengineConnectionField -from graphene_mongo.synchronous.types import MongoengineObjectType from .utils import execute_count @@ -1460,3 +1458,21 @@ class Query(graphene.ObjectType): # select_related joins via $lookup in the same aggregate — no separate find assert len(cap.projected_fields_for("test_article")) == 0 + + +def test_connection_field_resolver_returns_document_raises(fixtures): + """A connection field resolver that returns a single document raises TypeError.""" + + class Query(graphene.ObjectType): + articles = MongoengineConnectionField(nodes.ArticleNode) + + def resolve_articles(self, info): + return models.Article.objects.first() + + schema = graphene.Schema(query=Query) + result = schema.execute("{ articles { edges { node { headline } } } }") + assert result.errors + assert "Article" in str(result.errors[0]) + assert "not supported" in str(result.errors[0]) + + diff --git a/tests/synchronous/test_utils.py b/tests/synchronous/test_utils.py index ce2888e8..1522bfef 100644 --- a/tests/synchronous/test_utils.py +++ b/tests/synchronous/test_utils.py @@ -2,7 +2,12 @@ from .. import types from ..models import Article, Child, Reporter -from graphene_mongo.base.utils import get_model_fields, get_query_fields, is_valid_mongoengine_model +from graphene_mongo.base.utils import ( + get_model_fields, + get_query_fields, + get_select_related_paths, + is_valid_mongoengine_model, +) def test_get_model_fields_no_duplication(): @@ -102,3 +107,40 @@ def resolve_children(self, info, *args, **kwargs): "qux": {}, }, } + + +def test_get_select_related_paths_top_level(): + """Returns a path for each top-level reference field that was queried.""" + queried = {"editor": {"firstName": {}}, "headline": {}} + paths = get_select_related_paths(Article, queried) + assert paths == ["editor"] + + +def test_get_select_related_paths_nested(): + """Recursively returns nested reference paths using __ notation.""" + # Editor.company is a ReferenceField(Publisher) + queried = {"editor": {"firstName": {}, "company": {"name": {}}}} + paths = get_select_related_paths(Article, queried) + assert "editor" in paths + assert "editor__company" in paths + + +def test_get_select_related_paths_no_refs(): + """Returns an empty list when no reference fields are queried.""" + queried = {"headline": {}, "pubDate": {}} + paths = get_select_related_paths(Article, queried) + assert paths == [] + + +def test_get_select_related_paths_unknown_field_ignored(): + """Unknown GraphQL fields (not in model._fields) are silently skipped.""" + queried = {"nonExistentField": {"sub": {}}, "headline": {}} + paths = get_select_related_paths(Article, queried) + assert paths == [] + + +def test_get_select_related_paths_reporter_articles(): + """ListField(ReferenceField) is also included — articles is a list of Article refs.""" + queried = {"articles": {"headline": {}}} + paths = get_select_related_paths(Reporter, queried) + assert "articles" in paths diff --git a/tests/test_converter.py b/tests/test_converter.py index ed896d34..c0946780 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,23 +1,8 @@ import graphene import mongoengine -from pytest import raises - -from .models import ( - Article, - Editor, - EmbeddedArticle, - EmbeddedFoo, - Player, - Reporter, - ProfessorMetadata, - ProfessorVector, - Publisher, -) -from graphene_mongo import registry + from graphene_mongo import advanced_types from graphene_mongo.base.converter import convert_mongoengine_field -from graphene_mongo.synchronous.fields import MongoengineConnectionField -from graphene_mongo.synchronous.types import MongoengineObjectType def assert_conversion(mongoengine_field, graphene_field, *args, **kwargs): @@ -29,6 +14,8 @@ def assert_conversion(mongoengine_field, graphene_field, *args, **kwargs): def test_should_unknown_mongoengine_field_raise_exception(): + from pytest import raises + with raises(Exception) as excinfo: convert_mongoengine_field(None) assert "Don't know how to convert the MongoEngine field" in str(excinfo) @@ -125,240 +112,4 @@ def test_should_field_convert_list(): def test_should_geo_convert_list(): - assert_conversion(mongoengine.GeoPointField, graphene.List, field=mongoengine.FloatField()) - - -def test_should_reference_convert_dynamic(): - class E(MongoengineObjectType): - class Meta: - model = Editor - interfaces = (graphene.Node,) - - dynamic_field = convert_mongoengine_field(EmbeddedArticle._fields["editor"], E._meta.registry) - assert isinstance(dynamic_field, graphene.Dynamic) - graphene_type = dynamic_field.get_type() - assert isinstance(graphene_type, graphene.Field) - assert graphene_type.type == E - - -def test_should_lazy_reference_convert_dynamic(): - class P(MongoengineObjectType): - class Meta: - model = Publisher - interfaces = (graphene.Node,) - - dynamic_field = convert_mongoengine_field(Editor._fields["company"], P._meta.registry) - - assert isinstance(dynamic_field, graphene.Dynamic) - graphene_type = dynamic_field.get_type() - assert isinstance(graphene_type, graphene.Field) - assert graphene_type.type == P - - -def test_should_embedded_convert_dynamic(): - class PM(MongoengineObjectType): - class Meta: - model = ProfessorMetadata - interfaces = (graphene.Node,) - - dynamic_field = convert_mongoengine_field( - ProfessorVector._fields["metadata"], PM._meta.registry - ) - assert isinstance(dynamic_field, graphene.Dynamic) - graphene_type = dynamic_field.get_type() - assert isinstance(graphene_type, graphene.Field) - assert graphene_type.type == PM - - -def test_should_convert_none(): - registry.reset_global_registry() - dynamic_field = convert_mongoengine_field( - EmbeddedArticle._fields["editor"], registry.get_global_registry() - ) - assert isinstance(dynamic_field, graphene.Dynamic) - graphene_type = dynamic_field.get_type() - assert graphene_type is None - - -def test_should_convert_none_lazily(): - registry.reset_global_registry() - dynamic_field = convert_mongoengine_field( - Editor._fields["company"], registry.get_global_registry() - ) - assert isinstance(dynamic_field, graphene.Dynamic) - graphene_type = dynamic_field.get_type() - assert graphene_type is None - - -def test_should_list_of_reference_convert_list(): - class A(MongoengineObjectType): - class Meta: - model = Article - - graphene_field = convert_mongoengine_field(Reporter._fields["articles"], A._meta.registry) - assert isinstance(graphene_field, graphene.List) - dynamic_field = graphene_field.get_type() - assert dynamic_field._of_type == A - - -def test_should_list_of_generic_reference_covert_list(): - class A(MongoengineObjectType): - class Meta: - model = Article - - class E(MongoengineObjectType): - class Meta: - model = Editor - - class R(MongoengineObjectType): - class Meta: - model = Reporter - - generic_references_field = convert_mongoengine_field( - Reporter._fields["generic_references"], registry.get_global_registry() - ) - assert isinstance(generic_references_field, graphene.List) - field = generic_references_field.get_type() - assert field._of_type._meta.types == (A, E) - - -def test_should_list_of_embedded_convert_list(): - class E(MongoengineObjectType): - class Meta: - model = EmbeddedArticle - - graphene_field = convert_mongoengine_field( - Reporter._fields["embedded_articles"], E._meta.registry - ) - assert isinstance(graphene_field, graphene.List) - dynamic_field = graphene_field.get_type() - assert dynamic_field._of_type == E - - -def test_should_embedded_list_convert_list(): - class E(MongoengineObjectType): - class Meta: - model = EmbeddedArticle - - graphene_field = convert_mongoengine_field( - Reporter._fields["embedded_list_articles"], E._meta.registry - ) - assert isinstance(graphene_field, graphene.List) - dynamic_field = graphene_field.get_type() - assert dynamic_field._of_type == E - - -def test_should_self_reference_convert_dynamic(): - class P(MongoengineObjectType): - class Meta: - model = Player - interfaces = (graphene.Node,) - - dynamic_field = convert_mongoengine_field(Player._fields["opponent"], P._meta.registry) - assert isinstance(dynamic_field, graphene.Dynamic) - graphene_type = dynamic_field.get_type() - assert isinstance(graphene_type, graphene.Field) - assert graphene_type.type == P - - graphene_field = convert_mongoengine_field(Player._fields["players"], P._meta.registry) - assert isinstance(graphene_field, MongoengineConnectionField) - - -def test_should_list_of_self_reference_convert_list(): - class A(MongoengineObjectType): - class Meta: - model = Article - - class P(MongoengineObjectType): - class Meta: - model = Player - - graphene_field = convert_mongoengine_field(Player._fields["players"], P._meta.registry) - assert isinstance(graphene_field, graphene.List) - dynamic_field = graphene_field.get_type() - assert dynamic_field._of_type == P - - -def test_should_description_convert_common_metadata(): - class A(MongoengineObjectType): - class Meta: - model = Article - - headline_field = convert_mongoengine_field(Article._fields["headline"], A._meta.registry) - assert headline_field.kwargs["description"] == "The article headline." - - pubDate_field = convert_mongoengine_field(Article._fields["pub_date"], A._meta.registry) - assert pubDate_field.kwargs["description"] == "Publication Date\nThe date of first press." - - firstName_field = convert_mongoengine_field(Editor._fields["first_name"], A._meta.registry) - assert firstName_field.kwargs["description"] == "Editor's first name.\n(fname)" - - metadata_field = convert_mongoengine_field(Editor._fields["metadata"], A._meta.registry) - assert metadata_field.kwargs["description"] == "Arbitrary metadata." - - -def test_should_description_convert_reference_metadata(): - class A(MongoengineObjectType): - class Meta: - model = Article - - class E(MongoengineObjectType): - class Meta: - model = Editor - - editor_field = convert_mongoengine_field(Article._fields["editor"], A._meta.registry).get_type() - assert editor_field.description == "An Editor of a publication." - - -def test_should_generic_reference_convert_union(): - class A(MongoengineObjectType): - class Meta: - model = Article - - class E(MongoengineObjectType): - class Meta: - model = Editor - - class R(MongoengineObjectType): - class Meta: - model = Reporter - - generic_reference_field = convert_mongoengine_field( - Reporter._fields["generic_reference"], registry.get_global_registry() - ) - assert isinstance(generic_reference_field, graphene.Field) - if not Reporter._fields["generic_reference"].required: - assert isinstance(generic_reference_field.type(), graphene.Union) - assert generic_reference_field.type()._meta.types == (A, E) - else: - assert issubclass(generic_reference_field.type.of_type, graphene.Union) - assert generic_reference_field.type.of_type._meta.types == (A, E) - - -def test_should_generic_embedded_document_convert_union(): - class D(MongoengineObjectType): - class Meta: - model = EmbeddedArticle - - class F(MongoengineObjectType): - class Meta: - model = EmbeddedFoo - - class A(MongoengineObjectType): - class Meta: - model = Article - - class E(MongoengineObjectType): - class Meta: - model = Editor - - class R(MongoengineObjectType): - class Meta: - model = Reporter - - generic_embedded_document = convert_mongoengine_field( - Reporter._fields["generic_embedded_document"], registry.get_global_registry() - ) - assert isinstance(generic_embedded_document, graphene.Field) - assert isinstance(generic_embedded_document.type(), graphene.Union) - assert generic_embedded_document.type()._meta.types == (D, F) + assert_conversion(mongoengine.GeoPointField, graphene.List, field=mongoengine.FloatField()) \ No newline at end of file diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 00000000..6bbcca7a --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,327 @@ +"""Tests for graphene_mongo/base/telemetry.py.""" + +from unittest.mock import MagicMock + +import pytest +from opentelemetry.trace import StatusCode + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def make_info(field_name="articles", parent_type_name="Query", op_type="query", op_name="TestOp"): + info = MagicMock() + info.field_name = field_name + info.parent_type.name = parent_type_name + info.operation.operation.value = op_type + info.operation.name.value = op_name + return info + + +def make_tracer_and_span(): + span = MagicMock() + cm = MagicMock() + cm.__enter__ = MagicMock(return_value=span) + cm.__exit__ = MagicMock(return_value=False) + tracer = MagicMock() + tracer.start_as_current_span.return_value = cm + return tracer, span + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def mock_tracer(monkeypatch): + """Replace _get_tracer with a mock so no real OTEL backend is needed.""" + import graphene_mongo.base.telemetry as telem + + tracer, span = make_tracer_and_span() + monkeypatch.setattr(telem, "_get_tracer", lambda: tracer) + return tracer, span + + +# ── No-op path (OTEL disabled) ──────────────────────────────────────────────── + + +class TestFieldSpanNoOtel: + def test_yields_none(self, monkeypatch): + import graphene_mongo.base.telemetry as telem + from graphene_mongo.base.telemetry import field_span + + monkeypatch.setattr(telem, "_OTEL_AVAILABLE", False) + with field_span(make_info(), {}) as span: + assert span is None + + def test_no_exception_on_clean_exit(self, monkeypatch): + import graphene_mongo.base.telemetry as telem + from graphene_mongo.base.telemetry import field_span + + monkeypatch.setattr(telem, "_OTEL_AVAILABLE", False) + with field_span(make_info(), {"first": 10}): + pass + + def test_propagates_exception(self, monkeypatch): + import graphene_mongo.base.telemetry as telem + from graphene_mongo.base.telemetry import field_span + + monkeypatch.setattr(telem, "_OTEL_AVAILABLE", False) + with pytest.raises(ValueError, match="boom"): + with field_span(make_info(), {}): + raise ValueError("boom") + + +class TestNodeSpanNoOtel: + def test_yields_none(self, monkeypatch): + import graphene_mongo.base.telemetry as telem + from graphene_mongo.base.telemetry import node_span + + monkeypatch.setattr(telem, "_OTEL_AVAILABLE", False) + with node_span(make_info(), "ArticleType", "abc") as span: + assert span is None + + def test_no_exception_on_clean_exit(self, monkeypatch): + import graphene_mongo.base.telemetry as telem + from graphene_mongo.base.telemetry import node_span + + monkeypatch.setattr(telem, "_OTEL_AVAILABLE", False) + with node_span(make_info(), "ArticleType", "abc"): + pass + + def test_propagates_exception(self, monkeypatch): + import graphene_mongo.base.telemetry as telem + from graphene_mongo.base.telemetry import node_span + + monkeypatch.setattr(telem, "_OTEL_AVAILABLE", False) + with pytest.raises(RuntimeError, match="db error"): + with node_span(make_info(), "ArticleType", "abc"): + raise RuntimeError("db error") + + +# ── Active path — field_span ────────────────────────────────────────────────── + + +class TestFieldSpanWithOtel: + def test_span_name_includes_field_name(self, mock_tracer): + tracer, _ = mock_tracer + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(field_name="articles"), {}): + pass + + assert tracer.start_as_current_span.call_args[0][0] == "graphql articles" + + def test_yields_span(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(), {}) as s: + assert s is span + + def test_field_name_attribute(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(field_name="reporters"), {}): + pass + + span.set_attribute.assert_any_call("graphql.field.name", "reporters") + + def test_parent_type_attribute(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(parent_type_name="Query"), {}): + pass + + span.set_attribute.assert_any_call("graphql.field.parent_type", "Query") + + def test_operation_type_attribute(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(op_type="mutation"), {}): + pass + + span.set_attribute.assert_any_call("graphql.operation.type", "mutation") + + def test_operation_name_attribute(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(op_name="MyQuery"), {}): + pass + + span.set_attribute.assert_any_call("graphql.operation.name", "MyQuery") + + def test_pagination_first_attribute(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(), {"first": 10}): + pass + + span.set_attribute.assert_any_call("graphql.pagination.first", 10) + + def test_pagination_last_attribute(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(), {"last": 5}): + pass + + span.set_attribute.assert_any_call("graphql.pagination.last", 5) + + def test_no_pagination_attributes_when_absent(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(), {}): + pass + + calls_str = str(span.set_attribute.call_args_list) + assert "pagination" not in calls_str + + def test_exception_recorded_on_span(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import field_span + + exc = ValueError("boom") + with pytest.raises(ValueError): + with field_span(make_info(), {}): + raise exc + + span.record_exception.assert_called_once_with(exc) + + def test_exception_sets_error_status(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import field_span + + with pytest.raises(ValueError): + with field_span(make_info(), {}): + raise ValueError("boom") + + span.set_status.assert_called_once() + assert span.set_status.call_args[0][0] == StatusCode.ERROR + + def test_exception_propagates(self, mock_tracer): + from graphene_mongo.base.telemetry import field_span + + with pytest.raises(RuntimeError, match="propagated"): + with field_span(make_info(), {}): + raise RuntimeError("propagated") + + def test_no_error_status_on_clean_exit(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(), {}): + pass + + span.set_status.assert_not_called() + span.record_exception.assert_not_called() + + +# ── Active path — node_span ─────────────────────────────────────────────────── + + +class TestNodeSpanWithOtel: + def test_span_name_includes_type_name(self, mock_tracer): + tracer, _ = mock_tracer + from graphene_mongo.base.telemetry import node_span + + with node_span(make_info(), "ArticleType", "abc"): + pass + + assert tracer.start_as_current_span.call_args[0][0] == "graphql node ArticleType" + + def test_yields_span(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import node_span + + with node_span(make_info(), "ArticleType", "abc") as s: + assert s is span + + def test_parent_type_attribute(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import node_span + + with node_span(make_info(), "ReporterType", "xyz"): + pass + + span.set_attribute.assert_any_call("graphql.field.parent_type", "ReporterType") + + def test_node_id_attribute(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import node_span + + with node_span(make_info(), "ArticleType", "abc123"): + pass + + span.set_attribute.assert_any_call("graphql.node.id", "abc123") + + def test_node_id_is_stringified(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import node_span + + with node_span(make_info(), "ArticleType", 42): + pass + + span.set_attribute.assert_any_call("graphql.node.id", "42") + + def test_operation_type_attribute(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import node_span + + with node_span(make_info(op_type="query"), "ArticleType", "x"): + pass + + span.set_attribute.assert_any_call("graphql.operation.type", "query") + + def test_operation_name_attribute(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import node_span + + with node_span(make_info(op_name="FetchNode"), "ArticleType", "x"): + pass + + span.set_attribute.assert_any_call("graphql.operation.name", "FetchNode") + + def test_exception_recorded_on_span(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import node_span + + exc = ValueError("not found") + with pytest.raises(ValueError): + with node_span(make_info(), "ArticleType", "abc"): + raise exc + + span.record_exception.assert_called_once_with(exc) + + def test_exception_sets_error_status(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import node_span + + with pytest.raises(ValueError): + with node_span(make_info(), "ArticleType", "abc"): + raise ValueError("not found") + + span.set_status.assert_called_once() + assert span.set_status.call_args[0][0] == StatusCode.ERROR + + def test_exception_propagates(self, mock_tracer): + from graphene_mongo.base.telemetry import node_span + + with pytest.raises(RuntimeError, match="not found"): + with node_span(make_info(), "ArticleType", "abc"): + raise RuntimeError("not found") + + def test_no_error_status_on_clean_exit(self, mock_tracer): + _, span = mock_tracer + from graphene_mongo.base.telemetry import node_span + + with node_span(make_info(), "ArticleType", "abc"): + pass + + span.set_status.assert_not_called() + span.record_exception.assert_not_called() \ No newline at end of file diff --git a/tests/test_telemetry_exporter.py b/tests/test_telemetry_exporter.py new file mode 100644 index 00000000..8dc9fe67 --- /dev/null +++ b/tests/test_telemetry_exporter.py @@ -0,0 +1,199 @@ +"""Demonstrates what an OTEL exporter receives from graphene-mongo spans. + +Uses InMemorySpanExporter (part of opentelemetry-sdk) to capture real spans +and assert on the data a production exporter (Jaeger, OTLP, etc.) would see. +""" + +from unittest.mock import MagicMock + +import pytest +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import SpanKind, StatusCode + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def exporter(monkeypatch): + """Wire a real TracerProvider + InMemorySpanExporter into graphene-mongo. + + Creates a local TracerProvider (not the global one — OTEL only allows + setting the global provider once per process) and patches _get_tracer + to return a tracer from it directly. + """ + import graphene_mongo.base.telemetry as telem + + mem_exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(mem_exporter)) + + tracer = provider.get_tracer("graphene_mongo") + monkeypatch.setattr(telem, "_get_tracer", lambda: tracer) + + yield mem_exporter + + +def make_info(field_name="articles", parent_type_name="Query", op_type="query", op_name="GetArticles"): + info = MagicMock() + info.field_name = field_name + info.parent_type.name = parent_type_name + info.operation.operation.value = op_type + info.operation.name.value = op_name + return info + + +# ── field_span exporter output ──────────────────────────────────────────────── + + +class TestFieldSpanExporter: + def test_span_is_exported(self, exporter): + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(field_name="articles"), {}): + pass + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + + def test_span_name(self, exporter): + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(field_name="articles"), {}): + pass + + span = exporter.get_finished_spans()[0] + assert span.name == "graphql articles" + + def test_span_kind_is_internal(self, exporter): + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(), {}): + pass + + span = exporter.get_finished_spans()[0] + assert span.kind == SpanKind.INTERNAL + + def test_graphql_attributes(self, exporter): + from graphene_mongo.base.telemetry import field_span + + with field_span( + make_info(field_name="articles", parent_type_name="Query", op_type="query", op_name="GetArticles"), {}): + pass + + attrs = exporter.get_finished_spans()[0].attributes + assert attrs["graphql.field.name"] == "articles" + assert attrs["graphql.field.parent_type"] == "Query" + assert attrs["graphql.operation.type"] == "query" + assert attrs["graphql.operation.name"] == "GetArticles" + + def test_pagination_attributes(self, exporter): + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(), {"first": 10, "last": None}): + pass + + attrs = exporter.get_finished_spans()[0].attributes + assert attrs["graphql.pagination.first"] == 10 + assert "graphql.pagination.last" not in attrs + + def test_status_ok_on_clean_exit(self, exporter): + from graphene_mongo.base.telemetry import field_span + + with field_span(make_info(), {}): + pass + + span = exporter.get_finished_spans()[0] + assert span.status.status_code == StatusCode.UNSET + + def test_status_error_on_exception(self, exporter): + from graphene_mongo.base.telemetry import field_span + + with pytest.raises(RuntimeError): + with field_span(make_info(), {}): + raise RuntimeError("db connection lost") + + span = exporter.get_finished_spans()[0] + assert span.status.status_code == StatusCode.ERROR + assert "db connection lost" in span.status.description + + def test_exception_event_recorded(self, exporter): + from graphene_mongo.base.telemetry import field_span + + with pytest.raises(ValueError): + with field_span(make_info(), {}): + raise ValueError("invalid filter") + + span = exporter.get_finished_spans()[0] + # record_exception() adds an "exception" event to the span + assert len(span.events) == 1 + event = span.events[0] + assert event.name == "exception" + assert "invalid filter" in event.attributes["exception.message"] + assert event.attributes["exception.type"] == "ValueError" + + +# ── node_span exporter output ───────────────────────────────────────────────── + + +class TestNodeSpanExporter: + def test_span_is_exported(self, exporter): + from graphene_mongo.base.telemetry import node_span + + with node_span(make_info(), "ArticleType", "abc123"): + pass + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + + def test_span_name(self, exporter): + from graphene_mongo.base.telemetry import node_span + + with node_span(make_info(), "ArticleType", "abc123"): + pass + + assert exporter.get_finished_spans()[0].name == "graphql node ArticleType" + + def test_span_kind_is_internal(self, exporter): + from graphene_mongo.base.telemetry import node_span + + with node_span(make_info(), "ArticleType", "abc123"): + pass + + assert exporter.get_finished_spans()[0].kind == SpanKind.INTERNAL + + def test_node_attributes(self, exporter): + from graphene_mongo.base.telemetry import node_span + + with node_span(make_info(op_type="query", op_name="FetchNode"), "ArticleType", "abc123"): + pass + + attrs = exporter.get_finished_spans()[0].attributes + assert attrs["graphql.field.parent_type"] == "ArticleType" + assert attrs["graphql.node.id"] == "abc123" + assert attrs["graphql.operation.type"] == "query" + assert attrs["graphql.operation.name"] == "FetchNode" + + def test_status_error_on_exception(self, exporter): + from graphene_mongo.base.telemetry import node_span + + with pytest.raises(LookupError): + with node_span(make_info(), "ArticleType", "abc123"): + raise LookupError("document not found") + + span = exporter.get_finished_spans()[0] + assert span.status.status_code == StatusCode.ERROR + + def test_exception_event_recorded(self, exporter): + from graphene_mongo.base.telemetry import node_span + + with pytest.raises(LookupError): + with node_span(make_info(), "ArticleType", "abc123"): + raise LookupError("document not found") + + event = exporter.get_finished_spans()[0].events[0] + assert event.name == "exception" + assert event.attributes["exception.type"] == "LookupError" + assert "document not found" in event.attributes["exception.message"] diff --git a/uv.lock b/uv.lock index 776beb55..1660053e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,43 @@ version = 1 revision = 3 requires-python = ">=3.10, <4" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] [[package]] name = "backports-asyncio-runner" @@ -11,6 +48,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -147,12 +310,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -211,10 +399,24 @@ dependencies = [ { name = "promise" }, ] +[package.optional-dependencies] +telemetry = [ + { name = "opentelemetry-api" }, +] + [package.dev-dependencies] +dev = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] test = [ { name = "mock" }, { name = "mongomock" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -226,13 +428,21 @@ requires-dist = [ { name = "graphene", specifier = ">=3.1.1" }, { name = "graphene-federation", specifier = ">=3.1.5" }, { name = "mongoengine", git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.2" }, + { name = "opentelemetry-api", marker = "extra == 'telemetry'", specifier = ">=1.0.0" }, { name = "promise", specifier = ">=2.3" }, ] +provides-extras = ["telemetry"] [package.metadata.requires-dev] +dev = [ + { name = "sphinx", specifier = ">=8.1.3" }, + { name = "sphinx-autobuild", specifier = ">=2024.10.3" }, +] test = [ { name = "mock", specifier = ">=5.0.1" }, { name = "mongomock", specifier = ">=4.1.2" }, + { name = "opentelemetry-api", specifier = ">=1.0.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.0.0" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -260,6 +470,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/16/a4cf06adbc711bd364a73ce043b0b08d8fa5aae3df11b6ee4248bcdad2e0/graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5", size = 16940, upload-time = "2022-04-16T11:03:43.895Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -269,6 +506,103 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mock" version = "5.2.0" @@ -300,6 +634,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/4d/8bea712978e3aff017a2ab50f262c620e9239cc36f348aae45e48d6a4786/mongomock-4.3.0-py2.py3-none-any.whl", hash = "sha256:5ef86bd12fc8806c6e7af32f21266c61b6c4ba96096f85129852d1c4fec1327e", size = 64891, upload-time = "2024-11-16T11:23:24.748Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -474,6 +847,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + [[package]] name = "ruff" version = "0.15.14" @@ -517,6 +914,217 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-autobuild" +version = "2024.10.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "starlette", marker = "python_full_version < '3.11'" }, + { name = "uvicorn", marker = "python_full_version < '3.11'" }, + { name = "watchfiles", marker = "python_full_version < '3.11'" }, + { name = "websockets", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/c0/eba125db38c84d3c74717008fd3cb5000b68cd7e2cbafd1349c6a38c3d3b/sphinx_autobuild-2024.10.3-py3-none-any.whl", hash = "sha256:158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa", size = 11908, upload-time = "2024-10-02T23:15:28.739Z" }, +] + +[[package]] +name = "sphinx-autobuild" +version = "2025.8.25" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "starlette", marker = "python_full_version >= '3.11'" }, + { name = "uvicorn", marker = "python_full_version >= '3.11'" }, + { name = "watchfiles", marker = "python_full_version >= '3.11'" }, + { name = "websockets", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a", size = 12535, upload-time = "2025-08-25T18:44:54.164Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "starlette" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -579,3 +1187,211 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/5a/2bf22ecb24916983bf1cc0095e7dea2741d14d6553b0d6a2ac8bc96eca93/watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9", size = 400471, upload-time = "2026-05-18T04:31:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/dea1f6a0e76607841a60fb51af150e70124864673f61704abb62b90cdcc7/watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4", size = 394599, upload-time = "2026-05-18T04:30:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/18/52/752dcc7dc817baef5e89518732925795ce52e36a683a9a3c9fb68b21504e/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631", size = 455458, upload-time = "2026-05-18T04:30:29.126Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/366ebbb22fcc504c2f72b45f0b7e72f40a18795cc01752c16066d597b67a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994", size = 460513, upload-time = "2026-05-18T04:31:40.85Z" }, + { url = "https://files.pythonhosted.org/packages/ad/44/1f9e1b15e7a729062e0d0c3d0d7225ea4ab98b2267ef87287153be2495fc/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e", size = 493616, upload-time = "2026-05-18T04:30:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/7e/55/8b1086dcc8a1d6a697a62767bd7ea368e74c61c6fd171683cfe24a3fe5d2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19", size = 573154, upload-time = "2026-05-18T04:30:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/14/7a/242f400cc77fafa7b18d53d19d9cb64fc6a6f61f28c55913bae7c674d92a/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8", size = 467046, upload-time = "2026-05-18T04:30:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/02/c8/79eee650c62d2c186598489814468e389b5def0ebe755399ff645b35b1b2/watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07", size = 457100, upload-time = "2026-05-18T04:31:13.064Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/519f6dbb7a95e4fe7c1513ed25b1520295ef9905a27f1f2226a73892bfb7/watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551", size = 467038, upload-time = "2026-05-18T04:30:32.915Z" }, + { url = "https://files.pythonhosted.org/packages/2f/12/951af6b9f89097e02511122258402cb3578443021930b70cf968d6310dc0/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310", size = 632563, upload-time = "2026-05-18T04:30:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/28/cc/0cba1f0a6117b7ec117271bdc3cb3a5a252005959755a2c09a745e0942cc/watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df", size = 660851, upload-time = "2026-05-18T04:31:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/26347558cc8bf6877845e66b315f644d03c173906aa09e233a3f4fd23928/watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1", size = 277023, upload-time = "2026-05-18T04:30:18.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/68/a5e67b6b68e94f4c1511d61c46c55eba0737583620b6febf194c7b9cc23f/watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d", size = 290107, upload-time = "2026-05-18T04:32:09.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] From 950d9173f057392dde3c9c0d136f6e14505e73d1 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 20:33:09 +0530 Subject: [PATCH 34/52] docs: overhaul documentation structure and content - Restructure flat RST pages into subdirectory layout matching graphene-python.org pattern - Split content into focused sub-pages: quickstart/, types/, prefetching/, relay/, telemetry/, examples/ - Use rubric directives for non-TOC headings to keep sidebar clean - Revert field type mapping from tables back to definition lists - Remove all uv references; use pip throughout - Move telemetry framework wiring into per-framework example pages - Add sphinx_graphene_theme zip URL to docs/requirements.txt --- docs/_static/custom.css | 5 + docs/examples/django.rst | 107 ++++++++++++++++ docs/examples/falcon.rst | 87 +++++++++++++ docs/examples/fastapi.rst | 107 ++++++++++++++++ docs/examples/flask.rst | 100 +++++++++++++++ docs/examples/index.rst | 13 ++ docs/installation.rst | 43 +++++++ docs/prefetching/get_queryset.rst | 17 +++ docs/prefetching/how_it_works.rst | 35 ++++++ docs/prefetching/index.rst | 14 +++ docs/prefetching/sync_vs_async.rst | 12 ++ docs/prefetching/utils.rst | 41 +++++++ docs/prefetching/what_gets_prefetched.rst | 16 +++ docs/quickstart/async.rst | 103 ++++++++++++++++ docs/quickstart/differences.rst | 25 ++++ docs/quickstart/index.rst | 17 +++ docs/quickstart/sync.rst | 102 ++++++++++++++++ docs/relay/connection.rst | 44 +++++++ docs/relay/index.rst | 11 ++ docs/relay/nodes.rst | 91 ++++++++++++++ docs/requirements.txt | 3 + docs/telemetry/index.rst | 95 +++++++++++++++ docs/types/fields.rst | 141 ++++++++++++++++++++++ docs/types/filtering.rst | 109 +++++++++++++++++ docs/types/index.rst | 10 ++ docs/types/mutations.rst | 139 +++++++++++++++++++++ docs/types/objecttypes.rst | 122 +++++++++++++++++++ 27 files changed, 1609 insertions(+) create mode 100644 docs/_static/custom.css create mode 100644 docs/examples/django.rst create mode 100644 docs/examples/falcon.rst create mode 100644 docs/examples/fastapi.rst create mode 100644 docs/examples/flask.rst create mode 100644 docs/examples/index.rst create mode 100644 docs/installation.rst create mode 100644 docs/prefetching/get_queryset.rst create mode 100644 docs/prefetching/how_it_works.rst create mode 100644 docs/prefetching/index.rst create mode 100644 docs/prefetching/sync_vs_async.rst create mode 100644 docs/prefetching/utils.rst create mode 100644 docs/prefetching/what_gets_prefetched.rst create mode 100644 docs/quickstart/async.rst create mode 100644 docs/quickstart/differences.rst create mode 100644 docs/quickstart/index.rst create mode 100644 docs/quickstart/sync.rst create mode 100644 docs/relay/connection.rst create mode 100644 docs/relay/index.rst create mode 100644 docs/relay/nodes.rst create mode 100644 docs/telemetry/index.rst create mode 100644 docs/types/fields.rst create mode 100644 docs/types/filtering.rst create mode 100644 docs/types/index.rst create mode 100644 docs/types/mutations.rst create mode 100644 docs/types/objecttypes.rst diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 00000000..b9bccc9a --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,5 @@ +/* Remove paragraph margin inside table cells for compact tables */ +.documentwrapper td p, +.documentwrapper th p { + margin: 0; +} \ No newline at end of file diff --git a/docs/examples/django.rst b/docs/examples/django.rst new file mode 100644 index 00000000..a0718065 --- /dev/null +++ b/docs/examples/django.rst @@ -0,0 +1,107 @@ +Django +====== + +Full source: ``examples/django_mongoengine`` + +Bike shop domain: ``Bike`` and ``Shop`` documents. + +.. code:: bash + + cd examples/django_mongoengine + pip install -e ".[dev]" + python manage.py migrate + python manage.py runserver + +Open the playground at `http://localhost:8000/graphql `_. + +Sample queries: + +.. code:: graphql + + query { + bikes { + edges { + node { id name year brand speed } + } + } + } + + mutation { + createBike(name: "Trail Blazer", year: 2024, brand: "Trek", speed: 21) { + bike { id name year brand } + } + } + +Running tests: + +.. code:: bash + + cd examples/django_mongoengine + pytest -v + +OpenTelemetry +------------- + +Install extras: + +.. code:: bash + + pip install "graphene-mongo[telemetry]" \ + opentelemetry-instrumentation-django \ + opentelemetry-instrumentation-pymongo \ + opentelemetry-exporter-otlp + +``telemetry.py`` + +.. code:: python + + import os + + def setup_telemetry(): + try: + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.instrumentation.django import DjangoInstrumentor + from opentelemetry.instrumentation.pymongo import PymongoInstrumentor + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + except ImportError: + return + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + provider = TracerProvider() + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))) + trace.set_tracer_provider(provider) + PymongoInstrumentor().instrument() + DjangoInstrumentor().instrument() + +Call it from ``AppConfig.ready()``: + +.. code:: python + + # bike/apps.py + from django.apps import AppConfig + + class BikeConfig(AppConfig): + name = "bike" + + def ready(self): + from telemetry import setup_telemetry + setup_telemetry() + +Run: + +.. code:: bash + + OTEL_SERVICE_NAME=bike-shop-api \ + OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ + python manage.py runserver + +Span output: + +.. code:: text + + GET /graphql ← Django HTTP span + └─ graphql bikes ← graphene-mongo field span + └─ mongodb.aggregate ← pymongo auto-instrumentation + └─ graphql node BikeType ← graphene-mongo node span + └─ mongodb.aggregate \ No newline at end of file diff --git a/docs/examples/falcon.rst b/docs/examples/falcon.rst new file mode 100644 index 00000000..9d496731 --- /dev/null +++ b/docs/examples/falcon.rst @@ -0,0 +1,87 @@ +Falcon +====== + +Full source: ``examples/falcon_mongoengine`` + +Bookmarks domain: ``Category`` and ``Bookmark`` documents. + +.. code:: bash + + cd examples/falcon_mongoengine + pip install -e ".[dev]" + uvicorn app:app --reload --port 9000 + +.. code:: bash + + curl -X POST http://localhost:9000/graphql \ + -H "Content-Type: application/json" \ + -d '{"query": "{ categories { edges { node { name color } } } }"}' + +Running tests: + +.. code:: bash + + cd examples/falcon_mongoengine + pytest -v + +OpenTelemetry +------------- + +Install extras: + +.. code:: bash + + pip install "graphene-mongo[telemetry]" \ + opentelemetry-instrumentation-falcon \ + opentelemetry-instrumentation-pymongo \ + opentelemetry-exporter-otlp + +``telemetry.py`` + +.. code:: python + + import os + + def setup_telemetry(): + try: + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.instrumentation.falcon import FalconInstrumentor + from opentelemetry.instrumentation.pymongo import PymongoInstrumentor + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + except ImportError: + return + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + provider = TracerProvider() + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))) + trace.set_tracer_provider(provider) + PymongoInstrumentor().instrument() + FalconInstrumentor().instrument() + +Call it from the ASGI lifespan handler: + +.. code:: python + + # app.py + import falcon.asgi + from telemetry import setup_telemetry + + async def process_startup(scope, event): + setup_telemetry() + +Run: + +.. code:: bash + + OTEL_SERVICE_NAME=bookmarks-api \ + OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ + uvicorn app:app --reload --port 9000 + +Span output: + +.. code:: text + + POST /graphql ← Falcon HTTP span + └─ graphql bookmarks ← graphene-mongo field span + └─ mongodb.aggregate ← pymongo auto-instrumentation \ No newline at end of file diff --git a/docs/examples/fastapi.rst b/docs/examples/fastapi.rst new file mode 100644 index 00000000..50aa1b69 --- /dev/null +++ b/docs/examples/fastapi.rst @@ -0,0 +1,107 @@ +FastAPI +======= + +Full source: ``examples/fastapi_mongoengine`` + +Library domain: ``Author`` and ``Book`` documents. + +.. code:: bash + + cd examples/fastapi_mongoengine + pip install -e ".[dev]" + uvicorn app:app --reload + +Open the playground at `http://localhost:8000/graphql `_. + +Sample queries: + +.. code:: graphql + + query { + books { + edges { + node { + title + genre + publishedYear + author { name nationality } + } + } + } + } + + mutation { + createBook(title: "Dune", publishedYear: 1965, genre: "Science Fiction") { + book { id title } + } + } + +Running tests: + +.. code:: bash + + cd examples/fastapi_mongoengine + pytest -v + +OpenTelemetry +------------- + +Install extras: + +.. code:: bash + + pip install "graphene-mongo[telemetry]" \ + opentelemetry-instrumentation-fastapi \ + opentelemetry-instrumentation-pymongo \ + opentelemetry-exporter-otlp + +``telemetry.py`` + +.. code:: python + + import os + + def setup_telemetry(app): + try: + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + from opentelemetry.instrumentation.pymongo import PymongoInstrumentor + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + except ImportError: + return + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + provider = TracerProvider() + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))) + trace.set_tracer_provider(provider) + PymongoInstrumentor().instrument() + FastAPIInstrumentor.instrument_app(app) + +Wire it in ``app.py``: + +.. code:: python + + from fastapi import FastAPI + from telemetry import setup_telemetry + + app = FastAPI() + setup_telemetry(app) + +Run: + +.. code:: bash + + OTEL_SERVICE_NAME=library-api \ + OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ + uvicorn app:app --reload + +Span output: + +.. code:: text + + POST /graphql ← FastAPI HTTP span + └─ graphql books ← graphene-mongo field span + └─ mongodb.aggregate ← pymongo auto-instrumentation + └─ graphql node AuthorType ← graphene-mongo node span + └─ mongodb.aggregate \ No newline at end of file diff --git a/docs/examples/flask.rst b/docs/examples/flask.rst new file mode 100644 index 00000000..5fc2d31f --- /dev/null +++ b/docs/examples/flask.rst @@ -0,0 +1,100 @@ +Flask +===== + +Full source: ``examples/flask_mongoengine`` + +HR domain: ``Department``, ``Employee``, ``Role``, and ``Task`` documents. + +.. code:: bash + + cd examples/flask_mongoengine + pip install -e ".[dev]" + python app.py + +Open the playground at `http://localhost:5000/graphql `_. + +Sample queries: + +.. code:: graphql + + query { + allEmployees { + edges { + node { + id + name + department { id name } + roles { edges { node { id name } } } + tasks { edges { node { name deadline } } } + } + } + } + } + +Running tests: + +.. code:: bash + + cd examples/flask_mongoengine + pytest -v + +OpenTelemetry +------------- + +Install extras: + +.. code:: bash + + pip install "graphene-mongo[telemetry]" \ + opentelemetry-instrumentation-flask \ + opentelemetry-instrumentation-pymongo \ + opentelemetry-exporter-otlp + +``telemetry.py`` + +.. code:: python + + import os + + def setup_telemetry(app): + try: + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.instrumentation.flask import FlaskInstrumentor + from opentelemetry.instrumentation.pymongo import PymongoInstrumentor + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + except ImportError: + return + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + provider = TracerProvider() + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))) + trace.set_tracer_provider(provider) + PymongoInstrumentor().instrument() + FlaskInstrumentor().instrument_app(app) + +Wire it in ``app.py``: + +.. code:: python + + from flask import Flask + from telemetry import setup_telemetry + + app = Flask(__name__) + setup_telemetry(app) + +Run: + +.. code:: bash + + OTEL_SERVICE_NAME=hr-api \ + OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ + python app.py + +Span output: + +.. code:: text + + POST /graphql ← Flask HTTP span + └─ graphql allEmployees ← graphene-mongo field span + └─ mongodb.aggregate ← pymongo auto-instrumentation \ No newline at end of file diff --git a/docs/examples/index.rst b/docs/examples/index.rst new file mode 100644 index 00000000..8c1d04fe --- /dev/null +++ b/docs/examples/index.rst @@ -0,0 +1,13 @@ +Examples +======== + +Four complete example applications are included in the ``examples/`` directory. +Each has its own ``pyproject.toml``, tests, and a ``telemetry.py``. + +.. toctree:: + :maxdepth: 1 + + fastapi + flask + falcon + django diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 00000000..8dcac837 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,43 @@ +Installation +============ + +.. rubric:: Requirements + +- Python 3.9+ +- MongoEngine 0.27+ +- Graphene 3.x + +.. rubric:: Install + +.. code:: bash + + pip install graphene-mongo + +.. rubric:: Optional Extras + +OpenTelemetry tracing support: + +.. code:: bash + + pip install "graphene-mongo[telemetry]" + +This installs ``opentelemetry-api``. You also need an SDK and exporter at runtime: + +.. code:: bash + + pip install opentelemetry-sdk opentelemetry-exporter-otlp \ + opentelemetry-instrumentation-pymongo + +.. rubric:: Development Install + +.. code:: bash + + git clone https://github.com/graphql-python/graphene-mongo.git + cd graphene-mongo + pip install -e ".[dev]" + +Run the test suite: + +.. code:: bash + + make test \ No newline at end of file diff --git a/docs/prefetching/get_queryset.rst b/docs/prefetching/get_queryset.rst new file mode 100644 index 00000000..b6b8a1e6 --- /dev/null +++ b/docs/prefetching/get_queryset.rst @@ -0,0 +1,17 @@ +Custom get_queryset +=================== + +You can supply a ``get_queryset`` callback on a connection field to apply custom +filters. ``select_related`` is applied on top of whatever queryset you return: + +.. code:: python + + def get_queryset(model, info, **args): + return model.objects(published=True) # filters only — select_related added automatically + + articles = MongoengineConnectionField(ArticleNode, get_queryset=get_queryset) + +If you return a ``QuerySet`` or ``AsyncQuerySet``, ``select_related`` is applied +automatically — your filters are preserved and the referenced fields the client +asked for are pre-fetched on top, all in one aggregation. If you return a dict, +it is used as filter kwargs. diff --git a/docs/prefetching/how_it_works.rst b/docs/prefetching/how_it_works.rst new file mode 100644 index 00000000..c24ffaa7 --- /dev/null +++ b/docs/prefetching/how_it_works.rst @@ -0,0 +1,35 @@ +How It Works +============ + +When a connection field resolves, graphene-mongo inspects the incoming GraphQL +selection set *before* the query runs. It walks every field the client requested +and collects the MongoEngine reference paths that need to be resolved — including +nested references (e.g. ``article → editor → company``). These paths are passed +to MongoEngine's ``select_related``, which compiles them into a single MongoDB +aggregation pipeline using ``$lookup`` stages. + +.. code:: graphql + + query { + articles { + edges { + node { + headline + editor { + firstName + company { name } + } + } + } + } + } + +The library detects that ``editor`` and ``editor.company`` are referenced fields, +then issues: + +.. code:: python + + Article.aobjects.select_related("editor", "editor__company") + +This becomes **one** aggregation with two ``$lookup`` stages — no N+1, no lazy +deref, no hidden thread pools. diff --git a/docs/prefetching/index.rst b/docs/prefetching/index.rst new file mode 100644 index 00000000..e61c91a3 --- /dev/null +++ b/docs/prefetching/index.rst @@ -0,0 +1,14 @@ +Automatic Pre-fetching +======================= + +The central design principle of graphene-mongo is: fetch exactly what the +GraphQL client asked for, in as few MongoDB round-trips as possible. + +.. toctree:: + :maxdepth: 1 + + how_it_works + what_gets_prefetched + utils + get_queryset + sync_vs_async diff --git a/docs/prefetching/sync_vs_async.rst b/docs/prefetching/sync_vs_async.rst new file mode 100644 index 00000000..36f4a4e1 --- /dev/null +++ b/docs/prefetching/sync_vs_async.rst @@ -0,0 +1,12 @@ +Sync vs Async +============= + +Both execution modes share the same pre-fetching logic. The difference is the +QuerySet manager: + +Sync (``MongoengineObjectType``) + Uses ``model.objects``. + +Async (``AsyncMongoengineObjectType``) + Uses ``model.aobjects``. The pipeline builder compiles everything into a + single ``aggregate()`` call. diff --git a/docs/prefetching/utils.rst b/docs/prefetching/utils.rst new file mode 100644 index 00000000..980be115 --- /dev/null +++ b/docs/prefetching/utils.rst @@ -0,0 +1,41 @@ +Utility Functions +================= + +Connection fields call these utilities internally. You can use them in custom +resolvers too: + +.. code:: python + + from graphene_mongo import get_query_fields, get_select_related_paths + + # Derive the paths the client actually queried + queried = get_query_fields(info) # {"editor": {"firstName": {}}, ...} + paths = get_select_related_paths(Reporter, queried) # ["editor", "editor__company"] + + # Apply only what the client asked for + qs = Reporter.aobjects.filter(active=True).select_related(*paths) + +``get_query_fields`` returns the nested selection-set dict from the GraphQL AST. +``get_select_related_paths`` walks that dict against the MongoEngine model and +returns ``__``-separated paths suitable for ``QuerySet.select_related``. + +Custom List resolvers +--------------------- + +Connection fields apply ``select_related`` automatically. When you write a +``graphene.List`` resolver or a single-document resolver outside the connection +pipeline, you are responsible for calling ``select_related`` yourself: + +.. code:: python + + from graphene_mongo import get_query_fields, get_select_related_paths + + # Hard-coded — over-fetches and breaks silently when model changes + async def resolve_reporter(self, info): + return await Reporter.aobjects.select_related("articles", "company").first() + + # Query-driven — fetches only what the client asked for + async def resolve_reporter(self, info): + queried = get_query_fields(info) + paths = get_select_related_paths(Reporter, queried) + return await Reporter.aobjects.select_related(*paths).first() diff --git a/docs/prefetching/what_gets_prefetched.rst b/docs/prefetching/what_gets_prefetched.rst new file mode 100644 index 00000000..9a8e48b0 --- /dev/null +++ b/docs/prefetching/what_gets_prefetched.rst @@ -0,0 +1,16 @@ +What Gets Pre-fetched +===================== + +``ReferenceField`` (e.g. ``article.editor``) + Pre-fetched via ``$lookup``; nested references are also recursed + (e.g. ``editor__company`` adds a second ``$lookup`` stage). + +``ListField(ReferenceField)`` (e.g. ``employee.roles``) + The entire list is hydrated in one aggregation; nested references inside + each list element are also pre-fetched via ``$map``. + +``EmbeddedDocumentField`` (e.g. ``professor.metadata``) + Always co-located in the document — no extra query needed. + +``GenericReferenceField`` (e.g. ``feed.item``) + Union resolved; each registered choice is pre-fetched. diff --git a/docs/quickstart/async.rst b/docs/quickstart/async.rst new file mode 100644 index 00000000..898b6452 --- /dev/null +++ b/docs/quickstart/async.rst @@ -0,0 +1,103 @@ +Asynchronous +============ + +Async mode requires both a standard MongoEngine connection (for schema +introspection) and an async Motor connection (for queries): + +1. Connect to MongoDB +--------------------- + +.. code:: python + + import mongoengine + mongoengine.connect("mydb") + mongoengine.async_connect("mydb") + +2. Define a document +--------------------- + +.. code:: python + + import mongoengine + + class Article(mongoengine.Document): + meta = {"collection": "articles"} + title = mongoengine.StringField(required=True) + published = mongoengine.BooleanField(default=False) + +3. Create an async GraphQL type +--------------------------------- + +.. code:: python + + from graphene.relay import Node + from graphene_mongo import AsyncMongoengineObjectType + + class ArticleType(AsyncMongoengineObjectType): + class Meta: + model = Article + interfaces = (Node,) + +``AsyncMongoengineObjectType`` uses ``model.aobjects`` (Motor) for all database +access. Field mapping, Relay pagination, and pre-fetching work identically to +the sync version. + +4. Build the schema +-------------------- + +.. code:: python + + import graphene + from graphene_mongo import AsyncMongoengineConnectionField + + class Query(graphene.ObjectType): + node = Node.Field() + articles = AsyncMongoengineConnectionField(ArticleType) + + schema = graphene.Schema(query=Query) + +5. Execute async queries +------------------------- + +.. code:: python + + import asyncio + + result = asyncio.run( + schema.execute_async("{ articles { edges { node { title } } } }") + ) + print(result.data) + +Full example +------------ + +.. code:: python + + import asyncio + import graphene + import mongoengine + from graphene.relay import Node + from graphene_mongo import AsyncMongoengineObjectType, AsyncMongoengineConnectionField + + mongoengine.connect("mydb") + mongoengine.async_connect("mydb") + + class Article(mongoengine.Document): + meta = {"collection": "articles"} + title = mongoengine.StringField(required=True) + published = mongoengine.BooleanField(default=False) + + class ArticleType(AsyncMongoengineObjectType): + class Meta: + model = Article + interfaces = (Node,) + + class Query(graphene.ObjectType): + node = Node.Field() + articles = AsyncMongoengineConnectionField(ArticleType) + + schema = graphene.Schema(query=Query) + result = asyncio.run( + schema.execute_async("{ articles { edges { node { title } } } }") + ) + print(result.data) \ No newline at end of file diff --git a/docs/quickstart/differences.rst b/docs/quickstart/differences.rst new file mode 100644 index 00000000..b239cabf --- /dev/null +++ b/docs/quickstart/differences.rst @@ -0,0 +1,25 @@ +Sync vs Async: Key Differences +================================ + +Both modes expose the same GraphQL schema and support Relay pagination, +filtering, pre-fetching, and mutations. The only differences are the +classes and managers you use. + +Type class + Sync: ``MongoengineObjectType`` — Async: ``AsyncMongoengineObjectType`` + +Connection field + Sync: ``MongoengineConnectionField`` — Async: ``AsyncMongoengineConnectionField`` + +QuerySet manager + Sync: ``model.objects`` — Async: ``model.aobjects`` + +Schema execution + Sync: ``schema.execute()`` — Async: ``await schema.execute_async()`` + +Resolvers + Sync: plain ``def`` — Async: ``async def`` + +Both modes apply ``select_related`` automatically — referenced documents are +pre-fetched in a single MongoDB aggregation, not lazy-loaded per document. +See :doc:`/prefetching/index` for details. \ No newline at end of file diff --git a/docs/quickstart/index.rst b/docs/quickstart/index.rst new file mode 100644 index 00000000..9bca01d1 --- /dev/null +++ b/docs/quickstart/index.rst @@ -0,0 +1,17 @@ +Getting Started +=============== + +Both sync and async APIs follow the same pattern: define a MongoEngine document, +wrap it in an ObjectType, attach it to a schema. graphene-mongo converts your +MongoEngine fields to GraphQL types automatically — no manual type declarations +needed. + +Add ``interfaces = (Node,)`` to get Relay cursor pagination, filtering, +and N+1-free pre-fetching out of the box. + +.. toctree:: + :maxdepth: 1 + + sync + async + differences \ No newline at end of file diff --git a/docs/quickstart/sync.rst b/docs/quickstart/sync.rst new file mode 100644 index 00000000..e51f0d1c --- /dev/null +++ b/docs/quickstart/sync.rst @@ -0,0 +1,102 @@ +Synchronous +=========== + +1. Connect to MongoDB +--------------------- + +.. code:: python + + import mongoengine + mongoengine.connect("mydb") + +2. Define a document +--------------------- + +.. code:: python + + import mongoengine + + class Article(mongoengine.Document): + meta = {"collection": "articles"} + title = mongoengine.StringField(required=True) + published = mongoengine.BooleanField(default=False) + +3. Create a GraphQL type +------------------------- + +.. code:: python + + from graphene.relay import Node + from graphene_mongo import MongoengineObjectType + + class ArticleType(MongoengineObjectType): + class Meta: + model = Article + interfaces = (Node,) + +``MongoengineObjectType`` inspects the MongoEngine model and converts each field +to the appropriate GraphQL scalar type automatically. Adding ``Node`` to +``interfaces`` enables Relay cursor pagination and global node lookups. + +4. Build the schema +-------------------- + +.. code:: python + + import graphene + from graphene_mongo import MongoengineConnectionField + + class Query(graphene.ObjectType): + node = Node.Field() + articles = MongoengineConnectionField(ArticleType) + + schema = graphene.Schema(query=Query) + +``MongoengineConnectionField`` handles pagination arguments (``first``, ``after``, +``last``, ``before``), ordering, and pre-fetching of referenced documents. + +5. Query the schema +-------------------- + +.. code:: python + + result = schema.execute(""" + { + articles { + edges { + node { id title published } + } + } + } + """) + print(result.data) + +Full example +------------ + +.. code:: python + + import graphene + import mongoengine + from graphene.relay import Node + from graphene_mongo import MongoengineObjectType, MongoengineConnectionField + + mongoengine.connect("mydb") + + class Article(mongoengine.Document): + meta = {"collection": "articles"} + title = mongoengine.StringField(required=True) + published = mongoengine.BooleanField(default=False) + + class ArticleType(MongoengineObjectType): + class Meta: + model = Article + interfaces = (Node,) + + class Query(graphene.ObjectType): + node = Node.Field() + articles = MongoengineConnectionField(ArticleType) + + schema = graphene.Schema(query=Query) + result = schema.execute("{ articles { edges { node { title } } } }") + print(result.data) \ No newline at end of file diff --git a/docs/relay/connection.rst b/docs/relay/connection.rst new file mode 100644 index 00000000..91315fd8 --- /dev/null +++ b/docs/relay/connection.rst @@ -0,0 +1,44 @@ +Connections +=========== + +graphene-mongo ships full support for the +`Relay cursor pagination spec `__. + +Pagination arguments +--------------------- + +All connection fields support the standard Relay pagination arguments: + +``first`` + Return the first N edges. + +``after`` + Return edges after this cursor. + +``last`` + Return the last N edges. + +``before`` + Return edges before this cursor. + +Example query: + +.. code:: graphql + + query { + articles(first: 10, after: "YXJyYXljb25uZWN0aW9u...") { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + id + title + } + } + } + } diff --git a/docs/relay/index.rst b/docs/relay/index.rst new file mode 100644 index 00000000..05215908 --- /dev/null +++ b/docs/relay/index.rst @@ -0,0 +1,11 @@ +Relay +===== + +graphene-mongo has complete support for `Relay `__ +and offers utilities to make integration from Python easy. + +.. toctree:: + :maxdepth: 1 + + nodes + connection diff --git a/docs/relay/nodes.rst b/docs/relay/nodes.rst new file mode 100644 index 00000000..25193433 --- /dev/null +++ b/docs/relay/nodes.rst @@ -0,0 +1,91 @@ +Nodes +===== + +Enabling Relay +-------------- + +Add ``Node`` to ``interfaces`` in the ``Meta`` class: + +.. code:: python + + from graphene.relay import Node + from graphene_mongo import MongoengineObjectType, MongoengineConnectionField + + class ArticleType(MongoengineObjectType): + class Meta: + model = Article + interfaces = (Node,) + + class Query(graphene.ObjectType): + node = Node.Field() # global node lookup + articles = MongoengineConnectionField(ArticleType) + +Node.Field() — single-document lookup +-------------------------------------- + +``Node.Field()`` on the query enables fetching any registered type by its global +Relay ID: + +.. code:: graphql + + query { + node(id: "QXJ0aWNsZVR5cGU6NjY...") { + ... on ArticleType { + title + editor { firstName } + } + } + } + +The default ``get_node`` implementation already applies ``select_related`` +automatically — it inspects the selection set and pre-fetches only what the +client requested. + +Overriding get_node +------------------- + +If you override ``get_node`` for custom filtering or access control, replicate +the pre-fetching yourself to avoid unhydrated references: + +.. code:: python + + from graphene_mongo import get_query_fields, get_select_related_paths + + class ReporterNode(AsyncMongoengineObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + @classmethod + async def get_node(cls, info, id): + queried = get_query_fields(info) + paths = get_select_related_paths(cls._meta.model, queried) + qs = cls._meta.model.aobjects.filter(pk=id) + if paths: + qs = qs.select_related(*paths) + return await qs.first() + +Omitting ``select_related`` here will cause referenced fields to resolve to +``None`` or raise an error. + +Async Relay +----------- + +Replace ``MongoengineObjectType`` and ``MongoengineConnectionField`` with their +async counterparts: + +.. code:: python + + from graphene_mongo import AsyncMongoengineObjectType, AsyncMongoengineConnectionField + + class ArticleType(AsyncMongoengineObjectType): + class Meta: + model = Article + interfaces = (Node,) + + class Query(graphene.ObjectType): + node = Node.Field() + articles = AsyncMongoengineConnectionField(ArticleType) + + schema = graphene.Schema(query=Query) + result = await schema.execute_async("{ articles { edges { node { title } } } }") diff --git a/docs/requirements.txt b/docs/requirements.txt index 570b0b2f..efc10e6c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,5 @@ +# Docs template +http://graphene-python.org/sphinx_graphene_theme.zip + sphinx>=7.0 sphinx-autobuild \ No newline at end of file diff --git a/docs/telemetry/index.rst b/docs/telemetry/index.rst new file mode 100644 index 00000000..cadfe857 --- /dev/null +++ b/docs/telemetry/index.rst @@ -0,0 +1,95 @@ +OpenTelemetry Tracing +====================== + +graphene-mongo has built-in OpenTelemetry support. Install the optional extra +to activate it: + +.. code:: bash + + pip install "graphene-mongo[telemetry]" + +When ``opentelemetry-api`` is installed, the library emits spans automatically — +no code changes required in your resolvers or schema. When it is not installed, +the library runs with zero overhead (a single boolean check per resolution). + +.. rubric:: Span hierarchy + +.. code:: text + + POST /graphql ← framework HTTP span + └─ graphql articles ← graphene-mongo (connection field) + └─ mongodb.aggregate ← opentelemetry-instrumentation-pymongo + └─ graphql node ReporterType ← graphene-mongo (Node.Field lookup) + └─ mongodb.aggregate + +.. rubric:: Span attributes + +``graphql.field.name`` + The field name being resolved (e.g. ``articles``, ``node``). + +``graphql.field.parent_type`` + The parent GraphQL type name (e.g. ``Query``). + +``graphql.operation.type`` + ``query``, ``mutation``, or ``subscription``. + +``graphql.operation.name`` + The named operation if provided by the client (e.g. ``ListArticles``). + +``graphql.pagination.first`` + Value of the ``first`` argument on connection fields. + +``graphql.pagination.last`` + Value of the ``last`` argument on connection fields. + +``graphql.node.id`` + The Relay global ID — set on node lookups (``Node.Field()``) only. + +Spans are marked ``ERROR`` and the exception is recorded (with full stacktrace) +if an unhandled exception propagates out of the resolver. + +.. rubric:: Running with a collector + +Point the app at any OTLP-compatible collector (Jaeger, Grafana Tempo, Datadog Agent): + +.. code:: bash + + OTEL_SERVICE_NAME=my-api \ + OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ + python app.py + +For Datadog, enable OTLP in ``datadog.yaml`` and use the Agent's gRPC intake: + +.. code:: bash + + OTEL_SERVICE_NAME=my-api \ + OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ + DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 \ + python app.py + +.. rubric:: Testing spans + +Use ``InMemorySpanExporter`` from ``opentelemetry-sdk`` to capture spans in tests: + +.. code:: python + + import pytest + import graphene_mongo.base.telemetry as telem + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + @pytest.fixture + def exporter(monkeypatch): + mem_exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(mem_exporter)) + tracer = provider.get_tracer("graphene_mongo") + monkeypatch.setattr(telem, "_get_tracer", lambda: tracer) + yield mem_exporter + + def test_span_emitted(exporter, schema): + schema.execute("{ articles { edges { node { title } } } }") + spans = exporter.get_finished_spans() + assert spans[0].name == "graphql articles" + assert spans[0].attributes["graphql.field.name"] == "articles" \ No newline at end of file diff --git a/docs/types/fields.rst b/docs/types/fields.rst new file mode 100644 index 00000000..9995094a --- /dev/null +++ b/docs/types/fields.rst @@ -0,0 +1,141 @@ +Field Type Mapping +================== + +MongoEngine fields are converted to GraphQL types automatically when you define a +``MongoengineObjectType`` or ``AsyncMongoengineObjectType``. + +Scalar Fields +------------- + +``StringField``, ``EmailField``, ``URLField`` + ``String`` + +``IntField``, ``SequenceField`` + ``Int`` + +``FloatField`` + ``Float`` + +``BooleanField`` + ``Boolean`` + +``DateTimeField`` + ``DateTime`` + +``DateField`` + ``Date`` + +``DecimalField``, ``Decimal128Field`` + ``Decimal`` + +``UUIDField``, ``ObjectIdField`` + ``ID`` + +``DictField``, ``MapField`` + ``JSONString`` + +Special Fields +-------------- + +``FileField`` + ``FileFieldType`` — sub-fields: ``contentType``, ``md5``, ``length``, ``data`` (base64). + +``PointField`` + ``PointFieldType`` — sub-fields: ``type``, ``coordinates``. + +``PolygonField`` + ``PolygonFieldType`` + +``MultiPolygonField`` + ``MultiPolygonFieldType`` + +``EnumField`` + ``graphene.Enum`` (auto-registered from the Python enum class). + +Reference Fields +---------------- + +``ReferenceField`` resolves to the target graphene type. When queried through a +connection field, the referenced document is pre-fetched in a single aggregation. +See :doc:`/prefetching/index` for how this works. + +.. code:: python + + class Author(mongoengine.Document): + name = mongoengine.StringField() + + class Book(mongoengine.Document): + title = mongoengine.StringField() + author = mongoengine.ReferenceField(Author) + + # author resolved in one aggregate — no N+1 + # { books { edges { node { title author { name } } } } } + +``GenericReferenceField`` becomes a GraphQL union type automatically: + +.. code:: python + + class Article(mongoengine.Document): ... + class Video(mongoengine.Document): ... + + class Feed(mongoengine.Document): + item = mongoengine.GenericReferenceField(choices=[Article, Video]) + +Embedded Documents +------------------ + +``EmbeddedDocumentField`` becomes a nested GraphQL object. +``ListField(EmbeddedDocumentField(...))`` becomes a Relay connection. + +.. code:: python + + class Address(mongoengine.EmbeddedDocument): + street = mongoengine.StringField() + city = mongoengine.StringField() + + class Task(mongoengine.EmbeddedDocument): + name = mongoengine.StringField() + + class Person(mongoengine.Document): + address = mongoengine.EmbeddedDocumentField(Address) + tasks = mongoengine.ListField(mongoengine.EmbeddedDocumentField(Task)) + +List of References +------------------ + +``ListField(ReferenceField(...))`` becomes a ``List`` or a Relay ``ConnectionField`` +when the target has a ``Node`` interface: + +.. code:: python + + class Employee(mongoengine.Document): + name = mongoengine.StringField() + roles = mongoengine.ListField(mongoengine.ReferenceField(Role)) + tags = mongoengine.ListField(mongoengine.StringField()) + +Self-Referential Fields +----------------------- + +A document can reference itself — graphene-mongo handles the circular dependency: + +.. code:: python + + class Employee(mongoengine.Document): + name = mongoengine.StringField() + leader = mongoengine.ReferenceField("self") + reports = mongoengine.ListField(mongoengine.ReferenceField("self")) + +Inheritance +----------- + +MongoEngine document inheritance is supported. Child types are registered +separately and resolved as GraphQL union types through ``GenericReferenceField``: + +.. code:: python + + class Animal(mongoengine.Document): + name = mongoengine.StringField() + meta = {"allow_inheritance": True} + + class Dog(Animal): + breed = mongoengine.StringField() \ No newline at end of file diff --git a/docs/types/filtering.rst b/docs/types/filtering.rst new file mode 100644 index 00000000..9152ffcc --- /dev/null +++ b/docs/types/filtering.rst @@ -0,0 +1,109 @@ +Filtering +========= + +graphene-mongo generates filter arguments automatically from the ``filter_fields`` +Meta option. These arguments can be passed directly in GraphQL queries. + +Declaring filter fields +----------------------- + +.. code:: python + + class ArticleType(MongoengineObjectType): + class Meta: + model = Article + interfaces = (Node,) + filter_fields = { + "title": ["exact", "icontains", "istartswith"], + "published": ["exact"], + "view_count": ["gte", "lte"], + } + +This generates query arguments like ``title``, ``titleIcontains``, ``titleIstartswith``, +``published``, ``viewCountGte``, ``viewCountLte``. + +Using filters in a query +------------------------- + +.. code:: graphql + + query { + articles(titleIcontains: "graphql", published: true) { + edges { + node { title viewCount } + } + } + } + +Supported operators +------------------- + +``exact`` + Exact match (``==``). + +``iexact`` + Case-insensitive exact match. + +``contains`` + Substring match (case-sensitive). + +``icontains`` + Substring match (case-insensitive). + +``startswith`` + Prefix match (case-sensitive). + +``istartswith`` + Prefix match (case-insensitive). + +``in`` + Value in a list. + +``nin`` + Value not in a list. + +``lt`` + Less than. + +``lte`` + Less than or equal. + +``gt`` + Greater than. + +``gte`` + Greater than or equal. + +``ne`` + Not equal. + +Excluding fields from filters +------------------------------- + +Use ``non_filter_fields`` to prevent auto-generation of filter arguments for +specific fields: + +.. code:: python + + class UserType(MongoengineObjectType): + class Meta: + model = User + interfaces = (Node,) + non_filter_fields = ("password_hash", "internal_score") + +Custom get_queryset +------------------- + +For more complex filtering (access control, multi-field logic, etc.) supply a +``get_queryset`` callback: + +.. code:: python + + def active_only(model, info, **args): + return model.objects(active=True) + + users = MongoengineConnectionField(UserType, get_queryset=active_only) + +The callback receives the model class, the GraphQL ``info`` object, and all +query arguments. Return a ``QuerySet`` (filters preserved) or a plain ``dict`` +(used as filter kwargs). ``select_related`` is applied on top automatically. \ No newline at end of file diff --git a/docs/types/index.rst b/docs/types/index.rst new file mode 100644 index 00000000..2f44aaa9 --- /dev/null +++ b/docs/types/index.rst @@ -0,0 +1,10 @@ +Types Reference +=============== + +.. toctree:: + :maxdepth: 1 + + objecttypes + fields + filtering + mutations \ No newline at end of file diff --git a/docs/types/mutations.rst b/docs/types/mutations.rst new file mode 100644 index 00000000..b36e3571 --- /dev/null +++ b/docs/types/mutations.rst @@ -0,0 +1,139 @@ +Mutations +========= + +graphene-mongo works with standard Graphene mutations. MongoEngine documents are +created, updated, and deleted using the regular MongoEngine API inside the +``mutate`` (or ``async def mutate``) method. + +Sync mutations +-------------- + +.. code:: python + + import graphene + from graphql_relay import from_global_id + from models import Employee, Department + + class CreateEmployee(graphene.Mutation): + class Arguments: + name = graphene.String(required=True) + department_id = graphene.ID() + + employee = graphene.Field(lambda: EmployeeType) + + def mutate(self, info, name, department_id=None): + dept = None + if department_id: + dept = Department.objects.get(pk=from_global_id(department_id)[1]) + emp = Employee(name=name, department=dept).save() + return CreateEmployee(employee=emp) + + class UpdateEmployee(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + name = graphene.String() + + employee = graphene.Field(lambda: EmployeeType) + + def mutate(self, info, id, name=None): + pk = from_global_id(id)[1] + emp = Employee.objects.get(pk=pk) + if name: + emp.name = name + emp.save() + return UpdateEmployee(employee=emp) + + class DeleteEmployee(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + + ok = graphene.Boolean() + + def mutate(self, info, id): + pk = from_global_id(id)[1] + Employee.objects.get(pk=pk).delete() + return DeleteEmployee(ok=True) + + class Mutation(graphene.ObjectType): + create_employee = CreateEmployee.Field() + update_employee = UpdateEmployee.Field() + delete_employee = DeleteEmployee.Field() + + schema = graphene.Schema(query=Query, mutation=Mutation) + +Async mutations +--------------- + +Replace ``Employee.objects`` with ``Employee.aobjects`` and make ``mutate`` +an ``async def``: + +.. code:: python + + class CreateBook(graphene.Mutation): + class Arguments: + title = graphene.String(required=True) + genre = graphene.String() + + book = graphene.Field(lambda: BookType) + + async def mutate(self, info, title, genre=None): + book = Book(title=title, genre=genre) + await book.asave() + return CreateBook(book=book) + + class DeleteBook(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + + ok = graphene.Boolean() + + async def mutate(self, info, id): + from graphql_relay import from_global_id + pk = from_global_id(id)[1] + book = await Book.aobjects.get(pk=pk) + await book.adelete() + return DeleteBook(ok=True) + +Input types +----------- + +For complex mutations, use ``graphene.InputObjectType`` to group arguments: + +.. code:: python + + class EmployeeInput(graphene.InputObjectType): + name = graphene.String(required=True) + department_id = graphene.ID() + + class CreateEmployee(graphene.Mutation): + class Arguments: + input = EmployeeInput(required=True) + + employee = graphene.Field(lambda: EmployeeType) + + def mutate(self, info, input): + emp = Employee(name=input.name).save() + return CreateEmployee(employee=emp) + +Mutations in GraphQL +--------------------- + +.. code:: graphql + + mutation { + createEmployee(name: "Alice", departmentId: "RGVwYXJ0bWVudFR5...") { + employee { id name department { name } } + } + } + + mutation { + updateEmployee(id: "RW1wbG95ZWVU...", name: "Alicia") { + employee { id name } + } + } + + mutation { + deleteEmployee(id: "RW1wbG95ZWVU...") { + ok + } + } \ No newline at end of file diff --git a/docs/types/objecttypes.rst b/docs/types/objecttypes.rst new file mode 100644 index 00000000..741a54ba --- /dev/null +++ b/docs/types/objecttypes.rst @@ -0,0 +1,122 @@ +Types and Meta Options +====================== + +``MongoengineObjectType`` and ``AsyncMongoengineObjectType`` are the core classes +that convert MongoEngine documents into Graphene types. + +Defining a type +--------------- + +Sync: + +.. code:: python + + from graphene_mongo import MongoengineObjectType + + class ArticleType(MongoengineObjectType): + class Meta: + model = ArticleModel # required + interfaces = (Node,) # enables Relay pagination + +Async: + +.. code:: python + + from graphene_mongo import AsyncMongoengineObjectType + + class ArticleType(AsyncMongoengineObjectType): + class Meta: + model = ArticleModel + interfaces = (Node,) + +All MongoEngine fields on the model are converted automatically. +See :doc:`/types/fields` for the full mapping. + +Meta options reference +---------------------- + +All options are declared inside the nested ``Meta`` class. + +``model`` — *Document class* — **required** + The MongoEngine document to expose. + +``interfaces`` — *tuple* + e.g. ``(Node,)`` — enables Relay cursor pagination and global ``Node.Field()`` lookup. + +``only_fields`` — *tuple[str]* + Whitelist of field names to expose. All other fields are hidden. + +``exclude_fields`` — *tuple[str]* + Field names to hide from the schema. Inverse of ``only_fields``. + +``required_fields`` — *tuple[str]* + Fields always fetched from the DB regardless of what the client selected. + Useful for fields needed by resolvers that aren't directly queried. + +``non_required_fields`` — *tuple[str]* + Force these graphene fields to be non-required, even if the MongoEngine + field has ``required=True``. + +``filter_fields`` — *dict* + Lookup-style filter arguments auto-generated on the connection field. + Example: ``{"name": ["exact", "icontains"], "age": ["gte", "lte"]}``. + See :doc:`/types/filtering` for supported operators. + +``non_filter_fields`` — *tuple[str]* + Fields excluded from auto-generated filter arguments. + +``order_by`` — *str* + Default MongoEngine ordering expression applied to all queries. + Example: ``"-created_at"`` (descending), ``"name"`` (ascending). + +``registry`` — *Registry* + Explicit type registry. Useful for isolating types between test modules + or building multiple schemas in one process. + +``connection_field_class`` — *type* + Override the connection field class used for this type's connections. + +Examples +-------- + +Whitelist fields: + +.. code:: python + + class UserType(MongoengineObjectType): + class Meta: + model = UserModel + only_fields = ("id", "email", "name") + +Exclude sensitive fields: + +.. code:: python + + class UserType(MongoengineObjectType): + class Meta: + model = UserModel + exclude_fields = ("password_hash", "internal_notes") + +Enable filtering: + +.. code:: python + + class ArticleType(MongoengineObjectType): + class Meta: + model = ArticleModel + interfaces = (Node,) + filter_fields = { + "title": ["exact", "icontains", "istartswith"], + "published": ["exact"], + "view_count": ["gte", "lte"], + } + +Default ordering: + +.. code:: python + + class ArticleType(MongoengineObjectType): + class Meta: + model = ArticleModel + interfaces = (Node,) + order_by = "-published_at" # newest first \ No newline at end of file From 34bba7a92d39b1abc5faed66d93ed2928eb57916 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 20:33:51 +0530 Subject: [PATCH 35/52] =?UTF-8?q?chore:=20expand=20MongoDB=20version=20mat?= =?UTF-8?q?rix=20to=204.4=E2=80=938.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffbcef8d..ca6e6d86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - mongodb-version: ["7.0", "8.0"] + mongodb-version: ["4.4", "5.0", "6.0", "7.0", "8.0" ] steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v6 From 4e7f5adebfa165d6cda96af27a312be26dede276 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 20:56:43 +0530 Subject: [PATCH 36/52] fix: install ruff via --with flag in linting CI step --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca6e6d86..01c7b467 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: with: python-version: ${{ env.MAIN_PYTHON_VERSION }} - name: Run ruff - run: uv run ruff check . + run: uv run --with ruff ruff check . test: runs-on: ubuntu-latest From ad93e4cde6dc1e94e41ce40d17e88f7f8c73d194 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 21:06:04 +0530 Subject: [PATCH 37/52] fix: resolve all ruff linting errors and install tox via --with in CI - Remove unused nullcontext import from telemetry.py - Remove unused service_name assignments in example telemetry files - Fix noqa directives in django settings_test.py - Add noqa: E402 for mid-file imports in test_relay_query files - Install tox and tox-uv via --with flag in CI test step --- .github/workflows/ci.yml | 2 +- examples/django_mongoengine/bike_catalog/settings_test.py | 4 ++-- examples/django_mongoengine/telemetry.py | 1 - examples/falcon_mongoengine/telemetry.py | 1 - examples/fastapi_mongoengine/telemetry.py | 1 - examples/flask_mongoengine/telemetry.py | 1 - graphene_mongo/base/telemetry.py | 2 +- tests/asynchronous/test_relay_query.py | 2 +- tests/synchronous/test_relay_query.py | 2 +- 9 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01c7b467..d1b9d3e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: mongodb-version: ${{ matrix.mongodb-version }} - name: Run tests run: | - uv run tox -e py$(echo "${{ matrix.python-version }}" | tr -d .) + uv run --with tox --with tox-uv tox -e py$(echo "${{ matrix.python-version }}" | tr -d .) build: runs-on: ubuntu-latest diff --git a/examples/django_mongoengine/bike_catalog/settings_test.py b/examples/django_mongoengine/bike_catalog/settings_test.py index 0c608b02..5a78b46f 100644 --- a/examples/django_mongoengine/bike_catalog/settings_test.py +++ b/examples/django_mongoengine/bike_catalog/settings_test.py @@ -1,3 +1,3 @@ -from .settings import * # flake8: noqa +from .settings import * # noqa: F403, F405 -mongoengine.connect("graphene-mongo-test", host="mongomock://localhost", alias="default") +mongoengine.connect("graphene-mongo-test", host="mongomock://localhost", alias="default") # noqa: F405 diff --git a/examples/django_mongoengine/telemetry.py b/examples/django_mongoengine/telemetry.py index 1dc3e4b4..f92e0245 100644 --- a/examples/django_mongoengine/telemetry.py +++ b/examples/django_mongoengine/telemetry.py @@ -30,7 +30,6 @@ def setup_telemetry(): return endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - service_name = os.getenv("OTEL_SERVICE_NAME", "graphene-mongo-django") provider = TracerProvider() provider.add_span_processor( diff --git a/examples/falcon_mongoengine/telemetry.py b/examples/falcon_mongoengine/telemetry.py index ab80bfb9..d4704ef2 100644 --- a/examples/falcon_mongoengine/telemetry.py +++ b/examples/falcon_mongoengine/telemetry.py @@ -30,7 +30,6 @@ def setup_telemetry(): return endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - service_name = os.getenv("OTEL_SERVICE_NAME", "graphene-mongo-falcon") provider = TracerProvider() provider.add_span_processor( diff --git a/examples/fastapi_mongoengine/telemetry.py b/examples/fastapi_mongoengine/telemetry.py index 46d98b55..be0da168 100644 --- a/examples/fastapi_mongoengine/telemetry.py +++ b/examples/fastapi_mongoengine/telemetry.py @@ -30,7 +30,6 @@ def setup_telemetry(app): return endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - service_name = os.getenv("OTEL_SERVICE_NAME", "graphene-mongo-fastapi") provider = TracerProvider() provider.add_span_processor( diff --git a/examples/flask_mongoengine/telemetry.py b/examples/flask_mongoengine/telemetry.py index 8f5e7c01..039db76c 100644 --- a/examples/flask_mongoengine/telemetry.py +++ b/examples/flask_mongoengine/telemetry.py @@ -30,7 +30,6 @@ def setup_telemetry(app): return endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - service_name = os.getenv("OTEL_SERVICE_NAME", "graphene-mongo-flask") provider = TracerProvider() provider.add_span_processor( diff --git a/graphene_mongo/base/telemetry.py b/graphene_mongo/base/telemetry.py index 992cedee..cf5b46df 100644 --- a/graphene_mongo/base/telemetry.py +++ b/graphene_mongo/base/telemetry.py @@ -13,7 +13,7 @@ pip install graphene-mongo[telemetry] """ -from contextlib import contextmanager, nullcontext +from contextlib import contextmanager try: from opentelemetry import trace diff --git a/tests/asynchronous/test_relay_query.py b/tests/asynchronous/test_relay_query.py index 87f76be5..69e45c0d 100644 --- a/tests/asynchronous/test_relay_query.py +++ b/tests/asynchronous/test_relay_query.py @@ -1292,7 +1292,7 @@ class Query(graphene.ObjectType): # MongoDB projection tests — verify only requested + required fields are fetched # --------------------------------------------------------------------------- -from ..mongo_capture import captured_commands +from ..mongo_capture import captured_commands # noqa: E402 async def test_projection_only_queried_fields(fixtures): diff --git a/tests/synchronous/test_relay_query.py b/tests/synchronous/test_relay_query.py index 8834c5ca..71d39ae3 100644 --- a/tests/synchronous/test_relay_query.py +++ b/tests/synchronous/test_relay_query.py @@ -1294,7 +1294,7 @@ class Query(graphene.ObjectType): # MongoDB projection tests — verify only requested + required fields are fetched # --------------------------------------------------------------------------- -from ..mongo_capture import captured_commands +from ..mongo_capture import captured_commands # noqa: E402 def test_projection_only_queried_fields(fixtures): From 0d5cf54027fafc4a5e535601f4055e3834e435ec Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 22:26:24 +0530 Subject: [PATCH 38/52] =?UTF-8?q?refactor:=20remove=20built-in=20OpenTelem?= =?UTF-8?q?etry=20tracing=20=E2=80=94=20use=20graphene=20middleware=20inst?= =?UTF-8?q?ead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telemetry belongs at the GraphQL execution layer via graphene middleware, not in individual MongoEngine integration libraries. - Delete graphene_mongo/base/telemetry.py - Remove field_span and node_span wrappers from fields and types - Remove telemetry optional dependency from pyproject.toml - Remove telemetry.py from all four framework examples - Remove OpenTelemetry sections from example docs - Remove telemetry/ docs section - Bump mongoengine to v0.30.0-alpha.3 in pyproject.toml and tox.ini --- .claude/worktrees/agent-a03ecbb2b2e4554d3 | 1 + docs/examples/django.rst | 67 ---------- docs/examples/falcon.rst | 62 --------- docs/examples/fastapi.rst | 63 ---------- docs/examples/flask.rst | 61 --------- docs/examples/index.rst | 2 +- docs/index.rst | 1 - docs/installation.rst | 15 --- docs/telemetry/index.rst | 95 -------------- examples/django_mongoengine/bike/apps.py | 3 +- examples/django_mongoengine/pyproject.toml | 11 +- examples/django_mongoengine/telemetry.py | 41 ------ examples/falcon_mongoengine/app.py | 2 - examples/falcon_mongoengine/pyproject.toml | 11 +- examples/falcon_mongoengine/telemetry.py | 41 ------ examples/fastapi_mongoengine/app.py | 2 - examples/fastapi_mongoengine/pyproject.toml | 11 +- examples/fastapi_mongoengine/telemetry.py | 41 ------ examples/flask_mongoengine/app.py | 2 - examples/flask_mongoengine/pyproject.toml | 11 +- examples/flask_mongoengine/telemetry.py | 41 ------ graphene_mongo/asynchronous/fields.py | 17 ++- graphene_mongo/asynchronous/types.py | 35 +++--- graphene_mongo/base/telemetry.py | 133 -------------------- graphene_mongo/synchronous/fields.py | 17 ++- graphene_mongo/synchronous/types.py | 35 +++--- pyproject.toml | 8 +- tox.ini | 2 +- uv.lock | 54 +------- 29 files changed, 61 insertions(+), 824 deletions(-) create mode 160000 .claude/worktrees/agent-a03ecbb2b2e4554d3 delete mode 100644 docs/telemetry/index.rst delete mode 100644 examples/django_mongoengine/telemetry.py delete mode 100644 examples/falcon_mongoengine/telemetry.py delete mode 100644 examples/fastapi_mongoengine/telemetry.py delete mode 100644 examples/flask_mongoengine/telemetry.py delete mode 100644 graphene_mongo/base/telemetry.py diff --git a/.claude/worktrees/agent-a03ecbb2b2e4554d3 b/.claude/worktrees/agent-a03ecbb2b2e4554d3 new file mode 160000 index 00000000..c49a6d8d --- /dev/null +++ b/.claude/worktrees/agent-a03ecbb2b2e4554d3 @@ -0,0 +1 @@ +Subproject commit c49a6d8d7f784d5ab9478c25f90c18e24708f3e7 diff --git a/docs/examples/django.rst b/docs/examples/django.rst index a0718065..131a60f7 100644 --- a/docs/examples/django.rst +++ b/docs/examples/django.rst @@ -38,70 +38,3 @@ Running tests: cd examples/django_mongoengine pytest -v - -OpenTelemetry -------------- - -Install extras: - -.. code:: bash - - pip install "graphene-mongo[telemetry]" \ - opentelemetry-instrumentation-django \ - opentelemetry-instrumentation-pymongo \ - opentelemetry-exporter-otlp - -``telemetry.py`` - -.. code:: python - - import os - - def setup_telemetry(): - try: - from opentelemetry import trace - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - from opentelemetry.instrumentation.django import DjangoInstrumentor - from opentelemetry.instrumentation.pymongo import PymongoInstrumentor - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - except ImportError: - return - endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - provider = TracerProvider() - provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))) - trace.set_tracer_provider(provider) - PymongoInstrumentor().instrument() - DjangoInstrumentor().instrument() - -Call it from ``AppConfig.ready()``: - -.. code:: python - - # bike/apps.py - from django.apps import AppConfig - - class BikeConfig(AppConfig): - name = "bike" - - def ready(self): - from telemetry import setup_telemetry - setup_telemetry() - -Run: - -.. code:: bash - - OTEL_SERVICE_NAME=bike-shop-api \ - OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ - python manage.py runserver - -Span output: - -.. code:: text - - GET /graphql ← Django HTTP span - └─ graphql bikes ← graphene-mongo field span - └─ mongodb.aggregate ← pymongo auto-instrumentation - └─ graphql node BikeType ← graphene-mongo node span - └─ mongodb.aggregate \ No newline at end of file diff --git a/docs/examples/falcon.rst b/docs/examples/falcon.rst index 9d496731..e7b916c8 100644 --- a/docs/examples/falcon.rst +++ b/docs/examples/falcon.rst @@ -23,65 +23,3 @@ Running tests: cd examples/falcon_mongoengine pytest -v - -OpenTelemetry -------------- - -Install extras: - -.. code:: bash - - pip install "graphene-mongo[telemetry]" \ - opentelemetry-instrumentation-falcon \ - opentelemetry-instrumentation-pymongo \ - opentelemetry-exporter-otlp - -``telemetry.py`` - -.. code:: python - - import os - - def setup_telemetry(): - try: - from opentelemetry import trace - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - from opentelemetry.instrumentation.falcon import FalconInstrumentor - from opentelemetry.instrumentation.pymongo import PymongoInstrumentor - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - except ImportError: - return - endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - provider = TracerProvider() - provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))) - trace.set_tracer_provider(provider) - PymongoInstrumentor().instrument() - FalconInstrumentor().instrument() - -Call it from the ASGI lifespan handler: - -.. code:: python - - # app.py - import falcon.asgi - from telemetry import setup_telemetry - - async def process_startup(scope, event): - setup_telemetry() - -Run: - -.. code:: bash - - OTEL_SERVICE_NAME=bookmarks-api \ - OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ - uvicorn app:app --reload --port 9000 - -Span output: - -.. code:: text - - POST /graphql ← Falcon HTTP span - └─ graphql bookmarks ← graphene-mongo field span - └─ mongodb.aggregate ← pymongo auto-instrumentation \ No newline at end of file diff --git a/docs/examples/fastapi.rst b/docs/examples/fastapi.rst index 50aa1b69..4cc5baa5 100644 --- a/docs/examples/fastapi.rst +++ b/docs/examples/fastapi.rst @@ -42,66 +42,3 @@ Running tests: cd examples/fastapi_mongoengine pytest -v - -OpenTelemetry -------------- - -Install extras: - -.. code:: bash - - pip install "graphene-mongo[telemetry]" \ - opentelemetry-instrumentation-fastapi \ - opentelemetry-instrumentation-pymongo \ - opentelemetry-exporter-otlp - -``telemetry.py`` - -.. code:: python - - import os - - def setup_telemetry(app): - try: - from opentelemetry import trace - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor - from opentelemetry.instrumentation.pymongo import PymongoInstrumentor - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - except ImportError: - return - endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - provider = TracerProvider() - provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))) - trace.set_tracer_provider(provider) - PymongoInstrumentor().instrument() - FastAPIInstrumentor.instrument_app(app) - -Wire it in ``app.py``: - -.. code:: python - - from fastapi import FastAPI - from telemetry import setup_telemetry - - app = FastAPI() - setup_telemetry(app) - -Run: - -.. code:: bash - - OTEL_SERVICE_NAME=library-api \ - OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ - uvicorn app:app --reload - -Span output: - -.. code:: text - - POST /graphql ← FastAPI HTTP span - └─ graphql books ← graphene-mongo field span - └─ mongodb.aggregate ← pymongo auto-instrumentation - └─ graphql node AuthorType ← graphene-mongo node span - └─ mongodb.aggregate \ No newline at end of file diff --git a/docs/examples/flask.rst b/docs/examples/flask.rst index 5fc2d31f..fdeea890 100644 --- a/docs/examples/flask.rst +++ b/docs/examples/flask.rst @@ -37,64 +37,3 @@ Running tests: cd examples/flask_mongoengine pytest -v - -OpenTelemetry -------------- - -Install extras: - -.. code:: bash - - pip install "graphene-mongo[telemetry]" \ - opentelemetry-instrumentation-flask \ - opentelemetry-instrumentation-pymongo \ - opentelemetry-exporter-otlp - -``telemetry.py`` - -.. code:: python - - import os - - def setup_telemetry(app): - try: - from opentelemetry import trace - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - from opentelemetry.instrumentation.flask import FlaskInstrumentor - from opentelemetry.instrumentation.pymongo import PymongoInstrumentor - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - except ImportError: - return - endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - provider = TracerProvider() - provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))) - trace.set_tracer_provider(provider) - PymongoInstrumentor().instrument() - FlaskInstrumentor().instrument_app(app) - -Wire it in ``app.py``: - -.. code:: python - - from flask import Flask - from telemetry import setup_telemetry - - app = Flask(__name__) - setup_telemetry(app) - -Run: - -.. code:: bash - - OTEL_SERVICE_NAME=hr-api \ - OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ - python app.py - -Span output: - -.. code:: text - - POST /graphql ← Flask HTTP span - └─ graphql allEmployees ← graphene-mongo field span - └─ mongodb.aggregate ← pymongo auto-instrumentation \ No newline at end of file diff --git a/docs/examples/index.rst b/docs/examples/index.rst index 8c1d04fe..38bcc684 100644 --- a/docs/examples/index.rst +++ b/docs/examples/index.rst @@ -2,7 +2,7 @@ Examples ======== Four complete example applications are included in the ``examples/`` directory. -Each has its own ``pyproject.toml``, tests, and a ``telemetry.py``. +Each has its own ``pyproject.toml`` and tests. .. toctree:: :maxdepth: 1 diff --git a/docs/index.rst b/docs/index.rst index 8aeead33..555834cb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,5 +19,4 @@ Contents: types/index prefetching/index relay/index - telemetry/index examples/index \ No newline at end of file diff --git a/docs/installation.rst b/docs/installation.rst index 8dcac837..eb48ad98 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -13,21 +13,6 @@ Installation pip install graphene-mongo -.. rubric:: Optional Extras - -OpenTelemetry tracing support: - -.. code:: bash - - pip install "graphene-mongo[telemetry]" - -This installs ``opentelemetry-api``. You also need an SDK and exporter at runtime: - -.. code:: bash - - pip install opentelemetry-sdk opentelemetry-exporter-otlp \ - opentelemetry-instrumentation-pymongo - .. rubric:: Development Install .. code:: bash diff --git a/docs/telemetry/index.rst b/docs/telemetry/index.rst deleted file mode 100644 index cadfe857..00000000 --- a/docs/telemetry/index.rst +++ /dev/null @@ -1,95 +0,0 @@ -OpenTelemetry Tracing -====================== - -graphene-mongo has built-in OpenTelemetry support. Install the optional extra -to activate it: - -.. code:: bash - - pip install "graphene-mongo[telemetry]" - -When ``opentelemetry-api`` is installed, the library emits spans automatically — -no code changes required in your resolvers or schema. When it is not installed, -the library runs with zero overhead (a single boolean check per resolution). - -.. rubric:: Span hierarchy - -.. code:: text - - POST /graphql ← framework HTTP span - └─ graphql articles ← graphene-mongo (connection field) - └─ mongodb.aggregate ← opentelemetry-instrumentation-pymongo - └─ graphql node ReporterType ← graphene-mongo (Node.Field lookup) - └─ mongodb.aggregate - -.. rubric:: Span attributes - -``graphql.field.name`` - The field name being resolved (e.g. ``articles``, ``node``). - -``graphql.field.parent_type`` - The parent GraphQL type name (e.g. ``Query``). - -``graphql.operation.type`` - ``query``, ``mutation``, or ``subscription``. - -``graphql.operation.name`` - The named operation if provided by the client (e.g. ``ListArticles``). - -``graphql.pagination.first`` - Value of the ``first`` argument on connection fields. - -``graphql.pagination.last`` - Value of the ``last`` argument on connection fields. - -``graphql.node.id`` - The Relay global ID — set on node lookups (``Node.Field()``) only. - -Spans are marked ``ERROR`` and the exception is recorded (with full stacktrace) -if an unhandled exception propagates out of the resolver. - -.. rubric:: Running with a collector - -Point the app at any OTLP-compatible collector (Jaeger, Grafana Tempo, Datadog Agent): - -.. code:: bash - - OTEL_SERVICE_NAME=my-api \ - OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ - python app.py - -For Datadog, enable OTLP in ``datadog.yaml`` and use the Agent's gRPC intake: - -.. code:: bash - - OTEL_SERVICE_NAME=my-api \ - OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ - DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 \ - python app.py - -.. rubric:: Testing spans - -Use ``InMemorySpanExporter`` from ``opentelemetry-sdk`` to capture spans in tests: - -.. code:: python - - import pytest - import graphene_mongo.base.telemetry as telem - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import SimpleSpanProcessor - from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter - - @pytest.fixture - def exporter(monkeypatch): - mem_exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(mem_exporter)) - tracer = provider.get_tracer("graphene_mongo") - monkeypatch.setattr(telem, "_get_tracer", lambda: tracer) - yield mem_exporter - - def test_span_emitted(exporter, schema): - schema.execute("{ articles { edges { node { title } } } }") - spans = exporter.get_finished_spans() - assert spans[0].name == "graphql articles" - assert spans[0].attributes["graphql.field.name"] == "articles" \ No newline at end of file diff --git a/examples/django_mongoengine/bike/apps.py b/examples/django_mongoengine/bike/apps.py index 72ee9fa3..91c28c07 100644 --- a/examples/django_mongoengine/bike/apps.py +++ b/examples/django_mongoengine/bike/apps.py @@ -5,5 +5,4 @@ class BikeConfig(AppConfig): name = "bike" def ready(self): - from telemetry import setup_telemetry - setup_telemetry() + pass diff --git a/examples/django_mongoengine/pyproject.toml b/examples/django_mongoengine/pyproject.toml index c59efbec..4762eba2 100644 --- a/examples/django_mongoengine/pyproject.toml +++ b/examples/django_mongoengine/pyproject.toml @@ -11,18 +11,9 @@ dependencies = [ "pytest-django>=4.8", ] -[project.optional-dependencies] -telemetry = [ - "opentelemetry-api>=1.0.0", - "opentelemetry-sdk>=1.0.0", - "opentelemetry-exporter-otlp>=1.0.0", - "opentelemetry-instrumentation-django>=0.40b0", - "opentelemetry-instrumentation-pymongo>=0.40b0", -] - [tool.uv.sources] graphene-mongo = { path = "../.." } -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.3" } [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "bike_catalog.settings_test" diff --git a/examples/django_mongoengine/telemetry.py b/examples/django_mongoengine/telemetry.py deleted file mode 100644 index f92e0245..00000000 --- a/examples/django_mongoengine/telemetry.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Optional OpenTelemetry setup for the Django example. - -Install the extras to activate: - - pip install graphene-mongo[telemetry] \ - opentelemetry-instrumentation-django \ - opentelemetry-instrumentation-pymongo \ - opentelemetry-exporter-otlp - -Then call setup_telemetry() inside BikeConfig.ready() in apps.py. -Point OTEL_EXPORTER_OTLP_ENDPOINT at your collector (default: localhost:4317). -""" - -import os - - -def setup_telemetry(): - """Instrument Django + pymongo and export spans via OTLP. - - Silently does nothing when opentelemetry packages are not installed. - """ - try: - from opentelemetry import trace - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - from opentelemetry.instrumentation.django import DjangoInstrumentor - from opentelemetry.instrumentation.pymongo import PymongoInstrumentor - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - except ImportError: - return - - endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - - provider = TracerProvider() - provider.add_span_processor( - BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) - ) - trace.set_tracer_provider(provider) - - PymongoInstrumentor().instrument() - DjangoInstrumentor().instrument() \ No newline at end of file diff --git a/examples/falcon_mongoengine/app.py b/examples/falcon_mongoengine/app.py index 402bd3ff..911e396d 100644 --- a/examples/falcon_mongoengine/app.py +++ b/examples/falcon_mongoengine/app.py @@ -2,14 +2,12 @@ import falcon.asgi from api import GraphQLResource -from telemetry import setup_telemetry class MongoLifespan: async def process_startup(self, scope, event): mongoengine.connect("bookmarks_db") await mongoengine.async_connect("bookmarks_db") - setup_telemetry() async def process_shutdown(self, scope, event): mongoengine.disconnect() diff --git a/examples/falcon_mongoengine/pyproject.toml b/examples/falcon_mongoengine/pyproject.toml index 3c131289..1d0708f3 100644 --- a/examples/falcon_mongoengine/pyproject.toml +++ b/examples/falcon_mongoengine/pyproject.toml @@ -11,18 +11,9 @@ dependencies = [ "httpx>=0.27.0", ] -[project.optional-dependencies] -telemetry = [ - "opentelemetry-api>=1.0.0", - "opentelemetry-sdk>=1.0.0", - "opentelemetry-exporter-otlp>=1.0.0", - "opentelemetry-instrumentation-falcon>=0.40b0", - "opentelemetry-instrumentation-pymongo>=0.40b0", -] - [tool.uv.sources] graphene-mongo = { path = "../.." } -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.3" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/examples/falcon_mongoengine/telemetry.py b/examples/falcon_mongoengine/telemetry.py deleted file mode 100644 index d4704ef2..00000000 --- a/examples/falcon_mongoengine/telemetry.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Optional OpenTelemetry setup for the Falcon example. - -Install the extras to activate: - - pip install graphene-mongo[telemetry] \ - opentelemetry-instrumentation-falcon \ - opentelemetry-instrumentation-pymongo \ - opentelemetry-exporter-otlp - -Then call setup_telemetry() inside MongoLifespan.process_startup(). -Point OTEL_EXPORTER_OTLP_ENDPOINT at your collector (default: localhost:4317). -""" - -import os - - -def setup_telemetry(): - """Instrument Falcon + pymongo and export spans via OTLP. - - Silently does nothing when opentelemetry packages are not installed. - """ - try: - from opentelemetry import trace - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - from opentelemetry.instrumentation.falcon import FalconInstrumentor - from opentelemetry.instrumentation.pymongo import PymongoInstrumentor - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - except ImportError: - return - - endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - - provider = TracerProvider() - provider.add_span_processor( - BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) - ) - trace.set_tracer_provider(provider) - - PymongoInstrumentor().instrument() - FalconInstrumentor().instrument() \ No newline at end of file diff --git a/examples/fastapi_mongoengine/app.py b/examples/fastapi_mongoengine/app.py index 63a40161..33ccea4a 100644 --- a/examples/fastapi_mongoengine/app.py +++ b/examples/fastapi_mongoengine/app.py @@ -6,7 +6,6 @@ from database import init_db from schema import schema -from telemetry import setup_telemetry GRAPHQL_PLAYGROUND = """ @@ -37,7 +36,6 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Library GraphQL API", lifespan=lifespan) -setup_telemetry(app) @app.get("/graphql", response_class=HTMLResponse) diff --git a/examples/fastapi_mongoengine/pyproject.toml b/examples/fastapi_mongoengine/pyproject.toml index 53a2efa7..34bd283e 100644 --- a/examples/fastapi_mongoengine/pyproject.toml +++ b/examples/fastapi_mongoengine/pyproject.toml @@ -12,18 +12,9 @@ dependencies = [ "pytest-asyncio>=0.23.0", ] -[project.optional-dependencies] -telemetry = [ - "opentelemetry-api>=1.0.0", - "opentelemetry-sdk>=1.0.0", - "opentelemetry-exporter-otlp>=1.0.0", - "opentelemetry-instrumentation-fastapi>=0.40b0", - "opentelemetry-instrumentation-pymongo>=0.40b0", -] - [tool.uv.sources] graphene-mongo = { path = "../.." } -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.3" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/examples/fastapi_mongoengine/telemetry.py b/examples/fastapi_mongoengine/telemetry.py deleted file mode 100644 index be0da168..00000000 --- a/examples/fastapi_mongoengine/telemetry.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Optional OpenTelemetry setup for the FastAPI example. - -Install the extras to activate: - - pip install graphene-mongo[telemetry] \ - opentelemetry-instrumentation-fastapi \ - opentelemetry-instrumentation-pymongo \ - opentelemetry-exporter-otlp - -Then call setup_telemetry(app) inside the lifespan before yielding. -Point OTEL_EXPORTER_OTLP_ENDPOINT at your collector (default: localhost:4317). -""" - -import os - - -def setup_telemetry(app): - """Instrument FastAPI + pymongo and export spans via OTLP. - - Silently does nothing when opentelemetry packages are not installed. - """ - try: - from opentelemetry import trace - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor - from opentelemetry.instrumentation.pymongo import PymongoInstrumentor - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - except ImportError: - return - - endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - - provider = TracerProvider() - provider.add_span_processor( - BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) - ) - trace.set_tracer_provider(provider) - - PymongoInstrumentor().instrument() - FastAPIInstrumentor.instrument_app(app) \ No newline at end of file diff --git a/examples/flask_mongoengine/app.py b/examples/flask_mongoengine/app.py index 1e94a99f..0e6a2011 100644 --- a/examples/flask_mongoengine/app.py +++ b/examples/flask_mongoengine/app.py @@ -1,10 +1,8 @@ from database import init_db from flask import Flask, jsonify, request from schema import schema -from telemetry import setup_telemetry app = Flask(__name__) -setup_telemetry(app) @app.post("/graphql") diff --git a/examples/flask_mongoengine/pyproject.toml b/examples/flask_mongoengine/pyproject.toml index 6b2b45c7..0a2c1acc 100644 --- a/examples/flask_mongoengine/pyproject.toml +++ b/examples/flask_mongoengine/pyproject.toml @@ -9,18 +9,9 @@ dependencies = [ "mongomock>=4.1.2", ] -[project.optional-dependencies] -telemetry = [ - "opentelemetry-api>=1.0.0", - "opentelemetry-sdk>=1.0.0", - "opentelemetry-exporter-otlp>=1.0.0", - "opentelemetry-instrumentation-flask>=0.40b0", - "opentelemetry-instrumentation-pymongo>=0.40b0", -] - [tool.uv.sources] graphene-mongo = { path = "../.." } -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.3" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/examples/flask_mongoengine/telemetry.py b/examples/flask_mongoengine/telemetry.py deleted file mode 100644 index 039db76c..00000000 --- a/examples/flask_mongoengine/telemetry.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Optional OpenTelemetry setup for the Flask example. - -Install the extras to activate: - - pip install graphene-mongo[telemetry] \ - opentelemetry-instrumentation-flask \ - opentelemetry-instrumentation-pymongo \ - opentelemetry-exporter-otlp - -Then call setup_telemetry(app) before app.run(). -Point OTEL_EXPORTER_OTLP_ENDPOINT at your collector (default: localhost:4317). -""" - -import os - - -def setup_telemetry(app): - """Instrument Flask + pymongo and export spans via OTLP. - - Silently does nothing when opentelemetry packages are not installed. - """ - try: - from opentelemetry import trace - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - from opentelemetry.instrumentation.flask import FlaskInstrumentor - from opentelemetry.instrumentation.pymongo import PymongoInstrumentor - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - except ImportError: - return - - endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") - - provider = TracerProvider() - provider.add_span_processor( - BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) - ) - trace.set_tracer_provider(provider) - - PymongoInstrumentor().instrument() - FlaskInstrumentor().instrument_app(app) \ No newline at end of file diff --git a/graphene_mongo/asynchronous/fields.py b/graphene_mongo/asynchronous/fields.py index a26f1adc..888cbd03 100644 --- a/graphene_mongo/asynchronous/fields.py +++ b/graphene_mongo/asynchronous/fields.py @@ -14,7 +14,7 @@ from ..synchronous.fields import MongoengineConnectionField from ..base.registry import get_global_async_registry -from ..base.telemetry import field_span + from ..base.utils import ( ExecutorEnum, connection_from_iterables, @@ -448,12 +448,11 @@ async def connection_resolver(cls, resolver, connection_type, root, info, **args except Exception: pass - with field_span(info, args): - iterable = await resolver(root=root, info=info, **args) + iterable = await resolver(root=root, info=info, **args) - if isinstance(connection_type, graphene.NonNull): - connection_type = connection_type.of_type - on_resolve = partial(cls.resolve_connection, connection_type, args) - if Promise.is_thenable(iterable): - iterable = Promise.resolve(iterable).then(on_resolve).value - return on_resolve(iterable) + if isinstance(connection_type, graphene.NonNull): + connection_type = connection_type.of_type + on_resolve = partial(cls.resolve_connection, connection_type, args) + if Promise.is_thenable(iterable): + iterable = Promise.resolve(iterable).then(on_resolve).value + return on_resolve(iterable) diff --git a/graphene_mongo/asynchronous/types.py b/graphene_mongo/asynchronous/types.py index 98f280d8..077d5681 100644 --- a/graphene_mongo/asynchronous/types.py +++ b/graphene_mongo/asynchronous/types.py @@ -5,7 +5,7 @@ from .fields import AsyncMongoengineConnectionField from ..base.registry import get_global_async_registry, get_inputs_async_registry from ..base.types import create_graphene_generic_class as _create -from ..base.telemetry import node_span + from ..base.utils import ExecutorEnum, get_query_fields, get_select_related_paths @@ -43,23 +43,22 @@ def create_graphene_generic_class_async(object_type, option_type): # noinspection PyDecorator @classmethod async def get_node(cls, info, id): - with node_span(info, cls._meta.name, id): - required_fields = list() - for field in cls._meta.required_fields: - if field in cls._meta.model._fields_ordered: - required_fields.append(field) - queried_fields = get_query_fields(info) - if cls._meta.name in queried_fields: - queried_fields = queried_fields[cls._meta.name] - for field in queried_fields: - if to_snake_case(field) in cls._meta.model._fields_ordered: - required_fields.append(to_snake_case(field)) - required_fields = list(set(required_fields)) - related = get_select_related_paths(cls._meta.model, queried_fields) - qs = cls._meta.model.aobjects.only(*required_fields) - if related: - qs = qs.select_related(*related) - return await qs.get(pk=id) + required_fields = list() + for field in cls._meta.required_fields: + if field in cls._meta.model._fields_ordered: + required_fields.append(field) + queried_fields = get_query_fields(info) + if cls._meta.name in queried_fields: + queried_fields = queried_fields[cls._meta.name] + for field in queried_fields: + if to_snake_case(field) in cls._meta.model._fields_ordered: + required_fields.append(to_snake_case(field)) + required_fields = list(set(required_fields)) + related = get_select_related_paths(cls._meta.model, queried_fields) + qs = cls._meta.model.aobjects.only(*required_fields) + if related: + qs = qs.select_related(*related) + return await qs.get(pk=id) GenericType.get_node = get_node return GenericType, Options diff --git a/graphene_mongo/base/telemetry.py b/graphene_mongo/base/telemetry.py deleted file mode 100644 index cf5b46df..00000000 --- a/graphene_mongo/base/telemetry.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Optional OpenTelemetry tracing for graphene-mongo. - -Gracefully degrades to a no-op when "opentelemetry-api` is not installed — -no import errors, no performance overhead beyond a single boolean check. - -Span hierarchy produced per connection field resolution: - - graphql ← graphene-mongo (this module) - └─ mongodb.aggregate ← opentelemetry-instrumentation-pymongo (automatic) - -Install the optional dependency to activate: - - pip install graphene-mongo[telemetry] -""" - -from contextlib import contextmanager - -try: - from opentelemetry import trace - from opentelemetry.trace import SpanKind, StatusCode - - _OTEL_AVAILABLE = True -except ImportError: - _OTEL_AVAILABLE = False - -_tracer = None - - -def _get_tracer(): - global _tracer - if _tracer is None: - _tracer = trace.get_tracer("graphene_mongo") - return _tracer - - -def _set_graphql_attributes(span, info, args): - """Populate standard GraphQL semantic attributes on *span*.""" - span.set_attribute("graphql.field.name", info.field_name) - span.set_attribute("graphql.field.parent_type", info.parent_type.name) - - if info.operation: - op_type = getattr(info.operation, "operation", None) - if op_type is not None: - span.set_attribute("graphql.operation.type", op_type.value) - op_name = getattr(info.operation, "name", None) - if op_name is not None: - span.set_attribute("graphql.operation.name", op_name.value) - - for key in ("first", "last"): - val = args.get(key) - if val is not None: - span.set_attribute(f"graphql.pagination.{key}", int(val)) - - -@contextmanager -def field_span(info, args): - """Context manager that wraps a connection field resolution in an OTEL span. - - Creates a child span named ``graphql `` under whatever span is - currently active (e.g., an HTTP server span). All MongoDB commands issued - inside the block become grandchildren via opentelemetry-instrumentation-pymongo. - - Marks the span as "ERROR" and records the exception if one propagates out. - No-op when opentelemetry-api is not installed. - - Args: - info: GraphQL resolve info object. - args (dict): The raw GraphQL field arguments (first, last, before, after, …). - - Yields: - opentelemetry.trace.Span | None - """ - if not _OTEL_AVAILABLE: - yield None - return - - with _get_tracer().start_as_current_span( - f"graphql {info.field_name}", - kind=SpanKind.INTERNAL, - record_exception=False, - set_status_on_exception=False, - ) as span: - _set_graphql_attributes(span, info, args) - try: - yield span - except Exception as exc: - span.set_status(StatusCode.ERROR, str(exc)) - span.record_exception(exc) - raise - - -@contextmanager -def node_span(info, type_name, node_id): - """Context manager that wraps a ``get_node`` lookup in an OTEL span. - - Creates a child span named ``graphql node `` so Node.Field() - lookups are traceable separately from connection field resolutions. - - Args: - info: GraphQL resolve info object. - type_name (str): The GraphQL type name (e.g. ``"ReporterNode"``). - node_id (str): The Relay global ID being looked up. - - Yields: - opentelemetry.trace.Span | None - """ - if not _OTEL_AVAILABLE: - yield None - return - - with _get_tracer().start_as_current_span( - f"graphql node {type_name}", - kind=SpanKind.INTERNAL, - record_exception=False, - set_status_on_exception=False, - ) as span: - span.set_attribute("graphql.field.parent_type", type_name) - span.set_attribute("graphql.node.id", str(node_id)) - - if info.operation: - op_type = getattr(info.operation, "operation", None) - if op_type is not None: - span.set_attribute("graphql.operation.type", op_type.value) - op_name = getattr(info.operation, "name", None) - if op_name is not None: - span.set_attribute("graphql.operation.name", op_name.value) - - try: - yield span - except Exception as exc: - span.set_status(StatusCode.ERROR, str(exc)) - span.record_exception(exc) - raise \ No newline at end of file diff --git a/graphene_mongo/synchronous/fields.py b/graphene_mongo/synchronous/fields.py index 68d98414..4f03e21a 100644 --- a/graphene_mongo/synchronous/fields.py +++ b/graphene_mongo/synchronous/fields.py @@ -14,7 +14,7 @@ from pymongo.errors import OperationFailure from ..base.fields import BaseMongoengineConnectionField -from ..base.telemetry import field_span + from ..base.utils import ( ExecutorEnum, connection_from_iterables, @@ -424,18 +424,17 @@ def connection_resolver(cls, resolver, connection_type, root, info, **args): except Exception as error: logging.debug("Exception Occurred: ", exc_info=error) - with field_span(info, args): - iterable = resolver(root, info, **args) + iterable = resolver(root, info, **args) - if isinstance(connection_type, graphene.NonNull): - connection_type = connection_type.of_type + if isinstance(connection_type, graphene.NonNull): + connection_type = connection_type.of_type - on_resolve = partial(cls.resolve_connection, connection_type, args) + on_resolve = partial(cls.resolve_connection, connection_type, args) - if Promise.is_thenable(iterable): - return Promise.resolve(iterable).then(on_resolve) + if Promise.is_thenable(iterable): + return Promise.resolve(iterable).then(on_resolve) - return on_resolve(iterable) + return on_resolve(iterable) def wrap_resolve(self, parent_resolver): """Wrap the field's resolver to go through chained_resolver. diff --git a/graphene_mongo/synchronous/types.py b/graphene_mongo/synchronous/types.py index 6db5b4de..ea896257 100644 --- a/graphene_mongo/synchronous/types.py +++ b/graphene_mongo/synchronous/types.py @@ -8,7 +8,7 @@ from ..base.types import ( create_graphene_generic_class as _create, ) -from ..base.telemetry import node_span + from ..base.utils import ExecutorEnum, get_query_fields, get_select_related_paths @@ -45,23 +45,22 @@ def create_graphene_generic_class(object_type, option_type): @classmethod def get_node(cls, info, id): - with node_span(info, cls._meta.name, id): - required_fields = list() - for field in cls._meta.required_fields: - if field in cls._meta.model._fields_ordered: - required_fields.append(field) - queried_fields = get_query_fields(info) - if cls._meta.name in queried_fields: - queried_fields = queried_fields[cls._meta.name] - for field in queried_fields: - if to_snake_case(field) in cls._meta.model._fields_ordered: - required_fields.append(to_snake_case(field)) - required_fields = list(set(required_fields)) - related = get_select_related_paths(cls._meta.model, queried_fields) - qs = cls._meta.model.objects.only(*required_fields) - if related: - qs = qs.select_related(*related) - return qs.get(pk=id) + required_fields = list() + for field in cls._meta.required_fields: + if field in cls._meta.model._fields_ordered: + required_fields.append(field) + queried_fields = get_query_fields(info) + if cls._meta.name in queried_fields: + queried_fields = queried_fields[cls._meta.name] + for field in queried_fields: + if to_snake_case(field) in cls._meta.model._fields_ordered: + required_fields.append(to_snake_case(field)) + required_fields = list(set(required_fields)) + related = get_select_related_paths(cls._meta.model, queried_fields) + qs = cls._meta.model.objects.only(*required_fields) + if related: + qs = qs.select_related(*related) + return qs.get(pk=id) GenericType.get_node = get_node return GenericType, Options diff --git a/pyproject.toml b/pyproject.toml index 2c9a63b9..7f6a1caa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,17 +30,13 @@ dependencies = [ "mongoengine", ] -[project.optional-dependencies] -telemetry = [ - "opentelemetry-api>=1.0.0", -] [project.urls] Homepage = "https://github.com/graphql-python/graphene-mongo" Repository = "https://github.com/graphql-python/graphene-mongo" [tool.uv.sources] -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.2" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.3" } [dependency-groups] dev = [ @@ -54,8 +50,6 @@ test = [ "pytest-cov", "pytest-asyncio", "ruff", - "opentelemetry-api>=1.0.0", - "opentelemetry-sdk>=1.0.0", ] [build-system] diff --git a/tox.ini b/tox.ini index c9c2b94e..60a7c29e 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ setenv = COVERAGE_FILE = .coverage.{envname} deps = - mongoengine @ git+https://github.com/strollby/mongoengine@v0.30.0-alpha.2 + mongoengine @ git+https://github.com/strollby/mongoengine@v0.30.0-alpha.3 commands = pytest --cov-report= --cov=graphene_mongo tests/ {posargs} \ No newline at end of file diff --git a/uv.lock b/uv.lock index 1660053e..4ed3d81e 100644 --- a/uv.lock +++ b/uv.lock @@ -399,11 +399,6 @@ dependencies = [ { name = "promise" }, ] -[package.optional-dependencies] -telemetry = [ - { name = "opentelemetry-api" }, -] - [package.dev-dependencies] dev = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -415,8 +410,6 @@ dev = [ test = [ { name = "mock" }, { name = "mongomock" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -427,11 +420,9 @@ test = [ requires-dist = [ { name = "graphene", specifier = ">=3.1.1" }, { name = "graphene-federation", specifier = ">=3.1.5" }, - { name = "mongoengine", git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.2" }, - { name = "opentelemetry-api", marker = "extra == 'telemetry'", specifier = ">=1.0.0" }, + { name = "mongoengine", git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.3" }, { name = "promise", specifier = ">=2.3" }, ] -provides-extras = ["telemetry"] [package.metadata.requires-dev] dev = [ @@ -441,8 +432,6 @@ dev = [ test = [ { name = "mock", specifier = ">=5.0.1" }, { name = "mongomock", specifier = ">=4.1.2" }, - { name = "opentelemetry-api", specifier = ">=1.0.0" }, - { name = "opentelemetry-sdk", specifier = ">=1.0.0" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -615,7 +604,7 @@ wheels = [ [[package]] name = "mongoengine" version = "0.30.0" -source = { git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.2#9c63038fbe76242320097092a147dbe997a2d931" } +source = { git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.3#9c63038fbe76242320097092a147dbe997a2d931" } dependencies = [ { name = "pymongo" }, ] @@ -634,45 +623,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/4d/8bea712978e3aff017a2ab50f262c620e9239cc36f348aae45e48d6a4786/mongomock-4.3.0-py2.py3-none-any.whl", hash = "sha256:5ef86bd12fc8806c6e7af32f21266c61b6c4ba96096f85129852d1c4fec1327e", size = 64891, upload-time = "2024-11-16T11:23:24.748Z" }, ] -[[package]] -name = "opentelemetry-api" -version = "1.42.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, -] - -[[package]] -name = "opentelemetry-sdk" -version = "1.42.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.63b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, -] - [[package]] name = "packaging" version = "26.2" From b00b1f6dd4e68b1dbf795a35de6aa1a4b7f43d76 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 22:26:38 +0530 Subject: [PATCH 39/52] chore: remove accidental worktree submodule from index --- .claude/worktrees/agent-a03ecbb2b2e4554d3 | 1 - 1 file changed, 1 deletion(-) delete mode 160000 .claude/worktrees/agent-a03ecbb2b2e4554d3 diff --git a/.claude/worktrees/agent-a03ecbb2b2e4554d3 b/.claude/worktrees/agent-a03ecbb2b2e4554d3 deleted file mode 160000 index c49a6d8d..00000000 --- a/.claude/worktrees/agent-a03ecbb2b2e4554d3 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c49a6d8d7f784d5ab9478c25f90c18e24708f3e7 From 67955e0af8c03c74571e7f621f2ba19886edeeec Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 22:27:44 +0530 Subject: [PATCH 40/52] refactor: remove telemetry test files --- tests/test_telemetry.py | 327 ------------------------------- tests/test_telemetry_exporter.py | 199 ------------------- 2 files changed, 526 deletions(-) delete mode 100644 tests/test_telemetry.py delete mode 100644 tests/test_telemetry_exporter.py diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py deleted file mode 100644 index 6bbcca7a..00000000 --- a/tests/test_telemetry.py +++ /dev/null @@ -1,327 +0,0 @@ -"""Tests for graphene_mongo/base/telemetry.py.""" - -from unittest.mock import MagicMock - -import pytest -from opentelemetry.trace import StatusCode - - -# ── Helpers ─────────────────────────────────────────────────────────────────── - - -def make_info(field_name="articles", parent_type_name="Query", op_type="query", op_name="TestOp"): - info = MagicMock() - info.field_name = field_name - info.parent_type.name = parent_type_name - info.operation.operation.value = op_type - info.operation.name.value = op_name - return info - - -def make_tracer_and_span(): - span = MagicMock() - cm = MagicMock() - cm.__enter__ = MagicMock(return_value=span) - cm.__exit__ = MagicMock(return_value=False) - tracer = MagicMock() - tracer.start_as_current_span.return_value = cm - return tracer, span - - -# ── Fixtures ────────────────────────────────────────────────────────────────── - - -@pytest.fixture -def mock_tracer(monkeypatch): - """Replace _get_tracer with a mock so no real OTEL backend is needed.""" - import graphene_mongo.base.telemetry as telem - - tracer, span = make_tracer_and_span() - monkeypatch.setattr(telem, "_get_tracer", lambda: tracer) - return tracer, span - - -# ── No-op path (OTEL disabled) ──────────────────────────────────────────────── - - -class TestFieldSpanNoOtel: - def test_yields_none(self, monkeypatch): - import graphene_mongo.base.telemetry as telem - from graphene_mongo.base.telemetry import field_span - - monkeypatch.setattr(telem, "_OTEL_AVAILABLE", False) - with field_span(make_info(), {}) as span: - assert span is None - - def test_no_exception_on_clean_exit(self, monkeypatch): - import graphene_mongo.base.telemetry as telem - from graphene_mongo.base.telemetry import field_span - - monkeypatch.setattr(telem, "_OTEL_AVAILABLE", False) - with field_span(make_info(), {"first": 10}): - pass - - def test_propagates_exception(self, monkeypatch): - import graphene_mongo.base.telemetry as telem - from graphene_mongo.base.telemetry import field_span - - monkeypatch.setattr(telem, "_OTEL_AVAILABLE", False) - with pytest.raises(ValueError, match="boom"): - with field_span(make_info(), {}): - raise ValueError("boom") - - -class TestNodeSpanNoOtel: - def test_yields_none(self, monkeypatch): - import graphene_mongo.base.telemetry as telem - from graphene_mongo.base.telemetry import node_span - - monkeypatch.setattr(telem, "_OTEL_AVAILABLE", False) - with node_span(make_info(), "ArticleType", "abc") as span: - assert span is None - - def test_no_exception_on_clean_exit(self, monkeypatch): - import graphene_mongo.base.telemetry as telem - from graphene_mongo.base.telemetry import node_span - - monkeypatch.setattr(telem, "_OTEL_AVAILABLE", False) - with node_span(make_info(), "ArticleType", "abc"): - pass - - def test_propagates_exception(self, monkeypatch): - import graphene_mongo.base.telemetry as telem - from graphene_mongo.base.telemetry import node_span - - monkeypatch.setattr(telem, "_OTEL_AVAILABLE", False) - with pytest.raises(RuntimeError, match="db error"): - with node_span(make_info(), "ArticleType", "abc"): - raise RuntimeError("db error") - - -# ── Active path — field_span ────────────────────────────────────────────────── - - -class TestFieldSpanWithOtel: - def test_span_name_includes_field_name(self, mock_tracer): - tracer, _ = mock_tracer - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(field_name="articles"), {}): - pass - - assert tracer.start_as_current_span.call_args[0][0] == "graphql articles" - - def test_yields_span(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(), {}) as s: - assert s is span - - def test_field_name_attribute(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(field_name="reporters"), {}): - pass - - span.set_attribute.assert_any_call("graphql.field.name", "reporters") - - def test_parent_type_attribute(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(parent_type_name="Query"), {}): - pass - - span.set_attribute.assert_any_call("graphql.field.parent_type", "Query") - - def test_operation_type_attribute(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(op_type="mutation"), {}): - pass - - span.set_attribute.assert_any_call("graphql.operation.type", "mutation") - - def test_operation_name_attribute(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(op_name="MyQuery"), {}): - pass - - span.set_attribute.assert_any_call("graphql.operation.name", "MyQuery") - - def test_pagination_first_attribute(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(), {"first": 10}): - pass - - span.set_attribute.assert_any_call("graphql.pagination.first", 10) - - def test_pagination_last_attribute(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(), {"last": 5}): - pass - - span.set_attribute.assert_any_call("graphql.pagination.last", 5) - - def test_no_pagination_attributes_when_absent(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(), {}): - pass - - calls_str = str(span.set_attribute.call_args_list) - assert "pagination" not in calls_str - - def test_exception_recorded_on_span(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import field_span - - exc = ValueError("boom") - with pytest.raises(ValueError): - with field_span(make_info(), {}): - raise exc - - span.record_exception.assert_called_once_with(exc) - - def test_exception_sets_error_status(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import field_span - - with pytest.raises(ValueError): - with field_span(make_info(), {}): - raise ValueError("boom") - - span.set_status.assert_called_once() - assert span.set_status.call_args[0][0] == StatusCode.ERROR - - def test_exception_propagates(self, mock_tracer): - from graphene_mongo.base.telemetry import field_span - - with pytest.raises(RuntimeError, match="propagated"): - with field_span(make_info(), {}): - raise RuntimeError("propagated") - - def test_no_error_status_on_clean_exit(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(), {}): - pass - - span.set_status.assert_not_called() - span.record_exception.assert_not_called() - - -# ── Active path — node_span ─────────────────────────────────────────────────── - - -class TestNodeSpanWithOtel: - def test_span_name_includes_type_name(self, mock_tracer): - tracer, _ = mock_tracer - from graphene_mongo.base.telemetry import node_span - - with node_span(make_info(), "ArticleType", "abc"): - pass - - assert tracer.start_as_current_span.call_args[0][0] == "graphql node ArticleType" - - def test_yields_span(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import node_span - - with node_span(make_info(), "ArticleType", "abc") as s: - assert s is span - - def test_parent_type_attribute(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import node_span - - with node_span(make_info(), "ReporterType", "xyz"): - pass - - span.set_attribute.assert_any_call("graphql.field.parent_type", "ReporterType") - - def test_node_id_attribute(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import node_span - - with node_span(make_info(), "ArticleType", "abc123"): - pass - - span.set_attribute.assert_any_call("graphql.node.id", "abc123") - - def test_node_id_is_stringified(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import node_span - - with node_span(make_info(), "ArticleType", 42): - pass - - span.set_attribute.assert_any_call("graphql.node.id", "42") - - def test_operation_type_attribute(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import node_span - - with node_span(make_info(op_type="query"), "ArticleType", "x"): - pass - - span.set_attribute.assert_any_call("graphql.operation.type", "query") - - def test_operation_name_attribute(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import node_span - - with node_span(make_info(op_name="FetchNode"), "ArticleType", "x"): - pass - - span.set_attribute.assert_any_call("graphql.operation.name", "FetchNode") - - def test_exception_recorded_on_span(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import node_span - - exc = ValueError("not found") - with pytest.raises(ValueError): - with node_span(make_info(), "ArticleType", "abc"): - raise exc - - span.record_exception.assert_called_once_with(exc) - - def test_exception_sets_error_status(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import node_span - - with pytest.raises(ValueError): - with node_span(make_info(), "ArticleType", "abc"): - raise ValueError("not found") - - span.set_status.assert_called_once() - assert span.set_status.call_args[0][0] == StatusCode.ERROR - - def test_exception_propagates(self, mock_tracer): - from graphene_mongo.base.telemetry import node_span - - with pytest.raises(RuntimeError, match="not found"): - with node_span(make_info(), "ArticleType", "abc"): - raise RuntimeError("not found") - - def test_no_error_status_on_clean_exit(self, mock_tracer): - _, span = mock_tracer - from graphene_mongo.base.telemetry import node_span - - with node_span(make_info(), "ArticleType", "abc"): - pass - - span.set_status.assert_not_called() - span.record_exception.assert_not_called() \ No newline at end of file diff --git a/tests/test_telemetry_exporter.py b/tests/test_telemetry_exporter.py deleted file mode 100644 index 8dc9fe67..00000000 --- a/tests/test_telemetry_exporter.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Demonstrates what an OTEL exporter receives from graphene-mongo spans. - -Uses InMemorySpanExporter (part of opentelemetry-sdk) to capture real spans -and assert on the data a production exporter (Jaeger, OTLP, etc.) would see. -""" - -from unittest.mock import MagicMock - -import pytest -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from opentelemetry.trace import SpanKind, StatusCode - - -# ── Fixtures ────────────────────────────────────────────────────────────────── - - -@pytest.fixture -def exporter(monkeypatch): - """Wire a real TracerProvider + InMemorySpanExporter into graphene-mongo. - - Creates a local TracerProvider (not the global one — OTEL only allows - setting the global provider once per process) and patches _get_tracer - to return a tracer from it directly. - """ - import graphene_mongo.base.telemetry as telem - - mem_exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(mem_exporter)) - - tracer = provider.get_tracer("graphene_mongo") - monkeypatch.setattr(telem, "_get_tracer", lambda: tracer) - - yield mem_exporter - - -def make_info(field_name="articles", parent_type_name="Query", op_type="query", op_name="GetArticles"): - info = MagicMock() - info.field_name = field_name - info.parent_type.name = parent_type_name - info.operation.operation.value = op_type - info.operation.name.value = op_name - return info - - -# ── field_span exporter output ──────────────────────────────────────────────── - - -class TestFieldSpanExporter: - def test_span_is_exported(self, exporter): - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(field_name="articles"), {}): - pass - - spans = exporter.get_finished_spans() - assert len(spans) == 1 - - def test_span_name(self, exporter): - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(field_name="articles"), {}): - pass - - span = exporter.get_finished_spans()[0] - assert span.name == "graphql articles" - - def test_span_kind_is_internal(self, exporter): - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(), {}): - pass - - span = exporter.get_finished_spans()[0] - assert span.kind == SpanKind.INTERNAL - - def test_graphql_attributes(self, exporter): - from graphene_mongo.base.telemetry import field_span - - with field_span( - make_info(field_name="articles", parent_type_name="Query", op_type="query", op_name="GetArticles"), {}): - pass - - attrs = exporter.get_finished_spans()[0].attributes - assert attrs["graphql.field.name"] == "articles" - assert attrs["graphql.field.parent_type"] == "Query" - assert attrs["graphql.operation.type"] == "query" - assert attrs["graphql.operation.name"] == "GetArticles" - - def test_pagination_attributes(self, exporter): - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(), {"first": 10, "last": None}): - pass - - attrs = exporter.get_finished_spans()[0].attributes - assert attrs["graphql.pagination.first"] == 10 - assert "graphql.pagination.last" not in attrs - - def test_status_ok_on_clean_exit(self, exporter): - from graphene_mongo.base.telemetry import field_span - - with field_span(make_info(), {}): - pass - - span = exporter.get_finished_spans()[0] - assert span.status.status_code == StatusCode.UNSET - - def test_status_error_on_exception(self, exporter): - from graphene_mongo.base.telemetry import field_span - - with pytest.raises(RuntimeError): - with field_span(make_info(), {}): - raise RuntimeError("db connection lost") - - span = exporter.get_finished_spans()[0] - assert span.status.status_code == StatusCode.ERROR - assert "db connection lost" in span.status.description - - def test_exception_event_recorded(self, exporter): - from graphene_mongo.base.telemetry import field_span - - with pytest.raises(ValueError): - with field_span(make_info(), {}): - raise ValueError("invalid filter") - - span = exporter.get_finished_spans()[0] - # record_exception() adds an "exception" event to the span - assert len(span.events) == 1 - event = span.events[0] - assert event.name == "exception" - assert "invalid filter" in event.attributes["exception.message"] - assert event.attributes["exception.type"] == "ValueError" - - -# ── node_span exporter output ───────────────────────────────────────────────── - - -class TestNodeSpanExporter: - def test_span_is_exported(self, exporter): - from graphene_mongo.base.telemetry import node_span - - with node_span(make_info(), "ArticleType", "abc123"): - pass - - spans = exporter.get_finished_spans() - assert len(spans) == 1 - - def test_span_name(self, exporter): - from graphene_mongo.base.telemetry import node_span - - with node_span(make_info(), "ArticleType", "abc123"): - pass - - assert exporter.get_finished_spans()[0].name == "graphql node ArticleType" - - def test_span_kind_is_internal(self, exporter): - from graphene_mongo.base.telemetry import node_span - - with node_span(make_info(), "ArticleType", "abc123"): - pass - - assert exporter.get_finished_spans()[0].kind == SpanKind.INTERNAL - - def test_node_attributes(self, exporter): - from graphene_mongo.base.telemetry import node_span - - with node_span(make_info(op_type="query", op_name="FetchNode"), "ArticleType", "abc123"): - pass - - attrs = exporter.get_finished_spans()[0].attributes - assert attrs["graphql.field.parent_type"] == "ArticleType" - assert attrs["graphql.node.id"] == "abc123" - assert attrs["graphql.operation.type"] == "query" - assert attrs["graphql.operation.name"] == "FetchNode" - - def test_status_error_on_exception(self, exporter): - from graphene_mongo.base.telemetry import node_span - - with pytest.raises(LookupError): - with node_span(make_info(), "ArticleType", "abc123"): - raise LookupError("document not found") - - span = exporter.get_finished_spans()[0] - assert span.status.status_code == StatusCode.ERROR - - def test_exception_event_recorded(self, exporter): - from graphene_mongo.base.telemetry import node_span - - with pytest.raises(LookupError): - with node_span(make_info(), "ArticleType", "abc123"): - raise LookupError("document not found") - - event = exporter.get_finished_spans()[0].events[0] - assert event.name == "exception" - assert event.attributes["exception.type"] == "LookupError" - assert "document not found" in event.attributes["exception.message"] From 73c83bdc7d7f90e3cdefcebb429f4795cd7a5146 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 22:30:27 +0530 Subject: [PATCH 41/52] chore: bump actions/checkout to v6 and setup-uv to v8 for Node.js 24 --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1b9d3e1..91194451 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,8 @@ jobs: linting: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8 with: python-version: ${{ env.MAIN_PYTHON_VERSION }} - name: Run ruff @@ -28,8 +28,8 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] mongodb-version: ["4.4", "5.0", "6.0", "7.0", "8.0" ] steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8 with: python-version: ${{ matrix.python-version }} - name: Start MongoDB ${{ matrix.mongodb-version }} @@ -44,8 +44,8 @@ jobs: runs-on: ubuntu-latest needs: [linting, test] steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8 with: python-version: ${{ env.MAIN_PYTHON_VERSION }} - name: Build wheel From 4ebd763eae81b638899498356ed6767d6092d3d5 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Sun, 24 May 2026 22:31:48 +0530 Subject: [PATCH 42/52] fix: use full version tag astral-sh/setup-uv@v8.1.0 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91194451..cdc1e316 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v8 + - uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ env.MAIN_PYTHON_VERSION }} - name: Run ruff @@ -29,7 +29,7 @@ jobs: mongodb-version: ["4.4", "5.0", "6.0", "7.0", "8.0" ] steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v8 + - uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ matrix.python-version }} - name: Start MongoDB ${{ matrix.mongodb-version }} @@ -45,7 +45,7 @@ jobs: needs: [linting, test] steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v8 + - uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ env.MAIN_PYTHON_VERSION }} - name: Build wheel From 6aa18a01121da029cc41c0be0ae1782274fd5962 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Mon, 25 May 2026 01:54:30 +0530 Subject: [PATCH 43/52] chore: add pre-commit hook with ruff --- .pre-commit-config.yaml | 6 ++ pyproject.toml | 3 +- uv.lock | 169 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..0bbca40d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.10 + hooks: + - id: ruff + args: [--fix] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 7f6a1caa..905f3606 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,12 +36,13 @@ Homepage = "https://github.com/graphql-python/graphene-mongo" Repository = "https://github.com/graphql-python/graphene-mongo" [tool.uv.sources] -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.3" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.4" } [dependency-groups] dev = [ "sphinx>=8.1.3", "sphinx-autobuild>=2024.10.3", + "pre-commit>=4.0.0", ] test = [ "pytest", diff --git a/uv.lock b/uv.lock index 4ed3d81e..55c571fb 100644 --- a/uv.lock +++ b/uv.lock @@ -57,6 +57,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -301,6 +310,15 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -347,6 +365,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + [[package]] name = "graphene" version = "3.4.3" @@ -401,6 +428,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pre-commit" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, @@ -420,12 +448,13 @@ test = [ requires-dist = [ { name = "graphene", specifier = ">=3.1.1" }, { name = "graphene-federation", specifier = ">=3.1.5" }, - { name = "mongoengine", git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.3" }, + { name = "mongoengine", git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.4" }, { name = "promise", specifier = ">=2.3" }, ] [package.metadata.requires-dev] dev = [ + { name = "pre-commit", specifier = ">=4.0.0" }, { name = "sphinx", specifier = ">=8.1.3" }, { name = "sphinx-autobuild", specifier = ">=2024.10.3" }, ] @@ -468,6 +497,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.16" @@ -604,7 +642,7 @@ wheels = [ [[package]] name = "mongoengine" version = "0.30.0" -source = { git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.3#9c63038fbe76242320097092a147dbe997a2d931" } +source = { git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.4#9c63038fbe76242320097092a147dbe997a2d931" } dependencies = [ { name = "pymongo" }, ] @@ -623,6 +661,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/4d/8bea712978e3aff017a2ab50f262c620e9239cc36f348aae45e48d6a4786/mongomock-4.3.0-py2.py3-none-any.whl", hash = "sha256:5ef86bd12fc8806c6e7af32f21266c61b6c4ba96096f85129852d1c4fec1327e", size = 64891, upload-time = "2024-11-16T11:23:24.748Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -632,6 +679,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -641,6 +697,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "promise" version = "2.3" @@ -788,6 +860,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-discovery" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, +] + [[package]] name = "pytz" version = "2026.2" @@ -797,6 +882,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1161,6 +1310,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, ] +[[package]] +name = "virtualenv" +version = "21.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, +] + [[package]] name = "watchfiles" version = "1.2.0" From 546a2c21b5f927db1835e6e55ee021426cf29863 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Mon, 25 May 2026 01:55:21 +0530 Subject: [PATCH 44/52] chore: bump mongoengine to v0.30.0-alpha.4 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 60a7c29e..a485fb36 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ setenv = COVERAGE_FILE = .coverage.{envname} deps = - mongoengine @ git+https://github.com/strollby/mongoengine@v0.30.0-alpha.3 + mongoengine @ git+https://github.com/strollby/mongoengine@v0.30.0-alpha.4 commands = pytest --cov-report= --cov=graphene_mongo tests/ {posargs} \ No newline at end of file From c8879dc2ca1371105cc64b21e335140233864cf5 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Mon, 25 May 2026 07:05:17 +0530 Subject: [PATCH 45/52] chore: align CI with mongoengine pattern - Push on all branches with concurrency cancellation - Add MongoDB 8.3 to test matrix - Switch linting to pre-commit via uv sync --only-group dev - Switch tests to uv sync --only-group test + uv run tox - Add tox and tox-uv to test dependency group - Add enable-cache to all setup-uv steps - Bump mongodb-github-action to 1.12.1 - Add MONGODB_URI env var support to conftest --- .github/workflows/ci.yml | 25 ++++++--- pyproject.toml | 2 + tests/conftest.py | 5 +- uv.lock | 110 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdc1e316..1de32f3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,9 +3,12 @@ name: CI on: pull_request: push: - branches: ["develop", "master"] workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: MAIN_PYTHON_VERSION: "3.14" @@ -17,8 +20,12 @@ jobs: - uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ env.MAIN_PYTHON_VERSION }} - - name: Run ruff - run: uv run --with ruff ruff check . + enable-cache: true + cache-dependency-glob: "pyproject.toml" + - name: Install dev dependencies + run: uv sync --only-group dev + - name: Run pre-commit + run: uv run pre-commit run -a test: runs-on: ubuntu-latest @@ -26,19 +33,23 @@ jobs: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - mongodb-version: ["4.4", "5.0", "6.0", "7.0", "8.0" ] + mongodb-version: ["4.4", "5.0", "6.0", "7.0", "8.0", "8.3"] steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ matrix.python-version }} + enable-cache: true + cache-dependency-glob: "pyproject.toml" - name: Start MongoDB ${{ matrix.mongodb-version }} - uses: supercharge/mongodb-github-action@1.12.0 + uses: supercharge/mongodb-github-action@1.12.1 with: mongodb-version: ${{ matrix.mongodb-version }} + - name: Install test dependencies + run: uv sync --only-group test - name: Run tests run: | - uv run --with tox --with tox-uv tox -e py$(echo "${{ matrix.python-version }}" | tr -d .) + uv run tox -e py$(echo "${{ matrix.python-version }}" | tr -d .) build: runs-on: ubuntu-latest @@ -48,5 +59,7 @@ jobs: - uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ env.MAIN_PYTHON_VERSION }} + enable-cache: true + cache-dependency-glob: "pyproject.toml" - name: Build wheel run: uv build \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 905f3606..7ec59e0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,8 @@ test = [ "pytest-cov", "pytest-asyncio", "ruff", + "tox>=4.0", + "tox-uv", ] [build-system] diff --git a/tests/conftest.py b/tests/conftest.py index 4da2bc74..b1744da5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -193,5 +193,6 @@ async def setup() -> AsyncGenerator[None, Any]: """ gridfs.enable_gridfs_integration() - mongoengine.connect(DB_NAME) - await mongoengine.async_connect(DB_NAME) + host = os.environ.get("MONGODB_URI", "mongodb://localhost:27017") + mongoengine.connect(DB_NAME, host=host) + await mongoengine.async_connect(DB_NAME, host=host) diff --git a/uv.lock b/uv.lock index 55c571fb..e94f6d39 100644 --- a/uv.lock +++ b/uv.lock @@ -48,6 +48,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -442,6 +451,8 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "tox" }, + { name = "tox-uv" }, ] [package.metadata] @@ -465,6 +476,8 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "tox", specifier = ">=4.0" }, + { name = "tox-uv" }, ] [[package]] @@ -802,6 +815,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/cb/d9780b66939c4fc1f024bcc7be23a2abcfe06a9745ca8fa76dc73395482e/pymongo-4.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9543d8f84c2e5608565c08ac679774811e6730770d8a645439b073422a4276fb", size = 1058526, upload-time = "2026-04-20T16:39:27.924Z" }, ] +[[package]] +name = "pyproject-api" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/7b/c0e1333b61d41c69e59e5366e727b18c4992688caf0de1be10b3e5265f6b/pyproject_api-1.10.0.tar.gz", hash = "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330", size = 22785, upload-time = "2025-10-09T19:12:27.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/cc/cecf97be298bee2b2a37dd360618c819a2a7fd95251d8e480c1f0eb88f3b/pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09", size = 13218, upload-time = "2025-10-09T19:12:24.428Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -1278,6 +1304,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "tox" +version = "4.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "python-discovery" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomli-w" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/2c/7ca5edb5ecd6bcc5cc926fe87e62a84dcd3cbd03a32f9d0bee98d2bee7cf/tox-4.54.0.tar.gz", hash = "sha256:21e36fd8256590379620848d0b03b52f4d541b65b749de1a17c3e616978dad58", size = 279256, upload-time = "2026-05-12T19:13:05.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/18/20cf56a76c5d6117547179db9b5d31cc56e3e90507d1b0b748da74aa95c5/tox-4.54.0-py3-none-any.whl", hash = "sha256:a2d7c1177242ae9c3d9e404039e9f945ce16a3e5dfc66972c643e27d7e764f4b", size = 214527, upload-time = "2026-05-12T19:13:04.334Z" }, +] + +[[package]] +name = "tox-uv" +version = "1.35.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tox-uv-bare" }, + { name = "uv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/dc/6e9994c799bdbb309f829dd6b8d98764dd0757302f3433c380438a3a127b/tox_uv-1.35.2-py3-none-any.whl", hash = "sha256:2d99b0e3c782ba49e7cbe521c8d344758595961b17a3633738d67096641c1bde", size = 6565, upload-time = "2026-05-05T01:34:16.07Z" }, +] + +[[package]] +name = "tox-uv-bare" +version = "1.35.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tox" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/cb/168dc1ccf24e4065a9a0a33df55709ed2b5eb73bd2b13ddd53187e5dffb8/tox_uv_bare-1.35.2.tar.gz", hash = "sha256:49e28a804c97f23ea17e25859960c0fa78f35bccb7e14344cfd840e89a9aade9", size = 32333, upload-time = "2026-05-05T01:34:18.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/4a33dc81da39db7b31e5622333df361e8fe055b7ec636bd5fea762c9182d/tox_uv_bare-1.35.2-py3-none-any.whl", hash = "sha256:c0d590a41d1054a1ad0874e9e5943ff52402786e3d4599d8f8d37a65b566ef53", size = 22307, upload-time = "2026-05-05T01:34:17.681Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1296,6 +1380,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "uv" +version = "0.11.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/99/025154611a4bd97a23851574c15d73bb71ada09d35f092d6972f9ac87f70/uv-0.11.16.tar.gz", hash = "sha256:4b435fcb0af8f34833dcc1903a8a223856437efd0d515c2160a2871def221238", size = 4177038, upload-time = "2026-05-21T22:10:01.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e3/8b8cfc802bc476c67e31a39725538193265cf3a19585b4a60c232659f919/uv-0.11.16-py3-none-linux_armv6l.whl", hash = "sha256:c9e9d9cb73ee8cd2ad696dbf1bc3232abaac363270557684b6b85a2bdb8eb276", size = 23508087, upload-time = "2026-05-21T22:10:06.227Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/d5ca91c636ac88e902b6b3ff31ad32d2d02663232d844aff871467a323d2/uv-0.11.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:01172238a75e42a5a55d12555cd9ec98bee24249f3645b98a4b32eb5f1ff5e43", size = 23028989, upload-time = "2026-05-21T22:09:50.127Z" }, + { url = "https://files.pythonhosted.org/packages/c7/26/c84580dfec5a87c36fb1218eac17c5194fa3e58e2a9232cf085d69eb6bed/uv-0.11.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75f9b5bac49b97131973910c220feac60fe47b10a333941b237ff0ae4b36721", size = 21572023, upload-time = "2026-05-21T22:09:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/84/68/ba2bdc64fea96ef8c9796a991f244541b65bb9d31c661b322cc724857a4e/uv-0.11.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a801484f4507b6c2133e557350f3143b61b8f8b61dddb01ff7b84a74cdfab1fb", size = 23289936, upload-time = "2026-05-21T22:10:15.423Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/74922f693d5804a77d009338ca8dc709eff871fb60d9f2c263dede8d77d1/uv-0.11.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:eb538069e768b042cf870be700a210518ce628e36d99d9a83b85acaf484d7f6a", size = 23020906, upload-time = "2026-05-21T22:10:24.242Z" }, + { url = "https://files.pythonhosted.org/packages/60/81/cda8886f5df4dd28854a9b97bcc3ee6a7d1b5b5b23aaaccfbf1ed3e5e2bf/uv-0.11.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7cdb23457a4d1bc76bf1016638ea1d1ada0e8e032f656168e933d4d17c47e72", size = 23004220, upload-time = "2026-05-21T22:10:32.847Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/65837e07de23f0a40ab860bc6601f7c022d4bcf4b97ca79b6c35a2e72e65/uv-0.11.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:451327388d59ac3041cbda474296f3ceeafac5b1f645476198e7b95f504fcfd5", size = 24319651, upload-time = "2026-05-21T22:10:21.492Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/9d364542bf118433b60ed71422e47d2c8c470aca7d3aef0df9449a5f726a/uv-0.11.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e7992b8276149b3ffaf35ce9434702d3e16bae6ec393e99df209b870a7e19eb0", size = 25359517, upload-time = "2026-05-21T22:09:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/99/b4/650896e8cff5a3289cee860c41fd9876da83ca628c5871f9a61d5fc75c72/uv-0.11.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83a8db9b3314d900e7a240105afce43f806c9e04c59ea10a40bdbdca84c6d0c5", size = 24563421, upload-time = "2026-05-21T22:10:35.82Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7d/184711a8c02466e1486d57efdc9394ce09cbf43ee2c5794da70bd25db3fb/uv-0.11.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b10086165189c39c53142a0e2f34e0b8889ef681886f589ed17be45a1a774c7", size = 24676607, upload-time = "2026-05-21T22:10:39.784Z" }, + { url = "https://files.pythonhosted.org/packages/ee/3f/5b338df6505f77f73c20eae38cb29f57d14dba56dac835386e3dc6e2a5d6/uv-0.11.16-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:cfe1f06fb8f135a735a961065d5ee90f99cccf41749fb1f964edb5b3c3dae19b", size = 23401615, upload-time = "2026-05-21T22:10:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f9/54bbcbc77443dc76468f09a49cc9f4f92ca49b4159a011c6010d223de4ea/uv-0.11.16-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:2454f80d8b548fb2e246151578809b14ad4395b3f357d738bae1af11918e91af", size = 24104468, upload-time = "2026-05-21T22:09:53.323Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/b5f105514fddea5110fe3947cd18a9f199ff93dbad78e5e5a08e1b5d0ea2/uv-0.11.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:4249d57a563165d368050680deeb722f9c0053a0dbf3244b11cca3e6d85a3c7d", size = 24164861, upload-time = "2026-05-21T22:10:09.458Z" }, + { url = "https://files.pythonhosted.org/packages/f6/01/15d4ca2be7257862b077a9077ac31ce81c419f35ef7994e76356a317716b/uv-0.11.16-py3-none-musllinux_1_1_i686.whl", hash = "sha256:374c30126483ce95675c5de49e54c2454ddedb01c17b8321417fe4eb9da83406", size = 23644919, upload-time = "2026-05-21T22:10:03.129Z" }, + { url = "https://files.pythonhosted.org/packages/49/bf/9de3e262e6ff93aec2e0a4c238857293fd2c616dd79f25bb440f126bf32c/uv-0.11.16-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:746edfc9d1d8cd03dd58739989f634d3580648048d09f81a9c68da74c4eb9d62", size = 24973746, upload-time = "2026-05-21T22:10:18.413Z" }, + { url = "https://files.pythonhosted.org/packages/f6/7d/f4126dce104f1b5d0b451ce3ca41c4db69b963c2e78c3465fcda6440de31/uv-0.11.16-py3-none-win32.whl", hash = "sha256:50299b20aab2d28c05ff27d781ce2af3f5af2102bc304dc07a4ad54b05e2af8a", size = 22400991, upload-time = "2026-05-21T22:10:27.119Z" }, + { url = "https://files.pythonhosted.org/packages/8f/38/99627cb995a03389b227ce4b12b08e770565d0aa7850cd0420973194a638/uv-0.11.16-py3-none-win_amd64.whl", hash = "sha256:e901aafa5007beffafe57bfa44e5e248d99fb5d97036a3718fd65cf9723c5cd3", size = 25067163, upload-time = "2026-05-21T22:10:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/b6/68/3ed1c0bdfb4bec501e5cde73419b4f39c8a125ef905a85fc0f239f19eb9b/uv-0.11.16-py3-none-win_arm64.whl", hash = "sha256:d777cb29661cdfa7f90dae77406c85fb5b729bf8bc13941dc237958a1ea1ba00", size = 23502015, upload-time = "2026-05-21T22:09:56.014Z" }, +] + [[package]] name = "uvicorn" version = "0.47.0" From 4fb59546353046777b79e22ae90221acb35a8604 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Mon, 25 May 2026 09:05:22 +0530 Subject: [PATCH 46/52] feat: add ZonedDateTimeField support and harden test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump mongoengine to v0.30.0-alpha.5 (ships ZonedDateTimeField) - Add ZonedDateTimeType / ZonedDateTimeInputType to advanced_types - Register ZonedDateTimeField converter → graphene.Field(ZonedDateTimeType) - ZonedDateTimeType resolvers handle both raw dict and to_python datetime - Filter args: ZonedDateTimeType maps to graphene.DateTime scalar - filter_fields operators (gte/lte/gt/lt/in/nin/all) transparently rewrite to the utc subfield via _hydrate_args (_to_utc handles lists too) - Fix construct_fields crash when Dynamic field is in non_required_fields (EmbeddedDocumentField inputs now honour non_required_fields correctly) - Fix _field_args None filter values from complex nested list fields Test suite (229 → 246 tests): - Rename test_deep_relay_query.py → test_relay_query_deep.py (grouping) - Harden mongo_capture.py: async with support, command_count property - Add test_nested_embedded_input_create (sync + async) - Add geo __near filter tests: arg existence + live $near query - Add filter_fields invalid lookup tests (schema ok, query fails) - Add enum field query/filter tests, empty pageInfo, projection tests - Add ZonedDateTimeField tests: query, exact filter, range, in operator - Add test_utils: self-referential and relay edge→node unwrapping paths - Add tests/README.md with full coverage map --- graphene_mongo/base/advanced_types.py | 59 ++++ graphene_mongo/base/converter.py | 15 + graphene_mongo/base/fields.py | 62 +++- graphene_mongo/base/types.py | 18 +- pyproject.toml | 2 +- tests/README.md | 307 ++++++++++++++++++ tests/asynchronous/nodes.py | 86 ++++++ tests/asynchronous/test_inputs.py | 60 +++- tests/asynchronous/test_query.py | 84 ++++- tests/asynchronous/test_relay_query.py | 254 +++++++++++++++ tests/asynchronous/test_relay_query_deep.py | 323 +++++++++++++++++++ tests/asynchronous/types.py | 63 +++- tests/conftest.py | 81 +++++ tests/models.py | 83 +++++ tests/mongo_capture.py | 49 ++- tests/synchronous/nodes.py | 86 ++++++ tests/synchronous/test_inputs.py | 60 +++- tests/synchronous/test_query.py | 86 +++++- tests/synchronous/test_relay_query.py | 237 ++++++++++++++ tests/synchronous/test_relay_query_deep.py | 325 ++++++++++++++++++++ tests/synchronous/test_utils.py | 39 ++- tests/test_converter.py | 8 + tests/types.py | 67 ++++ uv.lock | 4 +- 24 files changed, 2431 insertions(+), 27 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/asynchronous/test_relay_query_deep.py create mode 100644 tests/synchronous/test_relay_query_deep.py diff --git a/graphene_mongo/base/advanced_types.py b/graphene_mongo/base/advanced_types.py index e9ec85c6..0e8b5231 100644 --- a/graphene_mongo/base/advanced_types.py +++ b/graphene_mongo/base/advanced_types.py @@ -1,8 +1,14 @@ import base64 +import datetime import graphene from graphene_federation import shareable +try: + from datetime import UTC +except ImportError: + UTC = datetime.timezone.utc + @shareable # Support Graphene Federation v2 class FileFieldType(graphene.ObjectType): @@ -142,3 +148,56 @@ class MultiPolygonFieldType(_CoordinatesTypeField): ) ) ) + + +@shareable # Support Graphene Federation v2 +class ZonedDateTimeType(graphene.ObjectType): + """GraphQL ObjectType for a MongoEngine ZonedDateTimeField. + + Stores a datetime together with its originating timezone so the frontend + can display the local time without losing DST or offset information. + + Fields: + utc (DateTime): The moment in time expressed as a UTC-normalised + ISO-8601 datetime string. Use this for all comparisons and sorting. + tz (String): IANA timezone name (e.g. "Asia/Kolkata", "America/New_York") + that identifies the wall-clock timezone the value was recorded in. + """ + + utc = graphene.DateTime(required=True) + tz = graphene.String(required=True) + + def resolve_utc(self, info): + """Return the UTC datetime. + + Handles both the raw MongoDB dict ({"utc": datetime, "tz": str}) and the + timezone-aware datetime that ZonedDateTimeField.to_python returns. + """ + if isinstance(self, dict): + return self["utc"] + return self.astimezone(UTC) + + def resolve_tz(self, info): + """Return the IANA timezone name. + + Handles both the raw MongoDB dict and the timezone-aware datetime that + ZonedDateTimeField.to_python returns. + """ + if isinstance(self, dict): + return self["tz"] + tzinfo = self.tzinfo + if hasattr(tzinfo, "key"): + return tzinfo.key + return str(tzinfo) + + +class ZonedDateTimeInputType(graphene.InputObjectType): + """GraphQL InputObjectType for writing a MongoEngine ZonedDateTimeField. + + Fields: + utc (DateTime): The moment expressed as a UTC datetime. Required. + tz (String): IANA timezone name (e.g. "Asia/Kolkata"). Required. + """ + + utc = graphene.DateTime(required=True) + tz = graphene.String(required=True) diff --git a/graphene_mongo/base/converter.py b/graphene_mongo/base/converter.py index b6b76d8b..ec4180c2 100644 --- a/graphene_mongo/base/converter.py +++ b/graphene_mongo/base/converter.py @@ -172,6 +172,21 @@ def convert_file_to_field(field, registry=None, executor: ExecutorEnum = Executo ) +@convert_mongoengine_field.register(mongoengine.ZonedDateTimeField) +def convert_zoned_datetime_to_field(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert ZonedDateTimeField → graphene.Field(ZonedDateTimeType). + + The raw MongoDB document stores {"utc": datetime, "tz": "timezone_name"}. + ZonedDateTimeType exposes both subfields so clients receive the UTC instant + and the IANA timezone name needed to reconstruct the original wall-clock time. + """ + return graphene.Field( + advanced_types.ZonedDateTimeType, + description=get_field_description(field, registry), + required=get_field_is_required(field, registry), + ) + + @convert_mongoengine_field.register(mongoengine.ListField) @convert_mongoengine_field.register(mongoengine.EmbeddedDocumentListField) @convert_mongoengine_field.register(mongoengine.GeoPointField) diff --git a/graphene_mongo/base/fields.py b/graphene_mongo/base/fields.py index f885f975..14963243 100644 --- a/graphene_mongo/base/fields.py +++ b/graphene_mongo/base/fields.py @@ -20,6 +20,7 @@ PointFieldInputType, PointFieldType, PolygonFieldType, + ZonedDateTimeType, ) from .converter import MongoEngineConversionError, convert_mongoengine_field from .registry import get_global_registry @@ -32,6 +33,24 @@ get_select_related_paths, ) +import datetime as _datetime + +_UTC = _datetime.timezone.utc + + +def _to_utc(value): + """Normalise a datetime (or list of datetimes) to UTC. + + Used by _hydrate_args to rewrite ZonedDateTimeField filter values before + they are passed to MongoEngine. Handles the in/nin/all list case as well as + single values, and assumes UTC when the value has no tzinfo. + """ + if isinstance(value, list): + return [_to_utc(v) for v in value] + if value.tzinfo is None: + value = value.replace(tzinfo=_UTC) + return value.astimezone(_UTC) + class BaseMongoengineConnectionField(ConnectionField): """Shared base class for sync and async MongoEngine connection fields. @@ -218,8 +237,8 @@ def is_filterable(k): FileFieldType, PointFieldType, MultiPolygonFieldType, - graphene.Union, PolygonFieldType, + graphene.Union, ), ): return False @@ -242,9 +261,17 @@ def is_filterable(k): def get_filter_type(_type): if isinstance(_type, Structure): return get_filter_type(_type.of_type) + if _type is ZonedDateTimeType: + return graphene.DateTime() return _type() - return {k: get_filter_type(v.type) for k, v in items if is_filterable(k)} + return { + k: ft + for k, v in items + if is_filterable(k) + for ft in [get_filter_type(v.type)] + if ft is not None + } @property def field_args(self): @@ -275,18 +302,18 @@ def filter_args(self): if self._type._meta.filter_fields: for field, filter_collection in self._type._meta.filter_fields.items(): for each in filter_collection: - if str(self._type._meta.fields[field].type) in ( - "PointFieldType", - "PointFieldType!", - ): + field_type_str = str(self._type._meta.fields[field].type) + if field_type_str in ("PointFieldType", "PointFieldType!"): if each == "max_distance": filter_type = graphene.Int else: filter_type = PointFieldInputType + elif field_type_str in ("ZonedDateTimeType", "ZonedDateTimeType!"): + filter_type = graphene.DateTime else: filter_type = getattr( graphene, - str(self._type._meta.fields[field].type).replace("!", ""), + field_type_str.replace("!", ""), ) advanced_filter_types = { "in": graphene.List(filter_type), @@ -420,6 +447,27 @@ def _hydrate_args(self, args: dict) -> None: hydrated[arg_name] = location["coordinates"] if (arg_name.split("__")[0] + "__max_distance") not in args: hydrated[arg_name.split("__")[0] + "__max_distance"] = 10000 + elif ( + arg_name in self.model._fields_ordered + and isinstance( + getattr(self.model, arg_name), mongoengine.ZonedDateTimeField + ) + ): + # ZonedDateTimeField stores {"utc": datetime, "tz": str}. + # Rewrite bare field filter to compare against the utc subfield. + hydrated[arg_name + "__utc"] = _to_utc(args.pop(arg_name)) + elif "__" in arg_name: + # Handle operator suffixes e.g. start_time__gte, start_time__lte, + # start_time__in (list), etc. + field_name, _, op = arg_name.partition("__") + if ( + field_name in self.model._fields_ordered + and isinstance( + getattr(self.model, field_name), mongoengine.ZonedDateTimeField + ) + ): + value = args.pop(arg_name) + hydrated[field_name + "__utc__" + op] = _to_utc(value) elif arg_name == "id": hydrated["id"] = from_global_id(args.pop("id", None))[1] args.update(hydrated) diff --git a/graphene_mongo/base/types.py b/graphene_mongo/base/types.py index cdd084c7..e0d83939 100644 --- a/graphene_mongo/base/types.py +++ b/graphene_mongo/base/types.py @@ -72,8 +72,22 @@ def construct_fields( if not converted: continue else: - if name in non_required_fields and "required" in converted.kwargs: - converted.kwargs["required"] = False + if name in non_required_fields: + if isinstance(converted, graphene.Dynamic): + _orig_fn = converted.type + + def _make_optional(fn): + def _thunk(): + result = fn() + if result is not None and hasattr(result, "kwargs"): + result.kwargs["required"] = False + return result + + return _thunk + + converted = graphene.Dynamic(_make_optional(_orig_fn)) + elif "required" in converted.kwargs: + converted.kwargs["required"] = False fields[name] = converted return fields, self_referenced diff --git a/pyproject.toml b/pyproject.toml index 7ec59e0b..2487a41e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ Homepage = "https://github.com/graphql-python/graphene-mongo" Repository = "https://github.com/graphql-python/graphene-mongo" [tool.uv.sources] -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.4" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.5" } [dependency-groups] dev = [ diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..5e64f346 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,307 @@ +# Test Suite + +**246 tests · 100% pass · sync and async mirrored** + +--- + +## Running the tests + +```bash +# All tests +uv run --group test pytest tests/ -q + +# One file +uv run --group test pytest tests/synchronous/test_relay_query.py -v + +# One test by name +uv run --group test pytest -k "test_enum_field_query" -v +``` + +--- + +## Layout + +``` +tests/ +├── conftest.py # shared fixtures and DB setup (module-scoped) +├── models.py # all MongoEngine models used across every test +├── mongo_capture.py # pymongo command listener — captures projections for assertion +├── types.py # plain (non-relay) graphene types shared by test_query tests +│ +├── synchronous/ +│ ├── nodes.py # relay MongoengineObjectType nodes (interfaces = (Node,)) +│ ├── utils.py # execute_count() — runs schema.execute with query_counter +│ ├── test_converter.py # field-type conversion: MongoEngine field → graphene type +│ ├── test_types.py # schema registration: only_fields, exclude_fields, order_by +│ ├── test_fields.py # filter/advance/required arg generation on connection fields +│ ├── test_utils.py # get_model_fields, get_query_fields, get_select_related_paths +│ ├── test_query.py # plain graphene.List queries (no relay, no connection field) +│ ├── test_relay_query.py # MongoengineConnectionField — the main relay query tests +│ ├── test_relay_query_deep.py # 10-level reference chain stress tests +│ ├── test_mutation.py # relay mutations +│ └── test_inputs.py # MongoengineInputType +│ +└── asynchronous/ + ├── nodes.py # relay AsyncMongoengineObjectType nodes + ├── types.py # non-relay async types (used by async test_query) + ├── utils.py # execute_count() — async variant using async_query_counter + └── test_*.py # mirrors every synchronous/ file above +``` + +The async suite is a deliberate line-for-line mirror of the sync suite. Every behaviour proven in sync is re-proven in +async to catch executor-specific regressions. + +--- + +## Shared infrastructure + +### `conftest.py` + +One `fixtures` fixture (module-scoped) seeds all collections before each test module runs. Collections are dropped and +re-created at the start of the fixture so each module starts clean. The fixture covers: + +- `Publisher`, `Editor`, `Article`, `Reporter`, `Player` — core document graph with references, embedded docs, GridFS, + and generic references +- `Parent`, `Child`, `ParentWithRelationship`, `ChildRegisteredBefore/After` — inheritance and forward-reference + registration +- `CellTower` — geo polygon fields +- `ProfessorVector` + `ProfessorMetadata` — embedded document with vector field +- `Bench`, `Exam`, `SchoolClass` — enum fields (`ListField(EnumField(GradeEnum))`) and generic embedded/reference fields +- `DeepL1` … `DeepL10` — 10-level reference chain for select_related stress tests +- `Event` — `ZonedDateTimeField` (two events in different IANA timezones) + +### `mongo_capture.py` + +A pymongo `CommandListener` registered at import time. Used in projection tests to assert that MongoDB actually fetches +only the fields the GraphQL query asked for — not all fields on the document. + +```python +with captured_commands() as cap: # or: async with captured_commands() as cap: + schema.execute(query) + +assert "fname" in cap.projected_fields() # field was fetched +assert "avatar" not in cap.projected_fields() # field was NOT fetched +assert cap.command_count == 1 # exactly one round-trip +``` + +--- + +## What each file covers + +### `test_converter.py` (22 shared + 15 sync + 15 async = 52 tests) + +Every MongoEngine field type → graphene type conversion. One test per field type. Covers scalar, numeric, date/time, +UUID, URL/email, ObjectId, list, embedded, reference, generic reference/embedded, geo (Point, Polygon, MultiPolygon), +file (GridFS), sequence, enum, and `ZonedDateTimeField`. + +**Known gaps documented:** `SortedListField`, `BinaryField`, `TimeField`, `JSONField` — not supported by the converter; +attempting to use them will raise `MongoEngineConversionError`. + +### `test_types.py` (12 sync + 12 async = 24 tests) + +Schema-level registration behaviour: + +- `only_fields` / `exclude_fields` restrict which fields appear on the type +- `order_by` meta option sets default ordering +- Forward references (type A references type B defined later) are resolved by `rescan_fields` +- Invalid models and missing models raise at class definition time, not at query time +- Filter args on `ListField(ReferenceField)` produce connection fields +- Inheritance (`ChildRegisteredBefore`, `ChildRegisteredAfter`) registers correctly + +### `test_fields.py` (5 sync + 2 async = 7 tests) + +Argument generation on `MongoengineConnectionField`: + +- `filter_fields` dict produces lookup-style args (`headline__icontains`, `first_name__istartswith`, `first_name__in`) +- Advance args are generated for geo fields (PointField → `point__near`) +- Required fields (`required_fields` meta option) always appear in the MongoDB projection + +### `test_utils.py` (13 sync only) + +Unit tests for the utility functions used internally by the field resolver: + +| Function | Tests | +|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `get_model_fields` | no duplicates, excluding fields, base model fields | +| `is_valid_mongoengine_model` | Document subclass vs non-model | +| `get_query_fields` | scalar, nested, fragment, alias, list field | +| `get_select_related_paths` | top-level ref, nested ref (`editor__company`), list of refs, unknown fields ignored, self-referential (terminates at query depth), relay `edges→node` unwrapping | + +### `test_query.py` (12 sync + 12 async = 24 tests) + +Plain `graphene.Field` / `graphene.List` resolvers — **no relay, no connection field**. Verifies that the underlying +model and type graph is correct independently of the connection field machinery. Also covers `ZonedDateTimeField` +querying and filtering: + +| Test | What it covers | +|------------------------------------------|----------------------------------------------------------------------------------| +| `test_should_query_zoned_datetime` | Fetches `utc` + `tz` for both events; confirms both subfields are non-null | +| `test_should_filter_zoned_datetime_by_utc` | Exact UTC equality filter returns only the matching event, with correct `tz` | +| `test_should_filter_zoned_datetime_range` | `gte`, `lte` range filters and `in` (list) all translate to the `utc` subfield | + +### `test_relay_query.py` (51 sync + 53 async = 104 tests) + +The core relay connection field tests. Grouped by behaviour: + +#### Filtering and querying + +| Test group | What it covers | +|---------------------------------------|---------------------------------------------------------------------------------------| +| `should_filter*` | Scalar filters, list-contains, reference field (global ID), inheritance, filter-by-id | +| `should_query*` | Reporters with nested docs, embedded docs, lazy reference, self-referential | +| `should_get_queryset_returns_*` | Custom `get_queryset` callback returning a dict (MongoDB match), or a QuerySet | +| `should_filter_mongoengine_queryset*` | Custom QuerySet passed directly to the field | + +#### Pagination + +| Test | What it covers | +|----------------------------------------------------------------|----------------------------------------------------------------| +| `should_first_n` | `first: N` returns N edges, `hasNextPage=True` | +| `should_last_n` | `last: N` returns the last N edges | +| `should_after` / `should_before` | Cursor-based pagination, no `pageInfo` (1 query) | +| `should_after_with_page_info` / `should_before_with_page_info` | `pageInfo` requested forces a count query (2 queries) | +| `should_first_without_page_info` | No `pageInfo` → no count query (1 query) | +| `empty_result_pageinfo` | Empty result set: `hasNextPage=False`, `hasPreviousPage=False` | + +#### N+1 / query count guarantees + +| Test | What it proves | +|--------------------------------------------------|----------------------------------------------------------------------| +| `editors_with_company_no_pagination` | `editor → company` resolved in 1 aggregate | +| `articles_with_editor_and_company_no_pagination` | `article → editor → company` in 1 aggregate | +| `articles_with_multiple_refs_no_pagination` | Multiple top-level references in 1 aggregate | +| `players_with_self_referential_no_pagination` | Self-referential `players` list in 1 aggregate | +| `editors_paginated_*` | Paginated queries with references: 2 queries max (count + aggregate) | +| `articles_paginated_first_with_editor` | `first: N` with reference: select_related still works | + +#### MongoDB projection (uses `mongo_capture.py`) + +| Test | What it proves | +|-----------------------------------------------|-------------------------------------------------------------------------------| +| `projection_only_queried_fields` | Only `firstName` queried → only `fname` projected | +| `projection_multiple_fields` | Both `firstName` and `lastName` queried → both projected | +| `projection_with_reference_field` | `company` queried → `company` projected, other editor fields not | +| `projection_list_reference_field` | `articles` list → `articles` field projected, article fields in sub-aggregate | +| `projection_generic_reference_field` | Generic reference projected on parent, type-specific fields on child | +| `projection_list_generic_reference_field` | List of generic refs projected correctly across multiple types | +| `only_fields_restricts_mongodb_projection` | Meta `only_fields` actually limits MongoDB `$project` | +| `exclude_fields_restricts_mongodb_projection` | Meta `exclude_fields` actually drops field from `$project` | +| `required_fields_always_projected` | Meta `required_fields` always in `$project` even when not queried | + +#### Enum fields + +| Test | What it proves | +|---------------------|--------------------------------------------------------------------------------| +| `enum_field_query` | `ListField(EnumField(GradeEnum))` serialises to `["A", "B"]` in relay response | +| `enum_field_filter` | Filtering with an enum value (`allowedGrades: A`) returns matching docs only | + +#### Geo filtering + +| Test | What it proves | +|-----------------------------------|-------------------------------------------------------------------------------------| +| `geo_near_filter_arg_exists` | `filter_fields = {"loc": ["near"]}` generates `loc__near: PointFieldInputType` arg | +| `geo_near_filter_query` | Live `$near` query returns only documents with a matching location | + +#### `filter_fields` validation + +| Test | What it proves | +|---------------------------------------------------|------------------------------------------------------------------------| +| `filter_fields_invalid_lookup_schema_arg_exists` | Unknown lookup name builds schema arg without error | +| `filter_fields_invalid_lookup_raises_at_query_time` | Querying with that arg fails at MongoEngine execution time | + +#### Error cases + +| Test | What it proves | +|--------------------------------------------------------------------|------------------------------------------------------------------------------------| +| `connection_field_resolver_returns_document_raises` | Returning a single Document from a resolver raises `TypeError` with useful message | +| `connection_field_get_queryset_rejects_sync_queryset` (async only) | `get_queryset` callback returning a sync `QuerySet` raises `TypeError` | + +### `test_relay_query_deep.py` (6 sync + 6 async = 12 tests) + +10-level reference chain stress tests. The model graph: + +``` +L1 → L2 → L3 → L4 → L5 → L6 → L7 → L8 → L9 → L10 + ↑ ↕ ↑ + L1.children L3.extraRefs L6.genericItem → L7 + (ListField) L5.siblings L8.extras → [L10] + (self-ref) +``` + +| Test | What it covers | +|-------------------------|----------------------------------------------------------------------------| +| `data_correctness` | All 10 levels resolve to the correct document names | +| `single_query` | The entire graph is resolved in **exactly 1 MongoDB query** — no N+1 | +| `list_of_references` | `ListField(ReferenceField)` at L1 and L3 resolve all items in 1 query | +| `generic_references` | `GenericReferenceField` at L3 and L6 resolve to the correct concrete types | +| `self_referential_list` | `ListField(ReferenceField('self'))` at L5 resolves correctly | +| `list_at_depth_8` | `ListField(ReferenceField)` at depth 8 resolves all items in 1 query | + +### `test_mutation.py` (2 sync + 2 async = 4 tests) + +Relay mutations via `MongoengineCreateMutation` / `MongoengineUpdateMutation`. Covers basic create and update for +`Reporter`. + +### `test_inputs.py` (3 sync + 3 async = 6 tests) + +`MongoengineInputType` for mutation inputs. Covers `non_required_fields` making required fields optional, and nested +`EmbeddedDocumentField` inputs (`ProfessorMetadataInput` nested inside `ProfessorVectorInput`). + +--- + +## `ZonedDateTimeField` support + +`ZonedDateTimeField` (mongoengine v0.30.0-alpha.5+) stores a datetime as `{"utc": datetime, "tz": "IANA/Zone"}`. +graphene-mongo exposes it as `ZonedDateTimeType` with two subfields: + +```graphql +startTime { + utc # DateTime — UTC-normalised instant, use for sorting and comparisons + tz # String — IANA timezone name (e.g. "Asia/Kolkata", "America/New_York") +} +``` + +**Filtering** behaves identically to a plain `DateTime` field. Any MongoEngine operator declared in `filter_fields` +is transparently rewritten to compare against the stored `utc` subfield: + +```python +class EventNode(MongoengineObjectType): + class Meta: + model = Event + filter_fields = {"start_time": ["gte", "lte", "gt", "lt", "in"]} +``` + +```graphql +# camelCase note: start_time__gte → startTime_Gte (double-underscore separator preserved) +{ events(startTime_Gte: "2024-07-01T00:00:00+00:00") { edges { node { name } } } } +``` + +List operators (`in`, `nin`, `all`) accept a list of `DateTime` values — each element is individually +normalised to UTC before the query is sent. + +--- + +## Coverage summary + +| Area | Status | Notes | +|---------------------------------------------|-------------|-----------------------------------------------------------------------------------------| +| Field type conversion (all supported types) | Covered | See `test_converter.py` | +| Schema registration (only/exclude/order_by) | Covered | See `test_types.py` | +| Filter arg generation | Covered | See `test_fields.py` | +| Internal utility functions | Covered | See `test_utils.py` | +| Plain resolvers (no relay) | Covered | See `test_query.py` | +| Relay connection field — filtering | Covered | See `test_relay_query.py` | +| Relay connection field — pagination | Covered | Cursor, first, last, empty set | +| N+1 prevention (select_related) | Covered | Query count asserted ≤ 2 | +| MongoDB projection accuracy | Covered | Wire-level via `mongo_capture.py` | +| Enum fields (query + filter) | Covered | `GradeEnum` via `SchoolClass` | +| Deep nested references (10 levels) | Covered | See `test_relay_query_deep.py` | +| Self-referential references | Covered | `Player.players`, `DeepL5.siblings` | +| Generic references | Covered | `Reporter.generic_reference`, L3/L6 in deep tests | +| Async execution (all of the above) | Covered | Full mirror under `tests/asynchronous/` | +| Mutations | Minimal | Create + update only | +| Input types | Covered | `non_required_fields` + nested `EmbeddedDocumentField` input (`test_inputs.py`) | +| Nested input objects | Covered | `ProfessorMetadataInput` nested in `ProfessorVectorInput` (`test_inputs.py`) | +| Geo field filtering (`__near`) | Covered | Arg existence + live `$near` query with 2dsphere index (`test_relay_query.py`) | +| `filter_fields` validation errors | Covered | Invalid lookup: schema builds fine, query fails at execution (`test_relay_query.py`) | +| `ZonedDateTimeField` (query + filter) | Covered | Read `utc`/`tz`, exact equality, range (`gte`/`lte`/`gt`/`lt`), list (`in`) operators | \ No newline at end of file diff --git a/tests/asynchronous/nodes.py b/tests/asynchronous/nodes.py index 1336ad3a..1214a942 100644 --- a/tests/asynchronous/nodes.py +++ b/tests/asynchronous/nodes.py @@ -106,3 +106,89 @@ class FooAsyncNode(AsyncMongoengineObjectType): class Meta: model = models.Foo interfaces = (Node,) + + +# Deep select_related stress-test nodes +class DeepL10AsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL10 + interfaces = (Node,) + + +class DeepL9AsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL9 + interfaces = (Node,) + + +class DeepL8AsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL8 + interfaces = (Node,) + + +class DeepL7AsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL7 + interfaces = (Node,) + + +class DeepL6AsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL6 + interfaces = (Node,) + + +class DeepL5AsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL5 + interfaces = (Node,) + + +class DeepL4AsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL4 + interfaces = (Node,) + + +class DeepL3AsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL3 + interfaces = (Node,) + + +class DeepL2AsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL2 + interfaces = (Node,) + + +class DeepL1AsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL1 + interfaces = (Node,) + + +class BenchAsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.Bench + interfaces = (Node,) + + +class ExamAsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.Exam + interfaces = (Node,) + + +class SchoolClassAsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.SchoolClass + interfaces = (Node,) + only_fields = ("allowed_grades", "subjects") + + +class EventAsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.Event + interfaces = (Node,) diff --git a/tests/asynchronous/test_inputs.py b/tests/asynchronous/test_inputs.py index bf428450..ddb7b0ec 100644 --- a/tests/asynchronous/test_inputs.py +++ b/tests/asynchronous/test_inputs.py @@ -1,9 +1,9 @@ import graphene from graphene.relay import Node -from ..models import Article, Editor -from .nodes import ArticleAsyncNode, EditorAsyncNode -from ..types import ArticleInput, EditorInput +from ..models import Article, Editor, ProfessorVector +from .nodes import ArticleAsyncNode, EditorAsyncNode, ProfessorVectorAsyncNode +from ..types import ArticleInput, EditorInput, ProfessorVectorInput from .utils import execute_count @@ -84,4 +84,58 @@ class Mutation(graphene.ObjectType): result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 + + +async def test_nested_embedded_input_create(fixtures): + """AsyncMongoengineInputType with a nested EmbeddedDocumentField input stores nested data correctly.""" + + class CreateProfessorVector(graphene.Mutation): + class Arguments: + professor_vector = ProfessorVectorInput(required=True) + + professor_vector = graphene.Field(ProfessorVectorAsyncNode) + + async def mutate(self, info, professor_vector): + pv = ProfessorVector(**professor_vector) + await pv.asave() + return CreateProfessorVector(professor_vector=pv) + + class Query(graphene.ObjectType): + node = Node.Field() + + class Mutation(graphene.ObjectType): + create_professor_vector = CreateProfessorVector.Field() + + query = """ + mutation { + createProfessorVector( + professorVector: { + vec: [3.14, 2.72] + metadata: { + firstName: "Alan" + lastName: "Turing" + departments: ["CS", "Math"] + } + } + ) { + professorVector { + vec + metadata { + firstName + lastName + departments + } + } + } + } + """ + schema = graphene.Schema(query=Query, mutation=Mutation, auto_camelcase=True) + result, count = await execute_count(schema, query) + assert not result.errors, result.errors + pv = result.data["createProfessorVector"]["professorVector"] + assert pv["vec"] == [3.14, 2.72] + assert pv["metadata"]["firstName"] == "Alan" + assert pv["metadata"]["lastName"] == "Turing" + assert pv["metadata"]["departments"] == ["CS", "Math"] assert count == 1 \ No newline at end of file diff --git a/tests/asynchronous/test_query.py b/tests/asynchronous/test_query.py index 300ff113..99d4691c 100644 --- a/tests/asynchronous/test_query.py +++ b/tests/asynchronous/test_query.py @@ -415,4 +415,86 @@ async def resolve_cell_towers(self, *args, **kwargs): result, count = await execute_count(schema, query) assert not result.errors assert result.data == expected - assert count == 1 \ No newline at end of file + assert count == 1 + +async def test_should_query_zoned_datetime(fixtures): + from .nodes import EventAsyncNode + from graphene_mongo import AsyncMongoengineConnectionField + + class Query(graphene.ObjectType): + events = AsyncMongoengineConnectionField(EventAsyncNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + result = await schema.execute_async( + "{ events { edges { node { name startTime { utc tz } } } } }" + ) + assert not result.errors, result.errors + edges = result.data["events"]["edges"] + assert len(edges) == 2 + names = {e["node"]["name"] for e in edges} + assert names == {"Kolkata Summit", "New York Meetup"} + for edge in edges: + st = edge["node"]["startTime"] + assert st["utc"] is not None + assert st["tz"] in ("Asia/Kolkata", "America/New_York") + + +async def test_should_filter_zoned_datetime_by_utc(fixtures): + from .nodes import EventAsyncNode + from graphene_mongo import AsyncMongoengineConnectionField + + class Query(graphene.ObjectType): + events = AsyncMongoengineConnectionField(EventAsyncNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + # Kolkata Summit: 2024-06-15 14:30 IST = 2024-06-15 09:00:00 UTC (exact equality) + result = await schema.execute_async( + '{ events(startTime: "2024-06-15T09:00:00+00:00") { edges { node { name startTime { tz } } } } }' + ) + assert not result.errors, result.errors + edges = result.data["events"]["edges"] + assert len(edges) == 1 + assert edges[0]["node"]["name"] == "Kolkata Summit" + assert edges[0]["node"]["startTime"]["tz"] == "Asia/Kolkata" + + +async def test_should_filter_zoned_datetime_range(fixtures): + """filter_fields gte/lte/gt/lt/in on ZonedDateTimeField compare against the utc subfield.""" + from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType + from graphene_mongo import AsyncMongoengineConnectionField + from .. import models as m + + class EventRangeAsyncNode(AsyncMongoengineObjectType): + class Meta: + model = m.Event + interfaces = (graphene.relay.Node,) + filter_fields = {"start_time": ["gte", "lte", "gt", "lt", "in"]} + + class Query(graphene.ObjectType): + events = AsyncMongoengineConnectionField(EventRangeAsyncNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + + # gte 2024-07-01 → only NY Meetup (startTime_Gte due to double-underscore camelcase) + result = await schema.execute_async( + '{ events(startTime_Gte: "2024-07-01T00:00:00+00:00") { edges { node { name } } } }' + ) + assert not result.errors, result.errors + names = [e["node"]["name"] for e in result.data["events"]["edges"]] + assert names == ["New York Meetup"] + + # lte 2024-07-01 → only Kolkata Summit + result = await schema.execute_async( + '{ events(startTime_Lte: "2024-07-01T00:00:00+00:00") { edges { node { name } } } }' + ) + assert not result.errors, result.errors + names = [e["node"]["name"] for e in result.data["events"]["edges"]] + assert names == ["Kolkata Summit"] + + # in [kolkata-utc, ny-utc] → both events (list of datetimes) + result = await schema.execute_async( + '{ events(startTime_In: ["2024-06-15T09:00:00+00:00", "2024-09-01T13:00:00+00:00"]) { edges { node { name } } } }' + ) + assert not result.errors, result.errors + names = {e["node"]["name"] for e in result.data["events"]["edges"]} + assert names == {"Kolkata Summit", "New York Meetup"} diff --git a/tests/asynchronous/test_relay_query.py b/tests/asynchronous/test_relay_query.py index 69e45c0d..2f6963e6 100644 --- a/tests/asynchronous/test_relay_query.py +++ b/tests/asynchronous/test_relay_query.py @@ -1493,3 +1493,257 @@ async def resolve_articles(self, info): assert count == 1 # AsyncQuerySet goes through select_related — single aggregation +async def test_connection_field_get_queryset_rejects_sync_queryset(fixtures): + """AsyncMongoengineConnectionField raises TypeError when get_queryset callback returns a sync QuerySet.""" + + class Query(graphene.ObjectType): + articles = AsyncMongoengineConnectionField( + nodes.ArticleAsyncNode, + get_queryset=lambda model, info, **kw: model.objects.all(), # sync QS — rejected + ) + + schema = graphene.Schema(query=Query) + result = await schema.execute_async( + "{ articles { edges { node { headline } } } }" + ) + assert result.errors + assert any("AsyncQuerySet" in str(e) for e in result.errors) + + +# --------------------------------------------------------------------------- +# Enum field tests +# --------------------------------------------------------------------------- + +async def test_enum_field_query(fixtures): + """ListField(EnumField) serialises enum values correctly in an async relay query.""" + class Query(graphene.ObjectType): + school_classes = AsyncMongoengineConnectionField(nodes.SchoolClassAsyncNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + result, count = await execute_count( + schema, + "{ schoolClasses { edges { node { allowedGrades } } } }", + ) + assert not result.errors, result.errors + all_grades = [ + e["node"]["allowedGrades"] + for e in result.data["schoolClasses"]["edges"] + ] + assert ["A", "B"] in all_grades + assert ["B"] in all_grades + assert count == 1 + + +async def test_enum_field_filter(fixtures): + """Filtering on a ListField(EnumField) by enum value returns only matching documents.""" + class Query(graphene.ObjectType): + school_classes = AsyncMongoengineConnectionField(nodes.SchoolClassAsyncNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + # Enum values are passed without quotes in GraphQL (A not "A") + result, count = await execute_count( + schema, + "{ schoolClasses(allowedGrades: A) { edges { node { allowedGrades } } } }", + ) + assert not result.errors, result.errors + edges = result.data["schoolClasses"]["edges"] + assert len(edges) == 1 + assert edges[0]["node"]["allowedGrades"] == ["A", "B"] + assert count == 1 + + +# --------------------------------------------------------------------------- +# Pagination edge cases +# --------------------------------------------------------------------------- + +async def test_empty_result_pageinfo(fixtures): + """pageInfo on an empty result set has hasNextPage=False and hasPreviousPage=False.""" + class Query(graphene.ObjectType): + articles = AsyncMongoengineConnectionField(nodes.ArticleAsyncNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + result, _ = await execute_count( + schema, + '{ articles(headline: "__no_such_article__") { edges { node { headline } } pageInfo { hasNextPage hasPreviousPage } } }', + ) + assert not result.errors, result.errors + data = result.data["articles"] + assert data["edges"] == [] + assert data["pageInfo"]["hasNextPage"] is False + assert data["pageInfo"]["hasPreviousPage"] is False + + +# --------------------------------------------------------------------------- +# Meta option projection tests +# --------------------------------------------------------------------------- + +async def test_only_fields_restricts_mongodb_projection(fixtures): + """only_fields on the Meta class limits which fields are fetched from MongoDB.""" + from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType + from ..mongo_capture import captured_commands + + class EditorOnlyNameAsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.Editor + interfaces = (Node,) + only_fields = ("first_name",) + + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(EditorOnlyNameAsyncNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + async with captured_commands() as cap: + result = await schema.execute_async( + "{ editors { edges { node { firstName } } } }" + ) + + assert not result.errors, result.errors + projected = cap.projected_fields() + assert "fname" in projected + assert "avatar" not in projected + assert "last_name" not in projected + + +async def test_exclude_fields_restricts_mongodb_projection(fixtures): + """exclude_fields removes fields from the MongoDB projection.""" + from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType + from ..mongo_capture import captured_commands + + class EditorNoAvatarAsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.Editor + interfaces = (Node,) + exclude_fields = ("avatar",) + + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(EditorNoAvatarAsyncNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + async with captured_commands() as cap: + result = await schema.execute_async( + "{ editors { edges { node { firstName lastName } } } }" + ) + + assert not result.errors, result.errors + projected = cap.projected_fields() + assert "fname" in projected + assert "last_name" in projected + assert "avatar" not in projected + + +async def test_required_fields_always_projected(fixtures): + """required_fields are included in the MongoDB projection even when not queried.""" + from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType + from ..mongo_capture import captured_commands + + class EditorRequiredLastNameAsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.Editor + interfaces = (Node,) + required_fields = ("last_name",) + + class Query(graphene.ObjectType): + editors = AsyncMongoengineConnectionField(EditorRequiredLastNameAsyncNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + async with captured_commands() as cap: + # Query only firstName — last_name is NOT in the GraphQL selection + result = await schema.execute_async( + "{ editors { edges { node { firstName } } } }" + ) + + assert not result.errors, result.errors + projected = cap.projected_fields() + assert "last_name" in projected # required_fields forces it into the projection + assert "fname" in projected # queried field is also projected + + +def test_geo_near_filter_arg_exists(): + """filter_fields {"loc": ["near"]} generates a loc__near arg with PointFieldInputType.""" + from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType + from graphene_mongo.base.advanced_types import PointFieldInputType + + class ChildGeoAsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.Child + interfaces = (Node,) + filter_fields = {"loc": ["near"]} + + field = AsyncMongoengineConnectionField(ChildGeoAsyncNode) + assert "loc__near" in field.args + assert isinstance(field.args["loc__near"], graphene.Argument) + assert field.args["loc__near"].type == PointFieldInputType + + +async def test_geo_near_filter_query(fixtures): + """loc__near filter returns only documents within the specified distance.""" + from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType + + class ChildGeoQueryAsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.Child + interfaces = (Node,) + filter_fields = {"loc": ["near"]} + + models.Child.ensure_indexes() + + class Query(graphene.ObjectType): + children = AsyncMongoengineConnectionField(ChildGeoQueryAsyncNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + # child2 is at [10, 20]; child1 has no location. + # Querying near [10, 20] should return child2 and exclude child1 (no loc). + # auto_camelcase converts loc__near → loc_Near (double-underscore separator is preserved) + result = await schema.execute_async( + """ + { + children(loc_Near: {coordinates: [10.0, 20.0]}) { + edges { + node { + bar + } + } + } + } + """ + ) + assert not result.errors, result.errors + bars = [e["node"]["bar"] for e in result.data["children"]["edges"]] + assert "bar" in bars + assert "BAR" not in bars # child1 has no loc, so it's excluded + + +def test_filter_fields_invalid_lookup_schema_arg_exists(): + """filter_fields with an unknown lookup builds the schema arg without error.""" + from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType + + class ArticleInvalidFilterAsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.Article + interfaces = (Node,) + filter_fields = {"headline": ["bad_op"]} + + field = AsyncMongoengineConnectionField(ArticleInvalidFilterAsyncNode) + assert "headline__bad_op" in field.args + + +async def test_filter_fields_invalid_lookup_raises_at_query_time(fixtures): + """An unknown lookup in filter_fields is accepted by the schema but fails at query execution.""" + from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType + + class ArticleInvalidLookupAsyncNode(AsyncMongoengineObjectType): + class Meta: + model = models.Article + interfaces = (Node,) + filter_fields = {"headline": ["bad_op"]} + + class Query(graphene.ObjectType): + articles = AsyncMongoengineConnectionField(ArticleInvalidLookupAsyncNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + result = await schema.execute_async( + '{ articles(headlineBadOp: "My Article") { edges { node { headline } } } }' + ) + assert result.errors + + diff --git a/tests/asynchronous/test_relay_query_deep.py b/tests/asynchronous/test_relay_query_deep.py new file mode 100644 index 00000000..2520d18b --- /dev/null +++ b/tests/asynchronous/test_relay_query_deep.py @@ -0,0 +1,323 @@ +""" +Async stress tests for select_related with 10 levels of nested references. +Mirror of tests/synchronous/test_deep_select_related.py using AsyncMongoengineObjectType. + +Chain via .child: + L1 → L2 → L3 → L4 → L5 → L6 → L7 → L8 → L9 → L10 + +Additional reference types exercised: + L1.children ListField(ReferenceField(L2)) + L3.generic_item GenericReferenceField → L4 + L3.extra_refs ListField(ReferenceField(L5)) + L5.siblings ListField(ReferenceField(L5)) — self-referential + L6.generic_item GenericReferenceField → L7 + L8.extras ListField(ReferenceField(L10)) + +All tests assert a single MongoDB query (no N+1) when using AsyncMongoengineConnectionField. +""" + +import graphene +import pytest +from graphene.relay import Node + +from . import nodes +from graphene_mongo.asynchronous.fields import AsyncMongoengineConnectionField +from .utils import execute_count + +# ListField(ReferenceField) fields (extraRefs, siblings, extras, children) +# are relay connections because their target types have interfaces = (Node,). +DEEP_QUERY = """ +{ + deepChain { + edges { + node { + name + child { + name + child { + name + genericItem { __typename } + extraRefs { + edges { + node { + name + } + } + } + child { + name + child { + name + siblings { + edges { + node { + name + } + } + } + child { + name + genericItem { __typename } + child { + name + child { + name + extras { + edges { + node { + name + } + } + } + child { + name + child { + name + } + } + } + } + } + } + } + } + } + children { + edges { + node { + name + child { + name + } + } + } + } + } + } + } +} +""" + + +@pytest.fixture(scope="module") +def deep_schema(): + class Query(graphene.ObjectType): + node = Node.Field() + deep_chain = AsyncMongoengineConnectionField(nodes.DeepL1AsyncNode) + + return graphene.Schema(query=Query, auto_camelcase=True) + + +async def test_deep_select_related_data_correctness(fixtures, deep_schema): + """All 10 levels resolve to the correct names.""" + result, _ = await execute_count(deep_schema, DEEP_QUERY) + assert not result.errors, result.errors + + node = result.data["deepChain"]["edges"][0]["node"] + assert node["name"] == "L1" + + l2 = node["child"] + assert l2["name"] == "L2-A" + + l3 = l2["child"] + assert l3["name"] == "L3" + assert l3["genericItem"]["__typename"] == "DeepL4AsyncNode" + extra_ref_nodes = [e["node"] for e in l3["extraRefs"]["edges"]] + assert {r["name"] for r in extra_ref_nodes} == {"L5-A", "L5-B"} + + l4 = l3["child"] + assert l4["name"] == "L4" + + l5 = l4["child"] + assert l5["name"] == "L5-A" + sibling_nodes = [e["node"] for e in l5["siblings"]["edges"]] + assert sibling_nodes == [{"name": "L5-B"}] + + l6 = l5["child"] + assert l6["name"] == "L6" + assert l6["genericItem"]["__typename"] == "DeepL7AsyncNode" + + l7 = l6["child"] + assert l7["name"] == "L7" + + l8 = l7["child"] + assert l8["name"] == "L8" + extra_nodes = [e["node"] for e in l8["extras"]["edges"]] + assert {e["name"] for e in extra_nodes} == {"L10-B", "L10-C"} + + l9 = l8["child"] + assert l9["name"] == "L9" + + l10 = l9["child"] + assert l10["name"] == "L10-A" + + children_nodes = [e["node"] for e in node["children"]["edges"]] + assert len(children_nodes) == 2 + assert {c["name"] for c in children_nodes} == {"L2-A", "L2-B"} + for c in children_nodes: + assert c["child"]["name"] == "L3" + + +async def test_deep_select_related_single_query(fixtures, deep_schema): + """The entire 10-level graph is resolved in a single MongoDB aggregate.""" + result, count = await execute_count(deep_schema, DEEP_QUERY) + assert not result.errors, result.errors + assert count == 1, f"Expected 1 query, got {count} — N+1 detected" + + +async def test_deep_list_of_references(fixtures, deep_schema): + """ListField(ReferenceField) at L1.children and L3.extraRefs resolve all items.""" + query = """ + { + deepChain { + edges { + node { + name + children { + edges { + node { + name + child { name } + } + } + } + child { + child { + extraRefs { + edges { + node { + name + child { name } + } + } + } + } + } + } + } + } + } + """ + result, count = await execute_count(deep_schema, query) + assert not result.errors, result.errors + node = result.data["deepChain"]["edges"][0]["node"] + + children_nodes = [e["node"] for e in node["children"]["edges"]] + assert len(children_nodes) == 2 + assert {c["name"] for c in children_nodes} == {"L2-A", "L2-B"} + + extra_ref_nodes = [e["node"] for e in node["child"]["child"]["extraRefs"]["edges"]] + assert len(extra_ref_nodes) == 2 + assert {r["name"] for r in extra_ref_nodes} == {"L5-A", "L5-B"} + for r in extra_ref_nodes: + assert r["child"]["name"] == "L6" + + assert count == 1 + + +async def test_deep_generic_references(fixtures, deep_schema): + """GenericReferenceFields at L3 and L6 resolve to the correct concrete types.""" + query = """ + { + deepChain { + edges { + node { + child { + child { + genericItem { __typename } + child { + child { + child { + genericItem { __typename } + } + } + } + } + } + } + } + } + } + """ + result, count = await execute_count(deep_schema, query) + assert not result.errors, result.errors + l2 = result.data["deepChain"]["edges"][0]["node"]["child"] + l3 = l2["child"] + assert l3["genericItem"]["__typename"] == "DeepL4AsyncNode" + l6 = l3["child"]["child"]["child"] + assert l6["genericItem"]["__typename"] == "DeepL7AsyncNode" + assert count == 1 + + +async def test_deep_self_referential_list(fixtures, deep_schema): + """Self-referential ListField(ReferenceField('self')) at L5 resolves correctly.""" + query = """ + { + deepChain { + edges { + node { + child { + child { + child { + child { + name + siblings { + edges { + node { + name + } + } + } + } + } + } + } + } + } + } + } + """ + result, count = await execute_count(deep_schema, query) + assert not result.errors, result.errors + l5 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"] + assert l5["name"] == "L5-A" + sibling_nodes = [e["node"] for e in l5["siblings"]["edges"]] + assert sibling_nodes == [{"name": "L5-B"}] + assert count == 1 + + +async def test_deep_list_at_depth_8(fixtures, deep_schema): + """ListField(ReferenceField) at L8.extras (pointing to L10) resolves all items.""" + query = """ + { + deepChain { + edges { + node { + child { child { child { child { child { child { child { + name + extras { + edges { + node { + name + } + } + } + child { + name + child { name } + } + } } } } } } } + } + } + } + } + """ + result, count = await execute_count(deep_schema, query) + assert not result.errors, result.errors + l8 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"]["child"]["child"]["child"] + assert l8["name"] == "L8" + extra_nodes = [e["node"] for e in l8["extras"]["edges"]] + assert {e["name"] for e in extra_nodes} == {"L10-B", "L10-C"} + assert l8["child"]["name"] == "L9" + assert l8["child"]["child"]["name"] == "L10-A" + assert count == 1 \ No newline at end of file diff --git a/tests/asynchronous/types.py b/tests/asynchronous/types.py index 517ef576..6c699158 100644 --- a/tests/asynchronous/types.py +++ b/tests/asynchronous/types.py @@ -90,4 +90,65 @@ class Meta: class ChildAsyncUnionType(Union): class Meta: types = (ChildAsyncType, AnotherChildAsyncType) - interfaces = (ParentAsyncInterface,) \ No newline at end of file + interfaces = (ParentAsyncInterface,) + + +# Deep select_related stress-test types +class DeepL10AsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL10 + registry = local_async_registry + + +class DeepL9AsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL9 + registry = local_async_registry + + +class DeepL8AsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL8 + registry = local_async_registry + + +class DeepL7AsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL7 + registry = local_async_registry + + +class DeepL6AsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL6 + registry = local_async_registry + + +class DeepL5AsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL5 + registry = local_async_registry + + +class DeepL4AsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL4 + registry = local_async_registry + + +class DeepL3AsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL3 + registry = local_async_registry + + +class DeepL2AsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL2 + registry = local_async_registry + + +class DeepL1AsyncType(AsyncMongoengineObjectType): + class Meta: + model = models.DeepL1 + registry = local_async_registry \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index b1744da5..9625d45b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,18 +10,34 @@ from .models import ( AnotherChild, Article, + Bench, CellTower, Child, ChildRegisteredAfter, ChildRegisteredBefore, + DeepL1, + DeepL2, + DeepL3, + DeepL4, + DeepL5, + DeepL6, + DeepL7, + DeepL8, + DeepL9, + DeepL10, Editor, EmbeddedArticle, + Event, + Exam, + GradeEnum, ParentWithRelationship, Player, ProfessorMetadata, ProfessorVector, Publisher, Reporter, + School, + SchoolClass, ) current_dirname = os.path.dirname(os.path.abspath(__file__)) @@ -183,6 +199,71 @@ def fixtures(): child3.parent = child4.parent = parent child3.save() child4.save() + + # Deep select_related chain — 10 levels + for cls in [DeepL1, DeepL2, DeepL3, DeepL4, DeepL5, DeepL6, DeepL7, DeepL8, DeepL9, DeepL10]: + cls.drop_collection() + + l10a = DeepL10(name="L10-A").save() + l10b = DeepL10(name="L10-B").save() + l10c = DeepL10(name="L10-C").save() + + l9 = DeepL9(name="L9", child=l10a).save() + + l8 = DeepL8(name="L8", child=l9, extras=[l10b, l10c]).save() + + l7 = DeepL7(name="L7", child=l8).save() + + l6 = DeepL6(name="L6", child=l7, generic_item=l7).save() + + l5a = DeepL5(name="L5-A", child=l6).save() + l5b = DeepL5(name="L5-B", child=l6).save() + l5a.siblings = [l5b] + l5a.save() + + l4 = DeepL4(name="L4", child=l5a).save() + + l3 = DeepL3(name="L3", child=l4, generic_item=l4, extra_refs=[l5a, l5b]).save() + + l2a = DeepL2(name="L2-A", child=l3).save() + l2b = DeepL2(name="L2-B", child=l3).save() + + DeepL1(name="L1", child=l2a, children=[l2a, l2b]).save() + + # Enum field models + for cls in [Bench, Exam, SchoolClass, School]: + cls.drop_collection() + + bench1 = Bench(size=10).save() + bench2 = Bench(size=20).save() + exam1 = Exam(size=5).save() + + sc1 = SchoolClass( + allowed_grades=[GradeEnum.A, GradeEnum.B], + subjects=["math", "science"], + records=[bench1, exam1], + ).save() + sc2 = SchoolClass( + allowed_grades=[GradeEnum.B], + subjects=["history"], + records=[bench2], + ).save() + + School(classes=[sc1, sc2]).save() + + # ZonedDateTimeField model + Event.drop_collection() + from zoneinfo import ZoneInfo + import datetime as _dt + Event( + name="Kolkata Summit", + start_time=_dt.datetime(2024, 6, 15, 14, 30, tzinfo=ZoneInfo("Asia/Kolkata")), + ).save() + Event( + name="New York Meetup", + start_time=_dt.datetime(2024, 9, 1, 9, 0, tzinfo=ZoneInfo("America/New_York")), + ).save() + return True diff --git a/tests/models.py b/tests/models.py index f3746c17..d8d2838f 100644 --- a/tests/models.py +++ b/tests/models.py @@ -201,3 +201,86 @@ class SchoolClass(mongoengine.Document): class School(mongoengine.Document): classes = mongoengine.ListField(mongoengine.ReferenceField(SchoolClass)) + + +# --------------------------------------------------------------------------- +# Deep select_related stress-test models — 10-level reference chain +# --------------------------------------------------------------------------- + +class DeepL10(mongoengine.Document): + meta = {"collection": "test_deep_l10"} + name = mongoengine.StringField() + + +class DeepL9(mongoengine.Document): + meta = {"collection": "test_deep_l9"} + name = mongoengine.StringField() + child = mongoengine.ReferenceField(DeepL10) + + +class DeepL8(mongoengine.Document): + meta = {"collection": "test_deep_l8"} + name = mongoengine.StringField() + child = mongoengine.ReferenceField(DeepL9) + # list of references at level 8 → level 10 + extras = mongoengine.ListField(mongoengine.ReferenceField(DeepL10)) + + +class DeepL7(mongoengine.Document): + meta = {"collection": "test_deep_l7"} + name = mongoengine.StringField() + child = mongoengine.ReferenceField(DeepL8) + + +class DeepL6(mongoengine.Document): + meta = {"collection": "test_deep_l6"} + name = mongoengine.StringField() + child = mongoengine.ReferenceField(DeepL7) + # generic reference at level 6 — can point at L7 or L8 + generic_item = mongoengine.GenericReferenceField(choices=[DeepL7, DeepL8]) + + +class DeepL5(mongoengine.Document): + meta = {"collection": "test_deep_l5"} + name = mongoengine.StringField() + child = mongoengine.ReferenceField(DeepL6) + # list of sibling-level references + siblings = mongoengine.ListField(mongoengine.ReferenceField("DeepL5")) + + +class DeepL4(mongoengine.Document): + meta = {"collection": "test_deep_l4"} + name = mongoengine.StringField() + child = mongoengine.ReferenceField(DeepL5) + + +class DeepL3(mongoengine.Document): + meta = {"collection": "test_deep_l3"} + name = mongoengine.StringField() + child = mongoengine.ReferenceField(DeepL4) + # generic reference at level 3 — can point at L4 or L5 + generic_item = mongoengine.GenericReferenceField(choices=[DeepL4, DeepL5]) + # list of references to a deeper level + extra_refs = mongoengine.ListField(mongoengine.ReferenceField(DeepL5)) + + +class DeepL2(mongoengine.Document): + meta = {"collection": "test_deep_l2"} + name = mongoengine.StringField() + child = mongoengine.ReferenceField(DeepL3) + + +class DeepL1(mongoengine.Document): + meta = {"collection": "test_deep_l1"} + name = mongoengine.StringField() + child = mongoengine.ReferenceField(DeepL2) + # list of references at level 1 → level 2 + children = mongoengine.ListField(mongoengine.ReferenceField(DeepL2)) + + +class Event(mongoengine.Document): + """Test model for ZonedDateTimeField — stores a name and a timezone-aware start time.""" + + meta = {"collection": "test_event"} + name = mongoengine.StringField(required=True) + start_time = mongoengine.ZonedDateTimeField() diff --git a/tests/mongo_capture.py b/tests/mongo_capture.py index c0fc2034..aad9b894 100644 --- a/tests/mongo_capture.py +++ b/tests/mongo_capture.py @@ -1,9 +1,24 @@ """ Pymongo command capture for projection/query assertion in tests. -Register the listener at import time (before mongoengine.connect creates +The listener is registered at import time (before mongoengine.connect creates the MongoClient) so every wire command is captured. + +Isolation model +--------------- +Each ``captured_commands`` / ``async_captured_commands`` block snapshots the +log length on enter and slices on exit — so concurrent or sequential blocks +never see each other's events even though they share the global log. + +Parallel processes (pytest-xdist) are safe because each worker has its own +Python interpreter and its own listener instance. + +Thread safety within a single process: CPython's GIL makes list.append +atomic, so the shared log is safe for concurrent in-process I/O threads +(e.g. Motor callbacks). The start/end snapshot is taken on the calling +thread and is not affected by other threads appending to the log. """ + import pymongo.monitoring as _pm @@ -26,8 +41,18 @@ def failed(self, event) -> None: class captured_commands: - """Context manager — yields itself; after the block, `.events` holds all - pymongo StartedEvent objects issued inside the block.""" + """Sync context manager — captures every pymongo wire command in the block. + + Usage:: + + with captured_commands() as cap: + schema.execute(query) + + assert cap.command_count == 1 + assert "name" in cap.projected_fields() + + Also supports ``async with`` for use inside ``async def`` test functions. + """ def __enter__(self): self._start = len(_capture._log) @@ -36,13 +61,27 @@ def __enter__(self): def __exit__(self, *_): self.events = _capture._log[self._start:] + async def __aenter__(self): + return self.__enter__() + + async def __aexit__(self, *args): + return self.__exit__(*args) + + @property + def command_count(self) -> int: + """Number of MongoDB commands (find + aggregate) issued in this block.""" + return sum( + 1 for e in self.events + if e.command_name in ("find", "aggregate") + ) + def projected_fields(self) -> set[str]: """ Return every field name that was positively projected across all - `find` and `aggregate` commands captured inside the block. + ``find`` and ``aggregate`` commands captured inside the block. - find: reads cmd['projection'] - - aggregate: reads every {'$project': ...} stage in cmd['pipeline'] + - aggregate: reads every ``{'$project': ...}`` stage in cmd['pipeline'] """ fields: set[str] = set() for event in self.events: diff --git a/tests/synchronous/nodes.py b/tests/synchronous/nodes.py index 32877bb4..e19e845e 100644 --- a/tests/synchronous/nodes.py +++ b/tests/synchronous/nodes.py @@ -106,3 +106,89 @@ class FooNode(MongoengineObjectType): class Meta: model = models.Foo interfaces = (Node,) + + +# Deep select_related stress-test nodes +class DeepL10Node(MongoengineObjectType): + class Meta: + model = models.DeepL10 + interfaces = (Node,) + + +class DeepL9Node(MongoengineObjectType): + class Meta: + model = models.DeepL9 + interfaces = (Node,) + + +class DeepL8Node(MongoengineObjectType): + class Meta: + model = models.DeepL8 + interfaces = (Node,) + + +class DeepL7Node(MongoengineObjectType): + class Meta: + model = models.DeepL7 + interfaces = (Node,) + + +class DeepL6Node(MongoengineObjectType): + class Meta: + model = models.DeepL6 + interfaces = (Node,) + + +class DeepL5Node(MongoengineObjectType): + class Meta: + model = models.DeepL5 + interfaces = (Node,) + + +class DeepL4Node(MongoengineObjectType): + class Meta: + model = models.DeepL4 + interfaces = (Node,) + + +class DeepL3Node(MongoengineObjectType): + class Meta: + model = models.DeepL3 + interfaces = (Node,) + + +class DeepL2Node(MongoengineObjectType): + class Meta: + model = models.DeepL2 + interfaces = (Node,) + + +class DeepL1Node(MongoengineObjectType): + class Meta: + model = models.DeepL1 + interfaces = (Node,) + + +class BenchNode(MongoengineObjectType): + class Meta: + model = models.Bench + interfaces = (Node,) + + +class ExamNode(MongoengineObjectType): + class Meta: + model = models.Exam + interfaces = (Node,) + + +class SchoolClassNode(MongoengineObjectType): + class Meta: + model = models.SchoolClass + interfaces = (Node,) + only_fields = ("allowed_grades", "subjects") + + +class EventNode(MongoengineObjectType): + class Meta: + model = models.Event + interfaces = (Node,) diff --git a/tests/synchronous/test_inputs.py b/tests/synchronous/test_inputs.py index 520e8498..9955ba15 100644 --- a/tests/synchronous/test_inputs.py +++ b/tests/synchronous/test_inputs.py @@ -2,9 +2,9 @@ from graphene.relay import Node -from ..models import Article, Editor -from .nodes import ArticleNode, EditorNode -from ..types import ArticleInput, EditorInput +from ..models import Article, Editor, ProfessorVector +from .nodes import ArticleNode, EditorNode, ProfessorVectorNode +from ..types import ArticleInput, EditorInput, ProfessorVectorInput from .utils import execute_count @@ -86,4 +86,58 @@ class Mutation(graphene.ObjectType): result, count = execute_count(schema, query) assert not result.errors assert result.data == expected + assert count == 1 + + +def test_nested_embedded_input_create(fixtures): + """MongoengineInputType with a nested EmbeddedDocumentField input stores nested data correctly.""" + + class CreateProfessorVector(graphene.Mutation): + class Arguments: + professor_vector = ProfessorVectorInput(required=True) + + professor_vector = graphene.Field(ProfessorVectorNode) + + def mutate(self, info, professor_vector): + pv = ProfessorVector(**professor_vector) + pv.save() + return CreateProfessorVector(professor_vector=pv) + + class Query(graphene.ObjectType): + node = Node.Field() + + class Mutation(graphene.ObjectType): + create_professor_vector = CreateProfessorVector.Field() + + query = """ + mutation { + createProfessorVector( + professorVector: { + vec: [3.14, 2.72] + metadata: { + firstName: "Alan" + lastName: "Turing" + departments: ["CS", "Math"] + } + } + ) { + professorVector { + vec + metadata { + firstName + lastName + departments + } + } + } + } + """ + schema = graphene.Schema(query=Query, mutation=Mutation, auto_camelcase=True) + result, count = execute_count(schema, query) + assert not result.errors, result.errors + pv = result.data["createProfessorVector"]["professorVector"] + assert pv["vec"] == [3.14, 2.72] + assert pv["metadata"]["firstName"] == "Alan" + assert pv["metadata"]["lastName"] == "Turing" + assert pv["metadata"]["departments"] == ["CS", "Math"] assert count == 1 \ No newline at end of file diff --git a/tests/synchronous/test_query.py b/tests/synchronous/test_query.py index 57b2bcef..4e6da4d6 100644 --- a/tests/synchronous/test_query.py +++ b/tests/synchronous/test_query.py @@ -419,4 +419,88 @@ def resolve_cell_towers(self, *args, **kwargs): result, count = execute_count(schema, query) assert not result.errors assert result.data == expected - assert count == 1 \ No newline at end of file + assert count == 1 + +def test_should_query_zoned_datetime(fixtures): + from .nodes import EventNode + from graphene_mongo.synchronous.fields import MongoengineConnectionField + + class Query(graphene.ObjectType): + events = MongoengineConnectionField(EventNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + result = schema.execute( + "{ events { edges { node { name startTime { utc tz } } } } }" + ) + assert not result.errors, result.errors + edges = result.data["events"]["edges"] + assert len(edges) == 2 + names = {e["node"]["name"] for e in edges} + assert names == {"Kolkata Summit", "New York Meetup"} + for edge in edges: + st = edge["node"]["startTime"] + assert st["utc"] is not None + assert st["tz"] in ("Asia/Kolkata", "America/New_York") + + +def test_should_filter_zoned_datetime_by_utc(fixtures): + """Exact equality on ZonedDateTimeField filters against the stored utc subfield.""" + from .nodes import EventNode + from graphene_mongo.synchronous.fields import MongoengineConnectionField + + class Query(graphene.ObjectType): + events = MongoengineConnectionField(EventNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + # Kolkata Summit: 2024-06-15 14:30 IST = 2024-06-15 09:00:00 UTC (exact equality) + result = schema.execute( + '{ events(startTime: "2024-06-15T09:00:00+00:00") { edges { node { name startTime { tz } } } } }' + ) + assert not result.errors, result.errors + edges = result.data["events"]["edges"] + assert len(edges) == 1 + assert edges[0]["node"]["name"] == "Kolkata Summit" + assert edges[0]["node"]["startTime"]["tz"] == "Asia/Kolkata" + + +def test_should_filter_zoned_datetime_range(fixtures): + """filter_fields gte/lte on ZonedDateTimeField compare against the utc subfield.""" + from graphene_mongo.synchronous.types import MongoengineObjectType + from graphene_mongo.synchronous.fields import MongoengineConnectionField + from .. import models as m + + class EventRangeNode(MongoengineObjectType): + class Meta: + model = m.Event + interfaces = (graphene.relay.Node,) + filter_fields = {"start_time": ["gte", "lte", "gt", "lt", "in"]} + + class Query(graphene.ObjectType): + events = MongoengineConnectionField(EventRangeNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + + # Both events: Kolkata=2024-06-15T09:00Z, NY=2024-09-01T13:00Z + # gte 2024-07-01 → only NY Meetup (auto_camelcase: start_time__gte → startTime_Gte) + result = schema.execute( + '{ events(startTime_Gte: "2024-07-01T00:00:00+00:00") { edges { node { name } } } }' + ) + assert not result.errors, result.errors + names = [e["node"]["name"] for e in result.data["events"]["edges"]] + assert names == ["New York Meetup"] + + # lte 2024-07-01 → only Kolkata Summit + result = schema.execute( + '{ events(startTime_Lte: "2024-07-01T00:00:00+00:00") { edges { node { name } } } }' + ) + assert not result.errors, result.errors + names = [e["node"]["name"] for e in result.data["events"]["edges"]] + assert names == ["Kolkata Summit"] + + # in [kolkata-utc, ny-utc] → both events (list of datetimes) + result = schema.execute( + '{ events(startTime_In: ["2024-06-15T09:00:00+00:00", "2024-09-01T13:00:00+00:00"]) { edges { node { name } } } }' + ) + assert not result.errors, result.errors + names = {e["node"]["name"] for e in result.data["events"]["edges"]} + assert names == {"Kolkata Summit", "New York Meetup"} diff --git a/tests/synchronous/test_relay_query.py b/tests/synchronous/test_relay_query.py index 71d39ae3..0b081c65 100644 --- a/tests/synchronous/test_relay_query.py +++ b/tests/synchronous/test_relay_query.py @@ -1476,3 +1476,240 @@ def resolve_articles(self, info): assert "not supported" in str(result.errors[0]) +# --------------------------------------------------------------------------- +# Enum field tests +# --------------------------------------------------------------------------- + +def test_enum_field_query(fixtures): + """ListField(EnumField) serialises enum values correctly in a relay query.""" + class Query(graphene.ObjectType): + school_classes = MongoengineConnectionField(nodes.SchoolClassNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + result, count = execute_count( + schema, + "{ schoolClasses { edges { node { allowedGrades } } } }", + ) + assert not result.errors, result.errors + all_grades = [ + e["node"]["allowedGrades"] + for e in result.data["schoolClasses"]["edges"] + ] + assert ["A", "B"] in all_grades + assert ["B"] in all_grades + assert count == 1 + + +def test_enum_field_filter(fixtures): + """Filtering on a ListField(EnumField) by enum value returns only matching documents.""" + class Query(graphene.ObjectType): + school_classes = MongoengineConnectionField(nodes.SchoolClassNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + # Enum values are passed without quotes in GraphQL (A not "A") + result, count = execute_count( + schema, + "{ schoolClasses(allowedGrades: A) { edges { node { allowedGrades } } } }", + ) + assert not result.errors, result.errors + edges = result.data["schoolClasses"]["edges"] + assert len(edges) == 1 + assert edges[0]["node"]["allowedGrades"] == ["A", "B"] + assert count == 1 + + +# --------------------------------------------------------------------------- +# Pagination edge cases +# --------------------------------------------------------------------------- + +def test_empty_result_pageinfo(fixtures): + """pageInfo on an empty result set has hasNextPage=False and hasPreviousPage=False.""" + class Query(graphene.ObjectType): + articles = MongoengineConnectionField(nodes.ArticleNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + result, _ = execute_count( + schema, + '{ articles(headline: "__no_such_article__") { edges { node { headline } } pageInfo { hasNextPage hasPreviousPage } } }', + ) + assert not result.errors, result.errors + data = result.data["articles"] + assert data["edges"] == [] + assert data["pageInfo"]["hasNextPage"] is False + assert data["pageInfo"]["hasPreviousPage"] is False + + +# --------------------------------------------------------------------------- +# Meta option projection tests +# --------------------------------------------------------------------------- + +def test_only_fields_restricts_mongodb_projection(fixtures): + """only_fields on the Meta class limits which fields are fetched from MongoDB.""" + from graphene_mongo.synchronous.types import MongoengineObjectType + from ..mongo_capture import captured_commands + + class EditorOnlyNameNode(MongoengineObjectType): + class Meta: + model = models.Editor + interfaces = (Node,) + only_fields = ("first_name",) + + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(EditorOnlyNameNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + with captured_commands() as cap: + result = schema.execute( + "{ editors { edges { node { firstName } } } }" + ) + + assert not result.errors, result.errors + projected = cap.projected_fields() + assert "fname" in projected + assert "avatar" not in projected + assert "last_name" not in projected + + +def test_exclude_fields_restricts_mongodb_projection(fixtures): + """exclude_fields removes fields from the MongoDB projection.""" + from graphene_mongo.synchronous.types import MongoengineObjectType + from ..mongo_capture import captured_commands + + class EditorNoAvatarNode(MongoengineObjectType): + class Meta: + model = models.Editor + interfaces = (Node,) + exclude_fields = ("avatar",) + + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(EditorNoAvatarNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + with captured_commands() as cap: + result = schema.execute( + "{ editors { edges { node { firstName lastName } } } }" + ) + + assert not result.errors, result.errors + projected = cap.projected_fields() + assert "fname" in projected + assert "last_name" in projected + assert "avatar" not in projected + + +def test_required_fields_always_projected(fixtures): + """required_fields are included in the MongoDB projection even when not queried.""" + from graphene_mongo.synchronous.types import MongoengineObjectType + from ..mongo_capture import captured_commands + + class EditorRequiredLastNameNode(MongoengineObjectType): + class Meta: + model = models.Editor + interfaces = (Node,) + required_fields = ("last_name",) + + class Query(graphene.ObjectType): + editors = MongoengineConnectionField(EditorRequiredLastNameNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + with captured_commands() as cap: + # Query only firstName — last_name is NOT in the GraphQL selection + result = schema.execute( + "{ editors { edges { node { firstName } } } }" + ) + + assert not result.errors, result.errors + projected = cap.projected_fields() + assert "last_name" in projected # required_fields forces it into the projection + assert "fname" in projected # queried field is also projected + + +def test_geo_near_filter_arg_exists(): + """filter_fields {"loc": ["near"]} generates a loc__near arg with PointFieldInputType.""" + from graphene_mongo.synchronous.types import MongoengineObjectType + from graphene_mongo.base.advanced_types import PointFieldInputType + + class ChildGeoNode(MongoengineObjectType): + class Meta: + model = models.Child + interfaces = (Node,) + filter_fields = {"loc": ["near"]} + + field = MongoengineConnectionField(ChildGeoNode) + assert "loc__near" in field.args + assert isinstance(field.args["loc__near"], graphene.Argument) + assert field.args["loc__near"].type == PointFieldInputType + + +def test_geo_near_filter_query(fixtures): + """loc__near filter returns only documents within the specified distance.""" + from graphene_mongo.synchronous.types import MongoengineObjectType + + class ChildGeoQueryNode(MongoengineObjectType): + class Meta: + model = models.Child + interfaces = (Node,) + filter_fields = {"loc": ["near"]} + + models.Child.ensure_indexes() + + class Query(graphene.ObjectType): + children = MongoengineConnectionField(ChildGeoQueryNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + # child2 is at [10, 20]; child1 has no location. + # Querying near [10, 20] should return child2 and exclude child1 (no loc). + # auto_camelcase converts loc__near → loc_Near (double-underscore separator is preserved) + result = schema.execute( + """ + { + children(loc_Near: {coordinates: [10.0, 20.0]}) { + edges { + node { + bar + } + } + } + } + """ + ) + assert not result.errors, result.errors + bars = [e["node"]["bar"] for e in result.data["children"]["edges"]] + assert "bar" in bars + assert "BAR" not in bars # child1 has no loc, so it's excluded + + +def test_filter_fields_invalid_lookup_schema_arg_exists(): + """filter_fields with an unknown lookup builds the schema arg without error.""" + from graphene_mongo.synchronous.types import MongoengineObjectType + + class ArticleInvalidFilterNode(MongoengineObjectType): + class Meta: + model = models.Article + interfaces = (Node,) + filter_fields = {"headline": ["bad_op"]} + + field = MongoengineConnectionField(ArticleInvalidFilterNode) + assert "headline__bad_op" in field.args + + +def test_filter_fields_invalid_lookup_raises_at_query_time(fixtures): + """An unknown lookup in filter_fields is accepted by the schema but fails at query execution.""" + from graphene_mongo.synchronous.types import MongoengineObjectType + + class ArticleInvalidLookupNode(MongoengineObjectType): + class Meta: + model = models.Article + interfaces = (Node,) + filter_fields = {"headline": ["bad_op"]} + + class Query(graphene.ObjectType): + articles = MongoengineConnectionField(ArticleInvalidLookupNode) + + schema = graphene.Schema(query=Query, auto_camelcase=True) + result = schema.execute( + '{ articles(headlineBadOp: "My Article") { edges { node { headline } } } }' + ) + assert result.errors + + diff --git a/tests/synchronous/test_relay_query_deep.py b/tests/synchronous/test_relay_query_deep.py new file mode 100644 index 00000000..deb1a900 --- /dev/null +++ b/tests/synchronous/test_relay_query_deep.py @@ -0,0 +1,325 @@ +""" +Stress tests for select_related with 10 levels of nested references. + +Chain via .child: + L1 → L2 → L3 → L4 → L5 → L6 → L7 → L8 → L9 → L10 + +Additional reference types exercised: + L1.children ListField(ReferenceField(L2)) + L3.generic_item GenericReferenceField → L4 + L3.extra_refs ListField(ReferenceField(L5)) + L5.siblings ListField(ReferenceField(L5)) — self-referential + L6.generic_item GenericReferenceField → L7 + L8.extras ListField(ReferenceField(L10)) + +All tests assert a single MongoDB query (no N+1) when using MongoengineConnectionField. +""" + +import graphene +import pytest +from graphene.relay import Node + +from . import nodes +from graphene_mongo.synchronous.fields import MongoengineConnectionField +from .utils import execute_count + +# 10-level deep connection query — traverses the full L1→...→L10 child chain +# and exercises ListField, GenericReferenceField, and self-referential refs. +# ListField(ReferenceField) fields (extraRefs, siblings, extras, children) +# are relay connections because their target types have interfaces = (Node,). +DEEP_QUERY = """ +{ + deepChain { + edges { + node { + name + child { + name + child { + name + genericItem { __typename } + extraRefs { + edges { + node { + name + } + } + } + child { + name + child { + name + siblings { + edges { + node { + name + } + } + } + child { + name + genericItem { __typename } + child { + name + child { + name + extras { + edges { + node { + name + } + } + } + child { + name + child { + name + } + } + } + } + } + } + } + } + } + children { + edges { + node { + name + child { + name + } + } + } + } + } + } + } +} +""" + + +@pytest.fixture(scope="module") +def deep_schema(): + class Query(graphene.ObjectType): + node = Node.Field() + deep_chain = MongoengineConnectionField(nodes.DeepL1Node) + + return graphene.Schema(query=Query, auto_camelcase=True) + + +def test_deep_select_related_data_correctness(fixtures, deep_schema): + """All 10 levels resolve to the correct names.""" + result, _ = execute_count(deep_schema, DEEP_QUERY) + assert not result.errors, result.errors + + node = result.data["deepChain"]["edges"][0]["node"] + assert node["name"] == "L1" + + l2 = node["child"] + assert l2["name"] == "L2-A" + + l3 = l2["child"] + assert l3["name"] == "L3" + assert l3["genericItem"]["__typename"] == "DeepL4Node" + extra_ref_nodes = [e["node"] for e in l3["extraRefs"]["edges"]] + assert {r["name"] for r in extra_ref_nodes} == {"L5-A", "L5-B"} + + l4 = l3["child"] + assert l4["name"] == "L4" + + l5 = l4["child"] + assert l5["name"] == "L5-A" + sibling_nodes = [e["node"] for e in l5["siblings"]["edges"]] + assert sibling_nodes == [{"name": "L5-B"}] + + l6 = l5["child"] + assert l6["name"] == "L6" + assert l6["genericItem"]["__typename"] == "DeepL7Node" + + l7 = l6["child"] + assert l7["name"] == "L7" + + l8 = l7["child"] + assert l8["name"] == "L8" + extra_nodes = [e["node"] for e in l8["extras"]["edges"]] + assert {e["name"] for e in extra_nodes} == {"L10-B", "L10-C"} + + l9 = l8["child"] + assert l9["name"] == "L9" + + l10 = l9["child"] + assert l10["name"] == "L10-A" + + # children list at L1 → two L2 docs + children_nodes = [e["node"] for e in node["children"]["edges"]] + assert len(children_nodes) == 2 + assert {c["name"] for c in children_nodes} == {"L2-A", "L2-B"} + for c in children_nodes: + assert c["child"]["name"] == "L3" + + +def test_deep_select_related_single_query(fixtures, deep_schema): + """The entire 10-level graph is resolved in a single MongoDB aggregate.""" + result, count = execute_count(deep_schema, DEEP_QUERY) + assert not result.errors, result.errors + assert count == 1, f"Expected 1 query, got {count} — N+1 detected" + + +def test_deep_list_of_references(fixtures, deep_schema): + """ListField(ReferenceField) at L1.children and L3.extraRefs resolve all items.""" + query = """ + { + deepChain { + edges { + node { + name + children { + edges { + node { + name + child { name } + } + } + } + child { + child { + extraRefs { + edges { + node { + name + child { name } + } + } + } + } + } + } + } + } + } + """ + result, count = execute_count(deep_schema, query) + assert not result.errors, result.errors + node = result.data["deepChain"]["edges"][0]["node"] + + children_nodes = [e["node"] for e in node["children"]["edges"]] + assert len(children_nodes) == 2 + assert {c["name"] for c in children_nodes} == {"L2-A", "L2-B"} + + extra_ref_nodes = [e["node"] for e in node["child"]["child"]["extraRefs"]["edges"]] + assert len(extra_ref_nodes) == 2 + assert {r["name"] for r in extra_ref_nodes} == {"L5-A", "L5-B"} + for r in extra_ref_nodes: + assert r["child"]["name"] == "L6" + + assert count == 1 + + +def test_deep_generic_references(fixtures, deep_schema): + """GenericReferenceFields at L3 and L6 resolve to the correct concrete types.""" + query = """ + { + deepChain { + edges { + node { + child { + child { + genericItem { __typename } + child { + child { + child { + genericItem { __typename } + } + } + } + } + } + } + } + } + } + """ + result, count = execute_count(deep_schema, query) + assert not result.errors, result.errors + l2 = result.data["deepChain"]["edges"][0]["node"]["child"] + l3 = l2["child"] + assert l3["genericItem"]["__typename"] == "DeepL4Node" + l6 = l3["child"]["child"]["child"] + assert l6["genericItem"]["__typename"] == "DeepL7Node" + assert count == 1 + + +def test_deep_self_referential_list(fixtures, deep_schema): + """Self-referential ListField(ReferenceField('self')) at L5 resolves correctly.""" + query = """ + { + deepChain { + edges { + node { + child { + child { + child { + child { + name + siblings { + edges { + node { + name + } + } + } + } + } + } + } + } + } + } + } + """ + result, count = execute_count(deep_schema, query) + assert not result.errors, result.errors + l5 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"] + assert l5["name"] == "L5-A" + sibling_nodes = [e["node"] for e in l5["siblings"]["edges"]] + assert sibling_nodes == [{"name": "L5-B"}] + assert count == 1 + + +def test_deep_list_at_depth_8(fixtures, deep_schema): + """ListField(ReferenceField) at L8.extras (pointing to L10) resolves all items.""" + query = """ + { + deepChain { + edges { + node { + child { child { child { child { child { child { child { + name + extras { + edges { + node { + name + } + } + } + child { + name + child { name } + } + } } } } } } } + } + } + } + } + """ + result, count = execute_count(deep_schema, query) + assert not result.errors, result.errors + l8 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"]["child"]["child"]["child"] + assert l8["name"] == "L8" + extra_nodes = [e["node"] for e in l8["extras"]["edges"]] + assert {e["name"] for e in extra_nodes} == {"L10-B", "L10-C"} + assert l8["child"]["name"] == "L9" + assert l8["child"]["child"]["name"] == "L10-A" + assert count == 1 \ No newline at end of file diff --git a/tests/synchronous/test_utils.py b/tests/synchronous/test_utils.py index 1522bfef..66cb26de 100644 --- a/tests/synchronous/test_utils.py +++ b/tests/synchronous/test_utils.py @@ -1,7 +1,7 @@ import graphene from .. import types -from ..models import Article, Child, Reporter +from ..models import Article, Child, Player, Reporter from graphene_mongo.base.utils import ( get_model_fields, get_query_fields, @@ -144,3 +144,40 @@ def test_get_select_related_paths_reporter_articles(): queried = {"articles": {"headline": {}}} paths = get_select_related_paths(Reporter, queried) assert "articles" in paths + + +def test_get_select_related_paths_self_referential_terminates(): + """Self-referential ListField(ReferenceField('self')) terminates at query depth.""" + # Player.players = ListField(ReferenceField('Player')) — a cycle in the schema. + # Termination is guaranteed by the finite depth of the queried_fields dict (from + # the parsed GraphQL query), not by any explicit cycle-breaking in the function. + queried = { + "players": { + "firstName": {}, + "players": { + "firstName": {}, + }, + } + } + paths = get_select_related_paths(Player, queried) + assert "players" in paths + assert "players__players" in paths + + +def test_get_select_related_paths_relay_connection_unwrapped(): + """Relay edges→node wrapper around sub-fields is unwrapped so nested paths are found.""" + # When Player.players is rendered as a Relay connection, sub-fields arrive + # wrapped in edges→node. The function should unwrap and still find nested refs. + queried = { + "players": { + "edges": { + "node": { + "firstName": {}, + "opponent": {"firstName": {}}, + } + } + } + } + paths = get_select_related_paths(Player, queried) + assert "players" in paths + assert "players__opponent" in paths diff --git a/tests/test_converter.py b/tests/test_converter.py index c0946780..4f672fbb 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -107,6 +107,14 @@ def test_should_file_convert_field(): assert graphene_type.type == advanced_types.FileFieldType +def test_should_zoned_datetime_convert_field(): + graphene_type = convert_mongoengine_field(mongoengine.ZonedDateTimeField()) + assert isinstance(graphene_type, graphene.Field) + assert graphene_type.type == advanced_types.ZonedDateTimeType + assert isinstance(graphene_type.type.utc, graphene.DateTime) + assert isinstance(graphene_type.type.tz, graphene.String) + + def test_should_field_convert_list(): assert_conversion(mongoengine.ListField, graphene.List, field=mongoengine.StringField()) diff --git a/tests/types.py b/tests/types.py index 2c506e32..1e07ff03 100644 --- a/tests/types.py +++ b/tests/types.py @@ -93,3 +93,70 @@ class Meta: only_fields = ["first_name", "last_name"] # allow providing only one of those ! Even None... non_required_fields = ["first_name", "last_name"] + + +# ProfessorMetadataInput must be registered before ProfessorVectorInput so the +# EmbeddedDocumentField converter can resolve the nested type from the inputs registry. +class ProfessorMetadataInput(MongoengineInputType): + class Meta: + model = models.ProfessorMetadata + only_fields = ["first_name", "last_name", "departments"] + non_required_fields = ["first_name", "last_name", "departments"] + + +class ProfessorVectorInput(MongoengineInputType): + class Meta: + model = models.ProfessorVector + only_fields = ["vec", "metadata"] + non_required_fields = ["vec", "metadata"] + + +# Deep select_related stress-test types +class DeepL10Type(MongoengineObjectType): + class Meta: + model = models.DeepL10 + + +class DeepL9Type(MongoengineObjectType): + class Meta: + model = models.DeepL9 + + +class DeepL8Type(MongoengineObjectType): + class Meta: + model = models.DeepL8 + + +class DeepL7Type(MongoengineObjectType): + class Meta: + model = models.DeepL7 + + +class DeepL6Type(MongoengineObjectType): + class Meta: + model = models.DeepL6 + + +class DeepL5Type(MongoengineObjectType): + class Meta: + model = models.DeepL5 + + +class DeepL4Type(MongoengineObjectType): + class Meta: + model = models.DeepL4 + + +class DeepL3Type(MongoengineObjectType): + class Meta: + model = models.DeepL3 + + +class DeepL2Type(MongoengineObjectType): + class Meta: + model = models.DeepL2 + + +class DeepL1Type(MongoengineObjectType): + class Meta: + model = models.DeepL1 diff --git a/uv.lock b/uv.lock index e94f6d39..29b0497e 100644 --- a/uv.lock +++ b/uv.lock @@ -459,7 +459,7 @@ test = [ requires-dist = [ { name = "graphene", specifier = ">=3.1.1" }, { name = "graphene-federation", specifier = ">=3.1.5" }, - { name = "mongoengine", git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.4" }, + { name = "mongoengine", git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.5" }, { name = "promise", specifier = ">=2.3" }, ] @@ -655,7 +655,7 @@ wheels = [ [[package]] name = "mongoengine" version = "0.30.0" -source = { git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.4#9c63038fbe76242320097092a147dbe997a2d931" } +source = { git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.5#ef5f9e4528089d899be04fc3657017e052bebbe0" } dependencies = [ { name = "pymongo" }, ] From d3405a08e82ccb01f1fe40464feeeb1a00cfd2a5 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Mon, 25 May 2026 09:06:10 +0530 Subject: [PATCH 47/52] chore: bump examples to mongoengine v0.30.0-alpha.5 and clean async docs --- docs/quickstart/async.rst | 5 ----- examples/django_mongoengine/pyproject.toml | 2 +- examples/falcon_mongoengine/pyproject.toml | 2 +- examples/fastapi_mongoengine/pyproject.toml | 2 +- examples/flask_mongoengine/pyproject.toml | 2 +- 5 files changed, 4 insertions(+), 9 deletions(-) diff --git a/docs/quickstart/async.rst b/docs/quickstart/async.rst index 898b6452..fffd52b6 100644 --- a/docs/quickstart/async.rst +++ b/docs/quickstart/async.rst @@ -1,16 +1,12 @@ Asynchronous ============ -Async mode requires both a standard MongoEngine connection (for schema -introspection) and an async Motor connection (for queries): - 1. Connect to MongoDB --------------------- .. code:: python import mongoengine - mongoengine.connect("mydb") mongoengine.async_connect("mydb") 2. Define a document @@ -79,7 +75,6 @@ Full example from graphene.relay import Node from graphene_mongo import AsyncMongoengineObjectType, AsyncMongoengineConnectionField - mongoengine.connect("mydb") mongoengine.async_connect("mydb") class Article(mongoengine.Document): diff --git a/examples/django_mongoengine/pyproject.toml b/examples/django_mongoengine/pyproject.toml index 4762eba2..c297be0e 100644 --- a/examples/django_mongoengine/pyproject.toml +++ b/examples/django_mongoengine/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ [tool.uv.sources] graphene-mongo = { path = "../.." } -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.3" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.5" } [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "bike_catalog.settings_test" diff --git a/examples/falcon_mongoengine/pyproject.toml b/examples/falcon_mongoengine/pyproject.toml index 1d0708f3..1b358b86 100644 --- a/examples/falcon_mongoengine/pyproject.toml +++ b/examples/falcon_mongoengine/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ [tool.uv.sources] graphene-mongo = { path = "../.." } -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.3" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.5" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/examples/fastapi_mongoengine/pyproject.toml b/examples/fastapi_mongoengine/pyproject.toml index 34bd283e..9b60b9db 100644 --- a/examples/fastapi_mongoengine/pyproject.toml +++ b/examples/fastapi_mongoengine/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ [tool.uv.sources] graphene-mongo = { path = "../.." } -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.3" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.5" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/examples/flask_mongoengine/pyproject.toml b/examples/flask_mongoengine/pyproject.toml index 0a2c1acc..f437c5a4 100644 --- a/examples/flask_mongoengine/pyproject.toml +++ b/examples/flask_mongoengine/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ [tool.uv.sources] graphene-mongo = { path = "../.." } -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.3" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.5" } [tool.pytest.ini_options] asyncio_mode = "auto" From 298f3c374d108ca85c7a9bf6a752ea882d1abb52 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Mon, 25 May 2026 09:09:30 +0530 Subject: [PATCH 48/52] chore: bump tox.ini to mongoengine v0.30.0-alpha.5 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index a485fb36..eec41afa 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ setenv = COVERAGE_FILE = .coverage.{envname} deps = - mongoengine @ git+https://github.com/strollby/mongoengine@v0.30.0-alpha.4 + mongoengine @ git+https://github.com/strollby/mongoengine@v0.30.0-alpha.5 commands = pytest --cov-report= --cov=graphene_mongo tests/ {posargs} \ No newline at end of file From 60c20de259c7ef4ef60f4c4caabdf060ab684d28 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Mon, 25 May 2026 12:41:53 +0530 Subject: [PATCH 49/52] =?UTF-8?q?chore:=20rename=20ZonedDateTimeField=20?= =?UTF-8?q?=E2=86=92=20AwareDateTimeField=20(mongoengine=20v0.30.0-alpha.6?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/django_mongoengine/pyproject.toml | 2 +- examples/falcon_mongoengine/pyproject.toml | 2 +- examples/fastapi_mongoengine/pyproject.toml | 2 +- examples/flask_mongoengine/pyproject.toml | 2 +- graphene_mongo/base/advanced_types.py | 8 ++++---- graphene_mongo/base/converter.py | 4 ++-- graphene_mongo/base/fields.py | 8 ++++---- pyproject.toml | 2 +- tests/README.md | 2 +- tests/asynchronous/test_query.py | 2 +- tests/conftest.py | 2 +- tests/models.py | 4 ++-- tests/synchronous/test_query.py | 4 ++-- tests/test_converter.py | 2 +- tox.ini | 2 +- uv.lock | 4 ++-- 16 files changed, 26 insertions(+), 26 deletions(-) diff --git a/examples/django_mongoengine/pyproject.toml b/examples/django_mongoengine/pyproject.toml index c297be0e..8e07a0bf 100644 --- a/examples/django_mongoengine/pyproject.toml +++ b/examples/django_mongoengine/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ [tool.uv.sources] graphene-mongo = { path = "../.." } -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.5" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.6" } [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "bike_catalog.settings_test" diff --git a/examples/falcon_mongoengine/pyproject.toml b/examples/falcon_mongoengine/pyproject.toml index 1b358b86..016f1897 100644 --- a/examples/falcon_mongoengine/pyproject.toml +++ b/examples/falcon_mongoengine/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ [tool.uv.sources] graphene-mongo = { path = "../.." } -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.5" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.6" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/examples/fastapi_mongoengine/pyproject.toml b/examples/fastapi_mongoengine/pyproject.toml index 9b60b9db..54d93d9e 100644 --- a/examples/fastapi_mongoengine/pyproject.toml +++ b/examples/fastapi_mongoengine/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ [tool.uv.sources] graphene-mongo = { path = "../.." } -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.5" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.6" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/examples/flask_mongoengine/pyproject.toml b/examples/flask_mongoengine/pyproject.toml index f437c5a4..ea464dac 100644 --- a/examples/flask_mongoengine/pyproject.toml +++ b/examples/flask_mongoengine/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ [tool.uv.sources] graphene-mongo = { path = "../.." } -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.5" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.6" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/graphene_mongo/base/advanced_types.py b/graphene_mongo/base/advanced_types.py index 0e8b5231..009052ff 100644 --- a/graphene_mongo/base/advanced_types.py +++ b/graphene_mongo/base/advanced_types.py @@ -152,7 +152,7 @@ class MultiPolygonFieldType(_CoordinatesTypeField): @shareable # Support Graphene Federation v2 class ZonedDateTimeType(graphene.ObjectType): - """GraphQL ObjectType for a MongoEngine ZonedDateTimeField. + """GraphQL ObjectType for a MongoEngine AwareDateTimeField. Stores a datetime together with its originating timezone so the frontend can display the local time without losing DST or offset information. @@ -171,7 +171,7 @@ def resolve_utc(self, info): """Return the UTC datetime. Handles both the raw MongoDB dict ({"utc": datetime, "tz": str}) and the - timezone-aware datetime that ZonedDateTimeField.to_python returns. + timezone-aware datetime that AwareDateTimeField.to_python returns. """ if isinstance(self, dict): return self["utc"] @@ -181,7 +181,7 @@ def resolve_tz(self, info): """Return the IANA timezone name. Handles both the raw MongoDB dict and the timezone-aware datetime that - ZonedDateTimeField.to_python returns. + AwareDateTimeField.to_python returns. """ if isinstance(self, dict): return self["tz"] @@ -192,7 +192,7 @@ def resolve_tz(self, info): class ZonedDateTimeInputType(graphene.InputObjectType): - """GraphQL InputObjectType for writing a MongoEngine ZonedDateTimeField. + """GraphQL InputObjectType for writing a MongoEngine AwareDateTimeField. Fields: utc (DateTime): The moment expressed as a UTC datetime. Required. diff --git a/graphene_mongo/base/converter.py b/graphene_mongo/base/converter.py index ec4180c2..5f0f6c37 100644 --- a/graphene_mongo/base/converter.py +++ b/graphene_mongo/base/converter.py @@ -172,9 +172,9 @@ def convert_file_to_field(field, registry=None, executor: ExecutorEnum = Executo ) -@convert_mongoengine_field.register(mongoengine.ZonedDateTimeField) +@convert_mongoengine_field.register(mongoengine.AwareDateTimeField) def convert_zoned_datetime_to_field(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): - """Convert ZonedDateTimeField → graphene.Field(ZonedDateTimeType). + """Convert AwareDateTimeField → graphene.Field(ZonedDateTimeType). The raw MongoDB document stores {"utc": datetime, "tz": "timezone_name"}. ZonedDateTimeType exposes both subfields so clients receive the UTC instant diff --git a/graphene_mongo/base/fields.py b/graphene_mongo/base/fields.py index 14963243..72b05b7f 100644 --- a/graphene_mongo/base/fields.py +++ b/graphene_mongo/base/fields.py @@ -41,7 +41,7 @@ def _to_utc(value): """Normalise a datetime (or list of datetimes) to UTC. - Used by _hydrate_args to rewrite ZonedDateTimeField filter values before + Used by _hydrate_args to rewrite AwareDateTimeField filter values before they are passed to MongoEngine. Handles the in/nin/all list case as well as single values, and assumes UTC when the value has no tzinfo. """ @@ -450,10 +450,10 @@ def _hydrate_args(self, args: dict) -> None: elif ( arg_name in self.model._fields_ordered and isinstance( - getattr(self.model, arg_name), mongoengine.ZonedDateTimeField + getattr(self.model, arg_name), mongoengine.AwareDateTimeField ) ): - # ZonedDateTimeField stores {"utc": datetime, "tz": str}. + # AwareDateTimeField stores {"utc": datetime, "tz": str}. # Rewrite bare field filter to compare against the utc subfield. hydrated[arg_name + "__utc"] = _to_utc(args.pop(arg_name)) elif "__" in arg_name: @@ -463,7 +463,7 @@ def _hydrate_args(self, args: dict) -> None: if ( field_name in self.model._fields_ordered and isinstance( - getattr(self.model, field_name), mongoengine.ZonedDateTimeField + getattr(self.model, field_name), mongoengine.AwareDateTimeField ) ): value = args.pop(arg_name) diff --git a/pyproject.toml b/pyproject.toml index 2487a41e..33383522 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ Homepage = "https://github.com/graphql-python/graphene-mongo" Repository = "https://github.com/graphql-python/graphene-mongo" [tool.uv.sources] -mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.5" } +mongoengine = { git = "https://github.com/strollby/mongoengine", rev = "v0.30.0-alpha.6" } [dependency-groups] dev = [ diff --git a/tests/README.md b/tests/README.md index 5e64f346..a1ed16e3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -251,7 +251,7 @@ Relay mutations via `MongoengineCreateMutation` / `MongoengineUpdateMutation`. C ## `ZonedDateTimeField` support -`ZonedDateTimeField` (mongoengine v0.30.0-alpha.5+) stores a datetime as `{"utc": datetime, "tz": "IANA/Zone"}`. +`ZonedDateTimeField` (mongoengine v0.30.0-alpha.6+) stores a datetime as `{"utc": datetime, "tz": "IANA/Zone"}`. graphene-mongo exposes it as `ZonedDateTimeType` with two subfields: ```graphql diff --git a/tests/asynchronous/test_query.py b/tests/asynchronous/test_query.py index 99d4691c..f32d5fa8 100644 --- a/tests/asynchronous/test_query.py +++ b/tests/asynchronous/test_query.py @@ -459,7 +459,7 @@ class Query(graphene.ObjectType): async def test_should_filter_zoned_datetime_range(fixtures): - """filter_fields gte/lte/gt/lt/in on ZonedDateTimeField compare against the utc subfield.""" + """filter_fields gte/lte/gt/lt/in on AwareDateTimeField compare against the utc subfield.""" from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType from graphene_mongo import AsyncMongoengineConnectionField from .. import models as m diff --git a/tests/conftest.py b/tests/conftest.py index 9625d45b..5e166e97 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -251,7 +251,7 @@ def fixtures(): School(classes=[sc1, sc2]).save() - # ZonedDateTimeField model + # AwareDateTimeField model Event.drop_collection() from zoneinfo import ZoneInfo import datetime as _dt diff --git a/tests/models.py b/tests/models.py index d8d2838f..a6c82e11 100644 --- a/tests/models.py +++ b/tests/models.py @@ -279,8 +279,8 @@ class DeepL1(mongoengine.Document): class Event(mongoengine.Document): - """Test model for ZonedDateTimeField — stores a name and a timezone-aware start time.""" + """Test model for AwareDateTimeField — stores a name and a timezone-aware start time.""" meta = {"collection": "test_event"} name = mongoengine.StringField(required=True) - start_time = mongoengine.ZonedDateTimeField() + start_time = mongoengine.AwareDateTimeField() diff --git a/tests/synchronous/test_query.py b/tests/synchronous/test_query.py index 4e6da4d6..2d9f057f 100644 --- a/tests/synchronous/test_query.py +++ b/tests/synchronous/test_query.py @@ -444,7 +444,7 @@ class Query(graphene.ObjectType): def test_should_filter_zoned_datetime_by_utc(fixtures): - """Exact equality on ZonedDateTimeField filters against the stored utc subfield.""" + """Exact equality on AwareDateTimeField filters against the stored utc subfield.""" from .nodes import EventNode from graphene_mongo.synchronous.fields import MongoengineConnectionField @@ -464,7 +464,7 @@ class Query(graphene.ObjectType): def test_should_filter_zoned_datetime_range(fixtures): - """filter_fields gte/lte on ZonedDateTimeField compare against the utc subfield.""" + """filter_fields gte/lte on AwareDateTimeField compare against the utc subfield.""" from graphene_mongo.synchronous.types import MongoengineObjectType from graphene_mongo.synchronous.fields import MongoengineConnectionField from .. import models as m diff --git a/tests/test_converter.py b/tests/test_converter.py index 4f672fbb..ef276ca1 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -108,7 +108,7 @@ def test_should_file_convert_field(): def test_should_zoned_datetime_convert_field(): - graphene_type = convert_mongoengine_field(mongoengine.ZonedDateTimeField()) + graphene_type = convert_mongoengine_field(mongoengine.AwareDateTimeField()) assert isinstance(graphene_type, graphene.Field) assert graphene_type.type == advanced_types.ZonedDateTimeType assert isinstance(graphene_type.type.utc, graphene.DateTime) diff --git a/tox.ini b/tox.ini index eec41afa..996ffd69 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ setenv = COVERAGE_FILE = .coverage.{envname} deps = - mongoengine @ git+https://github.com/strollby/mongoengine@v0.30.0-alpha.5 + mongoengine @ git+https://github.com/strollby/mongoengine@v0.30.0-alpha.6 commands = pytest --cov-report= --cov=graphene_mongo tests/ {posargs} \ No newline at end of file diff --git a/uv.lock b/uv.lock index 29b0497e..b0e68a73 100644 --- a/uv.lock +++ b/uv.lock @@ -459,7 +459,7 @@ test = [ requires-dist = [ { name = "graphene", specifier = ">=3.1.1" }, { name = "graphene-federation", specifier = ">=3.1.5" }, - { name = "mongoengine", git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.5" }, + { name = "mongoengine", git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.6" }, { name = "promise", specifier = ">=2.3" }, ] @@ -655,7 +655,7 @@ wheels = [ [[package]] name = "mongoengine" version = "0.30.0" -source = { git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.5#ef5f9e4528089d899be04fc3657017e052bebbe0" } +source = { git = "https://github.com/strollby/mongoengine?rev=v0.30.0-alpha.6#03ca52d4f1146ea7000628a45dbbdd54a69be7ec" } dependencies = [ { name = "pymongo" }, ] From 46e219ca1ec1454d7ff28ed6b7d15a95f5f936b8 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Mon, 25 May 2026 12:58:57 +0530 Subject: [PATCH 50/52] feat: replace ZonedDateTimeType with AwareDateTimeScalar (RFC 9557 IXDTF) AwareDateTimeField now serialises to a single RFC 9557 IXDTF scalar string: "2024-06-15T14:30:00+05:30[Asia/Kolkata]" The string carries the local wall-clock time, the correct UTC offset (DST-aware), and the IANA timezone annotation in brackets. JavaScript clients can parse it natively with Temporal.ZonedDateTime.from(). Filter inputs accept the full IXDTF format or any plain RFC 3339 string with a UTC offset; both are normalised to UTC before the MongoEngine query. --- graphene_mongo/base/advanced_types.py | 114 +++++++++++++++----------- graphene_mongo/base/converter.py | 12 ++- graphene_mongo/base/fields.py | 8 +- tests/README.md | 34 +++++--- tests/asynchronous/test_query.py | 37 ++++----- tests/synchronous/test_query.py | 39 ++++----- tests/test_converter.py | 7 +- 7 files changed, 133 insertions(+), 118 deletions(-) diff --git a/graphene_mongo/base/advanced_types.py b/graphene_mongo/base/advanced_types.py index 009052ff..ef743467 100644 --- a/graphene_mongo/base/advanced_types.py +++ b/graphene_mongo/base/advanced_types.py @@ -1,5 +1,6 @@ import base64 import datetime +import re as _re import graphene from graphene_federation import shareable @@ -9,6 +10,13 @@ except ImportError: UTC = datetime.timezone.utc +try: + from zoneinfo import ZoneInfo as _ZoneInfo +except ImportError: + from backports.zoneinfo import ZoneInfo as _ZoneInfo + +_IXDTF_RE = _re.compile(r"^(.*)\[([^\]!]+)\]$") + @shareable # Support Graphene Federation v2 class FileFieldType(graphene.ObjectType): @@ -150,54 +158,68 @@ class MultiPolygonFieldType(_CoordinatesTypeField): ) -@shareable # Support Graphene Federation v2 -class ZonedDateTimeType(graphene.ObjectType): - """GraphQL ObjectType for a MongoEngine AwareDateTimeField. - - Stores a datetime together with its originating timezone so the frontend - can display the local time without losing DST or offset information. - - Fields: - utc (DateTime): The moment in time expressed as a UTC-normalised - ISO-8601 datetime string. Use this for all comparisons and sorting. - tz (String): IANA timezone name (e.g. "Asia/Kolkata", "America/New_York") - that identifies the wall-clock timezone the value was recorded in. - """ - - utc = graphene.DateTime(required=True) - tz = graphene.String(required=True) - - def resolve_utc(self, info): - """Return the UTC datetime. +class AwareDateTimeScalar(graphene.Scalar): + """RFC 9557 IXDTF scalar for MongoEngine AwareDateTimeField. - Handles both the raw MongoDB dict ({"utc": datetime, "tz": str}) and the - timezone-aware datetime that AwareDateTimeField.to_python returns. - """ - if isinstance(self, dict): - return self["utc"] - return self.astimezone(UTC) + Serialises to the Internet Extended Date/Time Format (IXDTF): + "2024-05-16T12:00:00+09:00[Asia/Tokyo]" + ↑ local wall-clock time ↑ IANA timezone annotation - def resolve_tz(self, info): - """Return the IANA timezone name. - - Handles both the raw MongoDB dict and the timezone-aware datetime that - AwareDateTimeField.to_python returns. - """ - if isinstance(self, dict): - return self["tz"] - tzinfo = self.tzinfo - if hasattr(tzinfo, "key"): - return tzinfo.key - return str(tzinfo) + The datetime component represents local time in the annotated timezone + (including the correct UTC offset for DST). Clients can parse this + directly with the JavaScript Temporal API: + Temporal.ZonedDateTime.from("2024-05-16T12:00:00+09:00[Asia/Tokyo]") - -class ZonedDateTimeInputType(graphene.InputObjectType): - """GraphQL InputObjectType for writing a MongoEngine AwareDateTimeField. - - Fields: - utc (DateTime): The moment expressed as a UTC datetime. Required. - tz (String): IANA timezone name (e.g. "Asia/Kolkata"). Required. + Accepts as input: + - Full IXDTF string: "2024-06-15T14:30:00+05:30[Asia/Kolkata]" + - Plain RFC 3339 string with offset: "2024-06-15T09:00:00+00:00" + - UTC string: "2024-06-15T09:00:00Z" """ - utc = graphene.DateTime(required=True) - tz = graphene.String(required=True) + class Meta: + name = "AwareDateTime" + + @staticmethod + def serialize(value): + """Serialise a stored AwareDateTimeField value to an IXDTF string.""" + if isinstance(value, dict): + utc_dt = value["utc"] + tz_name = value["tz"] + else: + tzinfo = getattr(value, "tzinfo", None) + if hasattr(tzinfo, "key"): + tz_name = tzinfo.key + elif hasattr(tzinfo, "zone"): + tz_name = tzinfo.zone + else: + tz_name = str(tzinfo) + utc_dt = value + + if utc_dt.tzinfo is None: + utc_dt = utc_dt.replace(tzinfo=UTC) + + local_dt = utc_dt.astimezone(_ZoneInfo(tz_name)) + return f"{local_dt.isoformat()}[{tz_name}]" + + @staticmethod + def parse_value(value): + """Parse an IXDTF string (or plain ISO 8601) to a timezone-aware datetime.""" + if not isinstance(value, str): + raise ValueError(f"AwareDateTime requires a string, got {type(value).__name__}") + m = _IXDTF_RE.match(value) + if m: + dt_str, tz_name = m.group(1), m.group(2) + dt = datetime.datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=_ZoneInfo(tz_name)) + return dt + return datetime.datetime.fromisoformat(value) + + @staticmethod + def parse_literal(ast): + """Parse a GraphQL string literal to a timezone-aware datetime.""" + from graphql import StringValueNode + + if isinstance(ast, StringValueNode): + return AwareDateTimeScalar.parse_value(ast.value) + return None diff --git a/graphene_mongo/base/converter.py b/graphene_mongo/base/converter.py index 5f0f6c37..a9a0dfe0 100644 --- a/graphene_mongo/base/converter.py +++ b/graphene_mongo/base/converter.py @@ -173,15 +173,13 @@ def convert_file_to_field(field, registry=None, executor: ExecutorEnum = Executo @convert_mongoengine_field.register(mongoengine.AwareDateTimeField) -def convert_zoned_datetime_to_field(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): - """Convert AwareDateTimeField → graphene.Field(ZonedDateTimeType). +def convert_aware_datetime_to_field(field, registry=None, executor: ExecutorEnum = ExecutorEnum.SYNC): + """Convert AwareDateTimeField → AwareDateTimeScalar (RFC 9557 IXDTF). - The raw MongoDB document stores {"utc": datetime, "tz": "timezone_name"}. - ZonedDateTimeType exposes both subfields so clients receive the UTC instant - and the IANA timezone name needed to reconstruct the original wall-clock time. + Serialises to "2024-05-16T12:00:00+09:00[Asia/Tokyo]" — local wall-clock + time with the UTC offset and IANA timezone annotation per RFC 9557. """ - return graphene.Field( - advanced_types.ZonedDateTimeType, + return advanced_types.AwareDateTimeScalar( description=get_field_description(field, registry), required=get_field_is_required(field, registry), ) diff --git a/graphene_mongo/base/fields.py b/graphene_mongo/base/fields.py index 72b05b7f..37d02bff 100644 --- a/graphene_mongo/base/fields.py +++ b/graphene_mongo/base/fields.py @@ -15,12 +15,12 @@ from graphql_relay import from_global_id from .advanced_types import ( + AwareDateTimeScalar, FileFieldType, MultiPolygonFieldType, PointFieldInputType, PointFieldType, PolygonFieldType, - ZonedDateTimeType, ) from .converter import MongoEngineConversionError, convert_mongoengine_field from .registry import get_global_registry @@ -261,8 +261,6 @@ def is_filterable(k): def get_filter_type(_type): if isinstance(_type, Structure): return get_filter_type(_type.of_type) - if _type is ZonedDateTimeType: - return graphene.DateTime() return _type() return { @@ -308,8 +306,8 @@ def filter_args(self): filter_type = graphene.Int else: filter_type = PointFieldInputType - elif field_type_str in ("ZonedDateTimeType", "ZonedDateTimeType!"): - filter_type = graphene.DateTime + elif field_type_str in ("AwareDateTime", "AwareDateTime!"): + filter_type = AwareDateTimeScalar else: filter_type = getattr( graphene, diff --git a/tests/README.md b/tests/README.md index a1ed16e3..e85d7081 100644 --- a/tests/README.md +++ b/tests/README.md @@ -249,20 +249,23 @@ Relay mutations via `MongoengineCreateMutation` / `MongoengineUpdateMutation`. C --- -## `ZonedDateTimeField` support +## `AwareDateTimeField` support -`ZonedDateTimeField` (mongoengine v0.30.0-alpha.6+) stores a datetime as `{"utc": datetime, "tz": "IANA/Zone"}`. -graphene-mongo exposes it as `ZonedDateTimeType` with two subfields: +`AwareDateTimeField` (mongoengine v0.30.0-alpha.6+) stores a datetime as `{"utc": datetime, "tz": "IANA/Zone"}`. +graphene-mongo exposes it as the `AwareDateTime` **scalar** per [RFC 9557 (IXDTF)](https://datatracker.ietf.org/doc/html/rfc9557): ```graphql -startTime { - utc # DateTime — UTC-normalised instant, use for sorting and comparisons - tz # String — IANA timezone name (e.g. "Asia/Kolkata", "America/New_York") -} +# scalar — no subfields, just a string +startTime # "2024-06-15T14:30:00+05:30[Asia/Kolkata]" ``` -**Filtering** behaves identically to a plain `DateTime` field. Any MongoEngine operator declared in `filter_fields` -is transparently rewritten to compare against the stored `utc` subfield: +The string is the **local wall-clock time** in the stored timezone, with the correct UTC offset (honouring DST) and the IANA timezone annotation in brackets. JavaScript clients can parse it natively with the Temporal API: + +```javascript +Temporal.ZonedDateTime.from("2024-06-15T14:30:00+05:30[Asia/Kolkata]") +``` + +**Filtering** is transparently rewritten to compare against the stored `utc` subfield. Inputs accept full IXDTF strings or plain RFC 3339 strings with a UTC offset: ```python class EventNode(MongoengineObjectType): @@ -272,11 +275,16 @@ class EventNode(MongoengineObjectType): ``` ```graphql -# camelCase note: start_time__gte → startTime_Gte (double-underscore separator preserved) -{ events(startTime_Gte: "2024-07-01T00:00:00+00:00") { edges { node { name } } } } +# IXDTF input (recommended) +{ events(startTime_Gte: "2024-07-01T00:00:00+05:30[Asia/Kolkata]") { edges { node { name startTime } } } } + +# Plain RFC 3339 also accepted +{ events(startTime_Gte: "2024-07-01T00:00:00+00:00") { edges { node { name startTime } } } } ``` -List operators (`in`, `nin`, `all`) accept a list of `DateTime` values — each element is individually +camelCase note: `start_time__gte` → `startTime_Gte` (double-underscore separator preserved). + +List operators (`in`, `nin`, `all`) accept a list of `AwareDateTime` values — each element is individually normalised to UTC before the query is sent. --- @@ -304,4 +312,4 @@ normalised to UTC before the query is sent. | Nested input objects | Covered | `ProfessorMetadataInput` nested in `ProfessorVectorInput` (`test_inputs.py`) | | Geo field filtering (`__near`) | Covered | Arg existence + live `$near` query with 2dsphere index (`test_relay_query.py`) | | `filter_fields` validation errors | Covered | Invalid lookup: schema builds fine, query fails at execution (`test_relay_query.py`) | -| `ZonedDateTimeField` (query + filter) | Covered | Read `utc`/`tz`, exact equality, range (`gte`/`lte`/`gt`/`lt`), list (`in`) operators | \ No newline at end of file +| `AwareDateTimeField` (query + filter) | Covered | IXDTF scalar output, exact equality, range (`gte`/`lte`/`gt`/`lt`), list (`in`) operators | \ No newline at end of file diff --git a/tests/asynchronous/test_query.py b/tests/asynchronous/test_query.py index f32d5fa8..522f5278 100644 --- a/tests/asynchronous/test_query.py +++ b/tests/asynchronous/test_query.py @@ -417,7 +417,7 @@ async def resolve_cell_towers(self, *args, **kwargs): assert result.data == expected assert count == 1 -async def test_should_query_zoned_datetime(fixtures): +async def test_should_query_aware_datetime(fixtures): from .nodes import EventAsyncNode from graphene_mongo import AsyncMongoengineConnectionField @@ -425,21 +425,18 @@ class Query(graphene.ObjectType): events = AsyncMongoengineConnectionField(EventAsyncNode) schema = graphene.Schema(query=Query, auto_camelcase=True) - result = await schema.execute_async( - "{ events { edges { node { name startTime { utc tz } } } } }" - ) + result = await schema.execute_async("{ events { edges { node { name startTime } } } }") assert not result.errors, result.errors edges = result.data["events"]["edges"] assert len(edges) == 2 - names = {e["node"]["name"] for e in edges} - assert names == {"Kolkata Summit", "New York Meetup"} - for edge in edges: - st = edge["node"]["startTime"] - assert st["utc"] is not None - assert st["tz"] in ("Asia/Kolkata", "America/New_York") + by_name = {e["node"]["name"]: e["node"]["startTime"] for e in edges} + # IXDTF format: local wall-clock time + offset + [IANA annotation] + assert by_name["Kolkata Summit"] == "2024-06-15T14:30:00+05:30[Asia/Kolkata]" + assert by_name["New York Meetup"] == "2024-09-01T09:00:00-04:00[America/New_York]" -async def test_should_filter_zoned_datetime_by_utc(fixtures): +async def test_should_filter_aware_datetime_by_utc(fixtures): + """Exact equality on AwareDateTimeField using IXDTF input.""" from .nodes import EventAsyncNode from graphene_mongo import AsyncMongoengineConnectionField @@ -447,19 +444,19 @@ class Query(graphene.ObjectType): events = AsyncMongoengineConnectionField(EventAsyncNode) schema = graphene.Schema(query=Query, auto_camelcase=True) - # Kolkata Summit: 2024-06-15 14:30 IST = 2024-06-15 09:00:00 UTC (exact equality) + # Filter using local IXDTF string — Kolkata Summit is 2024-06-15 14:30 IST result = await schema.execute_async( - '{ events(startTime: "2024-06-15T09:00:00+00:00") { edges { node { name startTime { tz } } } } }' + '{ events(startTime: "2024-06-15T14:30:00+05:30[Asia/Kolkata]") { edges { node { name startTime } } } }' ) assert not result.errors, result.errors edges = result.data["events"]["edges"] assert len(edges) == 1 assert edges[0]["node"]["name"] == "Kolkata Summit" - assert edges[0]["node"]["startTime"]["tz"] == "Asia/Kolkata" + assert edges[0]["node"]["startTime"] == "2024-06-15T14:30:00+05:30[Asia/Kolkata]" -async def test_should_filter_zoned_datetime_range(fixtures): - """filter_fields gte/lte/gt/lt/in on AwareDateTimeField compare against the utc subfield.""" +async def test_should_filter_aware_datetime_range(fixtures): + """filter_fields gte/lte/in on AwareDateTimeField compare against the utc subfield.""" from graphene_mongo.asynchronous.types import AsyncMongoengineObjectType from graphene_mongo import AsyncMongoengineConnectionField from .. import models as m @@ -475,7 +472,7 @@ class Query(graphene.ObjectType): schema = graphene.Schema(query=Query, auto_camelcase=True) - # gte 2024-07-01 → only NY Meetup (startTime_Gte due to double-underscore camelcase) + # gte 2024-07-01 UTC → only NY Meetup (plain UTC offset, no IANA annotation) result = await schema.execute_async( '{ events(startTime_Gte: "2024-07-01T00:00:00+00:00") { edges { node { name } } } }' ) @@ -483,7 +480,7 @@ class Query(graphene.ObjectType): names = [e["node"]["name"] for e in result.data["events"]["edges"]] assert names == ["New York Meetup"] - # lte 2024-07-01 → only Kolkata Summit + # lte using plain offset → only Kolkata Summit result = await schema.execute_async( '{ events(startTime_Lte: "2024-07-01T00:00:00+00:00") { edges { node { name } } } }' ) @@ -491,9 +488,9 @@ class Query(graphene.ObjectType): names = [e["node"]["name"] for e in result.data["events"]["edges"]] assert names == ["Kolkata Summit"] - # in [kolkata-utc, ny-utc] → both events (list of datetimes) + # in using IXDTF strings → both events result = await schema.execute_async( - '{ events(startTime_In: ["2024-06-15T09:00:00+00:00", "2024-09-01T13:00:00+00:00"]) { edges { node { name } } } }' + '{ events(startTime_In: ["2024-06-15T14:30:00+05:30[Asia/Kolkata]", "2024-09-01T09:00:00-04:00[America/New_York]"]) { edges { node { name } } } }' ) assert not result.errors, result.errors names = {e["node"]["name"] for e in result.data["events"]["edges"]} diff --git a/tests/synchronous/test_query.py b/tests/synchronous/test_query.py index 2d9f057f..b0240f87 100644 --- a/tests/synchronous/test_query.py +++ b/tests/synchronous/test_query.py @@ -421,7 +421,7 @@ def resolve_cell_towers(self, *args, **kwargs): assert result.data == expected assert count == 1 -def test_should_query_zoned_datetime(fixtures): +def test_should_query_aware_datetime(fixtures): from .nodes import EventNode from graphene_mongo.synchronous.fields import MongoengineConnectionField @@ -429,22 +429,18 @@ class Query(graphene.ObjectType): events = MongoengineConnectionField(EventNode) schema = graphene.Schema(query=Query, auto_camelcase=True) - result = schema.execute( - "{ events { edges { node { name startTime { utc tz } } } } }" - ) + result = schema.execute("{ events { edges { node { name startTime } } } }") assert not result.errors, result.errors edges = result.data["events"]["edges"] assert len(edges) == 2 - names = {e["node"]["name"] for e in edges} - assert names == {"Kolkata Summit", "New York Meetup"} - for edge in edges: - st = edge["node"]["startTime"] - assert st["utc"] is not None - assert st["tz"] in ("Asia/Kolkata", "America/New_York") + by_name = {e["node"]["name"]: e["node"]["startTime"] for e in edges} + # IXDTF format: local wall-clock time + offset + [IANA annotation] + assert by_name["Kolkata Summit"] == "2024-06-15T14:30:00+05:30[Asia/Kolkata]" + assert by_name["New York Meetup"] == "2024-09-01T09:00:00-04:00[America/New_York]" -def test_should_filter_zoned_datetime_by_utc(fixtures): - """Exact equality on AwareDateTimeField filters against the stored utc subfield.""" +def test_should_filter_aware_datetime_by_utc(fixtures): + """Exact equality on AwareDateTimeField using IXDTF input.""" from .nodes import EventNode from graphene_mongo.synchronous.fields import MongoengineConnectionField @@ -452,19 +448,19 @@ class Query(graphene.ObjectType): events = MongoengineConnectionField(EventNode) schema = graphene.Schema(query=Query, auto_camelcase=True) - # Kolkata Summit: 2024-06-15 14:30 IST = 2024-06-15 09:00:00 UTC (exact equality) + # Filter using local IXDTF string — Kolkata Summit is 2024-06-15 14:30 IST result = schema.execute( - '{ events(startTime: "2024-06-15T09:00:00+00:00") { edges { node { name startTime { tz } } } } }' + '{ events(startTime: "2024-06-15T14:30:00+05:30[Asia/Kolkata]") { edges { node { name startTime } } } }' ) assert not result.errors, result.errors edges = result.data["events"]["edges"] assert len(edges) == 1 assert edges[0]["node"]["name"] == "Kolkata Summit" - assert edges[0]["node"]["startTime"]["tz"] == "Asia/Kolkata" + assert edges[0]["node"]["startTime"] == "2024-06-15T14:30:00+05:30[Asia/Kolkata]" -def test_should_filter_zoned_datetime_range(fixtures): - """filter_fields gte/lte on AwareDateTimeField compare against the utc subfield.""" +def test_should_filter_aware_datetime_range(fixtures): + """filter_fields gte/lte/in on AwareDateTimeField compare against the utc subfield.""" from graphene_mongo.synchronous.types import MongoengineObjectType from graphene_mongo.synchronous.fields import MongoengineConnectionField from .. import models as m @@ -480,8 +476,7 @@ class Query(graphene.ObjectType): schema = graphene.Schema(query=Query, auto_camelcase=True) - # Both events: Kolkata=2024-06-15T09:00Z, NY=2024-09-01T13:00Z - # gte 2024-07-01 → only NY Meetup (auto_camelcase: start_time__gte → startTime_Gte) + # gte 2024-07-01 UTC → only NY Meetup (plain UTC offset, no IANA annotation) result = schema.execute( '{ events(startTime_Gte: "2024-07-01T00:00:00+00:00") { edges { node { name } } } }' ) @@ -489,7 +484,7 @@ class Query(graphene.ObjectType): names = [e["node"]["name"] for e in result.data["events"]["edges"]] assert names == ["New York Meetup"] - # lte 2024-07-01 → only Kolkata Summit + # lte using IXDTF → only Kolkata Summit result = schema.execute( '{ events(startTime_Lte: "2024-07-01T00:00:00+00:00") { edges { node { name } } } }' ) @@ -497,9 +492,9 @@ class Query(graphene.ObjectType): names = [e["node"]["name"] for e in result.data["events"]["edges"]] assert names == ["Kolkata Summit"] - # in [kolkata-utc, ny-utc] → both events (list of datetimes) + # in using IXDTF strings → both events result = schema.execute( - '{ events(startTime_In: ["2024-06-15T09:00:00+00:00", "2024-09-01T13:00:00+00:00"]) { edges { node { name } } } }' + '{ events(startTime_In: ["2024-06-15T14:30:00+05:30[Asia/Kolkata]", "2024-09-01T09:00:00-04:00[America/New_York]"]) { edges { node { name } } } }' ) assert not result.errors, result.errors names = {e["node"]["name"] for e in result.data["events"]["edges"]} diff --git a/tests/test_converter.py b/tests/test_converter.py index ef276ca1..8366549c 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -107,12 +107,9 @@ def test_should_file_convert_field(): assert graphene_type.type == advanced_types.FileFieldType -def test_should_zoned_datetime_convert_field(): +def test_should_aware_datetime_convert_field(): graphene_type = convert_mongoengine_field(mongoengine.AwareDateTimeField()) - assert isinstance(graphene_type, graphene.Field) - assert graphene_type.type == advanced_types.ZonedDateTimeType - assert isinstance(graphene_type.type.utc, graphene.DateTime) - assert isinstance(graphene_type.type.tz, graphene.String) + assert isinstance(graphene_type, advanced_types.AwareDateTimeScalar) def test_should_field_convert_list(): From 97ae4367d87859a807884e11e111b144d8cba76a Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Mon, 25 May 2026 13:23:08 +0530 Subject: [PATCH 51/52] test: add three missing select_related scenarios to deep stress tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the 10-level reference-chain test suite with three new field-type combinations not previously covered: 1. ListField(GenericReferenceField) on a Document (DeepL8.generic_refs) 2. ListField(ReferenceField) inside an EmbeddedDocument (DeepEmbedWithRef.list_refs) — exposed as a relay connection; pre-loaded via select_related path "embed__list_refs" 3. Nested EmbeddedDocumentField within EmbeddedDocumentField (DeepNestedEmbed inside DeepEmbedWithRef) — ref resolved via "embed__nested__ref_item" in a single aggregation pipeline Adds DeepNestedEmbed model and registers DeepNestedEmbedType / DeepNestedEmbedAsyncType before DeepEmbedWithRef* in nodes files so the EmbeddedDocumentField converter sees the inner type at class-creation time. All 256 tests pass. --- graphene_mongo/base/utils.py | 7 + tests/asynchronous/nodes.py | 16 ++ tests/asynchronous/test_relay_query_deep.py | 223 ++++++++++++++++++- tests/conftest.py | 34 ++- tests/models.py | 25 +++ tests/synchronous/nodes.py | 16 ++ tests/synchronous/test_relay_query_deep.py | 229 +++++++++++++++++++- 7 files changed, 526 insertions(+), 24 deletions(-) diff --git a/graphene_mongo/base/utils.py b/graphene_mongo/base/utils.py index 62fc4aed..a2400083 100644 --- a/graphene_mongo/base/utils.py +++ b/graphene_mongo/base/utils.py @@ -283,6 +283,13 @@ def get_select_related_paths(model, queried_fields, prefix=""): # are visible to the recursion even when the sub-field is a connection. effective = sub_fields.get("edges", {}).get("node") or sub_fields paths += get_select_related_paths(inner.document_type, effective, prefix=path) + elif isinstance(inner, mongoengine.EmbeddedDocumentField): + # Embedded docs are stored inline — no select_related path needed for the + # embedded doc itself, but any ReferenceField inside it does need one. + # Path prefix grows (e.g. "embed") so nested refs become "embed__ref_item". + if sub_fields and hasattr(inner, "document_type"): + path = f"{prefix}__{snake}" if prefix else snake + paths += get_select_related_paths(inner.document_type, sub_fields, prefix=path) return paths diff --git a/tests/asynchronous/nodes.py b/tests/asynchronous/nodes.py index 1214a942..e2900c39 100644 --- a/tests/asynchronous/nodes.py +++ b/tests/asynchronous/nodes.py @@ -127,6 +127,22 @@ class Meta: interfaces = (Node,) +class DeepNestedEmbedAsyncType(AsyncMongoengineObjectType): + """Async type for DeepNestedEmbed — registered before DeepEmbedWithRefAsyncType so the + EmbeddedDocumentField converter for 'nested' can resolve the inner type at class-creation time.""" + + class Meta: + model = models.DeepNestedEmbed + + +class DeepEmbedWithRefAsyncType(AsyncMongoengineObjectType): + """Async type for DeepEmbedWithRef — registered before DeepL7AsyncNode so the + EmbeddedDocumentListField converter can resolve the inner type at class-creation time.""" + + class Meta: + model = models.DeepEmbedWithRef + + class DeepL7AsyncNode(AsyncMongoengineObjectType): class Meta: model = models.DeepL7 diff --git a/tests/asynchronous/test_relay_query_deep.py b/tests/asynchronous/test_relay_query_deep.py index 2520d18b..33e036d1 100644 --- a/tests/asynchronous/test_relay_query_deep.py +++ b/tests/asynchronous/test_relay_query_deep.py @@ -1,17 +1,44 @@ """ Async stress tests for select_related with 10 levels of nested references. -Mirror of tests/synchronous/test_deep_select_related.py using AsyncMongoengineObjectType. +Mirror of tests/synchronous/test_relay_query_deep.py using AsyncMongoengineObjectType. -Chain via .child: +Primary chain via .child: L1 → L2 → L3 → L4 → L5 → L6 → L7 → L8 → L9 → L10 -Additional reference types exercised: - L1.children ListField(ReferenceField(L2)) - L3.generic_item GenericReferenceField → L4 - L3.extra_refs ListField(ReferenceField(L5)) - L5.siblings ListField(ReferenceField(L5)) — self-referential - L6.generic_item GenericReferenceField → L7 - L8.extras ListField(ReferenceField(L10)) +Additional reference field variants exercised on the chain: + + Direct references (ListField / GenericReference on Document): + L1.children ListField(ReferenceField(L2)) — relay connection + L3.generic_item GenericReferenceField → L4 — union scalar + L3.extra_refs ListField(ReferenceField(L5)) — relay connection + L5.siblings ListField(ReferenceField('self')) — self-referential relay connection + L6.generic_item GenericReferenceField → L7 — union scalar + L8.extras ListField(ReferenceField(L10)) — relay connection + L8.generic_refs ListField(GenericReferenceField([L9, L10])) — list of union scalars [scenario 1] + + Embedded document with references (DeepEmbedWithRef on L7): + L7.embed EmbeddedDocumentField(DeepEmbedWithRef) + └─ .ref_item ReferenceField(L10) + └─ .generic_item GenericReferenceField([L9, L10]) + └─ .list_refs ListField(ReferenceField(L10)) — relay connection [scenario 2] + └─ .nested EmbeddedDocumentField(DeepNestedEmbed) + └─ .ref_item ReferenceField(L10) [scenario 3] + L7.embeds EmbeddedDocumentListField(DeepEmbedWithRef) — plain List (no relay) + └─ [].ref_item ReferenceField(L10) + └─ [].generic_item GenericReferenceField([L9, L10]) + └─ [].list_refs ListField(ReferenceField(L10)) + └─ [].nested EmbeddedDocumentField(DeepNestedEmbed) + └─ .ref_item ReferenceField(L10) + + Scenario coverage: + 1. ListField(GenericReferenceField) on a Document — L8.generic_refs + 2. ListField(ReferenceField) inside an EmbeddedDocument — DeepEmbedWithRef.list_refs + 3. Nested EmbeddedDocumentField within EmbeddedDocumentField with refs — DeepEmbedWithRef.nested + + The embedded-doc tests verify that get_select_related_paths recurses into + EmbeddedDocumentField (and transitively into nested embedded docs), producing + paths like "embed__ref_item", "embed__list_refs", "embed__nested__ref_item" so + that all references are bulk-fetched in the same aggregation pipeline (no N+1). All tests assert a single MongoDB query (no N+1) when using AsyncMongoengineConnectionField. """ @@ -320,4 +347,182 @@ async def test_deep_list_at_depth_8(fixtures, deep_schema): assert {e["name"] for e in extra_nodes} == {"L10-B", "L10-C"} assert l8["child"]["name"] == "L9" assert l8["child"]["child"]["name"] == "L10-A" + assert count == 1 + + +async def test_deep_embedded_doc_with_refs(fixtures, deep_schema): + """EmbeddedDocumentField containing ReferenceField and GenericReferenceField resolves + via select_related — both refItem and genericItem are resolved in 1 query.""" + query = """ + { + deepChain { + edges { + node { + child { child { child { child { child { child { + name + embed { + label + refItem { name } + genericItem { + __typename + ... on DeepL9AsyncNode { name } + ... on DeepL10AsyncNode { name } + } + } + } } } } } } + } + } + } + } + """ + result, count = await execute_count(deep_schema, query) + assert not result.errors, result.errors + l7 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"]["child"]["child"] + assert l7["name"] == "L7" + embed = l7["embed"] + assert embed["label"] == "embed-single" + assert embed["refItem"]["name"] == "L10-A" + assert embed["genericItem"]["__typename"] == "DeepL9AsyncNode" + assert embed["genericItem"]["name"] == "L9" + assert count == 1 + + +async def test_deep_embedded_doc_list_with_refs(fixtures, deep_schema): + """EmbeddedDocumentListField containing ReferenceField and GenericReferenceField + resolves all items and their references via select_related in 1 query.""" + query = """ + { + deepChain { + edges { + node { + child { child { child { child { child { child { + name + embeds { + label + refItem { name } + genericItem { + __typename + ... on DeepL9AsyncNode { name } + ... on DeepL10AsyncNode { name } + } + } + } } } } } } + } + } + } + } + """ + result, count = await execute_count(deep_schema, query) + assert not result.errors, result.errors + l7 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"]["child"]["child"] + assert l7["name"] == "L7" + embeds = l7["embeds"] + assert len(embeds) == 2 + by_label = {e["label"]: e for e in embeds} + assert by_label["embed-list-0"]["refItem"]["name"] == "L10-B" + assert by_label["embed-list-0"]["genericItem"]["__typename"] == "DeepL10AsyncNode" + assert by_label["embed-list-0"]["genericItem"]["name"] == "L10-C" + assert by_label["embed-list-1"]["refItem"]["name"] == "L10-C" + assert by_label["embed-list-1"]["genericItem"]["__typename"] == "DeepL9AsyncNode" + assert by_label["embed-list-1"]["genericItem"]["name"] == "L9" + assert count == 1 + + +async def test_deep_list_of_generic_references(fixtures, deep_schema): + """ListField(GenericReferenceField) at L8.genericRefs resolves all items via select_related.""" + query = """ + { + deepChain { + edges { + node { + child { child { child { child { child { child { child { + name + genericRefs { + __typename + ... on DeepL9AsyncNode { name } + ... on DeepL10AsyncNode { name } + } + } } } } } } } + } + } + } + } + """ + result, count = await execute_count(deep_schema, query) + assert not result.errors, result.errors + l8 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"]["child"]["child"]["child"] + assert l8["name"] == "L8" + generic_refs = l8["genericRefs"] + assert len(generic_refs) == 2 + by_type = {item["__typename"]: item for item in generic_refs} + assert by_type["DeepL9AsyncNode"]["name"] == "L9" + assert by_type["DeepL10AsyncNode"]["name"] == "L10-B" + assert count == 1 + + +async def test_deep_embed_list_refs(fixtures, deep_schema): + """ListField(ReferenceField) inside EmbeddedDocumentField resolves via select_related in 1 query.""" + query = """ + { + deepChain { + edges { + node { + child { child { child { child { child { child { + name + embed { + label + listRefs { + edges { + node { + name + } + } + } + } + } } } } } } + } + } + } + } + """ + result, count = await execute_count(deep_schema, query) + assert not result.errors, result.errors + l7 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"]["child"]["child"] + assert l7["name"] == "L7" + embed = l7["embed"] + assert embed["label"] == "embed-single" + list_ref_names = {e["node"]["name"] for e in embed["listRefs"]["edges"]} + assert list_ref_names == {"L10-B", "L10-C"} + assert count == 1 + + +async def test_deep_nested_embed_ref(fixtures, deep_schema): + """Nested EmbeddedDocumentField within EmbeddedDocumentField: embed.nested.refItem + is resolved via select_related path 'embed__nested__ref_item' in 1 query.""" + query = """ + { + deepChain { + edges { + node { + child { child { child { child { child { child { + name + embed { + label + nested { + refItem { name } + } + } + } } } } } } + } + } + } + } + """ + result, count = await execute_count(deep_schema, query) + assert not result.errors, result.errors + l7 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"]["child"]["child"] + assert l7["name"] == "L7" + embed = l7["embed"] + assert embed["label"] == "embed-single" + assert embed["nested"]["refItem"]["name"] == "L10-A" assert count == 1 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 5e166e97..7b4508d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,8 @@ DeepL8, DeepL9, DeepL10, + DeepEmbedWithRef, + DeepNestedEmbed, Editor, EmbeddedArticle, Event, @@ -210,9 +212,35 @@ def fixtures(): l9 = DeepL9(name="L9", child=l10a).save() - l8 = DeepL8(name="L8", child=l9, extras=[l10b, l10c]).save() - - l7 = DeepL7(name="L7", child=l8).save() + l8 = DeepL8(name="L8", child=l9, extras=[l10b, l10c], generic_refs=[l9, l10b]).save() + + l7 = DeepL7( + name="L7", + child=l8, + embed=DeepEmbedWithRef( + label="embed-single", + ref_item=l10a, + generic_item=l9, + list_refs=[l10b, l10c], + nested=DeepNestedEmbed(ref_item=l10a), + ), + embeds=[ + DeepEmbedWithRef( + label="embed-list-0", + ref_item=l10b, + generic_item=l10c, + list_refs=[l10a], + nested=DeepNestedEmbed(ref_item=l10b), + ), + DeepEmbedWithRef( + label="embed-list-1", + ref_item=l10c, + generic_item=l9, + list_refs=[l10a, l10b], + nested=DeepNestedEmbed(ref_item=l10c), + ), + ], + ).save() l6 = DeepL6(name="L6", child=l7, generic_item=l7).save() diff --git a/tests/models.py b/tests/models.py index a6c82e11..b6edd483 100644 --- a/tests/models.py +++ b/tests/models.py @@ -224,12 +224,37 @@ class DeepL8(mongoengine.Document): child = mongoengine.ReferenceField(DeepL9) # list of references at level 8 → level 10 extras = mongoengine.ListField(mongoengine.ReferenceField(DeepL10)) + # list of generic references — scenario 1 + generic_refs = mongoengine.ListField(mongoengine.GenericReferenceField(choices=[DeepL9, DeepL10])) + + +class DeepNestedEmbed(mongoengine.EmbeddedDocument): + """Nested EmbeddedDocument — lives inside DeepEmbedWithRef to test recursive embedded-doc path generation.""" + + ref_item = mongoengine.ReferenceField(DeepL10) + + +class DeepEmbedWithRef(mongoengine.EmbeddedDocument): + """EmbeddedDocument with ReferenceField, GenericReferenceField, ListField(ReferenceField), + and a nested EmbeddedDocumentField for deep select_related tests.""" + + label = mongoengine.StringField() + ref_item = mongoengine.ReferenceField(DeepL10) + generic_item = mongoengine.GenericReferenceField(choices=[DeepL9, DeepL10]) + # list of references inside an embedded doc — scenario 2 + list_refs = mongoengine.ListField(mongoengine.ReferenceField(DeepL10)) + # nested embedded doc with a reference — scenario 3 + nested = mongoengine.EmbeddedDocumentField(DeepNestedEmbed) class DeepL7(mongoengine.Document): meta = {"collection": "test_deep_l7"} name = mongoengine.StringField() child = mongoengine.ReferenceField(DeepL8) + # single embedded doc with ref fields + embed = mongoengine.EmbeddedDocumentField(DeepEmbedWithRef) + # list of embedded docs with ref fields + embeds = mongoengine.EmbeddedDocumentListField(DeepEmbedWithRef) class DeepL6(mongoengine.Document): diff --git a/tests/synchronous/nodes.py b/tests/synchronous/nodes.py index e19e845e..0e4cbe2d 100644 --- a/tests/synchronous/nodes.py +++ b/tests/synchronous/nodes.py @@ -127,6 +127,22 @@ class Meta: interfaces = (Node,) +class DeepNestedEmbedType(MongoengineObjectType): + """Sync type for DeepNestedEmbed — registered before DeepEmbedWithRefType so the + EmbeddedDocumentField converter for 'nested' can resolve the inner type at class-creation time.""" + + class Meta: + model = models.DeepNestedEmbed + + +class DeepEmbedWithRefType(MongoengineObjectType): + """Sync type for DeepEmbedWithRef — registered before DeepL7Node so the + EmbeddedDocumentListField converter can resolve the inner type at class-creation time.""" + + class Meta: + model = models.DeepEmbedWithRef + + class DeepL7Node(MongoengineObjectType): class Meta: model = models.DeepL7 diff --git a/tests/synchronous/test_relay_query_deep.py b/tests/synchronous/test_relay_query_deep.py index deb1a900..e7675112 100644 --- a/tests/synchronous/test_relay_query_deep.py +++ b/tests/synchronous/test_relay_query_deep.py @@ -1,16 +1,43 @@ """ Stress tests for select_related with 10 levels of nested references. -Chain via .child: +Primary chain via .child: L1 → L2 → L3 → L4 → L5 → L6 → L7 → L8 → L9 → L10 -Additional reference types exercised: - L1.children ListField(ReferenceField(L2)) - L3.generic_item GenericReferenceField → L4 - L3.extra_refs ListField(ReferenceField(L5)) - L5.siblings ListField(ReferenceField(L5)) — self-referential - L6.generic_item GenericReferenceField → L7 - L8.extras ListField(ReferenceField(L10)) +Additional reference field variants exercised on the chain: + + Direct references (ListField / GenericReference on Document): + L1.children ListField(ReferenceField(L2)) — relay connection + L3.generic_item GenericReferenceField → L4 — union scalar + L3.extra_refs ListField(ReferenceField(L5)) — relay connection + L5.siblings ListField(ReferenceField('self')) — self-referential relay connection + L6.generic_item GenericReferenceField → L7 — union scalar + L8.extras ListField(ReferenceField(L10)) — relay connection + L8.generic_refs ListField(GenericReferenceField([L9, L10])) — list of union scalars [scenario 1] + + Embedded document with references (DeepEmbedWithRef on L7): + L7.embed EmbeddedDocumentField(DeepEmbedWithRef) + └─ .ref_item ReferenceField(L10) + └─ .generic_item GenericReferenceField([L9, L10]) + └─ .list_refs ListField(ReferenceField(L10)) — relay connection [scenario 2] + └─ .nested EmbeddedDocumentField(DeepNestedEmbed) + └─ .ref_item ReferenceField(L10) [scenario 3] + L7.embeds EmbeddedDocumentListField(DeepEmbedWithRef) — plain List (no relay) + └─ [].ref_item ReferenceField(L10) + └─ [].generic_item GenericReferenceField([L9, L10]) + └─ [].list_refs ListField(ReferenceField(L10)) + └─ [].nested EmbeddedDocumentField(DeepNestedEmbed) + └─ .ref_item ReferenceField(L10) + + Scenario coverage: + 1. ListField(GenericReferenceField) on a Document — L8.generic_refs + 2. ListField(ReferenceField) inside an EmbeddedDocument — DeepEmbedWithRef.list_refs + 3. Nested EmbeddedDocumentField within EmbeddedDocumentField with refs — DeepEmbedWithRef.nested + + The embedded-doc tests verify that get_select_related_paths recurses into + EmbeddedDocumentField (and transitively into nested embedded docs), producing + paths like "embed__ref_item", "embed__list_refs", "embed__nested__ref_item" so + that all references are bulk-fetched in the same aggregation pipeline (no N+1). All tests assert a single MongoDB query (no N+1) when using MongoengineConnectionField. """ @@ -23,10 +50,10 @@ from graphene_mongo.synchronous.fields import MongoengineConnectionField from .utils import execute_count -# 10-level deep connection query — traverses the full L1→...→L10 child chain -# and exercises ListField, GenericReferenceField, and self-referential refs. -# ListField(ReferenceField) fields (extraRefs, siblings, extras, children) -# are relay connections because their target types have interfaces = (Node,). +# 10-level deep connection query — traverses the full L1→...→L10 child chain and +# exercises ListField refs, GenericReferenceField, self-referential refs, and embedded +# docs with refs (embed/embeds on L7). ListField(ReferenceField) fields are relay +# connections; EmbeddedDocumentListField fields are plain lists. DEEP_QUERY = """ { deepChain { @@ -322,4 +349,182 @@ def test_deep_list_at_depth_8(fixtures, deep_schema): assert {e["name"] for e in extra_nodes} == {"L10-B", "L10-C"} assert l8["child"]["name"] == "L9" assert l8["child"]["child"]["name"] == "L10-A" + assert count == 1 + + +def test_deep_embedded_doc_with_refs(fixtures, deep_schema): + """EmbeddedDocumentField containing ReferenceField and GenericReferenceField resolves + via select_related — both refItem and genericItem are resolved in 1 query.""" + query = """ + { + deepChain { + edges { + node { + child { child { child { child { child { child { + name + embed { + label + refItem { name } + genericItem { + __typename + ... on DeepL9Node { name } + ... on DeepL10Node { name } + } + } + } } } } } } + } + } + } + } + """ + result, count = execute_count(deep_schema, query) + assert not result.errors, result.errors + l7 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"]["child"]["child"] + assert l7["name"] == "L7" + embed = l7["embed"] + assert embed["label"] == "embed-single" + assert embed["refItem"]["name"] == "L10-A" + assert embed["genericItem"]["__typename"] == "DeepL9Node" + assert embed["genericItem"]["name"] == "L9" + assert count == 1 + + +def test_deep_embedded_doc_list_with_refs(fixtures, deep_schema): + """EmbeddedDocumentListField containing ReferenceField and GenericReferenceField + resolves all items and their references via select_related in 1 query.""" + query = """ + { + deepChain { + edges { + node { + child { child { child { child { child { child { + name + embeds { + label + refItem { name } + genericItem { + __typename + ... on DeepL9Node { name } + ... on DeepL10Node { name } + } + } + } } } } } } + } + } + } + } + """ + result, count = execute_count(deep_schema, query) + assert not result.errors, result.errors + l7 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"]["child"]["child"] + assert l7["name"] == "L7" + embeds = l7["embeds"] + assert len(embeds) == 2 + by_label = {e["label"]: e for e in embeds} + assert by_label["embed-list-0"]["refItem"]["name"] == "L10-B" + assert by_label["embed-list-0"]["genericItem"]["__typename"] == "DeepL10Node" + assert by_label["embed-list-0"]["genericItem"]["name"] == "L10-C" + assert by_label["embed-list-1"]["refItem"]["name"] == "L10-C" + assert by_label["embed-list-1"]["genericItem"]["__typename"] == "DeepL9Node" + assert by_label["embed-list-1"]["genericItem"]["name"] == "L9" + assert count == 1 + + +def test_deep_list_of_generic_references(fixtures, deep_schema): + """ListField(GenericReferenceField) at L8.genericRefs resolves all items via select_related.""" + query = """ + { + deepChain { + edges { + node { + child { child { child { child { child { child { child { + name + genericRefs { + __typename + ... on DeepL9Node { name } + ... on DeepL10Node { name } + } + } } } } } } } + } + } + } + } + """ + result, count = execute_count(deep_schema, query) + assert not result.errors, result.errors + l8 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"]["child"]["child"]["child"] + assert l8["name"] == "L8" + generic_refs = l8["genericRefs"] + assert len(generic_refs) == 2 + by_type = {item["__typename"]: item for item in generic_refs} + assert by_type["DeepL9Node"]["name"] == "L9" + assert by_type["DeepL10Node"]["name"] == "L10-B" + assert count == 1 + + +def test_deep_embed_list_refs(fixtures, deep_schema): + """ListField(ReferenceField) inside EmbeddedDocumentField resolves via select_related in 1 query.""" + query = """ + { + deepChain { + edges { + node { + child { child { child { child { child { child { + name + embed { + label + listRefs { + edges { + node { + name + } + } + } + } + } } } } } } + } + } + } + } + """ + result, count = execute_count(deep_schema, query) + assert not result.errors, result.errors + l7 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"]["child"]["child"] + assert l7["name"] == "L7" + embed = l7["embed"] + assert embed["label"] == "embed-single" + list_ref_names = {e["node"]["name"] for e in embed["listRefs"]["edges"]} + assert list_ref_names == {"L10-B", "L10-C"} + assert count == 1 + + +def test_deep_nested_embed_ref(fixtures, deep_schema): + """Nested EmbeddedDocumentField within EmbeddedDocumentField: embed.nested.refItem + is resolved via select_related path 'embed__nested__ref_item' in 1 query.""" + query = """ + { + deepChain { + edges { + node { + child { child { child { child { child { child { + name + embed { + label + nested { + refItem { name } + } + } + } } } } } } + } + } + } + } + """ + result, count = execute_count(deep_schema, query) + assert not result.errors, result.errors + l7 = result.data["deepChain"]["edges"][0]["node"]["child"]["child"]["child"]["child"]["child"]["child"] + assert l7["name"] == "L7" + embed = l7["embed"] + assert embed["label"] == "embed-single" + assert embed["nested"]["refItem"]["name"] == "L10-A" assert count == 1 \ No newline at end of file From 5d3c0e905c45f9d63ca8a5c49c19ec2cdeb67263 Mon Sep 17 00:00:00 2001 From: Arun Suresh Kumar <17829040+arunsureshkumar@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:23:13 +0530 Subject: [PATCH 52/52] fix: ensure extra_args handle unresolved federated types. --- graphene_mongo/base/fields.py | 83 +++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 39 deletions(-) diff --git a/graphene_mongo/base/fields.py b/graphene_mongo/base/fields.py index 37d02bff..6569aa55 100644 --- a/graphene_mongo/base/fields.py +++ b/graphene_mongo/base/fields.py @@ -178,6 +178,9 @@ def args(self): extra_args = dict( dict(dict(_field_args, **_advance_args), **_filter_args), **_extended_args ) + for arg_, type_ in extra_args.items(): + if hasattr(type_, "is_type_of") and type_.is_type_of is None: + extra_args[arg_] = graphene.ID(description=type_._meta.description) for key in list(self._base_args.keys()): extra_args.pop(key, None) return to_arguments(self._base_args or OrderedDict(), extra_args) @@ -209,6 +212,7 @@ def _field_args(self, items): dict[str, graphene scalar instance]: Filterable field names mapped to their scalar type instance (suitable for use as a GraphQL argument type). """ + def is_filterable(k): if hasattr(self.fields[k].type, "_sdl"): return False @@ -232,14 +236,14 @@ def is_filterable(k): if isinstance(converted, (ConnectionField, Dynamic)): return False if callable(getattr(converted, "type", None)) and isinstance( - converted.type(), - ( - FileFieldType, - PointFieldType, - MultiPolygonFieldType, - PolygonFieldType, - graphene.Union, - ), + converted.type(), + ( + FileFieldType, + PointFieldType, + MultiPolygonFieldType, + PolygonFieldType, + graphene.Union, + ), ): return False if isinstance(converted, graphene.List): @@ -247,13 +251,13 @@ def is_filterable(k): if hasattr(sub_type, "of_type"): sub_type = sub_type.of_type if issubclass(sub_type, graphene.Union) or issubclass( - sub_type, graphene.ObjectType + sub_type, graphene.ObjectType ): return False if ( - hasattr(field_, "type") - and hasattr(converted, "type") - and converted.type != field_.type + hasattr(field_, "type") + and hasattr(converted, "type") + and converted.type != field_.type ): return False return True @@ -335,6 +339,7 @@ def advance_args(self): Returns: dict[str, graphene argument]: Advanced argument mapping keyed by field name. """ + def get_advance_field(r, kv): field = kv[1] mongo_field = getattr(self.model, kv[0], None) @@ -342,8 +347,8 @@ def get_advance_field(r, kv): r.update({kv[0]: graphene.Argument(PointFieldInputType)}) return r if isinstance( - mongo_field, - (mongoengine.ReferenceField, mongoengine.GenericReferenceField), + mongo_field, + (mongoengine.ReferenceField, mongoengine.GenericReferenceField), ): r.update({kv[0]: graphene.ID()}) return r @@ -359,7 +364,7 @@ def get_advance_field(r, kv): else _type.type._of_type._meta ) if "id" in node.fields and not issubclass( - node.model, (mongoengine.EmbeddedDocument,) + node.model, (mongoengine.EmbeddedDocument,) ): r.update({kv[0]: node.fields["id"]._type.of_type()}) return r @@ -419,7 +424,7 @@ def _hydrate_args(self, args: dict) -> None: hydrated: dict = {} for arg_name, arg in args.copy().items(): if arg_name in reference_fields and not isinstance( - arg, mongoengine.base.metaclasses.TopLevelDocumentMetaclass + arg, mongoengine.base.metaclasses.TopLevelDocumentMetaclass ): try: reference_obj = reference_fields[arg_name].document_type( @@ -429,7 +434,7 @@ def _hydrate_args(self, args: dict) -> None: reference_obj = reference_fields[arg_name].document_type(pk=arg) hydrated[arg_name] = reference_obj elif arg_name in self.model._fields_ordered and isinstance( - getattr(self.model, arg_name), mongoengine.fields.GenericReferenceField + getattr(self.model, arg_name), mongoengine.fields.GenericReferenceField ): try: reference_obj = get_document( @@ -439,17 +444,17 @@ def _hydrate_args(self, args: dict) -> None: reference_obj = get_document(arg["_cls"])(pk=arg["_ref"].id) hydrated[arg_name] = reference_obj elif "__near" in arg_name and isinstance( - getattr(self.model, arg_name.split("__")[0]), mongoengine.fields.PointField + getattr(self.model, arg_name.split("__")[0]), mongoengine.fields.PointField ): location = args.pop(arg_name, None) hydrated[arg_name] = location["coordinates"] if (arg_name.split("__")[0] + "__max_distance") not in args: hydrated[arg_name.split("__")[0] + "__max_distance"] = 10000 elif ( - arg_name in self.model._fields_ordered - and isinstance( - getattr(self.model, arg_name), mongoengine.AwareDateTimeField - ) + arg_name in self.model._fields_ordered + and isinstance( + getattr(self.model, arg_name), mongoengine.AwareDateTimeField + ) ): # AwareDateTimeField stores {"utc": datetime, "tz": str}. # Rewrite bare field filter to compare against the utc subfield. @@ -459,10 +464,10 @@ def _hydrate_args(self, args: dict) -> None: # start_time__in (list), etc. field_name, _, op = arg_name.partition("__") if ( - field_name in self.model._fields_ordered - and isinstance( - getattr(self.model, field_name), mongoengine.AwareDateTimeField - ) + field_name in self.model._fields_ordered + and isinstance( + getattr(self.model, field_name), mongoengine.AwareDateTimeField + ) ): value = args.pop(arg_name) hydrated[field_name + "__utc__" + op] = _to_utc(value) @@ -538,7 +543,7 @@ def _build_args_copy(self, args: dict) -> dict: if key not in self.model._fields_ordered: args_copy.pop(key) elif isinstance( - getattr(self.model, key), mongoengine.fields.ReferenceField + getattr(self.model, key), mongoengine.fields.ReferenceField ) or isinstance( getattr(self.model, key), mongoengine.fields.GenericReferenceField ): @@ -584,13 +589,13 @@ def _prepare_resolver_inputs(self, _root, info, args: dict, resolved): if isinstance(getattr(_root, field_name, []), list): args["pk__in"] = [r.id for r in getattr(_root, field_name, [])] elif field_name in _root._fields_ordered and not ( - isinstance( - _root._fields[field_name].field, mongoengine.EmbeddedDocumentField - ) - or isinstance( - _root._fields[field_name].field, - mongoengine.GenericEmbeddedDocumentField, - ) + isinstance( + _root._fields[field_name].field, mongoengine.EmbeddedDocumentField + ) + or isinstance( + _root._fields[field_name].field, + mongoengine.GenericEmbeddedDocumentField, + ) ): raw = getattr(_root, field_name, []) if raw is not None: @@ -653,10 +658,10 @@ def _transform_qs_args(self, args: dict, args_copy: dict) -> dict: """ for arg_name, arg in args.copy().items(): if "." in arg_name or arg_name not in self.model._fields_ordered + ( - "first", - "last", - "before", - "after", + "first", + "last", + "before", + "after", ) + tuple(self.filter_args.keys()): args_copy.pop(arg_name, None) if arg_name == "_id" and isinstance(arg, dict): @@ -667,7 +672,7 @@ def _transform_qs_args(self, args: dict, args_copy: dict) -> dict: operation = list(arg.keys())[0] args_copy[ arg_name.replace(".", "__") + operation.replace("$", "__") - ] = arg[operation] + ] = arg[operation] else: args_copy[arg_name.replace(".", "__")] = arg elif "." in arg_name and isinstance(arg, ObjectId):