From a5c603cb48778ead95a5b5ccbfeeacddd8dac339 Mon Sep 17 00:00:00 2001 From: Keith James Date: Wed, 23 Oct 2024 13:27:25 +0100 Subject: [PATCH 1/4] Add email notification for ONT run events Add dependency on Partisan (for baton JSON IO - no requirement for iRODS access or baton itself). Add version() function to report the notification pipeline version for ONT. Add necessary ONT tables to MLWH ORM. Add dev Dockerfile and compose file. Rename some QC event-specific resources to have "QC" in the name, to allow ONT event-specific resources to be added. --- .github/workflows/run-tests.yml | 11 +- Dockerfile.dev | 61 ++ docker-compose.yml | 38 + docker/entrypoint.sh | 8 + poetry.lock | 854 ++++++++++++++++++ pyproject.toml | 19 +- pytest.ini | 6 + src/npg_notify/__init__.py | 25 + .../resources/ont_event_email_template.txt | 9 + src/npg_notify/db/mlwh.py | 79 +- src/npg_notify/ont/event.py | 463 ++++++++++ src/npg_notify/ont/porch.py | 528 +++++++++++ tests/conftest.py | 15 +- tests/data/ont_event_app_config.ini | 18 + tests/ont/conftest.py | 49 + tests/ont/test_generate_email.py | 70 ++ tests/ont/test_porch.py | 211 +++++ 17 files changed, 2450 insertions(+), 14 deletions(-) create mode 100644 Dockerfile.dev create mode 100644 docker-compose.yml create mode 100755 docker/entrypoint.sh create mode 100644 poetry.lock create mode 100644 pytest.ini create mode 100644 src/npg_notify/data/resources/ont_event_email_template.txt create mode 100644 src/npg_notify/ont/event.py create mode 100644 src/npg_notify/ont/porch.py create mode 100644 tests/data/ont_event_app_config.ini create mode 100644 tests/ont/conftest.py create mode 100644 tests/ont/test_generate_email.py create mode 100644 tests/ont/test_porch.py diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 1d7274a..a89da2e 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -28,6 +28,16 @@ jobs: MYSQL_PASSWORD: "test" MYSQL_DATABASE: "study_notify" + porch: + image: "ghcr.io/wtsi-npg/python-3.10-npg-porch-2.0.0" + ports: + - "8081:8081" + options: >- + --health-cmd "curl -f http://localhost:8081" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + steps: - uses: actions/checkout@v4 @@ -52,4 +62,3 @@ jobs: - name: Run linter (ruff) run: | poetry run ruff check --output-format=github . - diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..7d0cbd8 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,61 @@ +FROM python:3.12-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get install -q -y --no-install-recommends \ + apt-utils \ + ca-certificates \ + git \ + locales \ + unattended-upgrades && \ + unattended-upgrade -v && \ + locale-gen en_GB en_GB.UTF-8 && \ + localedef -i en_GB -c -f UTF-8 -A /usr/share/locale/locale.alias en_GB.UTF-8 && \ + apt-get remove -q -y unattended-upgrades && \ + apt-get autoremove -q -y && \ + apt-get clean -q -y && \ + rm -rf /var/lib/apt/lists/* + +ENV LANG=en_GB.UTF-8 \ + LANGUAGE=en_GB \ + LC_ALL=en_GB.UTF-8 \ + TZ=/Etc/UTC + +WORKDIR /app + +ARG APP_USER=appuser +ARG APP_UID=1000 +ARG APP_GID=$APP_UID + +RUN groupadd --gid $APP_GID $APP_USER && \ + useradd --uid $APP_UID --gid $APP_GID --shell /bin/bash --create-home $APP_USER + +ARG POETRY_VERSION="1.8.3" + +ENV POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_CREATE=false \ + POETRY_CACHE_DIR=/app/.poetry + +RUN python -m venv /app/venv && \ + . /app/venv/bin/activate && \ + pip install --no-cache-dir "poetry==$POETRY_VERSION" + +COPY pyproject.toml poetry.lock /app/ + +RUN . /app/venv/bin/activate && \ + poetry install --no-root + +COPY . /app + +RUN . /app/venv/bin/activate && \ + poetry install && \ + rm -rf "$POETRY_CACHE_DIR" + +RUN chown -R $APP_USER:$APP_USER /app + +USER $APP_USER + +ENTRYPOINT ["/app/docker/entrypoint.sh"] + +CMD ["/bin/bash", "-c", "sleep infinity"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0de0c8f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + mysql-server: + image: mysql:8.0 + restart: always + ports: + - "127.0.0.1:3306:3306" + environment: + MYSQL_USER: "test" + MYSQL_PASSWORD: "test" + MYSQL_DATABASE: "study_notify" + MYSQL_RANDOM_ROOT_PASSWORD: "true" + healthcheck: + test: mysqladmin ping + interval: 10s + timeout: 5s + retries: 10 + + porch-server: + image: wsinpg/python-3.10-npg-porch-2.0.0:latest + restart: always + ports: + - "127.0.0.1:8081:8081" + healthcheck: + test: curl -f http://localhost:8081 + interval: 10s + timeout: 5s + retries: 10 + + app: + build: + context: . + dockerfile: Dockerfile.dev + restart: always + depends_on: + mysql-server: + condition: service_healthy + porch-server: + condition: service_healthy diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..0a0f21f --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -eo pipefail + +# This virtualenv is created by the Dockerfile +source /app/venv/bin/activate + +exec "$@" diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..9b088cf --- /dev/null +++ b/poetry.lock @@ -0,0 +1,854 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[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" +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 = "cryptography" +version = "41.0.7" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-41.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:3c78451b78313fa81607fa1b3f1ae0a5ddd8014c38a02d9db0616133987b9cdf"}, + {file = "cryptography-41.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:928258ba5d6f8ae644e764d0f996d61a8777559f72dfeb2eea7e2fe0ad6e782d"}, + {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a1b41bc97f1ad230a41657d9155113c7521953869ae57ac39ac7f1bb471469a"}, + {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:841df4caa01008bad253bce2a6f7b47f86dc9f08df4b433c404def869f590a15"}, + {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5429ec739a29df2e29e15d082f1d9ad683701f0ec7709ca479b3ff2708dae65a"}, + {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:43f2552a2378b44869fe8827aa19e69512e3245a219104438692385b0ee119d1"}, + {file = "cryptography-41.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:af03b32695b24d85a75d40e1ba39ffe7db7ffcb099fe507b39fd41a565f1b157"}, + {file = "cryptography-41.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:49f0805fc0b2ac8d4882dd52f4a3b935b210935d500b6b805f321addc8177406"}, + {file = "cryptography-41.0.7-cp37-abi3-win32.whl", hash = "sha256:f983596065a18a2183e7f79ab3fd4c475205b839e02cbc0efbbf9666c4b3083d"}, + {file = "cryptography-41.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:90452ba79b8788fa380dfb587cca692976ef4e757b194b093d845e8d99f612f2"}, + {file = "cryptography-41.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:079b85658ea2f59c4f43b70f8119a52414cdb7be34da5d019a77bf96d473b960"}, + {file = "cryptography-41.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b640981bf64a3e978a56167594a0e97db71c89a479da8e175d8bb5be5178c003"}, + {file = "cryptography-41.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e3114da6d7f95d2dee7d3f4eec16dacff819740bbab931aff8648cb13c5ff5e7"}, + {file = "cryptography-41.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d5ec85080cce7b0513cfd233914eb8b7bbd0633f1d1703aa28d1dd5a72f678ec"}, + {file = "cryptography-41.0.7-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7a698cb1dac82c35fcf8fe3417a3aaba97de16a01ac914b89a0889d364d2f6be"}, + {file = "cryptography-41.0.7-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:37a138589b12069efb424220bf78eac59ca68b95696fc622b6ccc1c0a197204a"}, + {file = "cryptography-41.0.7-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:68a2dec79deebc5d26d617bfdf6e8aab065a4f34934b22d3b5010df3ba36612c"}, + {file = "cryptography-41.0.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:09616eeaef406f99046553b8a40fbf8b1e70795a91885ba4c96a70793de5504a"}, + {file = "cryptography-41.0.7-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48a0476626da912a44cc078f9893f292f0b3e4c739caf289268168d8f4702a39"}, + {file = "cryptography-41.0.7-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c7f3201ec47d5207841402594f1d7950879ef890c0c495052fa62f58283fde1a"}, + {file = "cryptography-41.0.7-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c5ca78485a255e03c32b513f8c2bc39fedb7f5c5f8535545bdc223a03b24f248"}, + {file = "cryptography-41.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6c391c021ab1f7a82da5d8d0b3cee2f4b2c455ec86c8aebbc84837a631ff309"}, + {file = "cryptography-41.0.7.tar.gz", hash = "sha256:13f93ce9bea8016c253b34afc6bd6a75993e5c40672ed5405a9c832f0d4a00bc"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +nox = ["nox"] +pep8test = ["black", "check-sdist", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "greenlet" +version = "3.1.1" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.7" +files = [ + {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617"}, + {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7"}, + {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6"}, + {file = "greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80"}, + {file = "greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a"}, + {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511"}, + {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395"}, + {file = "greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39"}, + {file = "greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9"}, + {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0"}, + {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942"}, + {file = "greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01"}, + {file = "greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e"}, + {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1"}, + {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c"}, + {file = "greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822"}, + {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01"}, + {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de"}, + {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa"}, + {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af"}, + {file = "greenlet-3.1.1-cp37-cp37m-win32.whl", hash = "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798"}, + {file = "greenlet-3.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef"}, + {file = "greenlet-3.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1"}, + {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd"}, + {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7"}, + {file = "greenlet-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef"}, + {file = "greenlet-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d"}, + {file = "greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c"}, + {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e"}, + {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e"}, + {file = "greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c"}, + {file = "greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22"}, + {file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +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 = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +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 = "npg-porch-cli" +version = "0.1.0" +description = "CLI client for communicating with npg_porch JSON API" +optional = false +python-versions = "^3.10" +files = [] +develop = false + +[package.dependencies] +requests = "^2.31.0" + +[package.source] +type = "git" +url = "https://github.com/wtsi-npg/npg_porch_cli.git" +reference = "0.1.0" +resolved_reference = "31e1371051d5b98a50a633d6b0d05d4fdd6a60d5" + +[[package]] +name = "npg-python-lib" +version = "0.3.2" +description = "A library of Python functions and classes common to NPG applications." +optional = false +python-versions = ">=3.10" +files = [ + {file = "npg_python_lib-0.3.2.tar.gz", hash = "sha256:c30a9d28fd54e45eab64c4561036cf4a770b483d89b9949ecf282090b136af5a"}, +] + +[package.dependencies] +python-dateutil = ">=2.9.0,<3" +structlog = ">=23.3.0" + +[package.extras] +test = ["black (>=24.3.0,<25)", "pytest (>=8.0,<9)", "pytest-it (>=0.1.5)"] + +[package.source] +type = "url" +url = "https://github.com/wtsi-npg/npg-python-lib/releases/download/0.3.2/npg_python_lib-0.3.2.tar.gz" + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "partisan" +version = "2.13.0" +description = "A Python API for iRODS using the baton iRODS client" +optional = false +python-versions = ">=3.10" +files = [ + {file = "partisan-2.13.0.tar.gz", hash = "sha256:a4b5edd8f97933e5f34ae942407069cacb032c41bb51bee53fe02ed8564053cb"}, +] + +[package.dependencies] +click = ">=8.1.7,<9" +python-dateutil = ">=2.9.0,<3" +structlog = ">=23.3.0" + +[package.extras] +test = ["black (>=24.3.0,<25)", "pytest (>=8.0,<9)", "pytest-it (>=0.1.5)"] + +[package.source] +type = "url" +url = "https://github.com/wtsi-npg/partisan/releases/download/2.13.0/partisan-2.13.0.tar.gz" + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pymysql" +version = "1.1.1" +description = "Pure Python MySQL Driver" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyMySQL-1.1.1-py3-none-any.whl", hash = "sha256:4de15da4c61dc132f4fb9ab763063e693d521a80fd0e87943b9a453dd4c19d6c"}, + {file = "pymysql-1.1.1.tar.gz", hash = "sha256:e127611aaf2b417403c60bf4dc570124aeb4a57f5f37b8e95ae399a42f904cd0"}, +] + +[package.extras] +ed25519 = ["PyNaCl (>=1.4.0)"] +rsa = ["cryptography"] + +[[package]] +name = "pytest" +version = "8.3.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-it" +version = "0.1.5" +description = "Pytest plugin to display test reports as a plaintext spec, inspired by Rspec: https://github.com/mattduck/pytest-it." +optional = false +python-versions = "*" +files = [ + {file = "pytest-it-0.1.5.tar.gz", hash = "sha256:d2d21e9dd371a423b357e955ebe4afd5cd848508e448e6476f21eb034733e2f1"}, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[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 = "requests-mock" +version = "1.12.1" +description = "Mock out responses from the requests package" +optional = false +python-versions = ">=3.5" +files = [ + {file = "requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401"}, + {file = "requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563"}, +] + +[package.dependencies] +requests = ">=2.22,<3" + +[package.extras] +fixture = ["fixtures"] + +[[package]] +name = "ruff" +version = "0.4.10" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c2c4d0859305ac5a16310eec40e4e9a9dec5dcdfbe92697acd99624e8638dac"}, + {file = "ruff-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a79489607d1495685cdd911a323a35871abfb7a95d4f98fc6f85e799227ac46e"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1dd1681dfa90a41b8376a61af05cc4dc5ff32c8f14f5fe20dba9ff5deb80cd6"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c75c53bb79d71310dc79fb69eb4902fba804a81f374bc86a9b117a8d077a1784"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18238c80ee3d9100d3535d8eb15a59c4a0753b45cc55f8bf38f38d6a597b9739"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d8f71885bce242da344989cae08e263de29752f094233f932d4f5cfb4ef36a81"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:330421543bd3222cdfec481e8ff3460e8702ed1e58b494cf9d9e4bf90db52b9d"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e9b6fb3a37b772628415b00c4fc892f97954275394ed611056a4b8a2631365e"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f54c481b39a762d48f64d97351048e842861c6662d63ec599f67d515cb417f6"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67fe086b433b965c22de0b4259ddfe6fa541c95bf418499bedb9ad5fb8d1c631"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:acfaaab59543382085f9eb51f8e87bac26bf96b164839955f244d07125a982ef"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3cea07079962b2941244191569cf3a05541477286f5cafea638cd3aa94b56815"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:338a64ef0748f8c3a80d7f05785930f7965d71ca260904a9321d13be24b79695"}, + {file = "ruff-0.4.10-py3-none-win32.whl", hash = "sha256:ffe3cd2f89cb54561c62e5fa20e8f182c0a444934bf430515a4b422f1ab7b7ca"}, + {file = "ruff-0.4.10-py3-none-win_amd64.whl", hash = "sha256:67f67cef43c55ffc8cc59e8e0b97e9e60b4837c8f21e8ab5ffd5d66e196e25f7"}, + {file = "ruff-0.4.10-py3-none-win_arm64.whl", hash = "sha256:dd1fcee327c20addac7916ca4e2653fbbf2e8388d8a6477ce5b4e986b68ae6c0"}, + {file = "ruff-0.4.10.tar.gz", hash = "sha256:3aa4f2bc388a30d346c56524f7cacca85945ba124945fe489952aadb6b5cd804"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.36" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "SQLAlchemy-2.0.36-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:59b8f3adb3971929a3e660337f5dacc5942c2cdb760afcabb2614ffbda9f9f72"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37350015056a553e442ff672c2d20e6f4b6d0b2495691fa239d8aa18bb3bc908"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8318f4776c85abc3f40ab185e388bee7a6ea99e7fa3a30686580b209eaa35c08"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c245b1fbade9c35e5bd3b64270ab49ce990369018289ecfde3f9c318411aaa07"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:69f93723edbca7342624d09f6704e7126b152eaed3cdbb634cb657a54332a3c5"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f9511d8dd4a6e9271d07d150fb2f81874a3c8c95e11ff9af3a2dfc35fe42ee44"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-win32.whl", hash = "sha256:c3f3631693003d8e585d4200730616b78fafd5a01ef8b698f6967da5c605b3fa"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-win_amd64.whl", hash = "sha256:a86bfab2ef46d63300c0f06936bd6e6c0105faa11d509083ba8f2f9d237fb5b5"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fd3a55deef00f689ce931d4d1b23fa9f04c880a48ee97af488fd215cf24e2a6c"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f5e9cd989b45b73bd359f693b935364f7e1f79486e29015813c338450aa5a71"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ddd9db6e59c44875211bc4c7953a9f6638b937b0a88ae6d09eb46cced54eff"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2519f3a5d0517fc159afab1015e54bb81b4406c278749779be57a569d8d1bb0d"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59b1ee96617135f6e1d6f275bbe988f419c5178016f3d41d3c0abb0c819f75bb"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39769a115f730d683b0eb7b694db9789267bcd027326cccc3125e862eb03bfd8"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-win32.whl", hash = "sha256:66bffbad8d6271bb1cc2f9a4ea4f86f80fe5e2e3e501a5ae2a3dc6a76e604e6f"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-win_amd64.whl", hash = "sha256:23623166bfefe1487d81b698c423f8678e80df8b54614c2bf4b4cfcd7c711959"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7b64e6ec3f02c35647be6b4851008b26cff592a95ecb13b6788a54ef80bbdd4"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46331b00096a6db1fdc052d55b101dbbfc99155a548e20a0e4a8e5e4d1362855"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdf3386a801ea5aba17c6410dd1dc8d39cf454ca2565541b5ac42a84e1e28f53"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9dfa18ff2a67b09b372d5db8743c27966abf0e5344c555d86cc7199f7ad83a"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:90812a8933df713fdf748b355527e3af257a11e415b613dd794512461eb8a686"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1bc330d9d29c7f06f003ab10e1eaced295e87940405afe1b110f2eb93a233588"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-win32.whl", hash = "sha256:79d2e78abc26d871875b419e1fd3c0bca31a1cb0043277d0d850014599626c2e"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-win_amd64.whl", hash = "sha256:b544ad1935a8541d177cb402948b94e871067656b3a0b9e91dbec136b06a2ff5"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5cc79df7f4bc3d11e4b542596c03826063092611e481fcf1c9dfee3c94355ef"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3c01117dd36800f2ecaa238c65365b7b16497adc1522bf84906e5710ee9ba0e8"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc633f4ee4b4c46e7adcb3a9b5ec083bf1d9a97c1d3854b92749d935de40b9b"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e46ed38affdfc95d2c958de328d037d87801cfcbea6d421000859e9789e61c2"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2985c0b06e989c043f1dc09d4fe89e1616aadd35392aea2844f0458a989eacf"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a121d62ebe7d26fec9155f83f8be5189ef1405f5973ea4874a26fab9f1e262c"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-win32.whl", hash = "sha256:0572f4bd6f94752167adfd7c1bed84f4b240ee6203a95e05d1e208d488d0d436"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-win_amd64.whl", hash = "sha256:8c78ac40bde930c60e0f78b3cd184c580f89456dd87fc08f9e3ee3ce8765ce88"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:be9812b766cad94a25bc63bec11f88c4ad3629a0cec1cd5d4ba48dc23860486b"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50aae840ebbd6cdd41af1c14590e5741665e5272d2fee999306673a1bb1fdb4d"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4557e1f11c5f653ebfdd924f3f9d5ebfc718283b0b9beebaa5dd6b77ec290971"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:07b441f7d03b9a66299ce7ccf3ef2900abc81c0db434f42a5694a37bd73870f2"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:28120ef39c92c2dd60f2721af9328479516844c6b550b077ca450c7d7dc68575"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-win32.whl", hash = "sha256:b81ee3d84803fd42d0b154cb6892ae57ea6b7c55d8359a02379965706c7efe6c"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-win_amd64.whl", hash = "sha256:f942a799516184c855e1a32fbc7b29d7e571b52612647866d4ec1c3242578fcb"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3d6718667da04294d7df1670d70eeddd414f313738d20a6f1d1f379e3139a545"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:72c28b84b174ce8af8504ca28ae9347d317f9dba3999e5981a3cd441f3712e24"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b11d0cfdd2b095e7b0686cf5fabeb9c67fae5b06d265d8180715b8cfa86522e3"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e32092c47011d113dc01ab3e1d3ce9f006a47223b18422c5c0d150af13a00687"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6a440293d802d3011028e14e4226da1434b373cbaf4a4bbb63f845761a708346"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c54a1e53a0c308a8e8a7dffb59097bff7facda27c70c286f005327f21b2bd6b1"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-win32.whl", hash = "sha256:1e0d612a17581b6616ff03c8e3d5eff7452f34655c901f75d62bd86449d9750e"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-win_amd64.whl", hash = "sha256:8958b10490125124463095bbdadda5aa22ec799f91958e410438ad6c97a7b793"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dc022184d3e5cacc9579e41805a681187650e170eb2fd70e28b86192a479dcaa"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b817d41d692bf286abc181f8af476c4fbef3fd05e798777492618378448ee689"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4e46a888b54be23d03a89be510f24a7652fe6ff660787b96cd0e57a4ebcb46d"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4ae3005ed83f5967f961fd091f2f8c5329161f69ce8480aa8168b2d7fe37f06"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03e08af7a5f9386a43919eda9de33ffda16b44eb11f3b313e6822243770e9763"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb986bad3ed5ceaf090200eba750b5245150bd97d3e67343a3cfed06feecf7"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-win32.whl", hash = "sha256:9fe53b404f24789b5ea9003fc25b9a3988feddebd7e7b369c8fac27ad6f52f28"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-win_amd64.whl", hash = "sha256:af148a33ff0349f53512a049c6406923e4e02bf2f26c5fb285f143faf4f0e46a"}, + {file = "SQLAlchemy-2.0.36-py3-none-any.whl", hash = "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e"}, + {file = "sqlalchemy-2.0.36.tar.gz", hash = "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5"}, +] + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} +pymysql = {version = "*", optional = true, markers = "extra == \"pymysql\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] +aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] +aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "sqlalchemy-utils" +version = "0.41.2" +description = "Various utility functions for SQLAlchemy." +optional = false +python-versions = ">=3.7" +files = [ + {file = "SQLAlchemy-Utils-0.41.2.tar.gz", hash = "sha256:bc599c8c3b3319e53ce6c5c3c471120bd325d0071fb6f38a10e924e3d07b9990"}, + {file = "SQLAlchemy_Utils-0.41.2-py3-none-any.whl", hash = "sha256:85cf3842da2bf060760f955f8467b87983fb2e30f1764fd0e24a48307dc8ec6e"}, +] + +[package.dependencies] +SQLAlchemy = ">=1.3" + +[package.extras] +arrow = ["arrow (>=0.3.4)"] +babel = ["Babel (>=1.3)"] +color = ["colour (>=0.0.4)"] +encrypted = ["cryptography (>=0.6)"] +intervals = ["intervals (>=0.7.1)"] +password = ["passlib (>=1.6,<2.0)"] +pendulum = ["pendulum (>=2.0.5)"] +phone = ["phonenumbers (>=5.9.2)"] +test = ["Jinja2 (>=2.3)", "Pygments (>=1.2)", "backports.zoneinfo", "docutils (>=0.10)", "flake8 (>=2.4.0)", "flexmock (>=0.9.7)", "isort (>=4.2.2)", "pg8000 (>=1.12.4)", "psycopg (>=3.1.8)", "psycopg2 (>=2.5.1)", "psycopg2cffi (>=2.8.1)", "pymysql", "pyodbc", "pytest (==7.4.4)", "python-dateutil (>=2.6)", "pytz (>=2014.2)"] +test-all = ["Babel (>=1.3)", "Jinja2 (>=2.3)", "Pygments (>=1.2)", "arrow (>=0.3.4)", "backports.zoneinfo", "colour (>=0.0.4)", "cryptography (>=0.6)", "docutils (>=0.10)", "flake8 (>=2.4.0)", "flexmock (>=0.9.7)", "furl (>=0.4.1)", "intervals (>=0.7.1)", "isort (>=4.2.2)", "passlib (>=1.6,<2.0)", "pendulum (>=2.0.5)", "pg8000 (>=1.12.4)", "phonenumbers (>=5.9.2)", "psycopg (>=3.1.8)", "psycopg2 (>=2.5.1)", "psycopg2cffi (>=2.8.1)", "pymysql", "pyodbc", "pytest (==7.4.4)", "python-dateutil", "python-dateutil (>=2.6)", "pytz (>=2014.2)"] +timezone = ["python-dateutil"] +url = ["furl (>=0.4.1)"] + +[[package]] +name = "structlog" +version = "24.4.0" +description = "Structured Logging for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "structlog-24.4.0-py3-none-any.whl", hash = "sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610"}, + {file = "structlog-24.4.0.tar.gz", hash = "sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4"}, +] + +[package.extras] +dev = ["freezegun (>=0.2.8)", "mypy (>=1.4)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "rich", "simplejson", "twisted"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-mermaid", "sphinxext-opengraph", "twisted"] +tests = ["freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"] +typing = ["mypy (>=1.4)", "rich", "twisted"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "28a6a3990ef81f7f9446958301ae72ca9ccf8dfb59ea14db92c8c8258b9b58c5" diff --git a/pyproject.toml b/pyproject.toml index 9a48d84..2565d2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,14 @@ [tool.poetry] name = "npg_notify" -version = "0.0.1" description = "Utility for client notifications" +version = "0.0.0" authors = ["Marina Gourtovaia"] license = "GPL-3.0-or-later" readme = "README.md" [tool.poetry.scripts] -npg_qc_state_notification = "npg_notify.porch_wrapper.qc_state:run" +npg_qc_state_notification = "npg_notify.porch_wrapper.qc_state:run" +npg_ont_event_notification = "npg_notify.ont.event:main" [tool.poetry.dependencies] python = "^3.11" @@ -16,15 +17,25 @@ SQLAlchemy-Utils = "^0.41.2" cryptography = "^41.0.3" PyYAML = "^6.0.0" npg_porch_cli = { git="https://github.com/wtsi-npg/npg_porch_cli.git", tag="0.1.0" } +partisan = { url = "https://github.com/wtsi-npg/partisan/releases/download/2.13.0/partisan-2.13.0.tar.gz" } +npg-python-lib = { url = "https://github.com/wtsi-npg/npg-python-lib/releases/download/0.3.2/npg_python_lib-0.3.2.tar.gz" } +requests = "^2.32.0" +structlog = "^24.4.0" [tool.poetry.dev-dependencies] pytest = "^8.2.2" +pytest-it = "^0.1.5" requests-mock = "^1.12.1" ruff = "^0.4.9" [build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] +build-backend = "poetry_dynamic_versioning.backend" + +[tool.poetry-dynamic-versioning] +enable = true +vcs = "git" +pattern = "default-unprefixed" [tool.ruff] # Set the maximum line length to 79. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..07326ee --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +pythonpath = src tests +testpaths = tests +python_functions = test_* +log_cli = False +log_cli_level = ERROR diff --git a/src/npg_notify/__init__.py b/src/npg_notify/__init__.py index e69de29..b8e7eb2 100644 --- a/src/npg_notify/__init__.py +++ b/src/npg_notify/__init__.py @@ -0,0 +1,25 @@ +# +# Copyright © 2024 Genome Research Ltd. All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import importlib.metadata + +__version__ = importlib.metadata.version("npg_notify") + + +def version() -> str: + """Return the current version.""" + return __version__ diff --git a/src/npg_notify/data/resources/ont_event_email_template.txt b/src/npg_notify/data/resources/ont_event_email_template.txt new file mode 100644 index 0000000..b9f463a --- /dev/null +++ b/src/npg_notify/data/resources/ont_event_email_template.txt @@ -0,0 +1,9 @@ +The ONT run for experiment $experiment_name, flowcell $flowcell_id has been $event. +The data are available in iRODS at the following path: + +$path + +This is an automated email from NPG. You are receiving it because you are registered +as a contact for one or more of the Studies listed below: + +$studies diff --git a/src/npg_notify/db/mlwh.py b/src/npg_notify/db/mlwh.py index bc9a454..e181b56 100644 --- a/src/npg_notify/db/mlwh.py +++ b/src/npg_notify/db/mlwh.py @@ -4,7 +4,7 @@ # Marina Gourtovaia # Kieron Taylor # -# This file is part of npg_notifications software package.. +# This file is part of npg_notifications software package. # # npg_notifications is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free @@ -45,6 +45,39 @@ class Base(DeclarativeBase): pass +class Sample(Base): + __tablename__ = "sample" + + id_sample_tmp = mapped_column(Integer, primary_key=True, autoincrement=True) + id_lims = mapped_column(String(10), nullable=False) + id_sample_lims = mapped_column(String(20), nullable=False) + created = mapped_column(DateTime, nullable=False) + last_updated = mapped_column(DateTime, nullable=False) + recorded_at = mapped_column(DateTime, nullable=False) + consent_withdrawn = mapped_column(Integer, nullable=False, default=0) + name = mapped_column(String(255), index=True) + organism = mapped_column(String(255)) + accession_number = mapped_column(String(50), index=True) + common_name = mapped_column(String(255)) + cohort = mapped_column(String(255)) + sanger_sample_id = mapped_column(String(255), index=True) + supplier_name = mapped_column(String(255), index=True) + public_name = mapped_column(String(255)) + donor_id = mapped_column(String(255)) + date_of_consent_withdrawn = mapped_column(DateTime) + marked_as_consent_withdrawn_by = mapped_column(String(255)) + + oseq_flowcell: Mapped["OseqFlowcell"] = relationship( + "OseqFlowcell", back_populates="sample" + ) + + def __repr__(self): + return ( + f"" + ) + + class Study(Base): """A representation for the 'study' table.""" @@ -63,6 +96,9 @@ class Study(Base): ), ) + oseq_flowcell: Mapped["OseqFlowcell"] = relationship( + "OseqFlowcell", back_populates="study" + ) study_users: Mapped[set["StudyUser"]] = relationship() def __repr__(self): @@ -116,6 +152,47 @@ class StudyNotFoundError(Exception): pass +class OseqFlowcell(Base): + __tablename__ = "oseq_flowcell" + + id_oseq_flowcell_tmp = mapped_column(Integer, primary_key=True, autoincrement=True) + id_flowcell_lims = mapped_column(String(255), nullable=False) + last_updated = mapped_column(DateTime, nullable=False) + recorded_at = mapped_column(DateTime, nullable=False) + id_sample_tmp = mapped_column( + ForeignKey("sample.id_sample_tmp"), nullable=False, index=True + ) + id_study_tmp = mapped_column( + ForeignKey("study.id_study_tmp"), nullable=False, index=True + ) + experiment_name = mapped_column(String(255), nullable=False) + instrument_name = mapped_column(String(255), nullable=False) + instrument_slot = mapped_column(Integer, nullable=False) + id_lims = mapped_column(String(10), nullable=False) + pipeline_id_lims = mapped_column(String(255)) + requested_data_type = mapped_column(String(255)) + tag_identifier = mapped_column(String(255)) + tag_sequence = mapped_column(String(255)) + tag_set_id_lims = mapped_column(String(255)) + tag_set_name = mapped_column(String(255)) + tag2_identifier = mapped_column(String(255)) + tag2_sequence = mapped_column(String(255)) + tag2_set_id_lims = mapped_column(String(255)) + tag2_set_name = mapped_column(String(255)) + flowcell_id = mapped_column(String(255)) + run_id = mapped_column(String(255)) + + sample: Mapped["Sample"] = relationship("Sample", back_populates="oseq_flowcell") + study: Mapped["Study"] = relationship("Study", back_populates="oseq_flowcell") + + def __repr__(self): + return ( + f"" + ) + + def get_study_contacts(session: Session, study_id: str) -> list[str]: """Retrieves emails of study contacts from the mlwh database. diff --git a/src/npg_notify/ont/event.py b/src/npg_notify/ont/event.py new file mode 100644 index 0000000..a14dffc --- /dev/null +++ b/src/npg_notify/ont/event.py @@ -0,0 +1,463 @@ +# +# Copyright © 2024 Genome Research Ltd. All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import argparse +import sys +from dataclasses import dataclass +from enum import Enum +from importlib import resources +from string import Template + +from npg.cli import add_io_arguments, add_logging_arguments +from npg.conf import IniData +from npg.log import configure_structlog +from partisan.irods import Collection +from sqlalchemy import asc, distinct +from sqlalchemy.orm import Session +from structlog import get_logger + +import npg_notify +from npg_notify.db.mlwh import OseqFlowcell, Study, get_study_contacts +from npg_notify.db.utils import get_connection +from npg_notify.mail import send_notification +from npg_notify.ont.porch import Pipeline, Task + +log = get_logger(__package__) + +MYSQL_MLWH_CONFIG_FILE_SECTION = "MySQL MLWH" +PORCH_CONFIG_FILE_SECTION = "PORCH" +MAIL_CONFIG_FILE_SECTION = "MAIL" + +description = """ +This application sends email notifications to the contacts of the studies associated +with Oxford Nanopore Technology (ONT) runs. The emails are triggered by events such as +the run being uploaded to iRODS or basecalled. + +The application can be run in two modes: 'add' or 'run'. + +In 'add' mode, the application reads ONT run collections in baton JSON format from a +file or STDIN, one per line. These collections must have the standard ONT run metadata: + + - ont:experiment_name + - ont:instrument_slot + - ont:flowcell_id + +For each collection, a new task is created and added to the Porch server. The collection +is then written to a file or STDOUT in baton JSON format, one per line. + +In 'run' mode, the application retrieves any ONT event email tasks that have not been +done from the Porch server and runs them to send notifications. For each task, an email +is sent to all the contacts of the studies associated with the run. + +The type of event to be reported in the email can be specified with the --event option. + +The application requires a configuration file that specifies the Porch server URL and +authentication token, and the MySQL MLWH database connection details. The configuration +file should be in INI format and have the following sections: + + [PORCH] + url = + token = + + [MySQL MLWH] + host = + port = + user = + password = + db = +""" + + +class MetadataError(ValueError): + """An exception raised when data selected for processing is missing metadata + required for that operation.""" + + pass + + +class EventType(str, Enum): + """The events that can trigger an email.""" + + UPLOADED = "uploaded" # The run has been uploaded to iRODS + BASECALLED = "basecalled" # The run has been basecalled (for cases where we don't know the basecall type) + BASECALLED_HAC = ( + "basecalled (HAC)" # The run has been basecalled HAC (high accuracy) + ) + BASECALLED_SUP = ( + "basecalled (SUP)" # The run has been basecalled SUP (super-high accuracy) + ) + BASECALLED_MOD = ( + "basecalled (MOD)" # The run has been basecalled MOD (modified bases) + ) + + def __str__(self): + return self.value + + +@dataclass(kw_only=True) +class ContactEmail(Task): + """A task for sending ONT event email. + + An email is sent to the contacts of the studies associated with the ONT run. + If the run is multiplexed, the email is sent to the contacts of all studies + involved. + + The path to the run is part of the identity of the task, so creating tasks for + multiple paths will send multiple emails. This is what we want because part of + the purpose of the emails is to inform the contacts of the run's location. + """ + + def __init__( + self, + experiment_name: str = None, + instrument_slot: int = None, + flowcell_id: str = None, + path: str = None, + event: EventType = None, + ): + """Create a new email task for an event on an ONT run. + + Args: + experiment_name: The experiment name. + instrument_slot: The instrument slot. + flowcell_id: The flowcell ID + path: The path to the collection containing the entire run. + event: The type event that triggers the email. + """ + super().__init__(Task.Status.PENDING) + + if experiment_name is None: + raise ValueError("experiment_name is required") + if instrument_slot is None: + raise ValueError("instrument_slot is required") + # GridION has 1-5, PromethION has 1-24 + if instrument_slot < 1 or instrument_slot > 24: + raise ValueError("instrument_slot must be between 1 and 24") + if flowcell_id is None: + raise ValueError("flowcell_id is required") + if path is None: + raise ValueError("path is required") + if event is None: + raise ValueError("event is required") + + self.experiment_name = experiment_name + self.instrument_slot = instrument_slot + self.flowcell_id = flowcell_id + self.path = path + self.event = event + + def subject(self) -> str: + """Return the subject of the email.""" + return ( + f"Update: ONT run {self.experiment_name} flowcell {self.flowcell_id} " + f"has been {self.event}" + ) + + def body(self, *study_ids: list[str]) -> str: + """Return the body of the email. + + Args: + *study_ids: The study IDs associated with the run. + """ + source = resources.files("npg_notify.data.resources").joinpath( + "ont_event_email_template.txt" + ) + with resources.as_file(source) as template: + with open(template) as f: + t = Template(f.read()) + return t.substitute( + { + "experiment_name": self.experiment_name, + "flowcell_id": self.flowcell_id, + "path": self.path, + "event": self.event, + "studies": "\n".join([*study_ids]), + } + ) + + def to_serializable(self) -> dict: + return { + "experiment_name": self.experiment_name, + "instrument_slot": self.instrument_slot, + "flowcell_id": self.flowcell_id, + "path": self.path, + "event": self.event, + } + + @classmethod + def from_serializable(cls, serializable: dict): + return cls(**serializable) + + +def add_email_tasks( + pipeline: Pipeline, event: EventType, reader, writer +) -> tuple[int, int, int]: + """Add new ONT event email tasks to the pipeline. + + For each collection read from the reader, a new task is created and added to the + pipeline. The collection is then written to the writer. + + Args: + pipeline: The pipeline to which the tasks are added. + event: The event that triggers the email. + reader: A reader of ONT run collections in baton JSON format, one per line. + These collections must have the standard ONT run metadata: + - ont:experiment_name + - ont:instrument_slot + - ont:flowcell_id + writer: A writer to which the collections are written after processing, also + in baton JSON format, one per line. + Returns: + The number of collections processed, the number of tasks added, and the number + of errors encountered. + """ + np = ns = ne = 0 + + for line in reader: + np += 1 + try: + coll = Collection.from_json(line) + try: + expt = coll.avu("ont:experiment_name").value + slot = int(coll.avu("ont:instrument_slot").value) + flowcell_id = coll.avu("ont:flowcell_id").value + except ValueError as e: + raise MetadataError( + "Collection does not have the metadata required for ONT event email" + ) from e + + task = ContactEmail( + experiment_name=expt, + instrument_slot=slot, + flowcell_id=flowcell_id, + path=coll.path.as_posix(), + event=event, + ) + if pipeline.add(task): + ns += 1 + log.info("Task added", pipeline=pipeline, task=task) + else: + log.info("Task already exists", pipeline=pipeline, task=task) + + print(coll.to_json(indent=None, sort_keys=True), file=writer) + except Exception as e: + ne += 1 + log.exception(e) + + return np, ns, ne + + +def run_email_tasks( + pipeline: Pipeline, session: Session, domain: str +) -> tuple[int, int, int]: + """Claim tasks from the pipeline and run them to send ONT event emails. + + For each task (representing an ONT run) an email will be sent to all the contacts + of the studies associated with the run. + + Args: + pipeline: The pipeline whose tasks are to be run. + session: An open MLWH DB session. + domain: A network domain name to use when sending email. The main will be sent + from mail. with @ in the From: header. + + Returns: + The number of tasks processed, the number of tasks that succeeded, and the + number of errors encountered. + """ + np = ns = ne = 0 + batch_size = 1 + + for task in pipeline.claim(batch_size): + try: + np += 1 + study_ids = find_studies_for_run( + session, task.experiment_name, task.instrument_slot, task.flowcell_id + ) + + # We are sending a single email to all contacts of all studies in the run + contacts = set() + for study_id in study_ids: + c = get_study_contacts(session=session, study_id=study_id) + contacts.update(c) + + if len(contacts) == 0: + log.info( + "No contacts found", + pipeline=pipeline, + task=task, + study_ids=study_ids, + ) + + pipeline.done(task) + ns += 1 + continue + + try: + send_notification( + domain=domain, + contacts=sorted(contacts), + subject=task.subject(), + content=task.body(study_ids), + ) + + pipeline.done(task) + ns += 1 + except Exception as e: + ne += 1 + log.exception(e) + + # Retry on failure to send as it's likely to be transient. + # + # There is only one email per run, so it's safe to retry without the + # risk of spamming the contacts; if it failed to send, nobody received + # the email. This would not be the case if we were sending multiple + # emails, one per study, for example. + pipeline.retry(task) + + except Exception as e: + ne += 1 + log.exception(e) + + # Retry on failure to query the MLWH as it's likely to be transient. + pipeline.retry(task) + + return np, ns, ne + + +def find_studies_for_run( + session: Session, experiment_name: str, instrument_slot: int, flowcell_id: str +) -> list[str]: + """Return the study IDs associated with an ONT run. + + Args: + session: An open MLWH DB session. + experiment_name: The experiment name. + instrument_slot: The instrument slot. + flowcell_id: The flowcell ID. + + Returns: + Study IDs associated with the run. + """ + return [ + elt + for elt, in session.query(distinct(Study.id_study_lims)) + .join(OseqFlowcell) + .filter( + OseqFlowcell.experiment_name == experiment_name, + OseqFlowcell.instrument_slot == instrument_slot, + OseqFlowcell.flowcell_id == flowcell_id, + ) + .order_by(asc(Study.id_study_lims)) + .all() + ] + + +def main(): + parser = argparse.ArgumentParser( + description=description, formatter_class=argparse.RawDescriptionHelpFormatter + ) + + parser.add_argument( + "action", + type=str, + help="The 'add' action acts as a producer by sending new notification " + "tasks to the Porch server. The 'run' action acts as a consumer " + "by retrieving any notification tasks that have not been done and " + "running them to send notifications.", + choices=["add", "run"], + # nargs="?", + ) + parser.add_argument( + "--event", + type=str, + help="The event that triggers the email.", + choices=[e.name for e in EventType], + default=EventType.UPLOADED, + ) + + parser.add_argument( + "--conf-file-path", + "--conf_file_path", + type=str, + required=True, + help="Configuration file path.", + ) + parser.add_argument( + "--version", + help="Print the version and exit.", + action="version", + version=npg_notify.version(), + ) + add_io_arguments(parser) + add_logging_arguments(parser) + + args = parser.parse_args() + configure_structlog( + config_file=args.log_config, + debug=args.debug, + verbose=args.verbose, + colour=args.colour, + json=args.json, + ) + + config_file = args.conf_file_path + config = IniData(Pipeline.ServerConfig).from_file(config_file, "PORCH") + + pipeline = Pipeline( + ContactEmail, + name="ont-event-email", + uri="https://github.com/wtsi/npg_notifications.git", + version=npg_notify.version(), + config=config, + ) + + num_processed, num_succeeded, num_errors = 0, 0, 0 + + action = args.action + event = args.event + if action == "add": + num_processed, num_succeeded, num_errors = add_email_tasks( + pipeline, event, args.input, args.output + ) + elif action == "run": + with get_connection(config_file, MYSQL_MLWH_CONFIG_FILE_SECTION) as session: + num_processed, num_succeeded, num_errors = run_email_tasks( + pipeline, session + ) + + if num_errors > 0: + log.error( + f"Failed to {action} some tasks", + pipeline=pipeline, + num_processed=num_processed, + num_succeeded=num_succeeded, + num_errors=num_errors, + ) + sys.exit(1) + + log.info( + f"Completed {action}", + pipeline=pipeline, + num_processed=num_processed, + num_added=num_succeeded, + num_errors=num_errors, + ) + + +if __name__ == "__main__": + main() diff --git a/src/npg_notify/ont/porch.py b/src/npg_notify/ont/porch.py new file mode 100644 index 0000000..6d9d191 --- /dev/null +++ b/src/npg_notify/ont/porch.py @@ -0,0 +1,528 @@ +# +# Copyright © 2024 Genome Research Ltd. All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import http +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Generic, Self, TypeVar +from urllib.parse import urljoin + +import requests +from structlog import get_logger + +log = get_logger(__package__) + +"""This module provides a task-centric API for interacting with a Porch server. It +hides the details sending requests to and receiving responses from the Porch server. +For a request/response-centric API, see https://github.com/wtsi-npg/npg_porch_cli +""" + + +class Task(ABC): + """A Porch task i.e. an instance of a pipeline to be executed. + + The identity of a Porch task is defined by the pipeline name and version, plus + the attributes and values of the task input. + + To define a new kind of Task, you need to create a subclass of 'Task' + and implement a 'to_serializable' method, which returns the Porch task input, + and a 'from_serializable' method which converts the same JSON back into a task + object. + + E.g. + class MyTask(Task): + input1: str + input2: int + + def __init__(self, input1: str = None, input2: int = None): + self.input1 = input1 + self.input2 = input2 + + def to_serializable(self) -> dict: + return { + "input1": self.input1, + "input2": self.input2, + } + + @classmethod + def from_serializable(cls, serializable: dict): + return cls(**serializable) + + """ + + class Status(str, Enum): + """The status of a task.""" + + PENDING = "PENDING" + CLAIMED = "CLAIMED" + RUNNING = "RUNNING" + DONE = "DONE" + CANCELLED = "CANCELLED" + FAILED = "FAILED" + + """The current status of the task.""" + status: Status + + def __init__(self, status: Status = None): + self.status = status + + def __eq__(self, other): + if not isinstance(other, Task): + return False + + return self.to_serializable() == other.to_serializable() + + def __hash__(self): + return hash(self.to_serializable()) + + @abstractmethod + def to_serializable(self) -> dict: + """Return a JSON-serializable dictionary of the task input.""" + raise NotImplementedError + + @classmethod + @abstractmethod + def from_serializable(cls, serializable: dict): + """Create a new task from a JSON-serializable dictionary.""" + raise NotImplementedError + + +T = TypeVar("T", bound=Task) + + +class Pipeline(Generic[T]): + """A Porch "pipeline". + + A Porch pipeline is type of pub/sub queue where tasks are added by one process and + later claimed and processed by another. The identity of a Porch pipeline is defined + by the pipeline name, URI and version. + + When a new pipeline is created, it must be registered with the Porch server before + tasks can be added to it. This is done using the `register` method. Once registered, + a pipeline token must be obtained using the `new_token` method. This token is used + to add, claim and update tasks for this pipeline. + + A pipeline's `register` and `new_token` methods require an admin token. The other + methods require a pipeline token. + + The identity of a Porch task is defined by the serializable task input; two task + objects with the same inputs are considered the same task. Porch uses this to try + to ensure that each task is created and processed once. + + To use this module for a new pipeline, you need to create a subclass of + `Pipeline.Task` and implement the `to_serializable` and `from_serializable` + methods. See the Porch documentation for more information on how the task + attributes and values are serialized as JSON. + + For example: + + from pipeline import Pipeline + + class SumTask(Task): + input1: int + input2: int + + def __init__(self, input1: int = 0, input2: int = 0): + super().__init__(Task.Status.PENDING) + self.input1 = input1 + self.input2 = input2 + + def to_serializable(self) -> dict: + return { + "input1": self.input1, + "input2": self.input2, + } + + def from_serializable(cls, serializable: dict): + return cls(**serializable) + + When this is done, you can create a new pipeline and add tasks to it: + + p = Pipeline("Sum of two integers", "http://localhost/sum", "1.0.0") + p = p.register() # Needs to be done once + + tasks = [ + SumTask(10, 42), + SumTask(10, 99), + ] + + for task in tasks: + p.add(task) + + Once tasks are added, you can claim and update their status as they are processed: + + claimed_task = p.claim() + try: + # Submit the task to a worker + p.run(task) # Tell the Porch server that the task is running + except Exception as e: + p.fail(task) # Tell the Porch server that the task failed + + Porch will ensure that each task is created exactly once and that each task is + claimed for processing once, by only one worker. + + This class uses the Porch REST API. See the Porch documentation for more + information. It has a timeout of 10 seconds and will retry failed requests up to 3 + times with an exponential backoff starting at 15 seconds. + + Note: We could consider using only the first or first and seconds parts of the + (SemVer) version number. This would allow bug-fix releases to be made that could + re-run existing tasks for that version. + """ + + @dataclass + class Config: + name: str + uri: str + + @dataclass + class ServerConfig: + """Configuration for a Porch pipeline server. + + This exists to collect external configuration for a pipeline in one place + where it can be passed to the pipeline constructor. If not provided, a + default configuration will be used which relies on environment variables. + + The configurable values are: + + - porch_url: The base URL of the Porch server (defaults to the PORCH_URL + environment variable). + - admin_token: The admin token for the Porch server (defaults to the + PORCH_ADMIN_TOKEN environment variable). + - pipeline_token: The pipeline token for the Porch server (defaults to the + PORCH_PIPELINE_TOKEN environment variable). + + If any of these values is set explicitly, that value will be used in preference + to the corresponding environment variable. + + e.g. + + [
] + url = http://localhost:8000 + pipeline_token = 11111111111111111111111111111111 + admin_token = 0000000000000000000000000000000 + + As this is a dataclass, instances can be created from an INI file using the + `IniData` class in the `conf` module: + + e.g. + + server_config = IniData(ServerConfig).from_file(,
) + + The token fields are set to not be included in the repr() output to avoid + leaking sensitive information in logs. + """ + + url: str + """The base URL of the Porch server.""" + + pipeline_token: str = field(repr=False, default=None) + admin_token: str = field(repr=False, default=None) + + def __init__( + self, cls: type[T], name: str, uri: str, version: str, config: ServerConfig + ): + """Create a new pipeline with no pipeline access token set. + + Args: + cls: The task class for this pipeline. Must be a subclass of Task. + name: The pipeline name. + uri: The pipeline URI. + version: The pipeline version. + config: The configuration for the pipeline server. + """ + + # Note that the task class is passed explicitly to the constructor because + # it's not reliable (Python 3.12) to get the class parameter for a generic + # type at runtime (using documented API) when using e.g. + # + # p = Pipeline[ExampleTask](...) + # + # If Pipeline is subclassed e.g. + # + # class FooPipeline(Pipeline[ExampleTask]): + # pass + # + # p = FooPipeline(...) + # + # One can use: + # + # cls = get_args(self.__orig_bases__[0])[0] + # + # However, I've not been able to get this to work without requiring a + # subclass and that puts a burden on the user of the API to jump through + # language hoops to get something that works. + + self.cls = cls + self.name = name + self.uri = uri + self.version = version + self.config = config + + self.timeout = 10 + + def register(self) -> Self: + """Register the pipeline with a Porch server. + + This needs to be done only once for each pipeline (i.e. unique name, URI and + version combination) and requires an admin token. If the pipeline already + exists, this method will log a warning and return the existing pipeline. + + An admin token is required to use this method. + + Returns: + The pipeline object. + """ + headers = self._headers(self.config.admin_token) + body = self._to_serializable() + + response = self._request( + "POST", self._pipeline_endpoint(), headers=headers, body=body + ) + + if response.status_code == 409: + log.warn(f"Pipeline already exists", pipeline=self) + return self + + response.raise_for_status() + + return self + + def new_token(self, token_desc: str) -> str: + """Create a new token for the pipeline. + + This token is only valid for this pipeline and can be used to add and update + tasks. The token should be stored securely. It cannot be obtained from the + server again. + + An admin token is required to use this method. + + Args: + token_desc: A description of the token's purpose. + + Returns: + The token string. + """ + url = urljoin(self._pipeline_endpoint(), f"{self.name}/token/{token_desc}") + headers = self._headers(self.config.admin_token) + + response = self._request("POST", url, headers=headers) + response.raise_for_status() + + return response.json()["token"] + + def add(self, task: T) -> bool: + """Add a new task for this pipeline. This method is idempotent, so adding the + same task multiple times will not create duplicates. + + Args: + task: The task to be queued, initially in the PENDING state. + + Returns: + True if the task was added, False if it already exists. + """ + url = self._task_endpoint() + headers = self._headers(self.config.pipeline_token) + body = self._to_serializable(task) + + response = self._request("POST", url, headers=headers, body=body) + response.raise_for_status() + + return response.status_code == http.HTTPStatus.CREATED + + def all(self) -> list[T]: + """Get all tasks for this pipeline.""" + return self._get_tasks() + + def pending(self) -> list[T]: + """Get all tasks for this pipeline that are pending.""" + return self._get_tasks(status=Task.Status.PENDING) + + def claimed(self) -> list[T]: + """Get all tasks for this pipeline that are claimed but not yet running.""" + return self._get_tasks(status=Task.Status.CLAIMED) + + def running(self) -> list[T]: + """Get all tasks for this pipeline that are currently running.""" + return self._get_tasks(status=Task.Status.RUNNING) + + def succeeded(self) -> list[T]: + """Get all tasks for this pipeline that have completed successfully.""" + return self._get_tasks(status=Task.Status.DONE) + + def cancelled(self) -> list[T]: + """Get all tasks for this pipeline that have been cancelled.""" + return self._get_tasks(status=Task.Status.CANCELLED) + + def failed(self) -> list[T]: + """Get all tasks for this pipeline that have failed.""" + return self._get_tasks(status=Task.Status.FAILED) + + def claim(self, num: int = 1) -> list[T]: + """Claim a number of tasks for this pipeline. + + Args: + num: The number of tasks to claim. + + Returns: + The claimed tasks. + """ + log.info("Task claim", num=num) + url = self._task_endpoint() + f"claim/?num_tasks={num}" + headers = self._headers(self.config.pipeline_token) + body = self._to_serializable() + + response = self._request("POST", url, headers=headers, body=body) + response.raise_for_status() + log.debug("Claim response", response=response.json()) + + claimed = [self._from_serializable(item) for item in response.json()] + log.info("Claimed tasks", claimed=claimed) + + return claimed + + def run(self, task: T) -> T: + """Mark a task as running. This should be called after claiming a task.""" + log.info("Task run", pipeline=self, task=task) + task.status = task.Status.RUNNING + return self._update_task(task) + + def done(self, task: T) -> T: + """Mark a task as done successfully.""" + log.info("Task done", pipeline=self, task=task) + task.status = task.Status.DONE + return self._update_task(task) + + def fail(self, task: T) -> T: + """Mark a task as failed.""" + log.info("Task fail", pipeline=self, task=task) + task.status = task.Status.FAILED + return self._update_task(task) + + def cancel(self, task: T) -> T: + """Mark a task as cancelled.""" + log.info("Task cancel", pipeline=self, task=task) + task.status = task.Status.CANCELLED + return self._update_task(task) + + def retry(self, task: T) -> T: + """Mark a task as pending again. This should be called after a task has + succeeded, failed or been cancelled and needs to be retried or re-run.""" + log.info("Task retry", pipeline=self, task=task) + task.status = task.Status.PENDING + return self._update_task(task) + + def _get_tasks(self, status: Task.Status = None) -> list[T]: + """Get all tasks for this pipeline with an optional status filter.""" + url = self._task_endpoint() + f"?pipeline_name={self.name}" + if status is not None: + url += f"&status={status.value}" + headers = self._headers(self.config.pipeline_token) + + response = self._request("GET", url, headers=headers) + response.raise_for_status() + + return [self._from_serializable(item) for item in response.json()] + + def _update_task(self, task: T) -> T: + """Update the status of a task.""" + url = self._task_endpoint() + headers = self._headers(self.config.pipeline_token) + body = self._to_serializable(task) + + response = self._request("put", url, headers=headers, body=body) + response.raise_for_status() + + return self._from_serializable(response.json()) + + def _to_serializable(self, task: T = None) -> dict: + """Convert task information to a JSON-serializable dictionary, ready to send + to a Porch server.""" + pipeline = { + "name": self.name, + "uri": self.uri, + "version": self.version, + } + + if task is None: + serializable = pipeline + else: + serializable = { + "pipeline": pipeline, + "task_input": task.to_serializable(), + "status": task.status, + } + + return serializable + + def _from_serializable(self, serializable: dict) -> T: + """Create a task from a JSON-serializable dictionary received from a Porch + server.""" + task = self.cls.from_serializable(serializable["task_input"]) + status = serializable["status"] + + if status not in Task.Status.__members__: + raise ValueError(f"Invalid task status from server: {status}") + task.status = status + return task + + def _request(self, method: str, url: str, headers: dict, body: dict = None): + """Make an HTTP request to a Porch server. + + This method will retry the request up to 3 times with an exponential backoff + starting at 15 seconds. If the request fails after 3 attempts, the last error + will be raised. + """ + num_attempts = 3 + last_error = None + wait = 15 + + for attempt in range(num_attempts): + log.debug("Request", method=method, url=url, body=body) + try: + response = requests.request( + method, url, headers=headers, json=body, timeout=self.timeout + ) + log.debug("Response", status_code=response.status_code, attempt=attempt) + + return response + except Exception as e: + last_error = e + log.error("Request failed", error=str(e), attempt=attempt, waiting=wait) + time.sleep(wait) + wait *= 2 + + raise last_error + + def _pipeline_endpoint(self) -> str: + return urljoin(self.config.url, "pipelines/") + + def _task_endpoint(self) -> str: + return urljoin(self.config.url, "tasks/") + + def __repr__(self): + return f"Pipeline({self.name}, {self.uri}, {self.version})" + + @staticmethod + def _headers(token: str = None) -> dict: + return { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + } diff --git a/tests/conftest.py b/tests/conftest.py index 0450e15..cdac6e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import os import pytest + from npg_notify.db.mlwh import Base from npg_notify.db.utils import ( batch_load_from_yaml, @@ -8,28 +9,26 @@ get_connection, ) -TEST_CONFIG_FILE = "qc_state_app_config.ini" -test_config = os.path.join(os.path.dirname(__file__), "data", TEST_CONFIG_FILE) +QC_TEST_CONFIG_FILE = "qc_state_app_config.ini" +qc_test_config = os.path.join(os.path.dirname(__file__), "data", QC_TEST_CONFIG_FILE) @pytest.fixture(scope="module", name="mlwh_test_session") -def get_test_db_session(): +def get_qc_test_db_session(): """ Establishes a connection to the database, creates a schema, loads data and returns a new database session. """ - fixtures_dir = os.path.join( - os.path.dirname(__file__), "data/mlwh_fixtures" - ) + fixtures_dir = os.path.join(os.path.dirname(__file__), "data/mlwh_fixtures") create_schema( base=Base, drop=True, - conf_file_path=test_config, + conf_file_path=qc_test_config, conf_file_section="MySQL MLWH", ) with get_connection( - conf_file_path=test_config, conf_file_section="MySQL MLWH" + conf_file_path=qc_test_config, conf_file_section="MySQL MLWH" ) as session: batch_load_from_yaml( session=session, diff --git a/tests/data/ont_event_app_config.ini b/tests/data/ont_event_app_config.ini new file mode 100644 index 0000000..917d8a7 --- /dev/null +++ b/tests/data/ont_event_app_config.ini @@ -0,0 +1,18 @@ +# Configuration for the npg_notify package. + +[MLWH] + +dbuser = test +dbpassword = test +dbhost = 127.0.0.1 +dbport = 3306 +dbschema = study_notify + +[PORCH] + +url = http://127.0.0.1:8081 +admin_token = 00000000000000000000000000000000 + +[MAIL] + +domain = some.com diff --git a/tests/ont/conftest.py b/tests/ont/conftest.py new file mode 100644 index 0000000..af47ea2 --- /dev/null +++ b/tests/ont/conftest.py @@ -0,0 +1,49 @@ +# +# Copyright © 2024 Genome Research Ltd. All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + + +from pathlib import Path + +import pytest +import requests +from npg.conf import IniData +from requests import HTTPError + +from npg_notify.ont.event import PORCH_CONFIG_FILE_SECTION +from npg_notify.ont.porch import Pipeline + +ont_test_config = Path("./tests/data/ont_event_app_config.ini") + + +def porch_server_available() -> bool: + config = IniData(Pipeline.ServerConfig).from_file( + ont_test_config, PORCH_CONFIG_FILE_SECTION + ) + try: + response = requests.request("GET", config.url, timeout=5) + return response.status_code == 200 + except (requests.ConnectionError, HTTPError): + return False + except Exception: + raise + + +@pytest.fixture(scope="session") +def porch_server_config() -> Pipeline.ServerConfig: + return IniData(Pipeline.ServerConfig).from_file( + ont_test_config, PORCH_CONFIG_FILE_SECTION + ) diff --git a/tests/ont/test_generate_email.py b/tests/ont/test_generate_email.py new file mode 100644 index 0000000..241bc26 --- /dev/null +++ b/tests/ont/test_generate_email.py @@ -0,0 +1,70 @@ +import json + +from pytest import mark as m + +from npg_notify.ont.event import ContactEmail, EventType + + +@m.describe("Generate ONT email") +class TestGenerateONTEmail: + + def test_serialize_deserialize_event(self): + expt = "experiment1" + slot = 1 + flowcell_id = "FAKE12345" + path = f"/testZone/home/irods/{expt}_{slot}_{flowcell_id}" + event_type = EventType.UPLOADED + + event1 = ContactEmail( + experiment_name=expt, + instrument_slot=slot, + flowcell_id=flowcell_id, + path=path, + event=event_type, + ) + + event2 = ContactEmail.from_serializable( + json.loads(json.dumps(event1.to_serializable())) + ) + assert event2.experiment_name == expt + assert event2.instrument_slot == slot + assert event2.flowcell_id == flowcell_id + assert event2.path == path + assert event2.event == event_type + + @m.context("When an ONT email is generated") + @m.it("Has the correct subject") + def test_generate_email(self): + expt = "experiment1" + slot = 1 + flowcell_id = "FAKE12345" + path = f"/testZone/home/irods/{expt}_{slot}_{flowcell_id}" + event_type = EventType.UPLOADED + studies = ["study1", "study2"] + + event = ContactEmail( + experiment_name=expt, + instrument_slot=slot, + flowcell_id=flowcell_id, + path=path, + event=event_type, + ) + + assert ( + event.subject() + == f"Update: ONT run {expt} flowcell {flowcell_id} has been {event_type}" + ) + + study_lines = "\n".join(studies) + + assert event.body(*studies) == ( + f"The ONT run for experiment {expt}, flowcell {flowcell_id} has been {event_type}.\n" + "The data are available in iRODS at the following path:\n" + "\n" + f"{path}\n" + "\n" + "This is an automated email from NPG. You are receiving it because you are registered\n" + "as a contact for one or more of the Studies listed below:\n" + "\n" + f"{study_lines}\n" + ) diff --git a/tests/ont/test_porch.py b/tests/ont/test_porch.py new file mode 100644 index 0000000..c389ff7 --- /dev/null +++ b/tests/ont/test_porch.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- +# +# Copyright © 2024 Genome Research Ltd. All rights reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +from decimal import Decimal +from pathlib import Path +from uuid import uuid4 + +from pytest import mark as m + +from conftest import porch_server_available + +from npg.conf import IniData + +from npg_notify import version +from npg_notify.ont.event import PORCH_CONFIG_FILE_SECTION +from npg_notify.ont.porch import Pipeline, Task + +porch_server_is_up = m.skipif( + not porch_server_available(), reason="Test Porch server is not available" +) + + +class ExampleTask(Task): + def __init__( + self, + item: str, + quantity: int = 1, + price: Decimal = Decimal("0.00"), + uuid: str = None, + ): + super().__init__(Task.Status.PENDING) + self.item = item + self.quantity = quantity + self.price = Decimal(price) + self.uuid = uuid if uuid is not None else uuid4().hex + + def to_serializable(self) -> dict: + return { + "item": self.item, + "quantity": self.quantity, + "price": str(self.price), + "uuid": self.uuid, + } + + @classmethod + def from_serializable(cls, serializable: dict): + return cls(**serializable) + + def __repr__(self): + return f"ExampleTask({self.item}, {self.quantity}, {self.price}, {self.uuid})" + + +@m.describe("Porch Pipeline") +@porch_server_is_up +class TestPorchPipeline: + @m.context("When configured from an INI file") + @m.it("Loads the configuration") + def test_configure(self): + ini = Path("./tests/data/ont_event_app_config.ini") + + config = IniData(Pipeline.ServerConfig).from_file( + ini, PORCH_CONFIG_FILE_SECTION + ) + + assert config is not None + assert config.admin_token == "0" * 32 + assert config.pipeline_token is None + assert config.url == "http://127.0.0.1:8081" + + @m.context("When configured from both an INI file and environment variables") + @m.it("Loads the configuration and falls back to environment variables") + def test_configure_env(self): + ini = Path("./tests/data/ont_event_app_config.ini") + + env_prefix = "PORCH_" + pipeline_token = "1" * 32 + os.environ[env_prefix + "PIPELINE_TOKEN"] = pipeline_token + + config: Pipeline.ServerConfig = IniData( + Pipeline.ServerConfig, use_env=True, env_prefix=env_prefix + ).from_file(ini, PORCH_CONFIG_FILE_SECTION) + + assert config is not None + assert config.admin_token == "0" * 32 + assert config.pipeline_token == pipeline_token + assert config.url == "http://127.0.0.1:8081" + + @m.context("When a pipeline has been defined") + @m.it("Can be registered") + def test_register_pipeline(self, porch_server_config): + p = Pipeline( + ExampleTask, + name="test_register_pipeline", + uri="http://www.sanger.ac.uk", + version=version(), + config=porch_server_config, + ) + + assert p.register() == p + + @m.context("After a pipeline is registered") + @m.it("Can create a new token") + def test_new_token(self, porch_server_config): + p = Pipeline( + ExampleTask, + name="test_new_token", + uri="http://www.sanger.ac.uk", + version=version(), + config=porch_server_config, + ) + p = p.register() + + t = p.new_token("test_new_token") + assert t is not None + + @m.context("After a pipeline is registered") + @m.it("Can have tasks added to it") + def test_add_task(self, porch_server_config): + p = Pipeline( + ExampleTask, + name="test_add_task", + uri="http://www.sanger.ac.uk", + version=version(), + config=porch_server_config, + ) + p = p.register() + + token = p.new_token("test_add_task") + porch_server_config.pipeline_token = token + + task1 = ExampleTask(item="bread", quantity=2, price=Decimal("4.20")) + assert p.add(task1) # True because task is not present + assert task1 in p.all() + + task2 = ExampleTask(item="sugar", quantity=2, price=Decimal("0.78")) + assert p.add(task2) + assert task2 in p.all() + + assert not p.add(task1) # False because task is already present + + @m.context("After a task has been added to a pipeline") + @m.it("Can be claimed") + def test_claim_task(self, porch_server_config): + p = Pipeline( + ExampleTask, + name="test_claim_task", + uri="http://www.sanger.ac.uk", + version=version(), + config=porch_server_config, + ) + p = p.register() + + token = p.new_token("test_claim_task") + porch_server_config.pipeline_token = token + + task = ExampleTask(item="bread", quantity=2, price=Decimal("4.20")) + p.add(task) + + assert task in p.all() + assert task in p.claim(1000) + + @m.context("After a task has been claimed") + @m.it("Can be updated") + def test_update_task(self, porch_server_config): + p = Pipeline( + ExampleTask, + name="test_update_task", + uri="http://www.sanger.ac.uk", + version=version(), + config=porch_server_config, + ) + p = p.register() + + token = p.new_token("test_update_task") + porch_server_config.pipeline_token = token + + task = ExampleTask(item="bread", quantity=2, price=Decimal("4.20")) + p.add(task) + + assert task in p.all() + assert task in p.claim(1000) + + assert p.run(task) + assert task in p.running() + + assert p.fail(task) + assert task in p.failed() + + assert p.retry(task) + assert task in p.pending() + + assert p.run(task) + assert task in p.running() + + assert p.done(task) + assert task in p.succeeded() From 633acf11bbd4f6146ca3b2d9e5f8f8b9b6f5418a Mon Sep 17 00:00:00 2001 From: Keith James Date: Fri, 25 Oct 2024 14:24:05 +0100 Subject: [PATCH 2/4] Fix test annotations --- tests/ont/test_generate_email.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/ont/test_generate_email.py b/tests/ont/test_generate_email.py index 241bc26..e07fa36 100644 --- a/tests/ont/test_generate_email.py +++ b/tests/ont/test_generate_email.py @@ -8,6 +8,8 @@ @m.describe("Generate ONT email") class TestGenerateONTEmail: + @m.context("When an ONT event is serialized and deserialized") + @m.it("Retains the correct values") def test_serialize_deserialize_event(self): expt = "experiment1" slot = 1 @@ -26,6 +28,7 @@ def test_serialize_deserialize_event(self): event2 = ContactEmail.from_serializable( json.loads(json.dumps(event1.to_serializable())) ) + assert event2.experiment_name == expt assert event2.instrument_slot == slot assert event2.flowcell_id == flowcell_id @@ -33,7 +36,7 @@ def test_serialize_deserialize_event(self): assert event2.event == event_type @m.context("When an ONT email is generated") - @m.it("Has the correct subject") + @m.it("Has the correct subject and body") def test_generate_email(self): expt = "experiment1" slot = 1 From 61864fc5ada92a1e0f73f09872f852dc4e23053e Mon Sep 17 00:00:00 2001 From: Keith James Date: Fri, 25 Oct 2024 14:24:36 +0100 Subject: [PATCH 3/4] Fix porch server image name --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 0de0c8f..9ca5f09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: retries: 10 porch-server: - image: wsinpg/python-3.10-npg-porch-2.0.0:latest + image: "ghcr.io/wtsi-npg/python-3.10-npg-porch-2.0.0" restart: always ports: - "127.0.0.1:8081:8081" From a67978db61ed37ae494baabb2efe78795f355165 Mon Sep 17 00:00:00 2001 From: Keith James Date: Fri, 25 Oct 2024 14:26:11 +0100 Subject: [PATCH 4/4] Increase email batch size to 100 --- src/npg_notify/ont/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/npg_notify/ont/event.py b/src/npg_notify/ont/event.py index a14dffc..01b235b 100644 --- a/src/npg_notify/ont/event.py +++ b/src/npg_notify/ont/event.py @@ -280,7 +280,7 @@ def run_email_tasks( number of errors encountered. """ np = ns = ne = 0 - batch_size = 1 + batch_size = 100 for task in pipeline.claim(batch_size): try: