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..9ca5f09
--- /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: "ghcr.io/wtsi-npg/python-3.10-npg-porch-2.0.0"
+ 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..01b235b
--- /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 = 100
+
+ 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..e07fa36
--- /dev/null
+++ b/tests/ont/test_generate_email.py
@@ -0,0 +1,73 @@
+import json
+
+from pytest import mark as m
+
+from npg_notify.ont.event import ContactEmail, EventType
+
+
+@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
+ 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 and body")
+ 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()