From 7e4645bfd6ce9ce80b0dd899edcf928956aaa0fe Mon Sep 17 00:00:00 2001 From: Ramin Haeri Azad Date: Mon, 20 Apr 2026 15:23:02 -0400 Subject: [PATCH 1/6] Converted the project to use UV --- .gitignore | 6 +- .python-version | 1 + .ruff.toml | 45 ++++++ Makefile | 23 ++- poetry.lock | 366 ------------------------------------------------ pyproject.toml | 67 ++++++--- uv.lock | 339 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 454 insertions(+), 393 deletions(-) create mode 100644 .python-version create mode 100644 .ruff.toml delete mode 100644 poetry.lock create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 23adf89..499327e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,11 @@ coverage *.sln *.sw? +# VSCode .history __pycache__ -debug.py \ No newline at end of file +debug.py +.venv/ +.serena/ +plans/ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..58a79bc --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,45 @@ +# Ruff configuration file + +# Exclude a variety of commonly ignored directories. +extend-exclude = [ + "__pycache__", + ".git", + ".venv", + ".eggs", + ".nox", + ".tox", + ".svn", + ".hg", + "build", + "dist", + ".mypy_cache", + ".pytest_cache", +] + +# Assume Python 3.10. +target-version = "py310" + +# Line length with preview to format +line-length = 120 +preview = true + +[lint] +# Enable flake8-bugbear rules +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "SIM", # flake8-simplify +] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] + +# Ignore E501 - long lines in help text are acceptable +ignore = ["E501"] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" diff --git a/Makefile b/Makefile index 1cc73b6..2c27bb8 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,28 @@ install: - poetry install + uv sync --all-extras test: - poetry run pytest + uv run --all-extras pytest + +lint: + uv run ruff check . + +fix: + uv run ruff check . --fix + +format: + uv run ruff format . + +check: format fix build: - poetry build + uv build clean: rm -rf dist + +local-install: clean build + pip install ./dist/obiba_mica-*.tar.gz + +local-install-force: clean build + pip install ./dist/obiba_mica-*.tar.gz --break-system-packages diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index afeca64..0000000 --- a/poetry.lock +++ /dev/null @@ -1,366 +0,0 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. - -[[package]] -name = "autopep8" -version = "2.0.4" -description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "autopep8-2.0.4-py2.py3-none-any.whl", hash = "sha256:067959ca4a07b24dbd5345efa8325f5f58da4298dab0dde0443d5ed765de80cb"}, - {file = "autopep8-2.0.4.tar.gz", hash = "sha256:2913064abd97b3419d1cc83ea71f042cb821f87e45b9c88cad5ad3c4ea87fe0c"}, -] - -[package.dependencies] -pycodestyle = ">=2.10.0" -tomli = {version = "*", markers = "python_version < \"3.11\""} - -[[package]] -name = "certifi" -version = "2025.6.15" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, - {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.2" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, - {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, - {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, -] - -[[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 = ["test"] -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 = "exceptiongroup" -version = "1.3.0" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["test"] -markers = "python_version < \"3.11\"" -files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "importlib-metadata" -version = "6.7.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.7" -groups = ["test"] -markers = "python_version == \"3.7\"" -files = [ - {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, - {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, -] - -[package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf (>=0.9.2)", "pytest-ruff"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -groups = ["test"] -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "packaging" -version = "24.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.7" -groups = ["test"] -files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, -] - -[[package]] -name = "pluggy" -version = "1.2.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.7" -groups = ["test"] -files = [ - {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, - {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pycodestyle" -version = "2.10.0" -description = "Python style guide checker" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, - {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, -] - -[[package]] -name = "pytest" -version = "7.4.4" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.7" -groups = ["test"] -files = [ - {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, - {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} - -[package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "requests" -version = "2.31.0" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -groups = ["dev", "test"] -markers = "python_version < \"3.11\"" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "typing-extensions" -version = "4.7.1" -description = "Backported and Experimental Type Hints for Python 3.7+" -optional = false -python-versions = ">=3.7" -groups = ["test"] -markers = "python_version < \"3.11\"" -files = [ - {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, - {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, -] - -[[package]] -name = "urllib3" -version = "1.26.15" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" -groups = ["main"] -files = [ - {file = "urllib3-1.26.15-py2.py3-none-any.whl", hash = "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42"}, - {file = "urllib3-1.26.15.tar.gz", hash = "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] - -[[package]] -name = "zipp" -version = "3.15.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.7" -groups = ["test"] -markers = "python_version == \"3.7\"" -files = [ - {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, - {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8 ; python_version < \"3.12\"", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\""] - -[metadata] -lock-version = "2.1" -python-versions = "^3.7" -content-hash = "045981922c6edf7a43ba05a2ab93a472b3e422907c8f33bcaebda3292b278b66" diff --git a/pyproject.toml b/pyproject.toml index 77180b5..d8f6236 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,54 @@ -[tool.poetry] +[project] name = "obiba-mica" version = "5.1.1" description = "OBiBa/Mica python client." -authors = ["Yannick Marcon "] -license = "GPL-v3" +authors = [ + {name = "Yannick Marcon", email = "yannick.marcon@obiba.org"} +] +license = {text = "GPL-3.0-only"} readme = "README.md" -packages = [{include = "obiba_mica"}] -homepage = "https://www.obiba.org" -repository = "https://github.com/obiba/mica-python-client" -documentation = "https://micadoc.obiba.org/en/latest/python-user-guide/" +requires-python = ">=3.10" +keywords = ["mica", "obiba", "data", "management"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "requests>=2.31.0", + "urllib3>=2.0", +] -[tool.poetry.urls] -"Bug Tracker" = "https://github.com/obiba/mica-python-client/issues" - -[tool.poetry.dependencies] -python = "^3.7" -requests = "^2.31.0" -urllib3 = "1.26.15" +[project.optional-dependencies] +test = [ + "pytest>=7.2.2", +] +dev = [ + "ruff>=0.10.0", +] -[tool.poetry.group.test.dependencies] -pytest = "^7.2.2" +[project.scripts] +mica = "obiba_mica.console:run" +[project.urls] +Homepage = "https://www.obiba.org" +Repository = "https://github.com/obiba/mica-python-client" +Documentation = "https://micadoc.obiba.org/en/latest/python-user-guide/" +"Bug Tracker" = "https://github.com/obiba/mica-python-client/issues" -[tool.poetry.group.dev.dependencies] -autopep8 = "^2.0.2" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" -[tool.poetry.scripts] -mica = 'obiba_mica.console:run' +[tool.hatch.build.targets.wheel] +packages = ["obiba_mica"] -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +[tool.pytest.ini_options] +markers = [ + "integration: marks tests that require a running Mica instance", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1e7513a --- /dev/null +++ b/uv.lock @@ -0,0 +1,339 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[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 = "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 = "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 = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[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 = "obiba-mica" +version = "5.1.1" +source = { editable = "." } +dependencies = [ + { name = "requests" }, + { name = "urllib3" }, +] + +[package.optional-dependencies] +dev = [ + { name = "ruff" }, +] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'test'", specifier = ">=7.2.2" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.10.0" }, + { name = "urllib3", specifier = ">=2.0" }, +] +provides-extras = ["test", "dev"] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + +[[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 = "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 = "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 = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + +[[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" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] From aacde3ea32528f825566a0d342249c1e357c76d7 Mon Sep 17 00:00:00 2001 From: Ramin Haeri Azad Date: Mon, 20 Apr 2026 15:26:44 -0400 Subject: [PATCH 2/6] Fixed GH build --- .github/workflows/ci.yml | 16 ++++++++++------ .github/workflows/deploy.yml | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dec7506..1300114 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,8 @@ jobs: test: runs-on: ubuntu-latest strategy: - max-parallel: 1 # Run tests sequentially to avoid conflicts on shared mica-demo.obiba.org server matrix: - python-version: [3.8.18, 3.10.18, 3.12.11] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 @@ -21,11 +20,16 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install Poetry - run: pip install poetry + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true - name: Install dependencies - run: poetry install -v + run: uv sync --all-extras + + - name: Run linting + run: uv run ruff check . - name: Run tests - run: poetry run pytest + run: uv run pytest diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cf21cdd..2780f67 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.12.11] + python-version: ["3.12"] steps: - uses: actions/checkout@v4 @@ -19,17 +19,19 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install Poetry - run: pip install poetry + - name: Install uv + uses: astral-sh/setup-uv@v4 - name: Install dependencies - run: poetry install -v + run: uv sync --all-extras - name: Run tests - run: poetry run pytest + run: uv run pytest -m "not integration" - - name: Configure PyPI credentials - run: poetry config http-basic.pypi ${{ secrets.PYPI_USER }} ${{ secrets.PYPI_PASSWORD }} + - name: Build package + run: uv build - name: Publish to PyPI - run: poetry publish --build --no-interaction + run: uv publish + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} From 56b6a69596d2fb28163dfddf211be336b5800347 Mon Sep 17 00:00:00 2001 From: Ramin Haeri Azad Date: Mon, 20 Apr 2026 15:43:53 -0400 Subject: [PATCH 3/6] Perform tests sequentially --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1300114..e28ccc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ jobs: test: runs-on: ubuntu-latest strategy: + max-parallel: 1 # Run tests sequentially to avoid conflicts on shared mica-demo.obiba.org server matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: From 18aa3d3fcd1c28db2c7124f6272f303729d28eec Mon Sep 17 00:00:00 2001 From: Ramin Haeri Azad Date: Mon, 20 Apr 2026 16:21:53 -0400 Subject: [PATCH 4/6] Fixed the formatting --- .github/workflows/ci.yml | 5 +- .gitignore | 3 +- obiba_mica/__init__.py | 22 +- obiba_mica/access.py | 424 ++++++----- obiba_mica/annotation.py | 262 ++++--- obiba_mica/console.py | 152 ++-- obiba_mica/core.py | 8 +- obiba_mica/file.py | 456 ++++++------ obiba_mica/import_zip.py | 6 +- obiba_mica/legacy.py | 4 +- obiba_mica/perm.py | 374 +++++----- obiba_mica/plugin.py | 349 ++++----- obiba_mica/rest.py | 210 +++--- obiba_mica/search.py | 866 ++++++++++++----------- obiba_mica/update_collected_dataset.py | 380 +++++----- obiba_mica/update_collected_datasets.py | 187 ++--- tests/__init__.py | 2 +- tests/test_access.py | 94 +-- tests/test_annotation.py | 34 +- tests/test_core.py | 39 +- tests/test_file.py | 309 ++++---- tests/test_import_zip.py | 45 +- tests/test_permission.py | 71 +- tests/test_plugin.py | 58 +- tests/test_rest.py | 149 ++-- tests/test_search.py | 216 +++--- tests/test_updated_collected_dataset.py | 195 +++-- tests/test_updated_collected_datasets.py | 241 ++++--- tests/utils.py | 209 +++--- 29 files changed, 2675 insertions(+), 2695 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e28ccc5..7bccbf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,9 @@ jobs: - name: Install dependencies run: uv sync --all-extras - - name: Run linting - run: uv run ruff check . + # TODO: Re-enable linting once existing code style issues are fixed + # - name: Run linting + # run: uv run ruff check . - name: Run tests run: uv run pytest diff --git a/.gitignore b/.gitignore index 499327e..6477d38 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ __pycache__ debug.py .venv/ .serena/ -plans/ \ No newline at end of file +.agent-sandbox +plans/ diff --git a/obiba_mica/__init__.py b/obiba_mica/__init__.py index bd3e260..2248745 100644 --- a/obiba_mica/__init__.py +++ b/obiba_mica/__init__.py @@ -1,11 +1,11 @@ -from obiba_mica.core import UriBuilder, MicaClient, MicaRequest, MicaResponse -from obiba_mica.rest import RestService -from obiba_mica.file import FileService -from obiba_mica.access import ProjectAccessService, NetworkAccessService, IndividualStudyAccessService, HarmonizationInitiativeAccessService, CollectedDatasetAccessService, HarmonizationProtocolAccessService, FileAccessService -from obiba_mica.perm import ProjectPermissionService, NetworkPermissionService, HarmonizationInitiativePermissionService, HarmonizationProtocolPermissionService, IndividualStudyPermissionService, CollectedDatasetPermissionService -from obiba_mica.import_zip import FileImportService -from obiba_mica.search import SearchService -from obiba_mica.annotation import AnnotationService -from obiba_mica.plugin import PluginService -from obiba_mica.update_collected_dataset import CollectedDatasetService -from obiba_mica.update_collected_datasets import CollectedDatasetsService +from obiba_mica.core import UriBuilder as UriBuilder, MicaClient as MicaClient, MicaRequest as MicaRequest, MicaResponse as MicaResponse +from obiba_mica.rest import RestService as RestService +from obiba_mica.file import FileService as FileService +from obiba_mica.access import ProjectAccessService as ProjectAccessService, NetworkAccessService as NetworkAccessService, IndividualStudyAccessService as IndividualStudyAccessService, HarmonizationInitiativeAccessService as HarmonizationInitiativeAccessService, CollectedDatasetAccessService as CollectedDatasetAccessService, HarmonizationProtocolAccessService as HarmonizationProtocolAccessService, FileAccessService as FileAccessService +from obiba_mica.perm import ProjectPermissionService as ProjectPermissionService, NetworkPermissionService as NetworkPermissionService, HarmonizationInitiativePermissionService as HarmonizationInitiativePermissionService, HarmonizationProtocolPermissionService as HarmonizationProtocolPermissionService, IndividualStudyPermissionService as IndividualStudyPermissionService, CollectedDatasetPermissionService as CollectedDatasetPermissionService +from obiba_mica.import_zip import FileImportService as FileImportService +from obiba_mica.search import SearchService as SearchService +from obiba_mica.annotation import AnnotationService as AnnotationService +from obiba_mica.plugin import PluginService as PluginService +from obiba_mica.update_collected_dataset import CollectedDatasetService as CollectedDatasetService +from obiba_mica.update_collected_datasets import CollectedDatasetsService as CollectedDatasetsService diff --git a/obiba_mica/access.py b/obiba_mica/access.py index 22b1dc0..210d897 100644 --- a/obiba_mica/access.py +++ b/obiba_mica/access.py @@ -4,261 +4,259 @@ from obiba_mica.core import UriBuilder, MicaClient -class AccessService: - """ - Base class for Mica document access management - """ - - SUBJECT_TYPES = ('USER', 'GROUP') - - def __init__(self, client, verbose: bool = False): - self.client = client - self.verbose = verbose - - def _get_resource_path(self, id: str): - """ - Returns the Mica document (Network, Initiativem Study, etc) resource path - - :param id - document id - """ - pass - - def __make_request(self): - request = self.client.new_request() - request.fail_on_error() - request.accept_json() - if self.verbose: - request.verbose() - return request - def add_access(self, id, type: str, subject: str, noFile: bool = True): - """ - Adds access to a user or group - - :param id - document id - :param subject - associated user/group - :param noFile - exclude access to document's files +class AccessService: """ - uri = UriBuilder(self._get_resource_path(id)) \ - .query('type', type.upper()) \ - .query('principal', subject) \ - .query('file', str(noFile).lower()) \ - .build() - - return self.__make_request().resource(uri).put().send() - - def delete_access(self, id, type: str, subject: str): + Base class for Mica document access management """ - Removes access from a user or group - :param id - document id - :param subject - associated user/group - """ - uri = UriBuilder(self._get_resource_path(id)) \ - .query('type', type.upper()) \ - .query('principal', subject) \ - .build() + SUBJECT_TYPES = ("USER", "GROUP") + + def __init__(self, client, verbose: bool = False): + self.client = client + self.verbose = verbose + + def _get_resource_path(self, id: str): + """ + Returns the Mica document (Network, Initiativem Study, etc) resource path + + :param id - document id + """ + pass + + def __make_request(self): + request = self.client.new_request() + request.fail_on_error() + request.accept_json() + if self.verbose: + request.verbose() + return request + + def add_access(self, id, type: str, subject: str, noFile: bool = True): + """ + Adds access to a user or group + + :param id - document id + :param subject - associated user/group + :param noFile - exclude access to document's files + """ + uri = UriBuilder(self._get_resource_path(id)).query("type", type.upper()).query("principal", subject).query("file", str(noFile).lower()).build() + + return self.__make_request().resource(uri).put().send() + + def delete_access(self, id, type: str, subject: str): + """ + Removes access from a user or group + + :param id - document id + :param subject - associated user/group + """ + uri = UriBuilder(self._get_resource_path(id)).query("type", type.upper()).query("principal", subject).build() + + return self.__make_request().resource(uri).delete().send() + + def list_accesses(self, id): + """ + Lists all associated accesses of a Mica document + + :param id - document id + """ + uri = UriBuilder(self._get_resource_path(id)).build() + + return self.__make_request().resource(uri).get().send() + + @classmethod + def add_permission_arguments(cls, parser, fileArg): + """ + Add permission arguments + + :param parser - commandline args parser + :param fileArg - If True, 'noFile' commandline arg is added + """ + parser.add_argument("--add", "-a", action="store_true", help="Grant an access right") + parser.add_argument("--delete", "-d", action="store_true", required=False, help="Delete an access right") + parser.add_argument("--list", "-ls", action="store_true", required=False, help="List access rights") + if fileArg: + parser.add_argument("--no-file", "-nf", action="store_true", help="Do not apply the access to the associated files") + parser.add_argument("--subject", "-s", required=False, help="Subject name to which the access will be granted. Use wildcard * to specify anyone or any group") + parser.add_argument("--type", "-ty", required=False, help="Subject type: user or group") + + @classmethod + def validate_args(cls, args): + """ + Validate action, permission and subject type + + :param args - commandline args + """ + if not args.add and not args.delete and not args.list: + raise Exception("You must specify an access operation: [--add|-a] or [--delete|-de] or [--list|-ls]") + + if not args.list: + if not args.subject: + raise Exception("You must specify a subject, a user or a group") + + if not args.type or args.type.upper() not in AccessService.SUBJECT_TYPES: + raise Exception(f"Valid subject types are: {', '.join(AccessService.SUBJECT_TYPES).lower()}") + + @classmethod + def do_command(cls, args): + """ + Execute access command - also used for tests + + :param args - commandline args + """ + # Build and send requests + cls.validate_args(args) + service = cls(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) + + try: + + if args.delete: + response = service.delete_access(args.id, args.type, args.subject) + elif args.add: + response = service.add_access(args.id, args.type, args.subject, "no_file" not in args or not args.no_file) + else: + response = service.list_accesses(args.id) + + if response.code != 204: + print(response.as_json()) + + except Exception as e: + print(Exception, e) - return self.__make_request().resource(uri).delete().send() - def list_accesses(self, id): +class ProjectAccessService(AccessService): """ - Lists all associated accesses of a Mica document - - :param id - document id + Project access management """ - uri = UriBuilder(self._get_resource_path(id)).build() - return self.__make_request().resource(uri).get().send() + @classmethod + def add_arguments(cls, parser): + super().add_permission_arguments(parser, True) + parser.add_argument("id", help="Research Project ID") - @classmethod - def add_permission_arguments(cls, parser, fileArg): - """ - Add permission arguments + def _get_resource_path(self, id: str): + return ["draft", "project", id, "accesses"] - :param parser - commandline args parser - :param fileArg - If True, 'noFile' commandline arg is added - """ - parser.add_argument('--add', '-a', action='store_true', help='Grant an access right') - parser.add_argument('--delete', '-d', action='store_true', required=False, help='Delete an access right') - parser.add_argument('--list', '-ls', action='store_true', required=False, help='List access rights') - if fileArg: - parser.add_argument('--no-file', '-nf', action='store_true', help='Do not apply the access to the associated files') - parser.add_argument('--subject', '-s', required=False, help='Subject name to which the access will be granted. Use wildcard * to specify anyone or any group') - parser.add_argument('--type', '-ty', required=False, help='Subject type: user or group') - - @classmethod - def validate_args(cls, args): - """ - Validate action, permission and subject type + @classmethod + def do_command(cls, args): + super().do_command(args) - :param args - commandline args - """ - if not args.add and not args.delete and not args.list: - raise Exception("You must specify an access operation: [--add|-a] or [--delete|-de] or [--list|-ls]") - if not args.list: - if not args.subject: - raise Exception("You must specify a subject, a user or a group") - - if not args.type or args.type.upper() not in AccessService.SUBJECT_TYPES: - raise Exception("Valid subject types are: %s" % ', '.join(AccessService.SUBJECT_TYPES).lower()) - - @classmethod - def do_command(cls, args): +class NetworkAccessService(AccessService): """ - Execute access command - also used for tests - - :param args - commandline args + Network access management """ - # Build and send requests - cls.validate_args(args) - service = cls(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) - - try: - if args.delete: - response = service.delete_access(args.id, args.type, args.subject) - elif args.add: - response = service.add_access(args.id, args.type, args.subject, 'no_file' not in args or not args.no_file) - else: - response = service.list_accesses(args.id) + @classmethod + def add_arguments(cls, parser): + super().add_permission_arguments(parser, True) + parser.add_argument("id", help="Network ID") - if response.code != 204: - print(response.as_json()) + def _get_resource_path(self, id: str): + return ["draft", "network", id, "accesses"] - except Exception as e: - print(Exception, e) + @classmethod + def do_command(cls, args): + super().do_command(args) -class ProjectAccessService(AccessService): - """ - Project access management - """ - - @classmethod - def add_arguments(cls, parser): - super(ProjectAccessService, cls).add_permission_arguments(parser, True) - parser.add_argument('id', help='Research Project ID') +class IndividualStudyAccessService(AccessService): + """ + Individual Study access management + """ - def _get_resource_path(self, id: str): - return ['draft', 'project', id, 'accesses'] + @classmethod + def add_arguments(cls, parser): + super().add_permission_arguments(parser, True) + parser.add_argument("id", help="Individual Study ID") - @classmethod - def do_command(cls, args): - super(ProjectAccessService, cls).do_command(args) + def _get_resource_path(self, id: str): + return ["draft", "individual-study", id, "accesses"] + @classmethod + def do_command(cls, args): + super().do_command(args) -class NetworkAccessService(AccessService): - """ - Network access management - """ - @classmethod - def add_arguments(cls, parser): - super(NetworkAccessService, cls).add_permission_arguments(parser, True) - parser.add_argument('id', help='Network ID') +class HarmonizationInitiativeAccessService(AccessService): + """ + Harmonization Initiative access management + """ - def _get_resource_path(self, id: str): - return ['draft', 'network', id, 'accesses'] + @classmethod + def add_arguments(cls, parser): + super().add_permission_arguments(parser, True) + parser.add_argument("id", help="Harmonization Initiative ID") - @classmethod - def do_command(cls, args): - super(NetworkAccessService, cls).do_command(args) + def _get_resource_path(self, id: str): + return ["draft", "harmonization-study", id, "accesses"] -class IndividualStudyAccessService(AccessService): - """ - Individual Study access management - """ + @classmethod + def do_command(cls, args): + super().do_command(args) - @classmethod - def add_arguments(cls, parser): - super(IndividualStudyAccessService, cls).add_permission_arguments(parser, True) - parser.add_argument('id', help='Individual Study ID') - def _get_resource_path(self, id: str): - return ['draft', 'individual-study', id, 'accesses'] +class CollectedDatasetAccessService(AccessService): + """ + Collected Dataset access management + """ - @classmethod - def do_command(cls, args): - super(IndividualStudyAccessService, cls).do_command(args) + @classmethod + def add_arguments(cls, parser): + super().add_permission_arguments(parser, True) + parser.add_argument("id", help="Collected Dataset ID") + def _get_resource_path(self, id: str): + return ["draft", "collected-dataset", id, "accesses"] -class HarmonizationInitiativeAccessService(AccessService): - """ - Harmonization Initiative access management - """ + @classmethod + def do_command(cls, args): + super().do_command(args) - @classmethod - def add_arguments(cls, parser): - super(HarmonizationInitiativeAccessService, cls).add_permission_arguments(parser, True) - parser.add_argument('id', help='Harmonization Initiative ID') - def _get_resource_path(self, id: str): - return ['draft', 'harmonization-study', id, 'accesses'] +class HarmonizationProtocolAccessService(AccessService): + """ + Harmonization Protocol access management + """ - @classmethod - def do_command(cls, args): - super(HarmonizationInitiativeAccessService, cls).do_command(args) + @classmethod + def add_arguments(cls, parser): + super().add_permission_arguments(parser, True) + parser.add_argument("id", help="Harmonization Protocol ID") -class CollectedDatasetAccessService(AccessService): - """ - Collected Dataset access management - """ + def _get_resource_path(self, id: str): + return ["draft", "harmonized-dataset", id, "accesses"] - @classmethod - def add_arguments(cls, parser): - super(CollectedDatasetAccessService, cls).add_permission_arguments(parser, True) - parser.add_argument('id', help='Collected Dataset ID') + @classmethod + def do_command(cls, args): + super().do_command(args) - def _get_resource_path(self, id: str): - return ['draft', 'collected-dataset', id, 'accesses'] - @classmethod - def do_command(cls, args): - super(CollectedDatasetAccessService, cls).do_command(args) +class FileAccessService(AccessService): + """ + file access management + """ -class HarmonizationProtocolAccessService(AccessService): - """ - Harmonization Protocol access management - """ + def _get_resource_path(self, id: str): + path = id + while path.startswith("/"): + path = path[1:] - @classmethod - def add_arguments(cls, parser): - super(HarmonizationProtocolAccessService, cls).add_permission_arguments(parser, True) - parser.add_argument('id', help='Harmonization Protocol ID') + return ["draft", "file-access", path] - def _get_resource_path(self, id: str): - return ['draft', 'harmonized-dataset', id, 'accesses'] + @classmethod + def add_arguments(cls, parser): + """ + Add command specific options + """ + super().add_permission_arguments(parser, False) + parser.add_argument("id", help="File path in Mica file system") - @classmethod - def do_command(cls, args): - super(HarmonizationProtocolAccessService, cls).do_command(args) + @classmethod + def get_resource_name(cls): + return "file-access" -class FileAccessService(AccessService): - """ - file access management - """ - - def _get_resource_path(self, id: str): - path = id - while path.startswith('/'): - path = path[1:] - - return ['draft', 'file-access', path] - - @classmethod - def add_arguments(cls, parser): - """ - Add command specific options - """ - super(FileAccessService, cls).add_permission_arguments(parser, False) - parser.add_argument('id', help='File path in Mica file system') - - @classmethod - def get_resource_name(cls): - return 'file-access' - - @classmethod - def do_command(cls, args): - super(FileAccessService, cls).do_command(args) + @classmethod + def do_command(cls, args): + super().do_command(args) diff --git a/obiba_mica/annotation.py b/obiba_mica/annotation.py index aba1338..dde2e7c 100755 --- a/obiba_mica/annotation.py +++ b/obiba_mica/annotation.py @@ -6,141 +6,135 @@ from obiba_mica.core import MicaClient, UriBuilder import csv -class AnnotationService: - """ - Service exports annotations of variables of one or all collected datasets of a Mica server - """ - - def __init__(self, client: MicaClient, verbose: bool = False): - self.client = client - self.verbose = verbose - - def __make_request(self): - request = self.client.new_request() - if self.verbose: - request.verbose() - return request - - def __send_request(self, ws): - """ - Create a new request - """ - attemps = 0 - success = False - response = None - while (attemps<10 and not success): - try: - attemps += 1 - request = self.__make_request() - response = request.get().resource(ws).send() - success = True - except Exception as e: - print(e, file=sys.stderr) - - if self.verbose: - print(response.pretty_json()) - - return response.as_json() - - def create_writer(self, outputFile: str = None): - """ - Creates a CSV writer associated with an output file if provided, otherwise the output is sent to STDOUT - - :param outputFile - csv output file - """ - file = sys.stdout - if outputFile: - file = open(outputFile, 'w') - writer = csv.DictWriter(file, fieldnames=['study','dataset','name','index','label', 'annotation'], - escapechar='"', quotechar='"', quoting=csv.QUOTE_ALL) - return writer - - def write_dataset_variable_annotations(self, datasetId, writer): - """ - Writes annotations of all vatiables of one collected dataset - - :param datasetId - collected dataset ID - :param writer - csv writer - """ - - # send request to get total count - ws = UriBuilder(['collected-dataset', datasetId, 'variables']).query('from', 0).query('limit', 0).build() - response = self.__send_request(ws) - total = response['total'] if 'total' in response else 0 - - f = 0 - while total > 0 and f < total: - ws = UriBuilder(['collected-dataset', datasetId, 'variables']).query('from', f).query('limit', 1000).build() - response = self.__send_request(ws) - f = f + 1000 - # format response - if 'variables' in response: - for var in response['variables']: - label = '' - if 'attributes' in var: - for attr in var['attributes']: - if attr['name'] == 'label': - label = attr['values'][0]['value'] - for attr in var['attributes']: - if 'namespace' in attr: - tag = attr['namespace'] + '::' + attr['name'] + '.' + attr['values'][0]['value'] - writer.writerow({'study': var['studyId'], - 'dataset': var['datasetId'], - 'name': var['name'], - 'index': str(var['index']), - 'label': label, - 'annotation': tag - }) - - def write_datasets_variable_annotations(self, writer): - """ - In case no dataset is provided, write the annotations of all variables of all collected datasets - - :param writer - csv writer +class AnnotationService: """ - ws = UriBuilder(['collected-datasets']).query('from', 0).query('limit', 0).build() - response = self.__send_request(ws) - total = response['total'] if 'total' in response else 0 - - f = 0 - while total > 0 and f < total: - ws = UriBuilder(['collected-datasets']).query('from', f).query('limit', 100).build() - response = self.__send_request(ws) - f = f + 100 - if 'datasets' in response: - i = 0 - for ds in response['datasets']: - try: - self.write_dataset_variable_annotations(ds['id'], writer) - i += 1 - except Exception as e: - print('Failed to write annotations of dataset %s' % ds['id']) - if i > 4: - break - - @classmethod - def add_arguments(cls, parser): - """ - Add annotations command specific options - - :param parser - commandline args parser - """ - parser.add_argument('--out', '-o', required=False, help='Output file (default is stdout)') - parser.add_argument('--dataset', '-d', required=False, help='Study dataset ID') - - @classmethod - def do_command(cls, args): + Service exports annotations of variables of one or all collected datasets of a Mica server """ - Execute annotations command - :param args - commandline args - """ - service = AnnotationService(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) - writer = service.create_writer(args.out) - writer.writeheader() - - if args.dataset == None: - service.write_datasets_variable_annotations(writer) - else: - service.write_dataset_variable_annotations(args.dataset, writer) + def __init__(self, client: MicaClient, verbose: bool = False): + self.client = client + self.verbose = verbose + + def __make_request(self): + request = self.client.new_request() + if self.verbose: + request.verbose() + return request + + def __send_request(self, ws): + """ + Create a new request + """ + attemps = 0 + success = False + response = None + while attemps < 10 and not success: + try: + attemps += 1 + request = self.__make_request() + response = request.get().resource(ws).send() + success = True + except Exception as e: + print(e, file=sys.stderr) + + if self.verbose: + print(response.pretty_json()) + + return response.as_json() + + def create_writer(self, outputFile: str = None): + """ + Creates a CSV writer associated with an output file if provided, otherwise the output is sent to STDOUT + + :param outputFile - csv output file + """ + file = sys.stdout + if outputFile: + file = open(outputFile, "w") + writer = csv.DictWriter(file, fieldnames=["study", "dataset", "name", "index", "label", "annotation"], escapechar='"', quotechar='"', quoting=csv.QUOTE_ALL) + + return writer + + def write_dataset_variable_annotations(self, datasetId, writer): + """ + Writes annotations of all vatiables of one collected dataset + + :param datasetId - collected dataset ID + :param writer - csv writer + """ + + # send request to get total count + ws = UriBuilder(["collected-dataset", datasetId, "variables"]).query("from", 0).query("limit", 0).build() + response = self.__send_request(ws) + total = response["total"] if "total" in response else 0 + + f = 0 + while total > 0 and f < total: + ws = UriBuilder(["collected-dataset", datasetId, "variables"]).query("from", f).query("limit", 1000).build() + response = self.__send_request(ws) + f = f + 1000 + # format response + if "variables" in response: + for var in response["variables"]: + label = "" + if "attributes" in var: + for attr in var["attributes"]: + if attr["name"] == "label": + label = attr["values"][0]["value"] + for attr in var["attributes"]: + if "namespace" in attr: + tag = attr["namespace"] + "::" + attr["name"] + "." + attr["values"][0]["value"] + writer.writerow({"study": var["studyId"], "dataset": var["datasetId"], "name": var["name"], "index": str(var["index"]), "label": label, "annotation": tag}) + + def write_datasets_variable_annotations(self, writer): + """ + In case no dataset is provided, write the annotations of all variables of all collected datasets + + :param writer - csv writer + """ + ws = UriBuilder(["collected-datasets"]).query("from", 0).query("limit", 0).build() + response = self.__send_request(ws) + total = response["total"] if "total" in response else 0 + + f = 0 + while total > 0 and f < total: + ws = UriBuilder(["collected-datasets"]).query("from", f).query("limit", 100).build() + response = self.__send_request(ws) + f = f + 100 + if "datasets" in response: + i = 0 + for ds in response["datasets"]: + try: + self.write_dataset_variable_annotations(ds["id"], writer) + i += 1 + except Exception: + print("Failed to write annotations of dataset %s" % ds["id"]) + if i > 4: + break + + @classmethod + def add_arguments(cls, parser): + """ + Add annotations command specific options + + :param parser - commandline args parser + """ + parser.add_argument("--out", "-o", required=False, help="Output file (default is stdout)") + parser.add_argument("--dataset", "-d", required=False, help="Study dataset ID") + + @classmethod + def do_command(cls, args): + """ + Execute annotations command + + :param args - commandline args + """ + service = AnnotationService(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) + writer = service.create_writer(args.out) + writer.writeheader() + + if args.dataset == None: + service.write_datasets_variable_annotations(writer) + else: + service.write_dataset_variable_annotations(args.dataset, writer) diff --git a/obiba_mica/console.py b/obiba_mica/console.py index c3022f0..83a2b4a 100755 --- a/obiba_mica/console.py +++ b/obiba_mica/console.py @@ -8,8 +8,23 @@ from obiba_mica.core import HTTPError from obiba_mica.rest import RestService from obiba_mica.file import FileService -from obiba_mica.access import ProjectAccessService, NetworkAccessService, IndividualStudyAccessService, HarmonizationInitiativeAccessService, CollectedDatasetAccessService, HarmonizationProtocolAccessService, FileAccessService -from obiba_mica.perm import ProjectPermissionService, NetworkPermissionService, HarmonizationInitiativePermissionService, HarmonizationProtocolPermissionService, IndividualStudyPermissionService, CollectedDatasetPermissionService +from obiba_mica.access import ( + ProjectAccessService, + NetworkAccessService, + IndividualStudyAccessService, + HarmonizationInitiativeAccessService, + CollectedDatasetAccessService, + HarmonizationProtocolAccessService, + FileAccessService, +) +from obiba_mica.perm import ( + ProjectPermissionService, + NetworkPermissionService, + HarmonizationInitiativePermissionService, + HarmonizationProtocolPermissionService, + IndividualStudyPermissionService, + CollectedDatasetPermissionService, +) from obiba_mica.import_zip import FileImportService from obiba_mica.search import SearchService from obiba_mica.annotation import AnnotationService @@ -17,19 +32,23 @@ from obiba_mica.update_collected_dataset import CollectedDatasetService from obiba_mica.update_collected_datasets import CollectedDatasetsService + def prompt_password(): - return getpass.getpass(prompt='Enter password: ') + return getpass.getpass(prompt="Enter password: ") + def add_mica_arguments(parser): """ Add Mica access arguments """ - parser.add_argument('--mica', '-mk', required=False, default='http://localhost:8082', help='Mica server base url (default: http://localhost:8082)') - parser.add_argument('--user', '-u', required=False, help='User name') - parser.add_argument('--password', '-p', nargs="?", required=False, help='User password') - parser.add_argument('--otp', '-ot', action='store_true', help='Whether a one-time password is to be provided (required when connecting with username/password AND two-factor authentication is enabled)') - parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output') - parser.add_argument('--no-ssl-verify', '-nv', action='store_true', help='Do not verify SSL certificates for HTTPS.') + parser.add_argument("--mica", "-mk", required=False, default="http://localhost:8082", help="Mica server base url (default: http://localhost:8082)") + parser.add_argument("--user", "-u", required=False, help="User name") + parser.add_argument("--password", "-p", nargs="?", required=False, help="User password") + parser.add_argument( + "--otp", "-ot", action="store_true", help="Whether a one-time password is to be provided (required when connecting with username/password AND two-factor authentication is enabled)" + ) + parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") + parser.add_argument("--no-ssl-verify", "-nv", action="store_true", help="Do not verify SSL certificates for HTTPS.") def add_subcommand(subparsers, name, help, add_args_func, default_func): @@ -48,65 +67,68 @@ def run(): """ # Parse arguments - parser = argparse.ArgumentParser(description='Mica command line tool.') - subparsers = parser.add_subparsers(title='sub-commands', - help='Available sub-commands. Use --help option on the sub-command ' - 'for more details.') + parser = argparse.ArgumentParser(description="Mica command line tool.") + subparsers = parser.add_subparsers(title="sub-commands", help="Available sub-commands. Use --help option on the sub-command " "for more details.") # Add subcommands - add_subcommand(subparsers, 'import-zip', 'Import data from zip file(s) that have been extracted from old Mica', - FileImportService.add_arguments, FileImportService.do_command) - add_subcommand(subparsers, 'file', 'Mica file system actions, for advanced users.', FileService.add_arguments, - FileService.do_command) - add_subcommand(subparsers, 'perm-network', 'Apply permission on a network.', - NetworkPermissionService.add_arguments, NetworkPermissionService.do_command) - add_subcommand(subparsers, 'perm-project', 'Apply permission on a research project.', - ProjectPermissionService.add_arguments, ProjectPermissionService.do_command) - add_subcommand(subparsers, 'perm-individual-study', 'Apply permission on an individual study.', - IndividualStudyPermissionService.add_arguments, IndividualStudyPermissionService.do_command) - add_subcommand(subparsers, 'perm-harmonization-initiative', 'Apply permission on a harmonization initiative.', - HarmonizationInitiativePermissionService.add_arguments, HarmonizationInitiativePermissionService.do_command) - add_subcommand(subparsers, 'perm-collected-dataset', 'Apply permission on a collected dataset.', - CollectedDatasetPermissionService.add_arguments, CollectedDatasetPermissionService.do_command) - add_subcommand(subparsers, 'perm-harmonization-protocol', 'Apply permission on a harmonization protocol.', - HarmonizationProtocolPermissionService.add_arguments, HarmonizationProtocolPermissionService.do_command) - - add_subcommand(subparsers, 'access-network', 'Apply access on a network.', - NetworkAccessService.add_arguments, NetworkAccessService.do_command) - add_subcommand(subparsers, 'access-project', 'Apply access on a research project.', - ProjectAccessService.add_arguments, ProjectAccessService.do_command) - add_subcommand(subparsers, 'access-individual-study', 'Apply access on an individual study.', - IndividualStudyAccessService.add_arguments, IndividualStudyAccessService.do_command) - add_subcommand(subparsers, 'access-harmonization-initiative', 'Apply access on a harmonization initiative.', - HarmonizationInitiativeAccessService.add_arguments, HarmonizationInitiativeAccessService.do_command) - add_subcommand(subparsers, 'access-collected-dataset', 'Apply access on a collected dataset.', - CollectedDatasetAccessService.add_arguments, CollectedDatasetAccessService.do_command) - add_subcommand(subparsers, 'access-harmonization-protocol', 'Apply access on a harmonization protocol.', - HarmonizationProtocolAccessService.add_arguments, HarmonizationProtocolAccessService.do_command) - add_subcommand(subparsers, 'access-file', 'Apply access on a file.', - FileAccessService.add_arguments, FileAccessService.do_command) - - add_subcommand(subparsers, 'search', 'Perform a search query on variables, datasets, studies (including populations and data collection events) and networks.', SearchService.add_arguments, - SearchService.do_command) - - add_subcommand(subparsers, 'annotations', 'Extract classification annotations from published variables.', AnnotationService.add_arguments, - AnnotationService.do_command) - - add_subcommand(subparsers, 'update-collected-dataset', 'Update collected dataset linkage with an Opal table.', CollectedDatasetService.add_arguments, - CollectedDatasetService.do_command) - add_subcommand(subparsers, 'update-collected-datasets', 'Update collected datasets linkage with an Opal table.', CollectedDatasetsService.add_arguments, - CollectedDatasetsService.do_command) - - add_subcommand(subparsers, 'plugin', 'Manage system plugins.', PluginService.add_arguments, - PluginService.do_command) - - add_subcommand(subparsers, 'rest', 'Request directly the Mica REST API, for advanced users.', RestService.add_arguments, - RestService.do_command) + add_subcommand(subparsers, "import-zip", "Import data from zip file(s) that have been extracted from old Mica", FileImportService.add_arguments, FileImportService.do_command) + add_subcommand(subparsers, "file", "Mica file system actions, for advanced users.", FileService.add_arguments, FileService.do_command) + add_subcommand(subparsers, "perm-network", "Apply permission on a network.", NetworkPermissionService.add_arguments, NetworkPermissionService.do_command) + add_subcommand(subparsers, "perm-project", "Apply permission on a research project.", ProjectPermissionService.add_arguments, ProjectPermissionService.do_command) + add_subcommand(subparsers, "perm-individual-study", "Apply permission on an individual study.", IndividualStudyPermissionService.add_arguments, IndividualStudyPermissionService.do_command) + add_subcommand( + subparsers, + "perm-harmonization-initiative", + "Apply permission on a harmonization initiative.", + HarmonizationInitiativePermissionService.add_arguments, + HarmonizationInitiativePermissionService.do_command, + ) + add_subcommand(subparsers, "perm-collected-dataset", "Apply permission on a collected dataset.", CollectedDatasetPermissionService.add_arguments, CollectedDatasetPermissionService.do_command) + add_subcommand( + subparsers, + "perm-harmonization-protocol", + "Apply permission on a harmonization protocol.", + HarmonizationProtocolPermissionService.add_arguments, + HarmonizationProtocolPermissionService.do_command, + ) + + add_subcommand(subparsers, "access-network", "Apply access on a network.", NetworkAccessService.add_arguments, NetworkAccessService.do_command) + add_subcommand(subparsers, "access-project", "Apply access on a research project.", ProjectAccessService.add_arguments, ProjectAccessService.do_command) + add_subcommand(subparsers, "access-individual-study", "Apply access on an individual study.", IndividualStudyAccessService.add_arguments, IndividualStudyAccessService.do_command) + add_subcommand( + subparsers, + "access-harmonization-initiative", + "Apply access on a harmonization initiative.", + HarmonizationInitiativeAccessService.add_arguments, + HarmonizationInitiativeAccessService.do_command, + ) + add_subcommand(subparsers, "access-collected-dataset", "Apply access on a collected dataset.", CollectedDatasetAccessService.add_arguments, CollectedDatasetAccessService.do_command) + add_subcommand( + subparsers, "access-harmonization-protocol", "Apply access on a harmonization protocol.", HarmonizationProtocolAccessService.add_arguments, HarmonizationProtocolAccessService.do_command + ) + add_subcommand(subparsers, "access-file", "Apply access on a file.", FileAccessService.add_arguments, FileAccessService.do_command) + + add_subcommand( + subparsers, + "search", + "Perform a search query on variables, datasets, studies (including populations and data collection events) and networks.", + SearchService.add_arguments, + SearchService.do_command, + ) + + add_subcommand(subparsers, "annotations", "Extract classification annotations from published variables.", AnnotationService.add_arguments, AnnotationService.do_command) + + add_subcommand(subparsers, "update-collected-dataset", "Update collected dataset linkage with an Opal table.", CollectedDatasetService.add_arguments, CollectedDatasetService.do_command) + add_subcommand(subparsers, "update-collected-datasets", "Update collected datasets linkage with an Opal table.", CollectedDatasetsService.add_arguments, CollectedDatasetsService.do_command) + + add_subcommand(subparsers, "plugin", "Manage system plugins.", PluginService.add_arguments, PluginService.do_command) + + add_subcommand(subparsers, "rest", "Request directly the Mica REST API, for advanced users.", RestService.add_arguments, RestService.do_command) # Execute selected command args = parser.parse_args() - if hasattr(args, 'func'): + if hasattr(args, "func"): try: if not args.password or len(args.password) == 0: args.password = prompt_password() @@ -114,10 +136,10 @@ def run(): args.func(args) except HTTPError as e: if e.error is not None: - print(e.error.get('status', e.error)) + print(e.error.get("status", e.error)) else: print(e) sys.exit(2) else: - print('Mica command line tool.') - print('For more details: mica --help') + print("Mica command line tool.") + print("For more details: mica --help") diff --git a/obiba_mica/core.py b/obiba_mica/core.py index c43f224..6f6328c 100755 --- a/obiba_mica/core.py +++ b/obiba_mica/core.py @@ -7,7 +7,7 @@ import os.path import getpass from http import HTTPStatus -import urllib.request, urllib.parse, urllib.error +import urllib.parse from functools import reduce from requests import Session, Request import urllib3 @@ -115,7 +115,7 @@ def close(self): try: self.new_request().resource("/auth/session/_current").delete().send() self.session.close() - except Exception as e: + except Exception: pass class LoginInfo: @@ -149,7 +149,7 @@ def parse(cls, args): else: raise ValueError("Invalid login information. Requires user and password.") - setattr(cls, "data", data) + cls.data = data return cls() @@ -396,7 +396,7 @@ def as_json(self): try: return self.response.json() - except Exception as e: + except Exception: if type(self.response.content) == str: return self.response.content else: diff --git a/obiba_mica/file.py b/obiba_mica/file.py index 4e59075..3ce01f7 100755 --- a/obiba_mica/file.py +++ b/obiba_mica/file.py @@ -5,268 +5,252 @@ import argparse import json from obiba_mica.core import MicaClient, HTTPError -import urllib.request import urllib.parse -import urllib.error import re import os import time -class FileAction(argparse.Action): - """ - Class used when parsing commandline args to stores the file action and destination - """ - def __call__(self, parser, namespace, values, option_string=None): - setattr(namespace, '_file_cmd', self.dest) - setattr(namespace, self.dest, values) - - -class StoreTrueFileAction(FileAction): - """ - Class used when parsing commandline args to set the nargs and const values - """ - def __init__(self, *args, **kwargs): - kwargs.update(dict(nargs=0, const=True)) - super(StoreTrueFileAction, self).__init__(*args, **kwargs) - - def __call__(self, parser, namespace, values, option_string=None): - super(StoreTrueFileAction, self).__call__( - parser, namespace, self.const, option_string=option_string) - - -class MicaFile: - """ - File on Mica file system - """ - - def __init__(self, path): - self.path = path - - def get_dl_ws(self): - """ - Returns the file download REST endpoint path - """ - return '/'.join(['/draft/file-dl', urllib.parse.quote(self.path.strip('/'))]) - - def get_ws(self): - """ - Returns the REST file endpoint path - """ - return '/'.join(['/draft/file', urllib.parse.quote(self.path.strip('/'))]) - - -class FileService(object): - """ - Mica filesystem management service - """ - - FILES_WS = '/draft/files' - STATUS_DRAFT = 'DRAFT' - STATUS_UNDER_REVIEW = 'UNDER_REVIEW' - STATUS_DELETED = 'DELETED' - - def __init__(self, client: MicaClient, verbose: bool = False): - self.client = client - self.verbose = verbose - - def __make_request(self): - """ - Builds a request object for a file operation - """ - request = self.client.new_request() - request.fail_on_error().accept_json() - - if self.verbose: - request.verbose() - - return request - - def __validate_status(self, file, status): - """ - Validates the input request, must match the actual file status - Retries on 404 errors with exponential backoff to handle eventual consistency - """ - max_retries = 7 - retry_delay = 1 - - for attempt in range(max_retries): - try: - state = self.get(file).as_json() - if state['revisionStatus'] != status: - raise Exception('Invalid file revision status. Found: %s, Required: %s' % ( - state['revisionStatus'], status)) - return # Success - except HTTPError as e: - if e.code == 404 and attempt < max_retries - 1: - # File not available yet (eventual consistency), retry with exponential backoff - time.sleep(retry_delay) - retry_delay *= 2 - else: - raise # Re-raise if not 404 or out of retries - - def get(self, file): - """ - Retrieves a file from Mica filesystem - - :param file - file path - """ - return self.__make_request().get().resource(MicaFile(file).get_ws()).send() - - def create(self, path, name): - """ - Creates a folder - - :param folder - Mica filesystem folder path - :param name - folder name - """ - self.__validate_status(path, self.STATUS_DRAFT) - return self.__make_request().post().resource(self.FILES_WS).content_type_json().content( - json.dumps(dict(id='', fileName='.', path='/'.join([MicaFile(path).path, name])))).send() - - def copy(self, file, dest): - """ - Copies a file to another destination in Mica filesystem - - :param file - Mica filesystem file - :param dest - destination folder - """ - return self.__make_request().put().resource('%s?copy=%s' % (MicaFile(file).get_ws(), urllib.parse.quote_plus(dest, safe=''))).send() - - def move(self, file, dest): - """ - Moves a file to another destination in Mica filesystem - - :param file - Mica filesystem file - :param dest - destination folder - """ - self.__validate_status(file, self.STATUS_DRAFT) - return self.__make_request().put().resource('%s?move=%s' % (MicaFile(file).get_ws(), urllib.parse.quote_plus(dest, safe=''))).send() - - def name(self, file, name): - """ - Renames a file - :param file - Mica filesystem file - :param name - new name - """ - self.__validate_status(file, self.STATUS_DRAFT) - return self.__make_request().put().resource('%s?name=%s' % (MicaFile(file).get_ws(), urllib.parse.quote_plus(name, safe=''))).send() - - def status(self, file, status): - """ - Changes a file status (DRAFT, UNDER_REVIEW, DELETED) - - :param file - Mica filesystem file - :param name - new name +class FileAction(argparse.Action): """ - return self.__make_request().put().resource('%s?status=%s' % (MicaFile(file).get_ws(), status.upper())).send() - - def publish(self, file, published): + Class used when parsing commandline args to stores the file action and destination """ - Publishes/ubpublishes a file - :param file - Mica filesystem file - :param published - If True, file gets published - """ - if published: - self.__validate_status(file, self.STATUS_UNDER_REVIEW) + def __call__(self, parser, namespace, values, option_string=None): + namespace._file_cmd = self.dest + setattr(namespace, self.dest, values) - return self.__make_request().put().resource('%s?publish=%s' % (MicaFile(file).get_ws(), str(published).lower())).send() - def unpublish(self, *args): +class StoreTrueFileAction(FileAction): """ - Unpublishes a file - - :param file - Mica filesystem file + Class used when parsing commandline args to set the nargs and const values """ - return self.publish(False) - def uploadTempFile(self, upload): - response = self.__make_request().content_upload(upload).post().resource('/files/temp').send() + def __init__(self, *args, **kwargs): + kwargs.update(dict(nargs=0, const=True)) + super().__init__(*args, **kwargs) - location = None - if 'Location' in response.headers: - location = response.headers['Location'] - elif 'location' in response.headers: - location = response.headers['location'] + def __call__(self, parser, namespace, values, option_string=None): + super().__call__(parser, namespace, self.const, option_string=option_string) - job_resource = re.sub(r'http.*\/ws', r'', location) - return self.__make_request().get().resource(job_resource).send().as_json() - - def upload(self, file, upload): +class MicaFile: """ - Uploads a file to a destination Mica's file system - - :param file - Mica filesystem destination path - :param upload - local file to upload + File on Mica file system """ - temp_file = self.uploadTempFile(upload) - fileName = temp_file.pop('name', '') - temp_file.update( - dict(fileName=os.path.basename(fileName), justUploaded=True, path=MicaFile(file).path)) - return self.__make_request().post().resource(self.FILES_WS).content_type_json().content( - json.dumps(temp_file)).send() + def __init__(self, path): + self.path = path - def download(self, file, *args): - return self.__make_request().get().resource(MicaFile(file).get_dl_ws()).send() + def get_dl_ws(self): + """ + Returns the file download REST endpoint path + """ + return "/".join(["/draft/file-dl", urllib.parse.quote(self.path.strip("/"))]) - def delete(self, file, *args): - self.__validate_status(file, self.STATUS_DELETED) - return self.__make_request().delete().resource(MicaFile(file).get_ws()).send() + def get_ws(self): + """ + Returns the REST file endpoint path + """ + return "/".join(["/draft/file", urllib.parse.quote(self.path.strip("/"))]) - @staticmethod - def add_arguments(parser): - """ - Add file command specific options - :param parser - commandline args parser +class FileService: """ - parser.add_argument('path', help='File path in Mica file system') - parser.add_argument('--json', '-j', action='store_true', - help='Pretty JSON formatting of the response') - group = parser.add_mutually_exclusive_group() - group.add_argument('--download', '-dl', - action=StoreTrueFileAction, help='Download file') - group.add_argument('--upload', '-up', action=FileAction, - help='Upload a local file to a folder in Mica file system, requires the folder to be in DRAFT state') - group.add_argument('--create', '-c', action=FileAction, - help='Create a folder at a specific location, requires the file to be in DRAFT state') - group.add_argument('--copy', '-cp', action=FileAction, - help='Copy a file to the specified destination') - group.add_argument('--move', '-mv', action=FileAction, - help='Move a file to the specified destination, requires the file to be in DRAFT state') - group.add_argument('--delete', '-d', action=StoreTrueFileAction, - help='Delete a file on Mica file system, requires the file to be in DELETED state') - group.add_argument('--name', '-n', action=FileAction, - help='Rename a file, requires the file to be in DRAFT state') - group.add_argument('--status', '-st', - action=FileAction, help='Change file status') - group.add_argument('--publish', '-pu', action=StoreTrueFileAction, - help='Publish a file, requires the file to be in UNDER_REVIEW state') - group.add_argument('--unpublish', '-un', - action=StoreTrueFileAction, help='Unpublish a file') - - @staticmethod - def do_command(args): + Mica filesystem management service """ - Execute file command - :param args - commandline args - """ - service = FileService(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) - response = None - - if hasattr(args, '_file_cmd'): - command = getattr(service, args._file_cmd) - fileCommandParams = getattr(args, args._file_cmd) - response = command(args.path, fileCommandParams) - else: - response = service.get() + FILES_WS = "/draft/files" + STATUS_DRAFT = "DRAFT" + STATUS_UNDER_REVIEW = "UNDER_REVIEW" + STATUS_DELETED = "DELETED" + + def __init__(self, client: MicaClient, verbose: bool = False): + self.client = client + self.verbose = verbose + + def __make_request(self): + """ + Builds a request object for a file operation + """ + request = self.client.new_request() + request.fail_on_error().accept_json() + + if self.verbose: + request.verbose() + + return request + + def __validate_status(self, file, status): + """ + Validates the input request, must match the actual file status + Retries on 404 errors with exponential backoff to handle eventual consistency + """ + max_retries = 7 + retry_delay = 1 + + for attempt in range(max_retries): + try: + state = self.get(file).as_json() + if state["revisionStatus"] != status: + raise Exception("Invalid file revision status. Found: %s, Required: %s" % (state["revisionStatus"], status)) + return # Success + except HTTPError as e: + if e.code == 404 and attempt < max_retries - 1: + # File not available yet (eventual consistency), retry with exponential backoff + time.sleep(retry_delay) + retry_delay *= 2 + else: + raise # Re-raise if not 404 or out of retries + + def get(self, file): + """ + Retrieves a file from Mica filesystem + + :param file - file path + """ + return self.__make_request().get().resource(MicaFile(file).get_ws()).send() + + def create(self, path, name): + """ + Creates a folder + + :param folder - Mica filesystem folder path + :param name - folder name + """ + self.__validate_status(path, self.STATUS_DRAFT) + return self.__make_request().post().resource(self.FILES_WS).content_type_json().content(json.dumps(dict(id="", fileName=".", path="/".join([MicaFile(path).path, name])))).send() + + def copy(self, file, dest): + """ + Copies a file to another destination in Mica filesystem + + :param file - Mica filesystem file + :param dest - destination folder + """ + return self.__make_request().put().resource("%s?copy=%s" % (MicaFile(file).get_ws(), urllib.parse.quote_plus(dest, safe=""))).send() + + def move(self, file, dest): + """ + Moves a file to another destination in Mica filesystem + + :param file - Mica filesystem file + :param dest - destination folder + """ + self.__validate_status(file, self.STATUS_DRAFT) + return self.__make_request().put().resource("%s?move=%s" % (MicaFile(file).get_ws(), urllib.parse.quote_plus(dest, safe=""))).send() + + def name(self, file, name): + """ + Renames a file + + :param file - Mica filesystem file + :param name - new name + """ + self.__validate_status(file, self.STATUS_DRAFT) + return self.__make_request().put().resource("%s?name=%s" % (MicaFile(file).get_ws(), urllib.parse.quote_plus(name, safe=""))).send() + + def status(self, file, status): + """ + Changes a file status (DRAFT, UNDER_REVIEW, DELETED) + + :param file - Mica filesystem file + :param name - new name + """ + return self.__make_request().put().resource("%s?status=%s" % (MicaFile(file).get_ws(), status.upper())).send() + + def publish(self, file, published): + """ + Publishes/ubpublishes a file + + :param file - Mica filesystem file + :param published - If True, file gets published + """ + if published: + self.__validate_status(file, self.STATUS_UNDER_REVIEW) + + return self.__make_request().put().resource("%s?publish=%s" % (MicaFile(file).get_ws(), str(published).lower())).send() + + def unpublish(self, *args): + """ + Unpublishes a file + + :param file - Mica filesystem file + """ + return self.publish(False) + + def uploadTempFile(self, upload): + response = self.__make_request().content_upload(upload).post().resource("/files/temp").send() + + location = None + if "Location" in response.headers: + location = response.headers["Location"] + elif "location" in response.headers: + location = response.headers["location"] + + job_resource = re.sub(r"http.*\/ws", r"", location) + return self.__make_request().get().resource(job_resource).send().as_json() + + def upload(self, file, upload): + """ + Uploads a file to a destination Mica's file system + + :param file - Mica filesystem destination path + :param upload - local file to upload + """ + temp_file = self.uploadTempFile(upload) + fileName = temp_file.pop("name", "") + temp_file.update(dict(fileName=os.path.basename(fileName), justUploaded=True, path=MicaFile(file).path)) + + return self.__make_request().post().resource(self.FILES_WS).content_type_json().content(json.dumps(temp_file)).send() + + def download(self, file, *args): + return self.__make_request().get().resource(MicaFile(file).get_dl_ws()).send() + + def delete(self, file, *args): + self.__validate_status(file, self.STATUS_DELETED) + return self.__make_request().delete().resource(MicaFile(file).get_ws()).send() + + @staticmethod + def add_arguments(parser): + """ + Add file command specific options + + :param parser - commandline args parser + """ + parser.add_argument("path", help="File path in Mica file system") + parser.add_argument("--json", "-j", action="store_true", help="Pretty JSON formatting of the response") + group = parser.add_mutually_exclusive_group() + group.add_argument("--download", "-dl", action=StoreTrueFileAction, help="Download file") + group.add_argument("--upload", "-up", action=FileAction, help="Upload a local file to a folder in Mica file system, requires the folder to be in DRAFT state") + group.add_argument("--create", "-c", action=FileAction, help="Create a folder at a specific location, requires the file to be in DRAFT state") + group.add_argument("--copy", "-cp", action=FileAction, help="Copy a file to the specified destination") + group.add_argument("--move", "-mv", action=FileAction, help="Move a file to the specified destination, requires the file to be in DRAFT state") + group.add_argument("--delete", "-d", action=StoreTrueFileAction, help="Delete a file on Mica file system, requires the file to be in DELETED state") + group.add_argument("--name", "-n", action=FileAction, help="Rename a file, requires the file to be in DRAFT state") + group.add_argument("--status", "-st", action=FileAction, help="Change file status") + group.add_argument("--publish", "-pu", action=StoreTrueFileAction, help="Publish a file, requires the file to be in UNDER_REVIEW state") + group.add_argument("--unpublish", "-un", action=StoreTrueFileAction, help="Unpublish a file") + + @staticmethod + def do_command(args): + """ + Execute file command + + :param args - commandline args + """ + service = FileService(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) + response = None + + if hasattr(args, "_file_cmd"): + command = getattr(service, args._file_cmd) + fileCommandParams = getattr(args, args._file_cmd) + response = command(args.path, fileCommandParams) + else: + response = service.get() - res = response.pretty_json() if args.json and not args.download and not args.upload else response.content + res = response.pretty_json() if args.json and not args.download and not args.upload else response.content - # output to stdout - if len(res) > 0: - print(res) + # output to stdout + if len(res) > 0: + print(res) diff --git a/obiba_mica/import_zip.py b/obiba_mica/import_zip.py index 1ac5c29..c87afc9 100644 --- a/obiba_mica/import_zip.py +++ b/obiba_mica/import_zip.py @@ -48,7 +48,7 @@ def needsLegacySupport(client, verbose=False): restService = RestService(client, verbose) response = restService.send_request("/auth/session/_current", restService.make_request("GET")) # Only the Mica server supporting Java 21 has version information in the response - versionInfo = response.version_info + versionInfo = response.version_info if versionInfo is not None: # Versions prior to 5.5.x need legacy support @@ -74,7 +74,7 @@ def __upgradeZip(self, path): if len(res) > 0: isStudy = res[0] == "study" - with open(file_path, mode="r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: # Read the content of the file content = json.loads(f.read()) if isStudy: @@ -97,7 +97,7 @@ def import_zip(self, path, publish: bool = None, legacy: bool = False): :param path - local path to the zip file :param publish - If True, after the upload, publish the zipped Mica documents (Network, Study, Dataset, files) """ - print("Importing {} ...".format(path)) + print(f"Importing {path} ...") query = "publish=%s" % str(publish).lower() if publish is not None and publish else "" request = self.__make_request() diff --git a/obiba_mica/legacy.py b/obiba_mica/legacy.py index f104836..743cc40 100644 --- a/obiba_mica/legacy.py +++ b/obiba_mica/legacy.py @@ -70,8 +70,8 @@ def getDatasetStudyTableInfo(dataset, info): @staticmethod def removeDatasetEntityState(dataset): - dataset.pop('obiba.mica.EntityStateDto.datasetState', None) - dataset.pop('state', None) + dataset.pop("obiba.mica.EntityStateDto.datasetState", None) + dataset.pop("state", None) @staticmethod def getCollectedDataset(dataset): diff --git a/obiba_mica/perm.py b/obiba_mica/perm.py index b5821e8..6de67d1 100644 --- a/obiba_mica/perm.py +++ b/obiba_mica/perm.py @@ -4,244 +4,244 @@ from obiba_mica.core import UriBuilder, MicaClient -class PermissionService: - """ - Base class for Mica document permission management - """ - - SUBJECT_TYPES = ('USER', 'GROUP') - PERMISSIONS = ('READER', 'EDITOR', 'REVIEWER') - - def __init__(self, client, verbose: bool = False): - self.client = client - self.verbose = verbose - - def _get_resource_path(self, id: str): - pass - - def __make_request(self): - request = self.client.new_request() - request.fail_on_error() - request.accept_json() - if self.verbose: - request.verbose() - return request - def add_permission(self, id, type: str, subject: str, permission: str): +class PermissionService: """ - Adds a user/group permission to a Mica document - - :param id - document id - :param subject - associated user/group - :param permission - 'READER', 'EDITOR', 'REVIEWER' + Base class for Mica document permission management """ - uri = UriBuilder(self._get_resource_path(id)) \ - .query('type', type.upper()) \ - .query('role', self.map_permission(permission)) \ - .query('principal', subject) \ - .build() - return self.__make_request().resource(uri).put().send() + SUBJECT_TYPES = ("USER", "GROUP") + PERMISSIONS = ("READER", "EDITOR", "REVIEWER") + + def __init__(self, client, verbose: bool = False): + self.client = client + self.verbose = verbose + + def _get_resource_path(self, id: str): + pass + + def __make_request(self): + request = self.client.new_request() + request.fail_on_error() + request.accept_json() + if self.verbose: + request.verbose() + return request + + def add_permission(self, id, type: str, subject: str, permission: str): + """ + Adds a user/group permission to a Mica document + + :param id - document id + :param subject - associated user/group + :param permission - 'READER', 'EDITOR', 'REVIEWER' + """ + uri = UriBuilder(self._get_resource_path(id)).query("type", type.upper()).query("role", self.map_permission(permission)).query("principal", subject).build() + + return self.__make_request().resource(uri).put().send() - def delete_permission(self, id, type: str, subject: str): - """ - Removes a user/group permission from a Mica document + def delete_permission(self, id, type: str, subject: str): + """ + Removes a user/group permission from a Mica document - :param id - document id - :param subject - associated user/group - """ - uri = UriBuilder(self._get_resource_path(id)) \ - .query('type', type.upper()) \ - .query('principal', subject) \ - .build() + :param id - document id + :param subject - associated user/group + """ + uri = UriBuilder(self._get_resource_path(id)).query("type", type.upper()).query("principal", subject).build() - return self.__make_request().resource(uri).delete().send() + return self.__make_request().resource(uri).delete().send() - def list_permissions(self, id): - """ - Lists all persmissions given to a Mica document + def list_permissions(self, id): + """ + Lists all persmissions given to a Mica document - :param id - document id - """ - uri = UriBuilder(self._get_resource_path(id)).build() + :param id - document id + """ + uri = UriBuilder(self._get_resource_path(id)).build() - return self.__make_request().resource(uri).get().send() + return self.__make_request().resource(uri).get().send() - @classmethod - def add_permission_arguments(cls, parser): - """ - Add permission arguments + @classmethod + def add_permission_arguments(cls, parser): + """ + Add permission arguments - :param parser - commandline args parser - """ - parser.add_argument('--add', '-a', action='store_true', help='Add a permission') - parser.add_argument('--delete', '-d', action='store_true', required=False, help='Delete a permission') - parser.add_argument('--list', '-ls', action='store_true', required=False, help='List permissions') - parser.add_argument('--permission', '-pe', help="Permission to apply: %s" % ', '.join(PermissionService.PERMISSIONS).lower()) - parser.add_argument('--subject', '-s', required=False, help='Subject name to which the permission will be granted') - parser.add_argument('--type', '-ty', required=False, help='Subject type: user or group') - - @classmethod - def map_permission(cls, permission): - """ - Map permission argument to permission query parameter + :param parser - commandline args parser + """ + parser.add_argument("--add", "-a", action="store_true", help="Add a permission") + parser.add_argument("--delete", "-d", action="store_true", required=False, help="Delete a permission") + parser.add_argument("--list", "-ls", action="store_true", required=False, help="List permissions") + parser.add_argument("--permission", "-pe", help="Permission to apply: %s" % ", ".join(PermissionService.PERMISSIONS).lower()) + parser.add_argument("--subject", "-s", required=False, help="Subject name to which the permission will be granted") + parser.add_argument("--type", "-ty", required=False, help="Subject type: user or group") - :param permission - permission name as a string - """ - if permission.upper() not in PermissionService.PERMISSIONS: - return None + @classmethod + def map_permission(cls, permission): + """ + Map permission argument to permission query parameter - return permission.upper() - - @classmethod - def validate_args(cls, args): - """ - Validate action, permission and subject type - - :param args - commandline args - """ - if not args.add and not args.delete and not args.list: - raise Exception("You must specify a permission operation: [--add|-a] or [--delete|-de]") + :param permission - permission name as a string + """ + if permission.upper() not in PermissionService.PERMISSIONS: + return None - if args.add: - if not args.permission: - raise Exception("A permission name is required: %s" % ', '.join(PermissionService.PERMISSIONS).lower()) - if cls.map_permission(args.permission) is None: - raise Exception("Valid permissions are: %s" % ', '.join(PermissionService.PERMISSIONS).lower()) + return permission.upper() - if not args.list: - if not args.subject: - raise Exception("You must specify a subject, a user or a group") + @classmethod + def validate_args(cls, args): + """ + Validate action, permission and subject type - if not args.type or args.type.upper() not in PermissionService.SUBJECT_TYPES: - raise Exception("Valid subject types are: %s" % ', '.join(PermissionService.SUBJECT_TYPES).lower()) + :param args - commandline args + """ + if not args.add and not args.delete and not args.list: + raise Exception("You must specify a permission operation: [--add|-a] or [--delete|-de]") - @classmethod - def do_command(cls, args): - """ - Execute permission command + if args.add: + if not args.permission: + raise Exception("A permission name is required: %s" % ", ".join(PermissionService.PERMISSIONS).lower()) + if cls.map_permission(args.permission) is None: + raise Exception("Valid permissions are: %s" % ", ".join(PermissionService.PERMISSIONS).lower()) - :param args - commandline args - """ - # Build and send requests - cls.validate_args(args) + if not args.list: + if not args.subject: + raise Exception("You must specify a subject, a user or a group") - service = cls(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) + if not args.type or args.type.upper() not in PermissionService.SUBJECT_TYPES: + raise Exception("Valid subject types are: %s" % ", ".join(PermissionService.SUBJECT_TYPES).lower()) - try: + @classmethod + def do_command(cls, args): + """ + Execute permission command + + :param args - commandline args + """ + # Build and send requests + cls.validate_args(args) + + service = cls(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) + + try: + + if args.delete: + response = service.delete_permission(args.id, args.type, args.subject) + elif args.add: + response = service.add_permission(args.id, args.type, args.subject, args.permission) + else: + response = service.list_permissions(args.id) - if args.delete: - response = service.delete_permission(args.id, args.type, args.subject) - elif args.add: - response = service.add_permission(args.id, args.type, args.subject, args.permission) - else: - response = service.list_permissions(args.id) + # format response + if response.code != 204: + print(response.as_json()) - # format response - if response.code != 204: - print(response.as_json()) + except Exception as e: + print(Exception, e) - except Exception as e: - print(Exception, e) class ProjectPermissionService(PermissionService): - """ - Apply permissions on a research project. - """ + """ + Apply permissions on a research project. + """ + + @classmethod + def add_arguments(cls, parser): + super().add_permission_arguments(parser) + parser.add_argument("id", help="Research Project ID") - @classmethod - def add_arguments(cls, parser): - super(ProjectPermissionService, cls).add_permission_arguments(parser) - parser.add_argument('id', help='Research Project ID') + def _get_resource_path(self, id: str): + return ["draft", "project", id, "permissions"] - def _get_resource_path(self, id: str): - return ['draft', 'project', id, 'permissions'] + @classmethod + def do_command(cls, args): + super().do_command(args) - @classmethod - def do_command(cls, args): - super(ProjectPermissionService, cls).do_command(args) class NetworkPermissionService(PermissionService): - """ - Apply permissions on a network. - """ + """ + Apply permissions on a network. + """ + + @classmethod + def add_arguments(cls, parser): + super().add_permission_arguments(parser) + parser.add_argument("id", help="Network ID") - @classmethod - def add_arguments(cls, parser): - super(NetworkPermissionService, cls).add_permission_arguments(parser) - parser.add_argument('id', help='Network ID') + def _get_resource_path(self, id: str): + return ["draft", "network", id, "permissions"] - def _get_resource_path(self, id: str): - return ['draft', 'network', id, 'permissions'] + @classmethod + def do_command(cls, args): + super().do_command(args) - @classmethod - def do_command(cls, args): - super(NetworkPermissionService, cls).do_command(args) class IndividualStudyPermissionService(PermissionService): - """ - Apply permissions on a individual study. - """ + """ + Apply permissions on a individual study. + """ - @classmethod - def add_arguments(cls, parser): - super(IndividualStudyPermissionService, cls).add_permission_arguments(parser) - parser.add_argument('id', help='Individual Study ID') + @classmethod + def add_arguments(cls, parser): + super().add_permission_arguments(parser) + parser.add_argument("id", help="Individual Study ID") - def _get_resource_path(self, id: str): - return ['draft', 'individual-study', id, 'permissions'] + def _get_resource_path(self, id: str): + return ["draft", "individual-study", id, "permissions"] + + @classmethod + def do_command(cls, args): + return super().do_command(args) - @classmethod - def do_command(cls, args): - return super(IndividualStudyPermissionService, cls).do_command(args) class HarmonizationInitiativePermissionService(PermissionService): - """ - Apply permissions on a harmonization initiative. - """ + """ + Apply permissions on a harmonization initiative. + """ - @classmethod - def add_arguments(cls, parser): - super(HarmonizationInitiativePermissionService, cls).add_permission_arguments(parser) - parser.add_argument('id', help='Harmonization Initiative ID') + @classmethod + def add_arguments(cls, parser): + super().add_permission_arguments(parser) + parser.add_argument("id", help="Harmonization Initiative ID") - def _get_resource_path(self, id: str): - return ['draft', 'harmonization-study', id, 'permissions'] + def _get_resource_path(self, id: str): + return ["draft", "harmonization-study", id, "permissions"] + + @classmethod + def do_command(cls, args): + super().do_command(args) - @classmethod - def do_command(cls, args): - super(HarmonizationInitiativePermissionService, cls).do_command(args) class HarmonizationProtocolPermissionService(PermissionService): - """ - Apply permissions on a harmonization protocol. - """ + """ + Apply permissions on a harmonization protocol. + """ + + @classmethod + def add_arguments(cls, parser): + super().add_permission_arguments(parser) + parser.add_argument("id", help="Harmonization Protocol ID") - @classmethod - def add_arguments(cls, parser): - super(HarmonizationProtocolPermissionService, cls).add_permission_arguments(parser) - parser.add_argument('id', help='Harmonization Protocol ID') + def _get_resource_path(self, id: str): + return ["draft", "harmonized-dataset", id, "permissions"] - def _get_resource_path(self, id: str): - return ['draft', 'harmonized-dataset', id, 'permissions'] + @classmethod + def do_command(cls, args): + super().do_command(args) - @classmethod - def do_command(cls, args): - super(HarmonizationProtocolPermissionService, cls).do_command(args) class CollectedDatasetPermissionService(PermissionService): - """ - Apply permissions on a collected dataset. - """ + """ + Apply permissions on a collected dataset. + """ - @classmethod - def add_arguments(cls, parser): - super(CollectedDatasetPermissionService, cls).add_permission_arguments(parser) - parser.add_argument('id', help='Collected Dataset ID') + @classmethod + def add_arguments(cls, parser): + super().add_permission_arguments(parser) + parser.add_argument("id", help="Collected Dataset ID") - def _get_resource_path(self, id: str): - return ['draft', 'collected-dataset', id, 'permissions'] + def _get_resource_path(self, id: str): + return ["draft", "collected-dataset", id, "permissions"] - @classmethod - def do_command(cls, args): - super(CollectedDatasetPermissionService, cls).do_command(args) + @classmethod + def do_command(cls, args): + super().do_command(args) diff --git a/obiba_mica/plugin.py b/obiba_mica/plugin.py index 7333f96..b0874aa 100755 --- a/obiba_mica/plugin.py +++ b/obiba_mica/plugin.py @@ -1,181 +1,182 @@ import sys from obiba_mica.core import MicaClient, MicaRequest -class PluginService: - """ - Mica plugin management. - """ - - def __init__(self, client, verbose: bool = False): - self.client = client - self.verbose = verbose - - def __make_request(self) -> MicaRequest: - request = self.client.new_request() - request.fail_on_error() - request.accept_json() - if self.verbose: - request.verbose() - return request - - def updates(self): - """ - Returns a list of plugin updates - """ - return self.__make_request().resource('/config/plugins/_updates').get().send() - - def available(self): - """ - Returns a list of available plugins - """ - return self.__make_request().resource('/config/plugins/_available').get().send() - - def fetch(self, name: str): - """ - Retrieves a plugin - - :param name - plugin name - """ - return self.__make_request().resource('/config/plugin/%s' % name).get().send() - - def install(self, nameVersion: str): - """ - Installs a plugin by name and version - - :param nameVersion - name and versoin separated by a colon (name:version) - """ - - parts = nameVersion.split(':') - if len(parts) == 1: - url = '/config/plugins?name=%s' % parts[0] - else: - url = '/config/plugins?name=%s$%d' % (parts[0], parts[1]) - - return self.__make_request().resource(url).post().send() - - def configure(self, configure: str): - """ - Adds configuration properties to a plugin - - Example: - - :param configure - plugin name - """ - request = self.__make_request().content_type_text_plain() - print('Enter plugin site properties (one property per line, Ctrl-D to end input):') - request.content(sys.stdin.read()) - return request.put().resource('/config/plugin/%s/cfg' % configure).send() - def remove(self, nameVersion: str): - """ - Removes a plugin by name and version - - :param nameVersion - name and versoin separated by a colon (name:version) - """ - return self.__make_request().resource('/config/plugin/%s' % nameVersion).delete().send() - - def reinstate(self, name: str): - """ - Reinstates/cancel a plugin uninstalation - - :param name - plugin name - """ - return self.__make_request().resource('/config/plugin/%s' % name).put().send() - - def status(self, name: str): - """ - Returns the status of the plugin - - :param name - plugin name - """ - return self.__make_request().resource('/config/plugin/%s/service' % name).get().send() - - def start(self, name: str): - """ - Starts a plugin - - :param name - plugin name - """ - return self.__make_request().resource('/config/plugin/%s/service' % name).put().send() - - def stop(self, name: str): +class PluginService: """ - Stops a plugin - - :param name - plugin name + Mica plugin management. """ - return self.__make_request().resource('/config/plugin/%s/service' % name).delete().send() - def list(self): - """ - Lists the installed plugins - """ - return self.__make_request().resource('/config/plugins').get().send() - - @classmethod - def add_arguments(cls, parser): - """ - Add plugin command specific options - - :param parser - commandline args parser - """ - - parser.add_argument('--list', '-ls', action='store_true', help='List the installed plugins.') - parser.add_argument('--updates', '-lu', action='store_true', help='List the installed plugins that can be updated.') - parser.add_argument('--available', '-la', action='store_true', help='List the new plugins that could be installed.') - parser.add_argument('--install', '-i', required=False, - help='Install a plugin by providing its name or name:version. If no version is specified, the latest version is installed. Requires system restart to be effective.') - parser.add_argument('--remove', '-rm', required=False, - help='Remove a plugin by providing its name. Requires system restart to be effective.') - parser.add_argument('--reinstate', '-ri', required=False, - help='Reinstate a plugin that was previously removed by providing its name.') - parser.add_argument('--fetch', '-f', required=False, help='Get the named plugin description.') - parser.add_argument('--configure', '-c', required=False, - help='Configure the plugin site properties. Usually requires to restart the associated service to be effective.') - parser.add_argument('--status', '-su', required=False, - help='Get the status of the service associated to the named plugin.') - parser.add_argument('--start', '-sa', required=False, help='Start the service associated to the named plugin.') - parser.add_argument('--stop', '-so', required=False, help='Stop the service associated to the named plugin.') - parser.add_argument('--json', '-j', action='store_true', help='Pretty JSON formatting of the response') - - @classmethod - def do_command(cls, args): - """ - Execute plugin command - - :param args - commandline args - """ - # Build and send request - client = MicaClient.build(MicaClient.LoginInfo.parse(args)) - service = PluginService(client, args.verbose) - - if args.updates: - response = service.updates() - elif args.available: - response = service.available() - elif args.install: - service.install(args.install) - elif args.fetch: - response = service.fetch(args.fetch) - elif args.configure: - response = service.configure(args.configure) - elif args.remove: - response = service.remove(args.remove) - elif args.reinstate: - response = service.reinstate(args.reinstate) - elif args.status: - response = service.status(args.status) - elif args.start: - response = service.start(args.start) - elif args.stop: - response = service.stop(args.stop) - else: - response = service.list() - - # format response - res = response.content - if args.json: - res = response.as_json() - - # output to stdout - print(res) + def __init__(self, client, verbose: bool = False): + self.client = client + self.verbose = verbose + + def __make_request(self) -> MicaRequest: + request = self.client.new_request() + request.fail_on_error() + request.accept_json() + if self.verbose: + request.verbose() + return request + + def updates(self): + """ + Returns a list of plugin updates + """ + return self.__make_request().resource("/config/plugins/_updates").get().send() + + def available(self): + """ + Returns a list of available plugins + """ + return self.__make_request().resource("/config/plugins/_available").get().send() + + def fetch(self, name: str): + """ + Retrieves a plugin + + :param name - plugin name + """ + return self.__make_request().resource("/config/plugin/%s" % name).get().send() + + def install(self, nameVersion: str): + """ + Installs a plugin by name and version + + :param nameVersion - name and versoin separated by a colon (name:version) + """ + + parts = nameVersion.split(":") + if len(parts) == 1: + url = "/config/plugins?name=%s" % parts[0] + else: + url = "/config/plugins?name=%s$%d" % (parts[0], parts[1]) + + return self.__make_request().resource(url).post().send() + + def configure(self, configure: str): + """ + Adds configuration properties to a plugin + + Example: + + :param configure - plugin name + """ + request = self.__make_request().content_type_text_plain() + print("Enter plugin site properties (one property per line, Ctrl-D to end input):") + request.content(sys.stdin.read()) + return request.put().resource("/config/plugin/%s/cfg" % configure).send() + + def remove(self, nameVersion: str): + """ + Removes a plugin by name and version + + :param nameVersion - name and versoin separated by a colon (name:version) + """ + return self.__make_request().resource("/config/plugin/%s" % nameVersion).delete().send() + + def reinstate(self, name: str): + """ + Reinstates/cancel a plugin uninstalation + + :param name - plugin name + """ + return self.__make_request().resource("/config/plugin/%s" % name).put().send() + + def status(self, name: str): + """ + Returns the status of the plugin + + :param name - plugin name + """ + return self.__make_request().resource("/config/plugin/%s/service" % name).get().send() + + def start(self, name: str): + """ + Starts a plugin + + :param name - plugin name + """ + return self.__make_request().resource("/config/plugin/%s/service" % name).put().send() + + def stop(self, name: str): + """ + Stops a plugin + + :param name - plugin name + """ + return self.__make_request().resource("/config/plugin/%s/service" % name).delete().send() + + def list(self): + """ + Lists the installed plugins + """ + return self.__make_request().resource("/config/plugins").get().send() + + @classmethod + def add_arguments(cls, parser): + """ + Add plugin command specific options + + :param parser - commandline args parser + """ + + parser.add_argument("--list", "-ls", action="store_true", help="List the installed plugins.") + parser.add_argument("--updates", "-lu", action="store_true", help="List the installed plugins that can be updated.") + parser.add_argument("--available", "-la", action="store_true", help="List the new plugins that could be installed.") + parser.add_argument( + "--install", + "-i", + required=False, + help="Install a plugin by providing its name or name:version. If no version is specified, the latest version is installed. Requires system restart to be effective.", + ) + parser.add_argument("--remove", "-rm", required=False, help="Remove a plugin by providing its name. Requires system restart to be effective.") + parser.add_argument("--reinstate", "-ri", required=False, help="Reinstate a plugin that was previously removed by providing its name.") + parser.add_argument("--fetch", "-f", required=False, help="Get the named plugin description.") + parser.add_argument("--configure", "-c", required=False, help="Configure the plugin site properties. Usually requires to restart the associated service to be effective.") + parser.add_argument("--status", "-su", required=False, help="Get the status of the service associated to the named plugin.") + parser.add_argument("--start", "-sa", required=False, help="Start the service associated to the named plugin.") + parser.add_argument("--stop", "-so", required=False, help="Stop the service associated to the named plugin.") + parser.add_argument("--json", "-j", action="store_true", help="Pretty JSON formatting of the response") + + @classmethod + def do_command(cls, args): + """ + Execute plugin command + + :param args - commandline args + """ + # Build and send request + client = MicaClient.build(MicaClient.LoginInfo.parse(args)) + service = PluginService(client, args.verbose) + + if args.updates: + response = service.updates() + elif args.available: + response = service.available() + elif args.install: + service.install(args.install) + elif args.fetch: + response = service.fetch(args.fetch) + elif args.configure: + response = service.configure(args.configure) + elif args.remove: + response = service.remove(args.remove) + elif args.reinstate: + response = service.reinstate(args.reinstate) + elif args.status: + response = service.status(args.status) + elif args.start: + response = service.start(args.start) + elif args.stop: + response = service.stop(args.stop) + else: + response = service.list() + + # format response + res = response.content + if args.json: + res = response.as_json() + + # output to stdout + print(res) diff --git a/obiba_mica/rest.py b/obiba_mica/rest.py index 5bf5ad8..6f5460d 100644 --- a/obiba_mica/rest.py +++ b/obiba_mica/rest.py @@ -1,120 +1,116 @@ import sys -import ast from obiba_mica.core import MicaClient from obiba_mica.core import MicaRequest -class RestService: - """ - Perform raw web services requests. - """ - - def __init__(self, client: MicaClient, verbose: bool = False): - self.client = client - self.verbose = verbose - - def make_request(self, method: str): - """ - Creates a MicaRequest instance - - Note: all responses are return in JSON form - - :param method - HTTP method - """ - request = self.client.new_request() - request.method(method) - request.fail_on_error() - request.accept_json() - if self.verbose: - request.verbose() - return request - - def make_request_with_content_type(self, method: str, contentType: str, content: str = None): - """ - Creates a MicaRequest instance with a request body - - :param method - HTTP method - :param contentType - request content type - :param content - data to be posted (if ignored a prompt will read-in data from commandline ending with a CTRL-D) - """ - request = self.make_request(method) - if contentType: - request.content_type(contentType) - - if content is not None: - request.content(content.encode('utf-8')) - else: - print('Enter content:') - request.content(str(sys.stdin.read()).encode('utf-8')) - - return request - def send_request(self, url: str, request: MicaRequest): - """ - Sends the request to Mica server - """ - return request.resource(url).send() - - - @classmethod - def add_arguments(cls, parser): +class RestService: """ - Add REST command specific options - - :param parser - commandline args parser + Perform raw web services requests. """ - parser.add_argument('ws', help='Web service path, for instance: /study/xxx') - parser.add_argument('--method', '-m', required=False, - help='HTTP method (default is GET, others are POST, PUT, DELETE, OPTIONS)') - parser.add_argument('--accept', '-a', required=False, help='Accept header (default is application/json)') - parser.add_argument('--content-type', '-ct', required=False, - help='Content-Type header (default is application/json)') - parser.add_argument('--json', '-j', action='store_true', help='Pretty JSON formatting of the response') + def __init__(self, client: MicaClient, verbose: bool = False): + self.client = client + self.verbose = verbose - @classmethod - def do_command(cls, args): - """ - Execute REST command + def make_request(self, method: str): + """ + Creates a MicaRequest instance - :param args - commandline args - """ - # Build and send request - client = MicaClient.build(MicaClient.LoginInfo.parse(args)) + Note: all responses are return in JSON form - try: - request = client.new_request() + :param method - HTTP method + """ + request = self.client.new_request() + request.method(method) request.fail_on_error() - - if args.accept: - request.accept(args.accept) - else: - request.accept_json() - - if args.content_type: - request.content_type(args.content_type) - print('Enter content:') - request.content(str(sys.stdin.read()).encode('utf-8')) - - if args.verbose: + request.accept_json() + if self.verbose: request.verbose() - - # send request - request.method(args.method).resource(args.ws) - response = request.send() - - # format response - if args.method in ['OPTIONS']: - # OPTIONS method - extract Allow header - print(response.headers['Allow']) - elif 'json' not in response.headers.get('Content-Type', 'application/json').lower(): - # Binary or non-JSON response - output raw content - if response.content: - sys.stdout.buffer.write(response.content) - else: - # JSON response - format and print - res = response.as_json() - if args.json: - res = response.pretty_json() - print(res) - finally: - client.close() + return request + + def make_request_with_content_type(self, method: str, contentType: str, content: str = None): + """ + Creates a MicaRequest instance with a request body + + :param method - HTTP method + :param contentType - request content type + :param content - data to be posted (if ignored a prompt will read-in data from commandline ending with a CTRL-D) + """ + request = self.make_request(method) + if contentType: + request.content_type(contentType) + + if content is not None: + request.content(content.encode("utf-8")) + else: + print("Enter content:") + request.content(str(sys.stdin.read()).encode("utf-8")) + + return request + + def send_request(self, url: str, request: MicaRequest): + """ + Sends the request to Mica server + """ + return request.resource(url).send() + + @classmethod + def add_arguments(cls, parser): + """ + Add REST command specific options + + :param parser - commandline args parser + """ + parser.add_argument("ws", help="Web service path, for instance: /study/xxx") + parser.add_argument("--method", "-m", required=False, help="HTTP method (default is GET, others are POST, PUT, DELETE, OPTIONS)") + parser.add_argument("--accept", "-a", required=False, help="Accept header (default is application/json)") + parser.add_argument("--content-type", "-ct", required=False, help="Content-Type header (default is application/json)") + parser.add_argument("--json", "-j", action="store_true", help="Pretty JSON formatting of the response") + + @classmethod + def do_command(cls, args): + """ + Execute REST command + + :param args - commandline args + """ + # Build and send request + client = MicaClient.build(MicaClient.LoginInfo.parse(args)) + + try: + request = client.new_request() + request.fail_on_error() + + if args.accept: + request.accept(args.accept) + else: + request.accept_json() + + if args.content_type: + request.content_type(args.content_type) + print("Enter content:") + request.content(str(sys.stdin.read()).encode("utf-8")) + + if args.verbose: + request.verbose() + + # send request + request.method(args.method).resource(args.ws) + response = request.send() + + # format response + if args.method in ["OPTIONS"]: + # OPTIONS method - extract Allow header + print(response.headers["Allow"]) + elif "json" not in response.headers.get("Content-Type", "application/json").lower(): + # Binary or non-JSON response - output raw content + if response.content: + sys.stdout.buffer.write(response.content) + else: + # JSON response - format and print + res = response.as_json() + if args.json: + res = response.pretty_json() + print(res) + finally: + client.close() diff --git a/obiba_mica/search.py b/obiba_mica/search.py index ecae6ed..6d32840 100755 --- a/obiba_mica/search.py +++ b/obiba_mica/search.py @@ -1,6 +1,6 @@ -''' +""" Mica search query. -''' +""" import json import sys @@ -9,428 +9,444 @@ from io import StringIO from obiba_mica.legacy import MicaLegacySupport + class SearchService: - def __init__(self, client: MicaClient, verbose: bool = False): - self.client = client - self.verbose = verbose - - def __make_request(self): - request = self.client.new_request() - request.fail_on_error() - request.post() - request.accept_json() - if self.verbose: - request.verbose() - return request - - def send_search_request(self, ws, query): - ''' - Create a new search request - - :param ws - REST endpoint (/variables/_rql) - :param query - RQL query - ''' - try: - request = self.__make_request() - response = request.resource(ws).content_type_form().form({'query': query}).send() - return response.as_json() - except Exception as e: - print(e, file=sys.stderr) - - return None - - def __as_rql(self, name, args): - return name + '(' + ','.join(args) + ')' - - def __append_rql(self, query, target, select, sort, start, limit, locale): - _fields = self.__as_rql('fields(', select) + ')' - _sort = self.__as_rql('sort', sort) - _limit = self.__as_rql('limit', [str(start), str(limit)]) - statement = ','.join([_fields, _limit, _sort]) - # normalize - q = query - if q == None or q == '': - q = target + '()' - - # hack: replace target call with statement - if target + '()' in q: - q = q.replace(target + '()', target + '(' + statement + ')') - elif target + '(' in q: - q = q.replace(target + '(', target + '(' + statement + ',') - else: - q = target + '(' + statement + '),' + q - - return q + ',locale(' + locale + ')' - - def __extract_label(self, labels, locale='en', locale_key='lang', value_key='value'): - if not labels: - return None - label_und = None - if labels: - for label in labels: - if label[locale_key] == locale: - return label[value_key] - if label[locale_key] == 'und': - label_und = label[value_key] - return label_und if label_und else '' - - def __new_writer(self, out, headers): - file = sys.stdout - if out: - if isinstance(out, StringIO): - file = out - else: - file = open(out, 'w') - writer = csv.DictWriter(file, fieldnames=headers, escapechar='"', quotechar='"', quoting=csv.QUOTE_ALL) - writer.writeheader() - return writer - - def __to_string(self, value): - if value == None: - return '' - return str(value) - - def __flatten(self, content, locale='en'): - flat = {} - for key in list(content.keys()): - value = content[key] - if type(value) is dict: - fvalue = self.__flatten(value, locale) - for k in fvalue: - nk = key + '.' + k if k != locale else key - flat[nk] = fvalue[k] - elif type(value) is list: - flat[key] = '|'.join(map(self.__to_string, value)) - else: - flat[key] = self.__to_string(value) - return flat - - def search_networks(self, query='', start=0, limit=100, locale='en', out=None): - """ - Searches all published networks matching the given query - - :param query - RQL query - :param start - starting index from which to retrieve data - :param limit - length of data to be retrieved - :param locale - default is 'en' - :param out - output file, if ignored the result is send to STDOUT - """ - - q = self.__append_rql(query, 'network', ['*'], ['id'], start, limit, locale) - ws = UriBuilder(['networks', '_rql']).build() - res = self.send_search_request(ws, q) - networks = MicaLegacySupport.getNetworkSearchResults(res) - if len(networks) > 0: - headers = ['id', 'name', 'acronym', 'description', 'studyIds'] - for item in networks: - if 'content' in item: - item['flat'] = self.__flatten(json.loads(item['content']), locale) - for key in list(item['flat'].keys()): - if key not in headers: - headers.append(key) - writer = self.__new_writer(out, headers) - for item in networks: - row = { - 'id': item['id'], - 'name': self.__extract_label(item['name'], locale), - 'description': self.__extract_label(item['description'], locale) if 'description' in item else '', - 'acronym': self.__extract_label(item['acronym'], locale), - 'studyIds': '|'.join(item['studyIds']) if 'studyIds' in item else '' - } - if 'flat' in item: - for key in item['flat']: - row[key] = item['flat'][key] - writer.writerow(row) - - def __search_studies(self, query='', start=0, limit=100, locale='en', out=None): - q = self.__append_rql(query, 'study', ['acronym', 'name', 'objectives', 'model'], ['id'], start, limit, locale) - ws = UriBuilder(['studies', '_rql']).build() - res = self.send_search_request(ws, q) - summaries = MicaLegacySupport.getStudySearchResults(res) - if len(summaries) > 0: - headers = ['id', 'name', 'acronym', 'objectives'] - for item in summaries: - if 'content' in item: - item['flat'] = self.__flatten(json.loads(item['content']), locale) - for key in list(item['flat'].keys()): - if key not in headers: - headers.append(key) - writer = self.__new_writer(out, headers) - for item in summaries: - row = { - 'id': item['id'], - 'name': self.__extract_label(item['name'], locale), - 'objectives': self.__extract_label(item['objectives'], locale) if 'objectives' in item else '', - 'acronym': self.__extract_label(item['acronym'], locale) - } - if 'flat' in item: - for key in item['flat']: - row[key] = item['flat'][key] - writer.writerow(row) - - def search_studies(self, query='', start=0, limit=100, locale='en', out=None): - """ - Searches all published individual studies matching the given query - - :param query - RQL query - :param start - starting index from which to retrieve data - :param limit - length of data to be retrieved - :param locale - default is 'en' - :param out - output file, if ignored the result is send to STDOUT - """ - typeQuery = self.__as_rql('study', [self.__as_rql('in', ['Mica_dataset.className', 'Study'])]) - theQuery = '%s,%s' % (typeQuery, query) if query is not None and len(query) > 0 else typeQuery - self.__search_studies(theQuery, start, limit, locale, out) - - def search_initiatives(self, query='', start=0, limit=100, locale='en', out=None): - """ - Searches all published initiatives matching the given query - - :param query - RQL query - :param start - starting index from which to retrieve data - :param limit - length of data to be retrieved - :param locale - default is 'en' - :param out - output file, if ignored the result is send to STDOUT - """ - typeQuery = self.__as_rql('study', [self.__as_rql('in', ['Mica_dataset.className', 'HarmonizationStudy'])]) - theQuery = '%s,%s' % (typeQuery, query) if query is not None and len(query) > 0 else typeQuery - self.__search_studies(theQuery, start, limit, locale, out) - - - def search_study_populations(self, query='', start=0, limit=100, locale='en', out=None): - """ - Searches the populations of a individual studies matching the given query - - :param query - RQL query - :param start - starting index from which to retrieve data - :param limit - length of data to be retrieved - :param locale - default is 'en' - :param out - output file, if ignored the result is send to STDOUT - """ - q = self.__append_rql(query, 'study', ['populations.name', 'populations.description', 'populations.model'], ['id'], start, limit, locale) - ws = UriBuilder(['studies', '_rql']).build() - res = self.send_search_request(ws, q) - summaries = MicaLegacySupport.getStudySearchResults(res) - if len(summaries) > 0: - headers = ['id', 'name', 'description', 'studyId'] - for item in summaries: - if 'populationSummaries' in item: - for pop in item['populationSummaries']: - if 'content' in pop: - pop['flat'] = self.__flatten(json.loads(pop['content']), locale) - for key in list(pop['flat'].keys()): - if key not in headers: - headers.append(key) - writer = self.__new_writer(out, headers) - for item in summaries: - if 'populationSummaries' in item: - for pop in item['populationSummaries']: - row = { - 'id': item['id'] + ':' + pop['id'], - 'name': self.__extract_label(pop['name'], locale), - 'description': self.__extract_label(pop['description'], locale) if 'description' in pop else '', - 'studyId': item['id'] - } - if 'flat' in pop: - for key in pop['flat']: - row[key] = pop['flat'][key] - writer.writerow(row) - - def search_study_dces(self, query='', start=0, limit=100, locale='en', out=None): - """ - Searches all published data collection events of individual studies matching the given query - - :param query - RQL query - :param start - starting index from which to retrieve data - :param limit - length of data to be retrieved - :param locale - default is 'en' - :param out - output file, if ignored the result is send to STDOUT - """ - q = self.__append_rql(query, 'study', ['populations.dataCollectionEvents'], ['id'], start, limit, locale) - ws = UriBuilder(['studies', '_rql']).build() - res = self.send_search_request(ws, q) - summaries = MicaLegacySupport.getStudySearchResults(res) - if len(summaries) > 0: - headers = ['id', 'name', 'description', 'studyId', 'populationId', 'start', 'end'] - for item in summaries: - if 'populationSummaries' in item: - for pop in item['populationSummaries']: - if 'dataCollectionEventSummaries' in pop: - for dce in pop['dataCollectionEventSummaries']: - if 'content' in dce: - dce['flat'] = self.__flatten(json.loads(dce['content']), locale) - for key in list(dce['flat'].keys()): - if key not in headers: - headers.append(key) - writer = self.__new_writer(out, headers) - for item in summaries: - if 'populationSummaries' in item: - for pop in item['populationSummaries']: - if 'dataCollectionEventSummaries' in pop: - for dce in pop['dataCollectionEventSummaries']: - row = { - 'id': item['id'] + ':' + pop['id'] + ':' + dce['id'], - 'name': self.__extract_label(dce['name'], locale), - 'description': self.__extract_label(dce['description'], locale) if 'description' in dce else '', - 'studyId': item['id'], - 'populationId': item['id'] + ':' + pop['id'], - 'start': dce['start'] if 'start' in dce else '', - 'end': dce['end'] if 'end' in dce else '' - } - if 'flat' in dce: - for key in dce['flat']: - row[key] = dce['flat'][key] - writer.writerow(row) - - def __search_datasets(self, query='', start=0, limit=100, locale='en', out=None): - q = self.__append_rql(query, 'dataset', ['*'], ['id'], start, limit, locale) - ws = UriBuilder(['datasets', '_rql']).build() - res = self.send_search_request(ws, q) - datasets = MicaLegacySupport.getDatasetSearchResults(res) - if len(datasets) > 0: - headers = ['id', 'name', 'acronym', 'description', 'variableType', 'entityType', 'studyId', 'populationId', 'dceId'] - for item in datasets: - if 'content' in item: - item['flat'] = self.__flatten(json.loads(item['content']), locale) - for key in list(item['flat'].keys()): - if key not in headers: - headers.append(key) - writer = self.__new_writer(out, headers) - for item in datasets: - info = {} - MicaLegacySupport.getDatasetStudyTableInfo(item, info) - - row = { - 'id': item['id'], - 'name': self.__extract_label(item['name'], locale), - 'acronym': self.__extract_label(item['acronym'], locale), - 'description': self.__extract_label(item['description'], locale) if 'description' in item else '', - 'variableType': item['variableType'], - 'entityType': item['entityType'], - 'studyId': info['study_id'], - 'populationId': info['population_id'], - 'dceId': info['dce_id'] - } - if 'flat' in item: - for key in item['flat']: - row[key] = item['flat'][key] - writer.writerow(row) - - def search_datasets(self, query='', start=0, limit=100, locale='en', out=None): - """ - Searches all published collected datasets matching the given query - - :param query - RQL query - :param start - starting index from which to retrieve data - :param limit - length of data to be retrieved - :param locale - default is 'en' - :param out - output file, if ignored the result is send to STDOUT - """ - typeQuery = self.__as_rql('dataset', [self.__as_rql('in', ['Mica_dataset.className', 'StudyDataset'])]) - theQuery = '%s,%s' % (typeQuery, query) if query is not None and len(query) > 0 else typeQuery - self.__search_datasets(theQuery, start, limit, locale, out) - - def search_protocols(self, query='', start=0, limit=100, locale='en', out=None): - """ - Searches all published harmonization protocols matching the given query - - :param query - RQL query - :param start - starting index from which to retrieve data - :param limit - length of data to be retrieved - :param locale - default is 'en' - :param out - output file, if ignored the result is send to STDOUT - """ - typeQuery = self.__as_rql('dataset', [self.__as_rql('in', ['Mica_dataset.className', 'HarmonizationDataset'])]) - theQuery = '%s,%s' % (typeQuery, query) if query is not None and len(query) > 0 else typeQuery - self.__search_datasets(theQuery, start, limit, locale, out) - - def search_variables(self, query='', start=0, limit=100, locale='en', out=None): - q = self.__append_rql(query, 'variable', ['*'], ['id'], start, limit, locale) - ws = UriBuilder(['variables', '_rql']).build() - res = self.send_search_request(ws, q) - summaries = MicaLegacySupport.getVariableSearchResults(res) - - def category_label(category): - if 'attributes' in category: - labels = [self.__extract_label(label['values'], locale) for label in [a for a in category['attributes'] if a['name'] == 'label']] - return labels[0] if len(labels) > 0 else '' - else: - return '' - - if len(summaries) > 0: - headers = ['id', 'name', 'label', 'description', 'valueType', 'nature', 'categories', 'categories.missing', 'categories.label', - 'datasetId', 'studyId', 'populationId', 'dceId', - 'variableType', 'mimeType', 'unit', 'referencedEntityType', 'repeatable', 'occurrenceGroup'] - for item in summaries: - if 'annotations' in item: - for annot in item['annotations']: - key = annot['taxonomy'] + '.' + annot['vocabulary'] - if key not in headers: - headers.append(key) - writer = self.__new_writer(out, headers) - for item in summaries: - row = { - 'id': item['id'], - 'name': item['name'], - 'label': self.__extract_label(item['variableLabel'], locale) if 'variableLabel' in item else '', - 'description': self.__extract_label(item['description'], locale) if 'description' in item else '', - 'datasetId': item['datasetId'], - 'studyId': item['studyId'], - 'populationId': item['populationId'] if 'populationId' in item else '', - 'dceId': item['dceId'] if 'dceId' in item else '', - 'variableType': item['variableType'], - 'valueType': item['valueType'] if 'valueType' in item else '', - 'nature': item['nature'] if 'nature' in item else '', - 'mimeType': item['mimeType'] if 'mimeType' in item else '', - 'unit': item['unit'] if 'unit' in item else '', - 'referencedEntityType': item['referencedEntityType'] if 'referencedEntityType' in item else '', - 'repeatable': item['repeatable'] if 'repeatable' in item else '', - 'occurrenceGroup': item['occurrenceGroup'] if 'occurrenceGroup' in item else '' - } - if 'categories' in item: - row['categories'] = '|'.join([c['name'] for c in item['categories']]) - row['categories.missing'] = '|'.join([str(c['missing']) for c in item['categories']]) - row['categories.label'] = '|'.join(map(category_label, item['categories'])) - if 'annotations' in item: - for annot in item['annotations']: - key = annot['taxonomy'] + '.' + annot['vocabulary'] - row[key] = annot['value'] - writer.writerow(row) - - - @classmethod - def add_arguments(self, parser): - ''' - Add tags command specific options - - :param parser - commandline args parser - ''' - parser.add_argument('--out', '-o', required=False, help='Output file (default is stdout).') - parser.add_argument('--target', '-t', required=True, choices=['variable', 'dataset', 'study', 'population', 'dce', 'network'], - help='Document type to be searched for.') - parser.add_argument('--query', '-q', required=False, help='Query that filters the documents. If not specified, no filter is applied.') - parser.add_argument('--start', '-s', required=False, type=int, default=0, help='Start search at document position.') - parser.add_argument('--limit', '-lm', required=False, type=int, default=100, help='Max number of documents.') - parser.add_argument('--locale', '-lc', required=False, default='en', help='The language for labels.') - - @classmethod - def do_command(self, args): - ''' - Execute search command - - :param args - commandline args - ''' - service = SearchService(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) - if args.target == 'network': - service.search_networks(query=args.query, start=args.start, limit=args.limit, locale=args.locale, out=args.out) - elif args.target == 'study': - self.search_studies(query=args.query, start=args.start, limit=args.limit, locale=args.locale, out=args.out) - elif args.target == 'population': - self.search_study_populations(query=args.query, start=args.start, limit=args.limit, locale=args.locale, out=args.out) - elif args.target == 'dce': - self.search_study_dces(query=args.query, start=args.start, limit=args.limit, locale=args.locale, out=args.out) - elif args.target == 'dataset': - self.search_datasets(query=args.query, start=args.start, limit=args.limit, locale=args.locale, out=args.out) - elif args.target == 'variable': - service.search_variables(query=args.query, start=args.start, limit=args.limit, locale=args.locale, out=args.out) + def __init__(self, client: MicaClient, verbose: bool = False): + self.client = client + self.verbose = verbose + + def __make_request(self): + request = self.client.new_request() + request.fail_on_error() + request.post() + request.accept_json() + if self.verbose: + request.verbose() + return request + + def send_search_request(self, ws, query): + """ + Create a new search request + + :param ws - REST endpoint (/variables/_rql) + :param query - RQL query + """ + try: + request = self.__make_request() + response = request.resource(ws).content_type_form().form({"query": query}).send() + return response.as_json() + except Exception as e: + print(e, file=sys.stderr) + + return None + + def __as_rql(self, name, args): + return name + "(" + ",".join(args) + ")" + + def __append_rql(self, query, target, select, sort, start, limit, locale): + _fields = self.__as_rql("fields(", select) + ")" + _sort = self.__as_rql("sort", sort) + _limit = self.__as_rql("limit", [str(start), str(limit)]) + statement = ",".join([_fields, _limit, _sort]) + # normalize + q = query + if q == None or q == "": + q = target + "()" + + # hack: replace target call with statement + if target + "()" in q: + q = q.replace(target + "()", target + "(" + statement + ")") + elif target + "(" in q: + q = q.replace(target + "(", target + "(" + statement + ",") + else: + q = target + "(" + statement + ")," + q + + return q + ",locale(" + locale + ")" + + def __extract_label(self, labels, locale="en", locale_key="lang", value_key="value"): + if not labels: + return None + label_und = None + if labels: + for label in labels: + if label[locale_key] == locale: + return label[value_key] + if label[locale_key] == "und": + label_und = label[value_key] + return label_und if label_und else "" + + def __new_writer(self, out, headers): + file = sys.stdout + if out: + if isinstance(out, StringIO): + file = out + else: + file = open(out, "w") + writer = csv.DictWriter(file, fieldnames=headers, escapechar='"', quotechar='"', quoting=csv.QUOTE_ALL) + writer.writeheader() + return writer + + def __to_string(self, value): + if value == None: + return "" + return str(value) + + def __flatten(self, content, locale="en"): + flat = {} + for key in list(content.keys()): + value = content[key] + if type(value) is dict: + fvalue = self.__flatten(value, locale) + for k in fvalue: + nk = key + "." + k if k != locale else key + flat[nk] = fvalue[k] + elif type(value) is list: + flat[key] = "|".join(map(self.__to_string, value)) + else: + flat[key] = self.__to_string(value) + return flat + + def search_networks(self, query="", start=0, limit=100, locale="en", out=None): + """ + Searches all published networks matching the given query + + :param query - RQL query + :param start - starting index from which to retrieve data + :param limit - length of data to be retrieved + :param locale - default is 'en' + :param out - output file, if ignored the result is send to STDOUT + """ + + q = self.__append_rql(query, "network", ["*"], ["id"], start, limit, locale) + ws = UriBuilder(["networks", "_rql"]).build() + res = self.send_search_request(ws, q) + networks = MicaLegacySupport.getNetworkSearchResults(res) + if len(networks) > 0: + headers = ["id", "name", "acronym", "description", "studyIds"] + for item in networks: + if "content" in item: + item["flat"] = self.__flatten(json.loads(item["content"]), locale) + for key in list(item["flat"].keys()): + if key not in headers: + headers.append(key) + writer = self.__new_writer(out, headers) + for item in networks: + row = { + "id": item["id"], + "name": self.__extract_label(item["name"], locale), + "description": self.__extract_label(item["description"], locale) if "description" in item else "", + "acronym": self.__extract_label(item["acronym"], locale), + "studyIds": "|".join(item["studyIds"]) if "studyIds" in item else "", + } + if "flat" in item: + for key in item["flat"]: + row[key] = item["flat"][key] + writer.writerow(row) + + def __search_studies(self, query="", start=0, limit=100, locale="en", out=None): + q = self.__append_rql(query, "study", ["acronym", "name", "objectives", "model"], ["id"], start, limit, locale) + ws = UriBuilder(["studies", "_rql"]).build() + res = self.send_search_request(ws, q) + summaries = MicaLegacySupport.getStudySearchResults(res) + if len(summaries) > 0: + headers = ["id", "name", "acronym", "objectives"] + for item in summaries: + if "content" in item: + item["flat"] = self.__flatten(json.loads(item["content"]), locale) + for key in list(item["flat"].keys()): + if key not in headers: + headers.append(key) + writer = self.__new_writer(out, headers) + for item in summaries: + row = { + "id": item["id"], + "name": self.__extract_label(item["name"], locale), + "objectives": self.__extract_label(item["objectives"], locale) if "objectives" in item else "", + "acronym": self.__extract_label(item["acronym"], locale), + } + if "flat" in item: + for key in item["flat"]: + row[key] = item["flat"][key] + writer.writerow(row) + + def search_studies(self, query="", start=0, limit=100, locale="en", out=None): + """ + Searches all published individual studies matching the given query + + :param query - RQL query + :param start - starting index from which to retrieve data + :param limit - length of data to be retrieved + :param locale - default is 'en' + :param out - output file, if ignored the result is send to STDOUT + """ + typeQuery = self.__as_rql("study", [self.__as_rql("in", ["Mica_dataset.className", "Study"])]) + theQuery = "%s,%s" % (typeQuery, query) if query is not None and len(query) > 0 else typeQuery + self.__search_studies(theQuery, start, limit, locale, out) + + def search_initiatives(self, query="", start=0, limit=100, locale="en", out=None): + """ + Searches all published initiatives matching the given query + + :param query - RQL query + :param start - starting index from which to retrieve data + :param limit - length of data to be retrieved + :param locale - default is 'en' + :param out - output file, if ignored the result is send to STDOUT + """ + typeQuery = self.__as_rql("study", [self.__as_rql("in", ["Mica_dataset.className", "HarmonizationStudy"])]) + theQuery = "%s,%s" % (typeQuery, query) if query is not None and len(query) > 0 else typeQuery + self.__search_studies(theQuery, start, limit, locale, out) + + def search_study_populations(self, query="", start=0, limit=100, locale="en", out=None): + """ + Searches the populations of a individual studies matching the given query + + :param query - RQL query + :param start - starting index from which to retrieve data + :param limit - length of data to be retrieved + :param locale - default is 'en' + :param out - output file, if ignored the result is send to STDOUT + """ + q = self.__append_rql(query, "study", ["populations.name", "populations.description", "populations.model"], ["id"], start, limit, locale) + ws = UriBuilder(["studies", "_rql"]).build() + res = self.send_search_request(ws, q) + summaries = MicaLegacySupport.getStudySearchResults(res) + if len(summaries) > 0: + headers = ["id", "name", "description", "studyId"] + for item in summaries: + if "populationSummaries" in item: + for pop in item["populationSummaries"]: + if "content" in pop: + pop["flat"] = self.__flatten(json.loads(pop["content"]), locale) + for key in list(pop["flat"].keys()): + if key not in headers: + headers.append(key) + writer = self.__new_writer(out, headers) + for item in summaries: + if "populationSummaries" in item: + for pop in item["populationSummaries"]: + row = { + "id": item["id"] + ":" + pop["id"], + "name": self.__extract_label(pop["name"], locale), + "description": self.__extract_label(pop["description"], locale) if "description" in pop else "", + "studyId": item["id"], + } + if "flat" in pop: + for key in pop["flat"]: + row[key] = pop["flat"][key] + writer.writerow(row) + + def search_study_dces(self, query="", start=0, limit=100, locale="en", out=None): + """ + Searches all published data collection events of individual studies matching the given query + + :param query - RQL query + :param start - starting index from which to retrieve data + :param limit - length of data to be retrieved + :param locale - default is 'en' + :param out - output file, if ignored the result is send to STDOUT + """ + q = self.__append_rql(query, "study", ["populations.dataCollectionEvents"], ["id"], start, limit, locale) + ws = UriBuilder(["studies", "_rql"]).build() + res = self.send_search_request(ws, q) + summaries = MicaLegacySupport.getStudySearchResults(res) + if len(summaries) > 0: + headers = ["id", "name", "description", "studyId", "populationId", "start", "end"] + for item in summaries: + if "populationSummaries" in item: + for pop in item["populationSummaries"]: + if "dataCollectionEventSummaries" in pop: + for dce in pop["dataCollectionEventSummaries"]: + if "content" in dce: + dce["flat"] = self.__flatten(json.loads(dce["content"]), locale) + for key in list(dce["flat"].keys()): + if key not in headers: + headers.append(key) + writer = self.__new_writer(out, headers) + for item in summaries: + if "populationSummaries" in item: + for pop in item["populationSummaries"]: + if "dataCollectionEventSummaries" in pop: + for dce in pop["dataCollectionEventSummaries"]: + row = { + "id": item["id"] + ":" + pop["id"] + ":" + dce["id"], + "name": self.__extract_label(dce["name"], locale), + "description": self.__extract_label(dce["description"], locale) if "description" in dce else "", + "studyId": item["id"], + "populationId": item["id"] + ":" + pop["id"], + "start": dce["start"] if "start" in dce else "", + "end": dce["end"] if "end" in dce else "", + } + if "flat" in dce: + for key in dce["flat"]: + row[key] = dce["flat"][key] + writer.writerow(row) + + def __search_datasets(self, query="", start=0, limit=100, locale="en", out=None): + q = self.__append_rql(query, "dataset", ["*"], ["id"], start, limit, locale) + ws = UriBuilder(["datasets", "_rql"]).build() + res = self.send_search_request(ws, q) + datasets = MicaLegacySupport.getDatasetSearchResults(res) + if len(datasets) > 0: + headers = ["id", "name", "acronym", "description", "variableType", "entityType", "studyId", "populationId", "dceId"] + for item in datasets: + if "content" in item: + item["flat"] = self.__flatten(json.loads(item["content"]), locale) + for key in list(item["flat"].keys()): + if key not in headers: + headers.append(key) + writer = self.__new_writer(out, headers) + for item in datasets: + info = {} + MicaLegacySupport.getDatasetStudyTableInfo(item, info) + + row = { + "id": item["id"], + "name": self.__extract_label(item["name"], locale), + "acronym": self.__extract_label(item["acronym"], locale), + "description": self.__extract_label(item["description"], locale) if "description" in item else "", + "variableType": item["variableType"], + "entityType": item["entityType"], + "studyId": info["study_id"], + "populationId": info["population_id"], + "dceId": info["dce_id"], + } + if "flat" in item: + for key in item["flat"]: + row[key] = item["flat"][key] + writer.writerow(row) + + def search_datasets(self, query="", start=0, limit=100, locale="en", out=None): + """ + Searches all published collected datasets matching the given query + + :param query - RQL query + :param start - starting index from which to retrieve data + :param limit - length of data to be retrieved + :param locale - default is 'en' + :param out - output file, if ignored the result is send to STDOUT + """ + typeQuery = self.__as_rql("dataset", [self.__as_rql("in", ["Mica_dataset.className", "StudyDataset"])]) + theQuery = "%s,%s" % (typeQuery, query) if query is not None and len(query) > 0 else typeQuery + self.__search_datasets(theQuery, start, limit, locale, out) + + def search_protocols(self, query="", start=0, limit=100, locale="en", out=None): + """ + Searches all published harmonization protocols matching the given query + + :param query - RQL query + :param start - starting index from which to retrieve data + :param limit - length of data to be retrieved + :param locale - default is 'en' + :param out - output file, if ignored the result is send to STDOUT + """ + typeQuery = self.__as_rql("dataset", [self.__as_rql("in", ["Mica_dataset.className", "HarmonizationDataset"])]) + theQuery = "%s,%s" % (typeQuery, query) if query is not None and len(query) > 0 else typeQuery + self.__search_datasets(theQuery, start, limit, locale, out) + + def search_variables(self, query="", start=0, limit=100, locale="en", out=None): + q = self.__append_rql(query, "variable", ["*"], ["id"], start, limit, locale) + ws = UriBuilder(["variables", "_rql"]).build() + res = self.send_search_request(ws, q) + summaries = MicaLegacySupport.getVariableSearchResults(res) + + def category_label(category): + if "attributes" in category: + labels = [self.__extract_label(label["values"], locale) for label in [a for a in category["attributes"] if a["name"] == "label"]] + return labels[0] if len(labels) > 0 else "" + else: + return "" + + if len(summaries) > 0: + headers = [ + "id", + "name", + "label", + "description", + "valueType", + "nature", + "categories", + "categories.missing", + "categories.label", + "datasetId", + "studyId", + "populationId", + "dceId", + "variableType", + "mimeType", + "unit", + "referencedEntityType", + "repeatable", + "occurrenceGroup", + ] + for item in summaries: + if "annotations" in item: + for annot in item["annotations"]: + key = annot["taxonomy"] + "." + annot["vocabulary"] + if key not in headers: + headers.append(key) + writer = self.__new_writer(out, headers) + for item in summaries: + row = { + "id": item["id"], + "name": item["name"], + "label": self.__extract_label(item["variableLabel"], locale) if "variableLabel" in item else "", + "description": self.__extract_label(item["description"], locale) if "description" in item else "", + "datasetId": item["datasetId"], + "studyId": item["studyId"], + "populationId": item["populationId"] if "populationId" in item else "", + "dceId": item["dceId"] if "dceId" in item else "", + "variableType": item["variableType"], + "valueType": item["valueType"] if "valueType" in item else "", + "nature": item["nature"] if "nature" in item else "", + "mimeType": item["mimeType"] if "mimeType" in item else "", + "unit": item["unit"] if "unit" in item else "", + "referencedEntityType": item["referencedEntityType"] if "referencedEntityType" in item else "", + "repeatable": item["repeatable"] if "repeatable" in item else "", + "occurrenceGroup": item["occurrenceGroup"] if "occurrenceGroup" in item else "", + } + if "categories" in item: + row["categories"] = "|".join([c["name"] for c in item["categories"]]) + row["categories.missing"] = "|".join([str(c["missing"]) for c in item["categories"]]) + row["categories.label"] = "|".join(map(category_label, item["categories"])) + if "annotations" in item: + for annot in item["annotations"]: + key = annot["taxonomy"] + "." + annot["vocabulary"] + row[key] = annot["value"] + writer.writerow(row) + + @classmethod + def add_arguments(self, parser): + """ + Add tags command specific options + + :param parser - commandline args parser + """ + parser.add_argument("--out", "-o", required=False, help="Output file (default is stdout).") + parser.add_argument("--target", "-t", required=True, choices=["variable", "dataset", "study", "population", "dce", "network"], help="Document type to be searched for.") + parser.add_argument("--query", "-q", required=False, help="Query that filters the documents. If not specified, no filter is applied.") + parser.add_argument("--start", "-s", required=False, type=int, default=0, help="Start search at document position.") + parser.add_argument("--limit", "-lm", required=False, type=int, default=100, help="Max number of documents.") + parser.add_argument("--locale", "-lc", required=False, default="en", help="The language for labels.") + + @classmethod + def do_command(self, args): + """ + Execute search command + + :param args - commandline args + """ + service = SearchService(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) + if args.target == "network": + service.search_networks(query=args.query, start=args.start, limit=args.limit, locale=args.locale, out=args.out) + elif args.target == "study": + self.search_studies(query=args.query, start=args.start, limit=args.limit, locale=args.locale, out=args.out) + elif args.target == "population": + self.search_study_populations(query=args.query, start=args.start, limit=args.limit, locale=args.locale, out=args.out) + elif args.target == "dce": + self.search_study_dces(query=args.query, start=args.start, limit=args.limit, locale=args.locale, out=args.out) + elif args.target == "dataset": + self.search_datasets(query=args.query, start=args.start, limit=args.limit, locale=args.locale, out=args.out) + elif args.target == "variable": + service.search_variables(query=args.query, start=args.start, limit=args.limit, locale=args.locale, out=args.out) diff --git a/obiba_mica/update_collected_dataset.py b/obiba_mica/update_collected_dataset.py index 599d363..5d0c688 100644 --- a/obiba_mica/update_collected_dataset.py +++ b/obiba_mica/update_collected_dataset.py @@ -2,205 +2,207 @@ import json from obiba_mica.legacy import MicaLegacySupport + class StudyTableBuilder: - def __init__(self, studyTable: None): - self.studyTable = studyTable if studyTable is not None else {} + def __init__(self, studyTable: None): + self.studyTable = studyTable if studyTable is not None else {} - def study(self, value): - self.studyTable['studyId'] = value - return self + def study(self, value): + self.studyTable["studyId"] = value + return self - def population(self, value): - self.studyTable['populationId'] = value - return self + def population(self, value): + self.studyTable["populationId"] = value + return self - def dce(self, value): - self.studyTable['dataCollectionEventId'] = value - return self + def dce(self, value): + self.studyTable["dataCollectionEventId"] = value + return self - def project(self, value): - self.studyTable['project'] = value - return self + def project(self, value): + self.studyTable["project"] = value + return self - def table(self, value): - self.studyTable['table'] = value - return self + def table(self, value): + self.studyTable["table"] = value + return self - def build(self): - return self.studyTable + def build(self): + return self.studyTable -class CollectedDatasetService: - """ - Update an existing collected dataset, mainly for managing the linkage with opal. - """ - - def __init__(self, client: MicaClient, verbose: bool = False): - self.client = client - self.verbose = verbose - - def new_request(self): - """ - Creates a MicaRequest instance - """ - request = self.client.new_request() - request.fail_on_error() - request.accept_json() - if self.verbose: - request.verbose() - return request - - def get_dataset(self, id): - """ - Retrieves a colleted - - :param id - dataset id - """ - path = '/draft/collected-dataset/' + id - request = self.new_request() - response = request.get().resource(path).send() - return json.loads(response.content) - - def update_study_table(self, dataset, comment=[], study: str = None, population: str = None, dce: str = None, project: str = None, table: str = None): - """ - Updates the collected dataset's study table holding the information to associated Opal Project/Table - - :param dataset - dataset document - :param comment - commit message - :param study - dataset's associated study ID - :param population - dataset's associated population ID - :param dce - dataset's associated data collection event ID - :param project - associated Opal project name - :param table - associated Opal table name - """ - MicaLegacySupport.removeDatasetEntityState(dataset) - dataset.pop('variableType', None) - dataset.pop('timestamps', None) - dataset.pop('published', None) - dataset.pop('permissions', None) - - collectedDataset = MicaLegacySupport.getCollectedDataset(dataset) - - if collectedDataset is None: - if not study or not population or not dce or not project or not table: - raise ValueError("Study table is missing and cannot be created.") - collectedDataset = {'studyTable': {}} - collectedDataset['studyTable'].pop('studySummary', None) - - builder = StudyTableBuilder(collectedDataset['studyTable']) - - # update - comment = [] - if study: - comment.append('Study: ' + study) - builder.study(study) - if population: - comment.append('Population: ' + population) - builder.population(population) - if dce: - comment.append('DCE: ' + dce) - builder.dce(dce) - if project: - comment.append('Project: ' + project) - builder.project(project) - if table: - comment.append('Table: ' + table) - builder.table(table) - - collectedDataset['studyTable'] = builder.build() - - def update_dataset(self, dataset, comment): - """ - Sends the updated collected dataset to Mica server - - :param dataset - updated dataset document - :param comment - commit comment - """ - path = '/draft/collected-dataset/%s' % dataset['id'] - request = self.new_request() - request.put().resource(path).query({'comment': ', '.join(comment) + ' (update-collected-dataset)'}).content_type_json() - request.content(json.dumps(dataset, separators=(',', ':'))) - if self.verbose: - print("Updated: ") - print(json.dumps(dataset, sort_keys=True, indent=2, separators=(',', ': '))) - return request.send() - - def update(self, datasetId: str, study: str = None, population: str = None, dce: str = None, project: str = None, table: str = None): - """ - Updates the collected dataset's study table holding the information to associated Opal Project/Table - - :param datasetId - dataset ID - :param comment - commit message - :param study - dataset's associated study ID - :param population - dataset's associated population ID - :param dce - dataset's associated data collection event ID - :param project - associated Opal project name - :param table - associated Opal table name - """ - if self.verbose: - print("Updating %s ..." % datasetId) - - # get existing and remove useless fields - dataset = self.get_dataset(datasetId) - comment = [] - self.update_study_table(dataset, comment, study, population, dce, project, table) - return self.update_dataset(dataset, comment) - - def __publish(self, datasetId: str, method: str = 'PUT'): - path = '/draft/collected-dataset/%s' % datasetId - request = self.new_request() - return request.method(method).resource(path + '/_publish').send() - - def publish(self, datasetId: str): - """ - Publishs a collected dataset - :param datasetId - dataset document ID +class CollectedDatasetService: """ - return self.__publish(datasetId, 'PUT') - - def unpublish(self, datasetId: str): + Update an existing collected dataset, mainly for managing the linkage with opal. """ - Unpublishes a collected dataset - :param datasetId - dataset document ID - """ - return self.__publish(datasetId, 'DELETE') - - @classmethod - def add_arguments(cls, parser): - """ - Add REST command specific options - - :param parser commandline args parser - """ - parser.add_argument('id', help='Collected dataset ID') - parser.add_argument('--study', '-std', required=False, help='Mica study') - parser.add_argument('--population', '-pop', required=False, help='Mica population') - parser.add_argument('--dce', '-dce', required=False, help='Mica study population data collection event') - parser.add_argument('--project', '-prj', required=False, help='Opal project') - parser.add_argument('--table', '-tbl', required=False, help='Opal table') - parser.add_argument('--publish', '-pub', action='store_true', help='Publish the colected dataset') - parser.add_argument('--unpublish', '-un', action='store_true', help='Unpublish the collected dataset') - - @classmethod - def do_command(cls, args): - """ - Execute dataset update command - - :param args - commandline args - """ - # Build and send request - service = CollectedDatasetService(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) - - if args.project or args.table: - service.update(args.id, args.study, args.population, args.dce, args.project, args.table) - - if args.publish: - if args.verbose: - print("Publishing " + args.id + "...") - service.publish(args.id) - elif args.unpublish: - if args.verbose: - print("Unpublishing " + args.id + "...") - service.unpublish(args.id) + def __init__(self, client: MicaClient, verbose: bool = False): + self.client = client + self.verbose = verbose + + def new_request(self): + """ + Creates a MicaRequest instance + """ + request = self.client.new_request() + request.fail_on_error() + request.accept_json() + if self.verbose: + request.verbose() + return request + + def get_dataset(self, id): + """ + Retrieves a colleted + + :param id - dataset id + """ + path = "/draft/collected-dataset/" + id + request = self.new_request() + response = request.get().resource(path).send() + return json.loads(response.content) + + def update_study_table(self, dataset, comment=[], study: str = None, population: str = None, dce: str = None, project: str = None, table: str = None): + """ + Updates the collected dataset's study table holding the information to associated Opal Project/Table + + :param dataset - dataset document + :param comment - commit message + :param study - dataset's associated study ID + :param population - dataset's associated population ID + :param dce - dataset's associated data collection event ID + :param project - associated Opal project name + :param table - associated Opal table name + """ + MicaLegacySupport.removeDatasetEntityState(dataset) + dataset.pop("variableType", None) + dataset.pop("timestamps", None) + dataset.pop("published", None) + dataset.pop("permissions", None) + + collectedDataset = MicaLegacySupport.getCollectedDataset(dataset) + + if collectedDataset is None: + if not study or not population or not dce or not project or not table: + raise ValueError("Study table is missing and cannot be created.") + collectedDataset = {"studyTable": {}} + collectedDataset["studyTable"].pop("studySummary", None) + + builder = StudyTableBuilder(collectedDataset["studyTable"]) + + # update + comment = [] + if study: + comment.append("Study: " + study) + builder.study(study) + if population: + comment.append("Population: " + population) + builder.population(population) + if dce: + comment.append("DCE: " + dce) + builder.dce(dce) + if project: + comment.append("Project: " + project) + builder.project(project) + if table: + comment.append("Table: " + table) + builder.table(table) + + collectedDataset["studyTable"] = builder.build() + + def update_dataset(self, dataset, comment): + """ + Sends the updated collected dataset to Mica server + + :param dataset - updated dataset document + :param comment - commit comment + """ + path = "/draft/collected-dataset/%s" % dataset["id"] + request = self.new_request() + request.put().resource(path).query({"comment": ", ".join(comment) + " (update-collected-dataset)"}).content_type_json() + request.content(json.dumps(dataset, separators=(",", ":"))) + if self.verbose: + print("Updated: ") + print(json.dumps(dataset, sort_keys=True, indent=2, separators=(",", ": "))) + return request.send() + + def update(self, datasetId: str, study: str = None, population: str = None, dce: str = None, project: str = None, table: str = None): + """ + Updates the collected dataset's study table holding the information to associated Opal Project/Table + + :param datasetId - dataset ID + :param comment - commit message + :param study - dataset's associated study ID + :param population - dataset's associated population ID + :param dce - dataset's associated data collection event ID + :param project - associated Opal project name + :param table - associated Opal table name + """ + if self.verbose: + print("Updating %s ..." % datasetId) + + # get existing and remove useless fields + dataset = self.get_dataset(datasetId) + comment = [] + self.update_study_table(dataset, comment, study, population, dce, project, table) + return self.update_dataset(dataset, comment) + + def __publish(self, datasetId: str, method: str = "PUT"): + path = "/draft/collected-dataset/%s" % datasetId + request = self.new_request() + return request.method(method).resource(path + "/_publish").send() + + def publish(self, datasetId: str): + """ + Publishs a collected dataset + + :param datasetId - dataset document ID + """ + return self.__publish(datasetId, "PUT") + + def unpublish(self, datasetId: str): + """ + Unpublishes a collected dataset + + :param datasetId - dataset document ID + """ + return self.__publish(datasetId, "DELETE") + + @classmethod + def add_arguments(cls, parser): + """ + Add REST command specific options + + :param parser commandline args parser + """ + parser.add_argument("id", help="Collected dataset ID") + parser.add_argument("--study", "-std", required=False, help="Mica study") + parser.add_argument("--population", "-pop", required=False, help="Mica population") + parser.add_argument("--dce", "-dce", required=False, help="Mica study population data collection event") + parser.add_argument("--project", "-prj", required=False, help="Opal project") + parser.add_argument("--table", "-tbl", required=False, help="Opal table") + parser.add_argument("--publish", "-pub", action="store_true", help="Publish the colected dataset") + parser.add_argument("--unpublish", "-un", action="store_true", help="Unpublish the collected dataset") + + @classmethod + def do_command(cls, args): + """ + Execute dataset update command + + :param args - commandline args + """ + # Build and send request + service = CollectedDatasetService(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) + + if args.project or args.table: + service.update(args.id, args.study, args.population, args.dce, args.project, args.table) + + if args.publish: + if args.verbose: + print("Publishing " + args.id + "...") + service.publish(args.id) + elif args.unpublish: + if args.verbose: + print("Unpublishing " + args.id + "...") + service.unpublish(args.id) diff --git a/obiba_mica/update_collected_datasets.py b/obiba_mica/update_collected_datasets.py index 7171087..22afa87 100644 --- a/obiba_mica/update_collected_datasets.py +++ b/obiba_mica/update_collected_datasets.py @@ -3,97 +3,98 @@ import json import re + class CollectedDatasetsService: - """ - Update several existing collected dataset, mainly for managing the linkage with opal. - """ - - def __init__(self, client: MicaClient, verbose: bool = False): - self.client = client - self.verbose = verbose - self.datasetService = CollectedDatasetService(client, verbose) - - def new_request(self): - """ - Creates a MicaRequest instance - """ - request = self.client.new_request() - request.fail_on_error() - request.accept_json() - if self.verbose: - request.verbose() - return request - - def update(self, datasetId: str, project: str): - """ - Updates a collecetd dataset's Opal project name - - :param datasetId - dataset document ID - :param project - Opal project name - """ - return self.datasetService.update(datasetId, project=project) - - def publish(self, datasetId: str): - """ - Publishes a collected dataset - """ - return self.datasetService.publish(datasetId) - - def unpublish(self, datasetId: str): - return self.datasetService.unpublish(datasetId) - - def get_dataset(self, id: str): - return self.datasetService.get_dataset(id) - - def get_datasets(self, pattern: str): - """ - Retrieves all collected datasets of a Mica server matching the regular expression pattern - - :param pattern - regular expression pattern - """ - path = '/draft/collected-datasets' - request = self.new_request() - response = request.get().resource(path).send() - datasets = json.loads(response.content) - - return list(filter(lambda dataset: re.match(pattern, dataset['id']), datasets)) - - @classmethod - def add_arguments(cls, parser): - """ - Add REST command specific options - - :param parser - commandline args parser - """ - parser.add_argument('id', help='Regular expression to filter the collected dataset IDs') - parser.add_argument('--project', '-prj', required=False, help='Opal project') - parser.add_argument('--dry', '-d', action='store_true', help='Dry run to evaluate the regular expression') - parser.add_argument('--publish', '-pub', action='store_true', help='Publish the colected dataset') - parser.add_argument('--unpublish', '-un', action='store_true', help='Unpublish the collected dataset') - - @classmethod - def do_command(cls, args): - """ - Execute datasets update command - - :param args - commandline args - """ - # Build and send request - datasetsService = CollectedDatasetsService(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) - datasets = datasetsService.get_datasets(args.id) - - for dataset in datasets: - id = dataset['id'] - if args.dry: - print(id) - else: - if args.project: - datasetsService.update(id, args.project) - if args.publish: - if args.verbose: - print("Publishing " + id + "...") - datasetsService.publish(id) - elif args.unpublish: - if args.verbose: - print("Unpublishing " + id + "...") - datasetsService.unpublish(id) + """ + Update several existing collected dataset, mainly for managing the linkage with opal. + """ + + def __init__(self, client: MicaClient, verbose: bool = False): + self.client = client + self.verbose = verbose + self.datasetService = CollectedDatasetService(client, verbose) + + def new_request(self): + """ + Creates a MicaRequest instance + """ + request = self.client.new_request() + request.fail_on_error() + request.accept_json() + if self.verbose: + request.verbose() + return request + + def update(self, datasetId: str, project: str): + """ + Updates a collecetd dataset's Opal project name + + :param datasetId - dataset document ID + :param project - Opal project name + """ + return self.datasetService.update(datasetId, project=project) + + def publish(self, datasetId: str): + """ + Publishes a collected dataset + """ + return self.datasetService.publish(datasetId) + + def unpublish(self, datasetId: str): + return self.datasetService.unpublish(datasetId) + + def get_dataset(self, id: str): + return self.datasetService.get_dataset(id) + + def get_datasets(self, pattern: str): + """ + Retrieves all collected datasets of a Mica server matching the regular expression pattern + + :param pattern - regular expression pattern + """ + path = "/draft/collected-datasets" + request = self.new_request() + response = request.get().resource(path).send() + datasets = json.loads(response.content) + + return list(filter(lambda dataset: re.match(pattern, dataset["id"]), datasets)) + + @classmethod + def add_arguments(cls, parser): + """ + Add REST command specific options + + :param parser - commandline args parser + """ + parser.add_argument("id", help="Regular expression to filter the collected dataset IDs") + parser.add_argument("--project", "-prj", required=False, help="Opal project") + parser.add_argument("--dry", "-d", action="store_true", help="Dry run to evaluate the regular expression") + parser.add_argument("--publish", "-pub", action="store_true", help="Publish the colected dataset") + parser.add_argument("--unpublish", "-un", action="store_true", help="Unpublish the collected dataset") + + @classmethod + def do_command(cls, args): + """ + Execute datasets update command + + :param args - commandline args + """ + # Build and send request + datasetsService = CollectedDatasetsService(MicaClient.build(MicaClient.LoginInfo.parse(args)), args.verbose) + datasets = datasetsService.get_datasets(args.id) + + for dataset in datasets: + id = dataset["id"] + if args.dry: + print(id) + else: + if args.project: + datasetsService.update(id, args.project) + if args.publish: + if args.verbose: + print("Publishing " + id + "...") + datasetsService.publish(id) + elif args.unpublish: + if args.verbose: + print("Unpublishing " + id + "...") + datasetsService.unpublish(id) diff --git a/tests/__init__.py b/tests/__init__.py index 9520e72..3dcdf69 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -from tests import test_rest \ No newline at end of file +from tests import test_rest as test_rest diff --git a/tests/test_access.py b/tests/test_access.py index 860ab91..38d61ca 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -2,63 +2,63 @@ from obiba_mica.access import IndividualStudyAccessService, FileAccessService from tests.utils import Utils -class TestClass(unittest.TestCase): - def setUp(self): - """Clean up before each test to ensure test isolation""" - # Clean up document access - try: - service = IndividualStudyAccessService(Utils.make_client()) - service.delete_access('clsa', 'USER', 'user1') - except Exception: - pass +class TestClass(unittest.TestCase): - # Clean up file access - try: - service = FileAccessService(Utils.make_client()) - file = '/individual-study/cls/population/1/data-collection-event/4/Wave 4 subject interview.pdf' - service.delete_access(file, 'USER', 'user1') - except Exception: - pass + def setUp(self): + """Clean up before each test to ensure test isolation""" + # Clean up document access + try: + service = IndividualStudyAccessService(Utils.make_client()) + service.delete_access("clsa", "USER", "user1") + except Exception: + pass - def test_documentAccess(self): - self.service = IndividualStudyAccessService(Utils.make_client()) + # Clean up file access + try: + service = FileAccessService(Utils.make_client()) + file = "/individual-study/cls/population/1/data-collection-event/4/Wave 4 subject interview.pdf" + service.delete_access(file, "USER", "user1") + except Exception: + pass - try: - response = self.service.add_access('clsa', 'USER', 'user1') - assert response.code == 204 + def test_documentAccess(self): + self.service = IndividualStudyAccessService(Utils.make_client()) - # Wait for access to be indexed/available - def check_access(): - response = self.service.list_accesses('clsa').as_json() - found = next((x for x in response if x['principal'] == 'user1'), None) - return found is not None + try: + response = self.service.add_access("clsa", "USER", "user1") + assert response.code == 204 - assert Utils.wait_for_condition(check_access, timeout=Utils.get_timeout(10)), "Access not found after add" + # Wait for access to be indexed/available + def check_access(): + response = self.service.list_accesses("clsa").as_json() + found = next((x for x in response if x["principal"] == "user1"), None) + return found is not None - response = self.service.delete_access('clsa', 'USER', 'user1') - assert response.code == 204 - except Exception as e: - assert False + assert Utils.wait_for_condition(check_access, timeout=Utils.get_timeout(10)), "Access not found after add" - def test_fileAccess(self): - self.service = FileAccessService(Utils.make_client()) + response = self.service.delete_access("clsa", "USER", "user1") + assert response.code == 204 + except Exception: + assert False - try: - file = '/individual-study/cls/population/1/data-collection-event/4/Wave 4 subject interview.pdf' - response = self.service.add_access(file, 'USER', 'user1') - assert response.code == 204 + def test_fileAccess(self): + self.service = FileAccessService(Utils.make_client()) - # Wait for access to be indexed/available - def check_access(): - response = self.service.list_accesses(file).as_json() - found = next((x for x in response if x['principal'] == 'user1'), None) - return found is not None + try: + file = "/individual-study/cls/population/1/data-collection-event/4/Wave 4 subject interview.pdf" + response = self.service.add_access(file, "USER", "user1") + assert response.code == 204 - assert Utils.wait_for_condition(check_access, timeout=Utils.get_timeout(10)), "File access not found after add" + # Wait for access to be indexed/available + def check_access(): + response = self.service.list_accesses(file).as_json() + found = next((x for x in response if x["principal"] == "user1"), None) + return found is not None - response = self.service.delete_access(file, 'USER', 'user1') - assert response.code == 204 - except Exception as e: - assert False + assert Utils.wait_for_condition(check_access, timeout=Utils.get_timeout(10)), "File access not found after add" + response = self.service.delete_access(file, "USER", "user1") + assert response.code == 204 + except Exception: + assert False diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 0e3ec0d..b1f278f 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -1,28 +1,28 @@ import unittest from obiba_mica.annotation import AnnotationService -from obiba_mica.core import MicaClient from tests.utils import Utils from os import path, remove + class TestClass(unittest.TestCase): - @classmethod - def setup_class(cls): - cls.service = AnnotationService(Utils.make_client()) + @classmethod + def setup_class(cls): + cls.service = AnnotationService(Utils.make_client()) - def test_datasetAnnotation(self): - try: - outputFile = '/tmp/cls-wave1.csv' - writer = self.service.create_writer(outputFile) - writer.writeheader() + def test_datasetAnnotation(self): + try: + outputFile = "/tmp/cls-wave1.csv" + writer = self.service.create_writer(outputFile) + writer.writeheader() - self.service.write_dataset_variable_annotations('cls-wave1', writer) + self.service.write_dataset_variable_annotations("cls-wave1", writer) - if path.exists(outputFile): - remove(outputFile) - else: - assert False + if path.exists(outputFile): + remove(outputFile) + else: + assert False - assert True - except Exception as e: - assert False + assert True + except Exception: + assert False diff --git a/tests/test_core.py b/tests/test_core.py index f9a1882..cc79538 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,29 +1,26 @@ import unittest -from obiba_mica.core import MicaClient -from os.path import exists from tests.utils import Utils class TestClass(unittest.TestCase): - def test_sendRestBadServer(self): - try: - Utils.make_client(server='http://deadbeef:8080') - assert False - except Exception: - assert True + def test_sendRestBadServer(self): + try: + Utils.make_client(server="http://deadbeef:8080") + assert False + except Exception: + assert True - def test_sendRestBadCredentials(self): - try: - Utils.make_client(user='admin') - assert False - except Exception: - assert True - - def test_invalidRestCall(self): - try: - self.__test_sendRestRequest('/draft/individual-studie') - assert False - except Exception as e: - assert True + def test_sendRestBadCredentials(self): + try: + Utils.make_client(user="admin") + assert False + except Exception: + assert True + def test_invalidRestCall(self): + try: + self.__test_sendRestRequest("/draft/individual-studie") + assert False + except Exception: + assert True diff --git a/tests/test_file.py b/tests/test_file.py index 0c47aae..e19c685 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -3,174 +3,165 @@ from tests.utils import Utils import json + class TestClass(unittest.TestCase): - @classmethod - def setup_class(cls): - cls.service = FileService(Utils.make_client()) - # Clean up any leftover file from previous test runs - cls._cleanup_test_file() - - @classmethod - def teardown_class(cls): - # Clean up after all tests complete - cls._cleanup_test_file() - - @classmethod - def _cleanup_test_file(cls): - """Clean up test file to ensure test isolation""" - from obiba_mica.core import HTTPError - try: - existing = cls.service.get('/individual-study/dummy.csv') - if existing: - current_status = existing.as_json().get('revisionStatus') - if current_status != FileService.STATUS_DELETED: - try: - cls.service.status('/individual-study/dummy.csv', FileService.STATUS_DELETED) - except Exception: - pass + @classmethod + def setup_class(cls): + cls.service = FileService(Utils.make_client()) + # Clean up any leftover file from previous test runs + cls._cleanup_test_file() + + @classmethod + def teardown_class(cls): + # Clean up after all tests complete + cls._cleanup_test_file() + + @classmethod + def _cleanup_test_file(cls): + """Clean up test file to ensure test isolation""" + from obiba_mica.core import HTTPError + + try: + existing = cls.service.get("/individual-study/dummy.csv") + if existing: + current_status = existing.as_json().get("revisionStatus") + if current_status != FileService.STATUS_DELETED: + try: + cls.service.status("/individual-study/dummy.csv", FileService.STATUS_DELETED) + except Exception: + pass + try: + cls.service.delete("/individual-study/dummy.csv") + except Exception: + pass + except HTTPError: + pass # File doesn't exist, which is fine + + def test_1_fileUpload(self): + try: + response = self.service.upload("/individual-study", "./tests/resources/dummy.csv") + + if response.code == 201: + # Wait for file to be indexed/available after upload + Utils.wait_for_condition(lambda: self.service.get("/individual-study/dummy.csv") is not None, timeout=Utils.get_timeout(10)) + assert True + else: + assert False + + except Exception: + assert False + + def __test_fileChangeStatus(self, file, status): + from obiba_mica.core import HTTPError + + def try_status_change(): + try: + response = self.service.status(file, status) + return response.code == 204 + except HTTPError as e: + # Retry on 404 (file not indexed yet) or 5xx (server errors) + if e.code == 404 or e.is_server_error(): + return False + raise + + # Retry with exponential backoff - longer timeout in CI + timeout = Utils.get_timeout(7) # 7s local, 21s in CI + success = Utils.wait_for_condition(try_status_change, timeout=timeout, interval=1, backoff="exponential") + assert success, f"Failed to change status to {status} for {file}" + + def __test_fileDelete(self, path): try: - cls.service.delete('/individual-study/dummy.csv') + response = self.service.delete(path) + + if response.code == 204: + assert True + else: + assert False + + except Exception: + assert False + + def test_2_fileUnderReviewStatus(self): + self.__test_fileChangeStatus("/individual-study/dummy.csv", FileService.STATUS_UNDER_REVIEW) + + def test_3_filePublish(self): + try: + response = self.service.publish("/individual-study/dummy.csv", True) + + if response.code == 204: + # Wait for publish to complete/propagate + Utils.wait_for_condition(lambda: self.service.get("/individual-study/dummy.csv") is not None, timeout=Utils.get_timeout(10)) + assert True + else: + assert False + except Exception: - pass - except HTTPError: - pass # File doesn't exist, which is fine - - def test_1_fileUpload(self): - try: - response = self.service.upload('/individual-study', './tests/resources/dummy.csv') - - if response.code == 201: - # Wait for file to be indexed/available after upload - Utils.wait_for_condition( - lambda: self.service.get('/individual-study/dummy.csv') is not None, - timeout=Utils.get_timeout(10) - ) - assert True - else: - assert False - - except Exception as e: - assert False - - def __test_fileChangeStatus(self, file, status): - from obiba_mica.core import HTTPError - - def try_status_change(): - try: - response = self.service.status(file, status) - return response.code == 204 - except HTTPError as e: - # Retry on 404 (file not indexed yet) or 5xx (server errors) - if e.code == 404 or e.is_server_error(): - return False - raise - - # Retry with exponential backoff - longer timeout in CI - timeout = Utils.get_timeout(7) # 7s local, 21s in CI - success = Utils.wait_for_condition(try_status_change, timeout=timeout, interval=1, backoff='exponential') - assert success, f"Failed to change status to {status} for {file}" - - def __test_fileDelete(self, path): - try: - response = self.service.delete(path) - - if response.code == 204: - assert True - else: - assert False - - except Exception as e: - assert False - - def test_2_fileUnderReviewStatus(self): - self.__test_fileChangeStatus('/individual-study/dummy.csv', FileService.STATUS_UNDER_REVIEW) - - def test_3_filePublish(self): - try: - response = self.service.publish('/individual-study/dummy.csv', True) - - if response.code == 204: - # Wait for publish to complete/propagate - Utils.wait_for_condition( - lambda: self.service.get('/individual-study/dummy.csv') is not None, - timeout=Utils.get_timeout(10) - ) - assert True - else: - assert False - - except Exception as e: - assert False - - def test_4_fileJson(self): - try: - response = self.service.get('/individual-study/dummy.csv') - - if response is None: - assert False - else: - parsed = json.loads(response.content) - if parsed['name'] != 'dummy.csv' and parsed['state']['publishedId'] == None: - assert False - - assert True - except Exception as e: - assert False - - def test_5_fileDownload(self): - try: - response = self.service.download('/individual-study/dummy.csv') - - if response is None or 'col1' not in response.content.decode('utf-8'): - assert False - - assert True - except Exception as e: - assert False - - def test_6_changeDeletedStatus(self): - self.__test_fileChangeStatus('/individual-study/dummy.csv', FileService.STATUS_DELETED) - - # Wait for status change to propagate before test_7 tries to delete - def check_status(): - try: - state = self.service.get('/individual-study/dummy.csv').as_json() - return state.get('revisionStatus') == FileService.STATUS_DELETED - except Exception: - return False - - assert Utils.wait_for_condition(check_status, timeout=Utils.get_timeout(10)), \ - "File status did not propagate to DELETED" - - def test_7_fileDelete(self): - self.__test_fileDelete('/individual-study/dummy.csv') - - def test_8_createFolder(self): - try: - response = self.service.create("/individual-study", "yoyo") - - if response.code == 201: - self.__test_fileChangeStatus('/individual-study/yoyo', FileService.STATUS_DELETED) - - # Wait for status change to propagate before delete + assert False + + def test_4_fileJson(self): + try: + response = self.service.get("/individual-study/dummy.csv") + + if response is None: + assert False + else: + parsed = json.loads(response.content) + if parsed["name"] != "dummy.csv" and parsed["state"]["publishedId"] == None: + assert False + + assert True + except Exception: + assert False + + def test_5_fileDownload(self): + try: + response = self.service.download("/individual-study/dummy.csv") + + if response is None or "col1" not in response.content.decode("utf-8"): + assert False + + assert True + except Exception: + assert False + + def test_6_changeDeletedStatus(self): + self.__test_fileChangeStatus("/individual-study/dummy.csv", FileService.STATUS_DELETED) + + # Wait for status change to propagate before test_7 tries to delete def check_status(): - try: - state = self.service.get('/individual-study/yoyo').as_json() - return state.get('revisionStatus') == FileService.STATUS_DELETED - except Exception: - return False + try: + state = self.service.get("/individual-study/dummy.csv").as_json() + return state.get("revisionStatus") == FileService.STATUS_DELETED + except Exception: + return False - assert Utils.wait_for_condition(check_status, timeout=Utils.get_timeout(10)), \ - "Folder status did not propagate to DELETED" + assert Utils.wait_for_condition(check_status, timeout=Utils.get_timeout(10)), "File status did not propagate to DELETED" - self.__test_fileDelete('/individual-study/yoyo') + def test_7_fileDelete(self): + self.__test_fileDelete("/individual-study/dummy.csv") - else: - assert False + def test_8_createFolder(self): + try: + response = self.service.create("/individual-study", "yoyo") + + if response.code == 201: + self.__test_fileChangeStatus("/individual-study/yoyo", FileService.STATUS_DELETED) - except Exception as e: - assert False + # Wait for status change to propagate before delete + def check_status(): + try: + state = self.service.get("/individual-study/yoyo").as_json() + return state.get("revisionStatus") == FileService.STATUS_DELETED + except Exception: + return False + assert Utils.wait_for_condition(check_status, timeout=Utils.get_timeout(10)), "Folder status did not propagate to DELETED" + self.__test_fileDelete("/individual-study/yoyo") + else: + assert False + + except Exception: + assert False diff --git a/tests/test_import_zip.py b/tests/test_import_zip.py index b700ef2..72940e8 100644 --- a/tests/test_import_zip.py +++ b/tests/test_import_zip.py @@ -21,20 +21,15 @@ def teardown_class(cls): @classmethod def _cleanup_test_resources(cls): """Clean up test resources to ensure test isolation""" - from obiba_mica.core import HTTPError restService = RestService(cls.client) - resources = [ - "/draft/network/dummy-test-network", - "/draft/individual-study/dummy-test-study" - ] + resources = ["/draft/network/dummy-test-network", "/draft/individual-study/dummy-test-study"] for resource in resources: try: # Try to change status to DELETED try: - restService.send_request(f"{resource}/_status?value=DELETED", - restService.make_request("PUT")) + restService.send_request(f"{resource}/_status?value=DELETED", restService.make_request("PUT")) except Exception: pass @@ -67,7 +62,7 @@ def try_status_change(): # Retry with exponential backoff - longer timeout in CI # Using longer timeout since status changes can take time after import timeout = Utils.get_timeout(10) # 10s local, 30s in CI - success = Utils.wait_for_condition(try_status_change, timeout=timeout, interval=1, backoff='exponential') + success = Utils.wait_for_condition(try_status_change, timeout=timeout, interval=1, backoff="exponential") if not success: error_msg = f"Failed to change status to DELETED for {resource}" if last_error: @@ -95,7 +90,7 @@ def try_delete(): # Retry delete with exponential backoff for 409 conflicts timeout = Utils.get_timeout(15) # 15s local, 45s in CI - success = Utils.wait_for_condition(try_delete, timeout=timeout, interval=1, backoff='exponential') + success = Utils.wait_for_condition(try_delete, timeout=timeout, interval=1, backoff="exponential") assert success, f"Failed to delete resource {resource} after {timeout}s (dependencies not cleared)" def test_1_importZip(self): @@ -107,19 +102,11 @@ def test_1_importZip(self): # Wait for resources to be indexed/available after import restService = RestService(self.client) - Utils.wait_for_condition( - lambda: restService.send_request("/draft/individual-study/dummy-test-study", - restService.make_request("GET")).code == 200, - timeout=Utils.get_timeout(10) - ) - Utils.wait_for_condition( - lambda: restService.send_request("/draft/network/dummy-test-network", - restService.make_request("GET")).code == 200, - timeout=Utils.get_timeout(10) - ) + Utils.wait_for_condition(lambda: restService.send_request("/draft/individual-study/dummy-test-study", restService.make_request("GET")).code == 200, timeout=Utils.get_timeout(10)) + Utils.wait_for_condition(lambda: restService.send_request("/draft/network/dummy-test-network", restService.make_request("GET")).code == 200, timeout=Utils.get_timeout(10)) else: assert True - except Exception as e: + except Exception: assert False def test_2_deleteDummy(self): @@ -132,7 +119,7 @@ def test_2_deleteDummy(self): self.__test_deleteResource(restService, "/draft/individual-study/dummy-test-study") else: assert True - except Exception as e: + except Exception: assert False def test_3_importZip(self): @@ -143,17 +130,9 @@ def test_3_importZip(self): # Wait for resources to be indexed/available after import restService = RestService(self.client) - Utils.wait_for_condition( - lambda: restService.send_request("/draft/individual-study/dummy-test-study", - restService.make_request("GET")).code == 200, - timeout=Utils.get_timeout(10) - ) - Utils.wait_for_condition( - lambda: restService.send_request("/draft/network/dummy-test-network", - restService.make_request("GET")).code == 200, - timeout=Utils.get_timeout(10) - ) - except Exception as e: + Utils.wait_for_condition(lambda: restService.send_request("/draft/individual-study/dummy-test-study", restService.make_request("GET")).code == 200, timeout=Utils.get_timeout(10)) + Utils.wait_for_condition(lambda: restService.send_request("/draft/network/dummy-test-network", restService.make_request("GET")).code == 200, timeout=Utils.get_timeout(10)) + except Exception: assert False def test_4_deleteDummy(self): @@ -163,5 +142,5 @@ def test_4_deleteDummy(self): self.__test_deleteResource(restService, "/draft/network/dummy-test-network") self.__test_changeResourceStatusToDelete(restService, "/draft/individual-study/dummy-test-study") self.__test_deleteResource(restService, "/draft/individual-study/dummy-test-study") - except Exception as e: + except Exception: assert False diff --git a/tests/test_permission.py b/tests/test_permission.py index 2ddcaa4..817d26f 100644 --- a/tests/test_permission.py +++ b/tests/test_permission.py @@ -2,40 +2,41 @@ from obiba_mica.perm import IndividualStudyPermissionService from tests.utils import Utils + class TestClass(unittest.TestCase): - @classmethod - def setup_class(cls): - cls.service = IndividualStudyPermissionService(Utils.make_client()) - # Clean up any leftover permissions from previous test runs - try: - cls.service.delete_permission('clsa', 'USER', 'user1') - except Exception: - pass - - @classmethod - def teardown_class(cls): - # Clean up after all tests complete - try: - cls.service.delete_permission('clsa', 'USER', 'user1') - except Exception: - pass - - def test_documentPermission(self): - try: - response = self.service.add_permission('clsa', 'USER', 'user1', 'READER') - assert response.code == 204 - - # Wait for permission to be indexed/available - def check_permission(): - response = self.service.list_permissions('clsa').as_json() - found = next((x for x in response if x['principal'] == 'user1'), None) - return found is not None - - assert Utils.wait_for_condition(check_permission, timeout=Utils.get_timeout(10)), "Permission not found after add" - - response = self.service.delete_permission('clsa', 'USER', 'user1') - assert response.code == 204 - - except Exception as e: - assert False + @classmethod + def setup_class(cls): + cls.service = IndividualStudyPermissionService(Utils.make_client()) + # Clean up any leftover permissions from previous test runs + try: + cls.service.delete_permission("clsa", "USER", "user1") + except Exception: + pass + + @classmethod + def teardown_class(cls): + # Clean up after all tests complete + try: + cls.service.delete_permission("clsa", "USER", "user1") + except Exception: + pass + + def test_documentPermission(self): + try: + response = self.service.add_permission("clsa", "USER", "user1", "READER") + assert response.code == 204 + + # Wait for permission to be indexed/available + def check_permission(): + response = self.service.list_permissions("clsa").as_json() + found = next((x for x in response if x["principal"] == "user1"), None) + return found is not None + + assert Utils.wait_for_condition(check_permission, timeout=Utils.get_timeout(10)), "Permission not found after add" + + response = self.service.delete_permission("clsa", "USER", "user1") + assert response.code == 204 + + except Exception: + assert False diff --git a/tests/test_plugin.py b/tests/test_plugin.py index c1e065d..664fb41 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -2,38 +2,38 @@ from obiba_mica.plugin import PluginService from tests.utils import Utils -class TestClass(unittest.TestCase): - __test__ = False # This test needs Mica to restart for each action, only useful for manual and controlled tests +class TestClass(unittest.TestCase): + __test__ = False # This test needs Mica to restart for each action, only useful for manual and controlled tests - @classmethod - def setup_class(cls): - cls.service = PluginService(Utils.make_client()) + @classmethod + def setup_class(cls): + cls.service = PluginService(Utils.make_client()) - def test_addSearchPlugin(self): - try: - response = self.service.install('mica-search-es') - assert response.code == 200 - except Exception as e: - assert False + def test_addSearchPlugin(self): + try: + response = self.service.install("mica-search-es") + assert response.code == 200 + except Exception: + assert False - def test_removeSearchPlugin(self): - try: - response = self.service.remove('mica-search-es') - assert response.code == 204 - except Exception as e: - assert False + def test_removeSearchPlugin(self): + try: + response = self.service.remove("mica-search-es") + assert response.code == 204 + except Exception: + assert False - def test_updateSearchPluginConfig(self): - try: - self.service.configure('mica-search-es') - assert True - except Exception as e: - assert False + def test_updateSearchPluginConfig(self): + try: + self.service.configure("mica-search-es") + assert True + except Exception: + assert False - def test_listPlugins(self): - try: - response = self.service.list() - assert response.code == 200 - except Exception as e: - assert False + def test_listPlugins(self): + try: + response = self.service.list() + assert response.code == 200 + except Exception: + assert False diff --git a/tests/test_rest.py b/tests/test_rest.py index 089b742..bf8b7b8 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -3,79 +3,80 @@ from obiba_mica.core import HTTPError from tests.utils import Utils + class TestClass(unittest.TestCase): - @classmethod - def setup_class(cls): - cls.service = RestService(Utils.make_client()) - - def test_validRestCall(self): - try: - response = self.service.send_request('/draft/individual-study/clsa', self.service.make_request('GET')).as_json() - if response['id'] != 'clsa': - assert False - - assert True - except Exception as e: - assert False - - def test_validRestCallWithParams(self): - try: - response = self.service.send_request('/draft/study-states', self.service.make_request('GET').query(('query', 'cls*'))).as_json() - if isinstance(response, list) and len(list(filter(lambda r: r['id'] == 'cls', response))) > 1: - assert False - - assert True - except Exception as e: - assert False - - def test_invalidRestCall(self): - try: - response = self.service.send_request('/draft/individual-study/potato', self.service.make_request('GET')).as_json() - if response['id'] != 'clsa': - assert False - - assert True - except HTTPError as e: - assert e.code == 404 - except Exception as e: - assert False - - def test_binaryDownload(self): - try: - # First, get a list of studies to find a valid study ID - studies_response = self.service.send_request('/draft/individual-studies', self.service.make_request('GET')).as_json() - - if not studies_response or len(studies_response) == 0: - # No studies available, skip test - assert True - return - - # Get the first study's ID - study_id = studies_response[0]['id'] - - # Create request for binary download with Accept: application/octet-stream - request = self.service.make_request('GET') - request.accept('application/octet-stream') - - # Download the study folder as binary (kept in memory, not written to disk) - response = self.service.send_request(f'/draft/file-dl//individual-study/{study_id}', request) - - # Verify we got binary content (not JSON) - assert response.content is not None, "Binary content should not be None" - assert len(response.content) > 0, "Binary content should not be empty" - assert response.code == 200, f"Expected 200, got {response.code}" - - # Verify Content-Type is not JSON (should be application/zip or application/octet-stream) - content_type = response.headers.get('Content-Type', '') - assert 'json' not in content_type.lower(), f"Content-Type should not be JSON, got: {content_type}" - - assert True - except HTTPError as e: - # If the endpoint doesn't exist or study has no files, that's ok for this test - if e.code in [404, 204]: - assert True - else: - assert False, f"Unexpected HTTPError: {e.code}" - except Exception as e: - assert False, f"Unexpected exception: {e}" + @classmethod + def setup_class(cls): + cls.service = RestService(Utils.make_client()) + + def test_validRestCall(self): + try: + response = self.service.send_request("/draft/individual-study/clsa", self.service.make_request("GET")).as_json() + if response["id"] != "clsa": + assert False + + assert True + except Exception: + assert False + + def test_validRestCallWithParams(self): + try: + response = self.service.send_request("/draft/study-states", self.service.make_request("GET").query(("query", "cls*"))).as_json() + if isinstance(response, list) and len(list(filter(lambda r: r["id"] == "cls", response))) > 1: + assert False + + assert True + except Exception: + assert False + + def test_invalidRestCall(self): + try: + response = self.service.send_request("/draft/individual-study/potato", self.service.make_request("GET")).as_json() + if response["id"] != "clsa": + assert False + + assert True + except HTTPError as e: + assert e.code == 404 + except Exception: + assert False + + def test_binaryDownload(self): + try: + # First, get a list of studies to find a valid study ID + studies_response = self.service.send_request("/draft/individual-studies", self.service.make_request("GET")).as_json() + + if not studies_response or len(studies_response) == 0: + # No studies available, skip test + assert True + return + + # Get the first study's ID + study_id = studies_response[0]["id"] + + # Create request for binary download with Accept: application/octet-stream + request = self.service.make_request("GET") + request.accept("application/octet-stream") + + # Download the study folder as binary (kept in memory, not written to disk) + response = self.service.send_request(f"/draft/file-dl//individual-study/{study_id}", request) + + # Verify we got binary content (not JSON) + assert response.content is not None, "Binary content should not be None" + assert len(response.content) > 0, "Binary content should not be empty" + assert response.code == 200, f"Expected 200, got {response.code}" + + # Verify Content-Type is not JSON (should be application/zip or application/octet-stream) + content_type = response.headers.get("Content-Type", "") + assert "json" not in content_type.lower(), f"Content-Type should not be JSON, got: {content_type}" + + assert True + except HTTPError as e: + # If the endpoint doesn't exist or study has no files, that's ok for this test + if e.code in [404, 204]: + assert True + else: + assert False, f"Unexpected HTTPError: {e.code}" + except Exception as e: + assert False, f"Unexpected exception: {e}" diff --git a/tests/test_search.py b/tests/test_search.py index 8bfa8d4..bbd3b91 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -3,113 +3,113 @@ from tests.utils import Utils from io import StringIO + class TestClass(unittest.TestCase): - @classmethod - def setup_class(cls): - cls.service = SearchService(Utils.make_client()) - - def test_searchNetworks(self): - try: - output = StringIO() - self.service.search_networks(query='network(in(id,bioshare-eu))', out=output) - response = output.getvalue() - if len(response) > 0 and 'bioshare-eu' in response: - assert True - else: - assert False - - except Exception as e: - assert False - - def test_searchStudies(self): - try: - output = StringIO() - self.service.search_studies(query='study(in(id,cls))', out=output) - response = output.getvalue() - if len(response) > 0 and 'cls' in response: - assert True - else: - assert False - - except Exception as e: - assert False - - def test_searchInitiatives(self): - try: - output = StringIO() - self.service.search_initiatives(query='study(in(id,cptp-hs))', out=output) - response = output.getvalue() - if len(response) > 0 and 'cptp-hs' in response: - assert True - else: - assert False - - except Exception as e: - assert False - - def test_searchStudyPopulations(self): - try: - output = StringIO() - self.service.search_study_populations(query='study(in(id,cls))', out=output) - response = output.getvalue() - if len(response) > 0 and 'cls:1' in response: - assert True - else: - assert False - - except Exception as e: - assert False - - def test_searchStudyDces(self): - try: - output = StringIO() - self.service.search_study_dces(query='study(in(id,cls))', out=output) - response = output.getvalue() - if len(response) > 0 and 'cls:1:1' in response: - assert True - else: - assert False - - except Exception as e: - assert False - - - def test_searchDatasets(self): - try: - output = StringIO() - self.service.search_datasets(query='dataset(in(id,cls-wave1))', out=output) - response = output.getvalue() - if len(response) > 0 and 'cls-wave1' in response: - assert True - else: - assert False - - except Exception as e: - assert False - - def test_searchProtocols(self): - try: - output = StringIO() - self.service.search_datasets(query='dataset(in(id,chpt-generic-ds))', out=output) - response = output.getvalue() - if len(response) > 0 and 'chpt-generic-ds' in response: - assert True - else: - assert False - - except Exception as e: - assert False - - def test_searchVariables(self): - try: - output = StringIO() - self.service.search_variables(query='variable(in(Mlstr_area.Lifestyle_behaviours,(Alcohol)))', out=output) - response = output.getvalue() - if len(response) > 0 and 'Alcohol' in response: - assert True - else: - assert False - - except Exception as e: - assert False \ No newline at end of file + @classmethod + def setup_class(cls): + cls.service = SearchService(Utils.make_client()) + + def test_searchNetworks(self): + try: + output = StringIO() + self.service.search_networks(query="network(in(id,bioshare-eu))", out=output) + response = output.getvalue() + if len(response) > 0 and "bioshare-eu" in response: + assert True + else: + assert False + + except Exception: + assert False + + def test_searchStudies(self): + try: + output = StringIO() + self.service.search_studies(query="study(in(id,cls))", out=output) + response = output.getvalue() + if len(response) > 0 and "cls" in response: + assert True + else: + assert False + + except Exception: + assert False + + def test_searchInitiatives(self): + try: + output = StringIO() + self.service.search_initiatives(query="study(in(id,cptp-hs))", out=output) + response = output.getvalue() + if len(response) > 0 and "cptp-hs" in response: + assert True + else: + assert False + + except Exception: + assert False + + def test_searchStudyPopulations(self): + try: + output = StringIO() + self.service.search_study_populations(query="study(in(id,cls))", out=output) + response = output.getvalue() + if len(response) > 0 and "cls:1" in response: + assert True + else: + assert False + + except Exception: + assert False + + def test_searchStudyDces(self): + try: + output = StringIO() + self.service.search_study_dces(query="study(in(id,cls))", out=output) + response = output.getvalue() + if len(response) > 0 and "cls:1:1" in response: + assert True + else: + assert False + + except Exception: + assert False + + def test_searchDatasets(self): + try: + output = StringIO() + self.service.search_datasets(query="dataset(in(id,cls-wave1))", out=output) + response = output.getvalue() + if len(response) > 0 and "cls-wave1" in response: + assert True + else: + assert False + + except Exception: + assert False + + def test_searchProtocols(self): + try: + output = StringIO() + self.service.search_datasets(query="dataset(in(id,chpt-generic-ds))", out=output) + response = output.getvalue() + if len(response) > 0 and "chpt-generic-ds" in response: + assert True + else: + assert False + + except Exception: + assert False + + def test_searchVariables(self): + try: + output = StringIO() + self.service.search_variables(query="variable(in(Mlstr_area.Lifestyle_behaviours,(Alcohol)))", out=output) + response = output.getvalue() + if len(response) > 0 and "Alcohol" in response: + assert True + else: + assert False + + except Exception: + assert False diff --git a/tests/test_updated_collected_dataset.py b/tests/test_updated_collected_dataset.py index 10dd219..901b8d9 100644 --- a/tests/test_updated_collected_dataset.py +++ b/tests/test_updated_collected_dataset.py @@ -3,103 +3,102 @@ from obiba_mica.legacy import MicaLegacySupport from tests.utils import Utils -class TestClass(unittest.TestCase): - - @classmethod - def setup_class(cls): - cls.service = CollectedDatasetService(Utils.make_client()) - # Ensure dataset is in expected state before tests - cls._ensure_dataset_state() - - @classmethod - def teardown_class(cls): - # Restore dataset to expected state after tests - cls._ensure_dataset_state() - - @classmethod - def _ensure_dataset_state(cls): - """Ensure cls-wave1 dataset is in the correct state (published, correct project/table)""" - try: - # Restore correct project and table values - cls.service.update('cls-wave1', project='CLS', table='Wave1') - # Ensure it's published - try: - cls.service.publish('cls-wave1') - except Exception: - pass # May already be published - except Exception: - pass # Dataset may not exist, which is fine for some test environments - - def test_1_updateProject(self): - try: - response = self.service.update('cls-wave1', project='dummy') - if response.code == 204: - # Wait for update to propagate before verifying - def check_update(): - dataset = self.service.get_dataset('cls-wave1') - collectedDataset = MicaLegacySupport.getCollectedDataset(dataset) - studyTable = collectedDataset['studyTable'] - return studyTable.get('project') == 'dummy' - - assert Utils.wait_for_condition(check_update, timeout=Utils.get_timeout(10)), "Update did not propagate" - else: - assert False - - except Exception as e: - assert False - - def test_2_updateTable(self): - try: - response = self.service.update('cls-wave1', table='dummy') - if response.code == 204: - # Wait for update to propagate before verifying - def check_update(): - dataset = self.service.get_dataset('cls-wave1') - collectedDataset = MicaLegacySupport.getCollectedDataset(dataset) - studyTable = collectedDataset['studyTable'] - return studyTable.get('table') == 'dummy' - - assert Utils.wait_for_condition(check_update, timeout=Utils.get_timeout(10)), "Update did not propagate" - else: - assert False - - except Exception as e: - assert False - - def test_3_updateProjectTable(self): - try: - response = self.service.update('cls-wave1', project='CLS', table='Wave1') - if response.code == 204: - # Wait for update to propagate before verifying - def check_update(): - dataset = self.service.get_dataset('cls-wave1') - collectedDataset = MicaLegacySupport.getCollectedDataset(dataset) - studyTable = collectedDataset['studyTable'] - return studyTable.get('project') == 'CLS' and studyTable.get('table') == 'Wave1' - - assert Utils.wait_for_condition(check_update, timeout=Utils.get_timeout(10)), "Update did not propagate" - else: - assert False - - except Exception as e: - assert False - - def test_4_unpublish(self): - try: - response = self.service.unpublish('cls-wave1') - if response.code == 204: - assert True - else: - assert False - - except Exception as e: - assert False - - def test_5_publish(self): - try: - response = self.service.publish('cls-wave1') - assert response.code == 204, f"Publish failed: {response.content}" - except Exception as e: - assert False, f"Exception during publish: {e}" +class TestClass(unittest.TestCase): + @classmethod + def setup_class(cls): + cls.service = CollectedDatasetService(Utils.make_client()) + # Ensure dataset is in expected state before tests + cls._ensure_dataset_state() + + @classmethod + def teardown_class(cls): + # Restore dataset to expected state after tests + cls._ensure_dataset_state() + + @classmethod + def _ensure_dataset_state(cls): + """Ensure cls-wave1 dataset is in the correct state (published, correct project/table)""" + try: + # Restore correct project and table values + cls.service.update("cls-wave1", project="CLS", table="Wave1") + # Ensure it's published + try: + cls.service.publish("cls-wave1") + except Exception: + pass # May already be published + except Exception: + pass # Dataset may not exist, which is fine for some test environments + + def test_1_updateProject(self): + try: + response = self.service.update("cls-wave1", project="dummy") + if response.code == 204: + # Wait for update to propagate before verifying + def check_update(): + dataset = self.service.get_dataset("cls-wave1") + collectedDataset = MicaLegacySupport.getCollectedDataset(dataset) + studyTable = collectedDataset["studyTable"] + return studyTable.get("project") == "dummy" + + assert Utils.wait_for_condition(check_update, timeout=Utils.get_timeout(10)), "Update did not propagate" + else: + assert False + + except Exception: + assert False + + def test_2_updateTable(self): + try: + response = self.service.update("cls-wave1", table="dummy") + if response.code == 204: + # Wait for update to propagate before verifying + def check_update(): + dataset = self.service.get_dataset("cls-wave1") + collectedDataset = MicaLegacySupport.getCollectedDataset(dataset) + studyTable = collectedDataset["studyTable"] + return studyTable.get("table") == "dummy" + + assert Utils.wait_for_condition(check_update, timeout=Utils.get_timeout(10)), "Update did not propagate" + else: + assert False + + except Exception: + assert False + + def test_3_updateProjectTable(self): + try: + response = self.service.update("cls-wave1", project="CLS", table="Wave1") + if response.code == 204: + # Wait for update to propagate before verifying + def check_update(): + dataset = self.service.get_dataset("cls-wave1") + collectedDataset = MicaLegacySupport.getCollectedDataset(dataset) + studyTable = collectedDataset["studyTable"] + return studyTable.get("project") == "CLS" and studyTable.get("table") == "Wave1" + + assert Utils.wait_for_condition(check_update, timeout=Utils.get_timeout(10)), "Update did not propagate" + else: + assert False + + except Exception: + assert False + + def test_4_unpublish(self): + try: + response = self.service.unpublish("cls-wave1") + if response.code == 204: + assert True + else: + assert False + + except Exception: + assert False + + def test_5_publish(self): + try: + response = self.service.publish("cls-wave1") + assert response.code == 204, f"Publish failed: {response.content}" + except Exception as e: + assert False, f"Exception during publish: {e}" diff --git a/tests/test_updated_collected_datasets.py b/tests/test_updated_collected_datasets.py index 2b69c6b..d5769bd 100644 --- a/tests/test_updated_collected_datasets.py +++ b/tests/test_updated_collected_datasets.py @@ -4,127 +4,126 @@ from obiba_mica.core import HTTPError from tests.utils import Utils + class TestClass(unittest.TestCase): - datasets = None - - @classmethod - def setup_class(cls): - cls.service = CollectedDatasetsService(Utils.make_client()) - # Ensure datasets are in expected state before tests - cls._ensure_datasets_state() - - @classmethod - def teardown_class(cls): - # Restore datasets to expected state after tests - cls._ensure_datasets_state() - - @classmethod - def _ensure_datasets_state(cls): - """Ensure cls datasets are in the correct state (published, correct project)""" - try: - datasets = cls.service.get_datasets('^cls-') - for dataset in datasets: + datasets = None + + @classmethod + def setup_class(cls): + cls.service = CollectedDatasetsService(Utils.make_client()) + # Ensure datasets are in expected state before tests + cls._ensure_datasets_state() + + @classmethod + def teardown_class(cls): + # Restore datasets to expected state after tests + cls._ensure_datasets_state() + + @classmethod + def _ensure_datasets_state(cls): + """Ensure cls datasets are in the correct state (published, correct project)""" + try: + datasets = cls.service.get_datasets("^cls-") + for dataset in datasets: + try: + # Restore correct project value + cls.service.update(dataset["id"], "CLS") + # Ensure it's published + try: + cls.service.publish(dataset["id"]) + except Exception: + pass # May already be published + except Exception: + pass # Individual dataset cleanup failure + except Exception: + pass # Datasets may not exist, which is fine for some test environments + + def __updateProjects(self, dataset, project: str): try: - # Restore correct project value - cls.service.update(dataset['id'], 'CLS') - # Ensure it's published - try: - cls.service.publish(dataset['id']) - except Exception: - pass # May already be published + datasetId = dataset["id"] + response = self.service.update(datasetId, project) + if response.code == 204: + dataset = self.service.get_dataset(datasetId) + collectedDataset = MicaLegacySupport.getCollectedDataset(dataset) + studyTable = collectedDataset["studyTable"] + + if studyTable["project"] == project: + assert True + else: + assert False + else: + assert False + + except Exception: + assert False + + def __unpublish(self, dataset): + def try_unpublish(): + try: + response = self.service.unpublish(dataset["id"]) + if response.code == 204: + return True + else: + # Unexpected response code, fail immediately + assert False, f"Unexpected response code while unpublishing {dataset['id']}: {response.code}" + except HTTPError as e: + # Retry on server errors (5xx), fail immediately on client errors (4xx) + if e.is_server_error(): + return False + assert False, f"HTTPError while unpublishing {dataset['id']}: {e}" + + # Retry with exponential backoff: 1s, 2s, 4s - longer timeout in CI + timeout = Utils.get_timeout(7) # 7s local, 21s in CI + success = Utils.wait_for_condition(try_unpublish, timeout=timeout, interval=1, backoff="exponential") + assert success, f"Failed to unpublish {dataset['id']} after retries" + + def __publish(self, dataset): + def try_publish(): + try: + response = self.service.publish(dataset["id"]) + if response.code == 204: + return True + else: + # Unexpected response code, fail immediately + assert False, f"Unexpected response code while publishing {dataset['id']}: {response.code}" + except HTTPError as e: + # Retry on server errors (5xx), fail immediately on client errors (4xx) + if e.is_server_error(): + return False + assert False, f"HTTPError while publishing {dataset['id']}: {e}" + + # Retry with exponential backoff: 1s, 2s, 4s - longer timeout in CI + timeout = Utils.get_timeout(7) # 7s local, 21s in CI + success = Utils.wait_for_condition(try_publish, timeout=timeout, interval=1, backoff="exponential") + assert success, f"Failed to publish {dataset['id']} after retries" + + def test_1_getDatasets(self): + try: + datasets = self.service.get_datasets("^cls-") + if len(datasets) > 0: + TestClass.datasets = datasets + else: + assert False + except Exception: - pass # Individual dataset cleanup failure - except Exception: - pass # Datasets may not exist, which is fine for some test environments - - - def __updateProjects(self, dataset, project: str): - try: - datasetId = dataset['id'] - response = self.service.update(datasetId, project) - if response.code == 204: - dataset = self.service.get_dataset(datasetId) - collectedDataset = MicaLegacySupport.getCollectedDataset(dataset) - studyTable = collectedDataset['studyTable'] - - if studyTable['project'] == project: - assert True - else: - assert False - else: - assert False - - except Exception as e: - assert False - - def __unpublish(self, dataset): - def try_unpublish(): - try: - response = self.service.unpublish(dataset['id']) - if response.code == 204: - return True - else: - # Unexpected response code, fail immediately - assert False, f"Unexpected response code while unpublishing {dataset['id']}: {response.code}" - except HTTPError as e: - # Retry on server errors (5xx), fail immediately on client errors (4xx) - if e.is_server_error(): - return False - assert False, f"HTTPError while unpublishing {dataset['id']}: {e}" - - # Retry with exponential backoff: 1s, 2s, 4s - longer timeout in CI - timeout = Utils.get_timeout(7) # 7s local, 21s in CI - success = Utils.wait_for_condition(try_unpublish, timeout=timeout, interval=1, backoff='exponential') - assert success, f"Failed to unpublish {dataset['id']} after retries" - - def __publish(self, dataset): - def try_publish(): - try: - response = self.service.publish(dataset['id']) - if response.code == 204: - return True - else: - # Unexpected response code, fail immediately - assert False, f"Unexpected response code while publishing {dataset['id']}: {response.code}" - except HTTPError as e: - # Retry on server errors (5xx), fail immediately on client errors (4xx) - if e.is_server_error(): - return False - assert False, f"HTTPError while publishing {dataset['id']}: {e}" - - # Retry with exponential backoff: 1s, 2s, 4s - longer timeout in CI - timeout = Utils.get_timeout(7) # 7s local, 21s in CI - success = Utils.wait_for_condition(try_publish, timeout=timeout, interval=1, backoff='exponential') - assert success, f"Failed to publish {dataset['id']} after retries" - - - def test_1_getDatasets(self): - try: - datasets = self.service.get_datasets('^cls-') - if len(datasets) > 0: - TestClass.datasets = datasets - else: - assert False - - except Exception as e: - assert False - - def test_2_updateDummyProject(self): - try: - for dataset in TestClass.datasets: - self.__updateProjects(dataset, 'dummy') - - except Exception as e: - assert False - - def test_3_updateCorrectProject(self): - for dataset in TestClass.datasets: - self.__updateProjects(dataset, 'CLS') - - def test_4_unpublish(self): - for dataset in TestClass.datasets: - self.__unpublish(dataset) - - def test_5_publish(self): - for dataset in TestClass.datasets: - self.__publish(dataset) + assert False + + def test_2_updateDummyProject(self): + try: + for dataset in TestClass.datasets: + self.__updateProjects(dataset, "dummy") + + except Exception: + assert False + + def test_3_updateCorrectProject(self): + for dataset in TestClass.datasets: + self.__updateProjects(dataset, "CLS") + + def test_4_unpublish(self): + for dataset in TestClass.datasets: + self.__unpublish(dataset) + + def test_5_publish(self): + for dataset in TestClass.datasets: + self.__publish(dataset) diff --git a/tests/utils.py b/tests/utils.py index d000b71..7c63a69 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,110 +4,107 @@ from argparse import ArgumentParser from obiba_mica import MicaClient + class Utils: - SERVER = 'https://mica-demo.obiba.org' -# SERVER = 'http://localhost:8082' - USER = 'administrator' - PASSWORD = 'password' - DEFAULT_PARAMS = {"accept": False, "content_type": False, "verbose": False, "method": 'GET'} - - @staticmethod - def is_ci_environment(): - """ - Detect if running in CI environment (GitHub Actions, etc.) - Returns True if CI environment variables are set - """ - return os.getenv('CI', '').lower() == 'true' or os.getenv('GITHUB_ACTIONS', '').lower() == 'true' - - @staticmethod - def get_timeout(base_timeout): - """ - Get timeout adjusted for CI environment. - CI environments are slower, so use 3x the base timeout. - - :param base_timeout - base timeout in seconds for local development - :return adjusted timeout (base * 3 if CI, else base) - """ - return base_timeout * 3 if Utils.is_ci_environment() else base_timeout - - @staticmethod - def make_client(server=None, user=None, password=None): - return MicaClient.buildWithAuthentication(server= Utils.SERVER if server is None else server, - user=Utils.USER if user is None else user, - password=Utils.PASSWORD if password is None else password, - otp=None) - - @staticmethod - def wait_for_condition(resource_callback, timeout=10, interval=1, backoff='fixed'): - """ - Poll until a condition is met or timeout occurs. - Useful for CI environments where server processing may be slower after write operations. - - :param resource_callback - callable that returns True when resource is ready, False otherwise - :param timeout - maximum time to wait in seconds (default: 10) - :param interval - base polling interval in seconds (default: 1) - :param backoff - backoff strategy: 'fixed' or 'exponential' (default: 'fixed') - For exponential: sleeps interval * 2^attempt (1s, 2s, 4s, 8s, ...) - :return True if condition met, False if timeout - - Example usage (fixed interval): - Utils.wait_for_condition( - lambda: restService.send_request('/draft/study/id', restService.make_request('GET')).code == 200 - ) - - Example usage (exponential backoff for retries): - Utils.wait_for_condition( - lambda: service.publish(id).code == 204, - timeout=10, - interval=1, - backoff='exponential' - ) - """ - elapsed = 0 - attempt = 0 - while elapsed < timeout: - try: - if resource_callback(): - return True - except Exception: - # Condition not met yet, keep waiting - pass - - # Calculate sleep time based on backoff strategy - if backoff == 'exponential': - sleep_time = interval * (2 ** attempt) - else: - sleep_time = interval - - time.sleep(sleep_time) - elapsed += sleep_time - attempt += 1 - return False - - @staticmethod - def make_arg_parser(): - parser = ArgumentParser() - parser.add_argument('--mica', '-mk', required=False, help='Mica server base url (default: http://localhost:8082)') - parser.add_argument('--user', '-u', required=False, help='User name') - parser.add_argument('--password', '-p', required=False, help='User password') - parser.add_argument('--otp', '-ot', action='store_true', help='Whether a one-time password is to be provided (required when connecting with username/password AND two-factor authentication is enabled)') - parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output') - return parser - - @staticmethod - def parse_arg_values(parser=None, server=None, user=None, password=None, params=[]): - - argv = [ - 'test', - '--mica', Utils.SERVER if server is None else server, - '--user', Utils.USER if user is None else user, - '--password', Utils.PASSWORD if password is None else password - ] - - if params is not None: - argv = argv + params - - sys.argv = argv - args = parser.parse_args() - - return args \ No newline at end of file + SERVER = "https://mica-demo.obiba.org" + # SERVER = 'http://localhost:8082' + USER = "administrator" + PASSWORD = "password" + DEFAULT_PARAMS = {"accept": False, "content_type": False, "verbose": False, "method": "GET"} + + @staticmethod + def is_ci_environment(): + """ + Detect if running in CI environment (GitHub Actions, etc.) + Returns True if CI environment variables are set + """ + return os.getenv("CI", "").lower() == "true" or os.getenv("GITHUB_ACTIONS", "").lower() == "true" + + @staticmethod + def get_timeout(base_timeout): + """ + Get timeout adjusted for CI environment. + CI environments are slower, so use 3x the base timeout. + + :param base_timeout - base timeout in seconds for local development + :return adjusted timeout (base * 3 if CI, else base) + """ + return base_timeout * 3 if Utils.is_ci_environment() else base_timeout + + @staticmethod + def make_client(server=None, user=None, password=None): + return MicaClient.buildWithAuthentication( + server=Utils.SERVER if server is None else server, user=Utils.USER if user is None else user, password=Utils.PASSWORD if password is None else password, otp=None + ) + + @staticmethod + def wait_for_condition(resource_callback, timeout=10, interval=1, backoff="fixed"): + """ + Poll until a condition is met or timeout occurs. + Useful for CI environments where server processing may be slower after write operations. + + :param resource_callback - callable that returns True when resource is ready, False otherwise + :param timeout - maximum time to wait in seconds (default: 10) + :param interval - base polling interval in seconds (default: 1) + :param backoff - backoff strategy: 'fixed' or 'exponential' (default: 'fixed') + For exponential: sleeps interval * 2^attempt (1s, 2s, 4s, 8s, ...) + :return True if condition met, False if timeout + + Example usage (fixed interval): + Utils.wait_for_condition( + lambda: restService.send_request('/draft/study/id', restService.make_request('GET')).code == 200 + ) + + Example usage (exponential backoff for retries): + Utils.wait_for_condition( + lambda: service.publish(id).code == 204, + timeout=10, + interval=1, + backoff='exponential' + ) + """ + elapsed = 0 + attempt = 0 + while elapsed < timeout: + try: + if resource_callback(): + return True + except Exception: + # Condition not met yet, keep waiting + pass + + # Calculate sleep time based on backoff strategy + if backoff == "exponential": + sleep_time = interval * (2**attempt) + else: + sleep_time = interval + + time.sleep(sleep_time) + elapsed += sleep_time + attempt += 1 + return False + + @staticmethod + def make_arg_parser(): + parser = ArgumentParser() + parser.add_argument("--mica", "-mk", required=False, help="Mica server base url (default: http://localhost:8082)") + parser.add_argument("--user", "-u", required=False, help="User name") + parser.add_argument("--password", "-p", required=False, help="User password") + parser.add_argument( + "--otp", "-ot", action="store_true", help="Whether a one-time password is to be provided (required when connecting with username/password AND two-factor authentication is enabled)" + ) + parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") + return parser + + @staticmethod + def parse_arg_values(parser=None, server=None, user=None, password=None, params=[]): + + argv = ["test", "--mica", Utils.SERVER if server is None else server, "--user", Utils.USER if user is None else user, "--password", Utils.PASSWORD if password is None else password] + + if params is not None: + argv = argv + params + + sys.argv = argv + args = parser.parse_args() + + return args From 9bec2bd81a9e2113da310eb06d9ddae8b871caf1 Mon Sep 17 00:00:00 2001 From: Ramin Haeri Azad Date: Mon, 20 Apr 2026 16:39:19 -0400 Subject: [PATCH 5/6] Trigger CI rebuild From e16507c64f6473adc9021a60cae7f6af2360bf8a Mon Sep 17 00:00:00 2001 From: Ramin Haeri Azad Date: Mon, 20 Apr 2026 16:46:50 -0400 Subject: [PATCH 6/6] Trying to find the ci error --- tests/test_access.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_access.py b/tests/test_access.py index 38d61ca..ce99a45 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -39,8 +39,8 @@ def check_access(): response = self.service.delete_access("clsa", "USER", "user1") assert response.code == 204 - except Exception: - assert False + except Exception as e: + raise AssertionError(f"test_documentAccess failed: {e}") from e def test_fileAccess(self): self.service = FileAccessService(Utils.make_client()) @@ -60,5 +60,5 @@ def check_access(): response = self.service.delete_access(file, "USER", "user1") assert response.code == 204 - except Exception: - assert False + except Exception as e: + raise AssertionError(f"test_fileAccess failed: {e}") from e