diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fb89f8193..33be8bab4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -124,11 +124,23 @@ jobs: fetch-depth: 0 - uses: lsst-sqre/build-and-push-to-ghcr@v1 - id: build + id: build1 with: + dockerfile: Dockerfile.controller image: ${{ github.repository }}-controller github_token: ${{ secrets.GITHUB_TOKEN }} - name: Report result run: | - echo Pushed ghcr.io/${{ github.repository }}-controller:${{ steps.build.outputs.tag }} + echo Pushed ghcr.io/${{ github.repository }}-controller:${{ steps.build1.outputs.tag }} + + - uses: lsst-sqre/build-and-push-to-ghcr@v1 + id: build2 + with: + dockerfile: Dockerfile.controller + image: ${{ github.repository }}-jupyterhub + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Report result + run: | + echo Pushed ghcr.io/${{ github.repository }}-jupyterhub:${{ steps.build2.outputs.tag }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2b8a7575d..41b72b534 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.3 + rev: v0.1.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -17,6 +17,7 @@ repos: rev: 23.10.1 hooks: - id: black + args: [--config, pyproject.toml] - repo: https://github.com/adamchainz/blacken-docs rev: 1.16.0 diff --git a/Dockerfile b/Dockerfile.controller similarity index 81% rename from Dockerfile rename to Dockerfile.controller index c1e1b403b..cf1875e64 100644 --- a/Dockerfile +++ b/Dockerfile.controller @@ -1,12 +1,14 @@ +# Docker build instructions for the Nublado controller. +# # This Dockerfile has four stages: # # base-image # Updates the base Python image with security patches and common system # packages. This image becomes the base of all other images. # dependencies-image -# Installs third-party dependencies (requirements/main.txt) into a virtual -# environment. This virtual environment is ideal for copying across build -# stages. +# Installs third-party dependencies (controller/requirements/main.txt) +# into a virtual environment. This virtual environment is ideal for +# copying across build stages. # install-image # Installs the app into the virtual environment. # runtime-image @@ -17,13 +19,13 @@ FROM python:3.11.5-slim-bullseye as base-image # Update system packages -COPY scripts/install-base-packages.sh . +COPY controller/scripts/install-base-packages.sh . RUN ./install-base-packages.sh && rm ./install-base-packages.sh FROM base-image AS dependencies-image # Install system packages only needed for building dependencies. -COPY scripts/install-dependency-packages.sh . +COPY controller/scripts/install-dependency-packages.sh . RUN ./install-dependency-packages.sh # Create a Python virtual environment @@ -56,7 +58,7 @@ RUN useradd --create-home appuser COPY --from=install-image /opt/venv /opt/venv # Copy the startup script -COPY scripts/start.sh /start.sh +COPY controller/scripts/start.sh /start.sh # Make sure we use the virtualenv ENV PATH="/opt/venv/bin:$PATH" diff --git a/Dockerfile.hub b/Dockerfile.hub new file mode 100644 index 000000000..012b75675 --- /dev/null +++ b/Dockerfile.hub @@ -0,0 +1,49 @@ +# Docker build instructions for the custom Nublado JupyterHub build. +# +# This Dockerfile has three stages: +# +# base-image +# Updates the base Python image with security patches and common system +# packages. This image becomes the base of all other images. +# dependencies-image +# Installs third-party dependencies (spawner/requirements/main.txt) into +# a virtual environment. This virtual environment is ideal for copying +# across build stages. +# runtime-image +# - Copies the virtual environment into place. +# - Installs the additional JupyterHub plugins. +# - Runs a non-root user. + +FROM jupyterhub/jupyterhub:4.0.2 as base-image + +# Update system packages +COPY spawner/scripts/install-base-packages.sh . +RUN ./install-base-packages.sh && rm install-base-packages.sh + +FROM base-image as dependencies-image + +# Install system packages only needed for building dependencies. +COPY spawner/scripts/install-dependency-packages.sh . +RUN ./install-dependency-packages.sh && rm install-dependency-packages.sh + +# Install the dependencies of the extra JupyterHub modules. +COPY spawner/requirements/main.txt ./requirements.txt +RUN pip install --quiet --no-cache-dir -r requirements.txt + +FROM dependencies-image as runtime-image + +# Install the extra JupyterHub modules. +COPY . /app +WORKDIR /app +RUN pip install --no-cache-dir spawner + +# Create a non-root user to run JupyterHub. Upstream uses 768 as the UID +# and GID. This value must be kept in sync with the Phalanx nublado +# application values.yaml. +RUN groupadd --gid 768 jovyan +RUN useradd --create-home jovyan --uid 768 --gid 768 +WORKDIR /home/jovyan + +USER jovyan +EXPOSE 8000 +EXPOSE 8081 diff --git a/controller/LICENSE b/controller/LICENSE deleted file mode 100644 index 1152a5eeb..000000000 --- a/controller/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2022-2023 Association of Universities for Research in Astronomy, Inc. (AURA) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/controller/LICENSE b/controller/LICENSE new file mode 120000 index 000000000..ea5b60640 --- /dev/null +++ b/controller/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/controller/requirements/dev.txt b/controller/requirements/dev.txt index 5035bdcf9..c2b6f249f 100644 --- a/controller/requirements/dev.txt +++ b/controller/requirements/dev.txt @@ -19,7 +19,7 @@ anyio==3.7.1 \ --hash=sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5 # via # -c controller/requirements/main.txt - # httpcore + # httpx asgi-lifespan==2.1.0 \ --hash=sha256:5e2effaf0bfe39829cf2d64e7ecc47c7d86d676a6599f7afba378c31f5e3a308 \ --hash=sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f @@ -247,15 +247,15 @@ h11==0.14.0 \ # via # -c controller/requirements/main.txt # httpcore -httpcore==0.18.0 \ - --hash=sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9 \ - --hash=sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced +httpcore==1.0.1 \ + --hash=sha256:c5e97ef177dca2023d0b9aad98e49507ef5423e9f1d94ffe2cfe250aa28e63b0 \ + --hash=sha256:fce1ddf9b606cfb98132ab58865c3728c52c8e4c3c46e2aabb3674464a186e92 # via # -c controller/requirements/main.txt # httpx -httpx==0.25.0 \ - --hash=sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100 \ - --hash=sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875 +httpx==0.25.1 \ + --hash=sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a \ + --hash=sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0 # via # -c controller/requirements/main.txt # respx @@ -802,7 +802,6 @@ sniffio==1.3.0 \ # -c controller/requirements/main.txt # anyio # asgi-lifespan - # httpcore # httpx snowballstemmer==2.2.0 \ --hash=sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1 \ diff --git a/controller/requirements/main.txt b/controller/requirements/main.txt index b39bc6fa9..a0119d679 100644 --- a/controller/requirements/main.txt +++ b/controller/requirements/main.txt @@ -110,7 +110,7 @@ anyio==3.7.1 \ --hash=sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5 # via # fastapi - # httpcore + # httpx # starlette # watchfiles async-timeout==4.0.3 \ @@ -402,9 +402,9 @@ google-auth==2.23.4 \ --hash=sha256:79905d6b1652187def79d491d6e23d0cbb3a21d3c7ba0dbaa9c8a01906b13ff3 \ --hash=sha256:d4bbc92fe4b8bfd2f3e8d88e5ba7085935da208ee38a134fc280e7ce682a05f2 # via google-api-core -google-cloud-artifact-registry==1.8.4 \ - --hash=sha256:2deba5c9c0065cf8e2e63c547e93a4921867d57261d57d4a756be1031529e228 \ - --hash=sha256:a5066555fe8324cc6e8498014b054b1d3e87a8f9e1b0fa1e47ba3bbc3daaa581 +google-cloud-artifact-registry==1.9.0 \ + --hash=sha256:3484d2a2c04fadfa480be16a554d01e47e4c4f4b5a123cd3725e865b4dd7f325 \ + --hash=sha256:aeee14226b1636e7bc043476cff756876068872bda3246a3b124c39d3f4e3be8 # via -r controller/requirements/main.in googleapis-common-protos[grpc]==1.61.0 \ --hash=sha256:22f1915393bb3245343f6efe87f6fe868532efc12aa26b391b15132e1279f1c0 \ @@ -487,9 +487,9 @@ h11==0.14.0 \ # via # httpcore # uvicorn -httpcore==0.18.0 \ - --hash=sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9 \ - --hash=sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced +httpcore==1.0.1 \ + --hash=sha256:c5e97ef177dca2023d0b9aad98e49507ef5423e9f1d94ffe2cfe250aa28e63b0 \ + --hash=sha256:fce1ddf9b606cfb98132ab58865c3728c52c8e4c3c46e2aabb3674464a186e92 # via httpx httptools==0.6.1 \ --hash=sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563 \ @@ -529,9 +529,9 @@ httptools==0.6.1 \ --hash=sha256:e57997ac7fb7ee43140cc03664de5f268813a481dff6245e0075925adc6aa185 \ --hash=sha256:fe467eb086d80217b7584e61313ebadc8d187a4d95bb62031b7bab4b205c3ba3 # via uvicorn -httpx==0.25.0 \ - --hash=sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100 \ - --hash=sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875 +httpx==0.25.1 \ + --hash=sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a \ + --hash=sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0 # via # -r controller/requirements/main.in # safir @@ -948,7 +948,6 @@ sniffio==1.3.0 \ --hash=sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384 # via # anyio - # httpcore # httpx sse-starlette==1.6.5 \ --hash=sha256:68b6b7eb49be0c72a2af80a055994c13afcaa4761b29226beb208f954c25a642 \ diff --git a/scripts/install-base-packages.sh b/controller/scripts/install-base-packages.sh similarity index 100% rename from scripts/install-base-packages.sh rename to controller/scripts/install-base-packages.sh diff --git a/scripts/install-dependency-packages.sh b/controller/scripts/install-dependency-packages.sh similarity index 100% rename from scripts/install-dependency-packages.sh rename to controller/scripts/install-dependency-packages.sh diff --git a/scripts/start.sh b/controller/scripts/start.sh similarity index 100% rename from scripts/start.sh rename to controller/scripts/start.sh diff --git a/noxfile.py b/noxfile.py index 1fb538cd9..bdd705b46 100644 --- a/noxfile.py +++ b/noxfile.py @@ -7,13 +7,15 @@ nox.options.default_venv_backend = "venv" nox.options.reuse_existing_virtualenvs = True - -# pip-installable dependencies for the controller package. +# pip-installable dependencies for all subpackages. PIP_DEPENDENCIES = [ ("--upgrade", "pip", "setuptools", "wheel"), ("-r", "controller/requirements/main.txt"), ("-r", "controller/requirements/dev.txt"), ("-e", "controller"), + ("-r", "spawner/requirements/main.txt"), + ("-r", "spawner/requirements/dev.txt"), + ("-e", "spawner"), ] @@ -73,10 +75,20 @@ def typing(session: nox.Session) -> None: session.install("mypy") session.run( "mypy", + *session.posargs, "noxfile.py", "controller/src", + "spawner/src", + ) + session.run( + "mypy", + *session.posargs, "controller/tests", + ) + session.run( + "mypy", *session.posargs, + "spawner/tests", ) @@ -84,21 +96,34 @@ def typing(session: nox.Session) -> None: def test(session: nox.Session) -> None: """Run tests.""" _install(session) - - # This will need to be more sophisticated once there are multiple - # subdirectories with their own tests and we have to decide which - # subdirectories to execute tests in based on the arguments. with session.chdir("controller"): controller_args = [ - a.removeprefix("controller/") for a in session.posargs + a.removeprefix("controller/") + for a in session.posargs + if a.startswith(("-", "controller/")) ] - session.run( - "pytest", - "--cov=controller", - "--cov-branch", - "--cov-report=", - *controller_args, - ) + if not session.posargs or controller_args: + session.run( + "pytest", + "--cov=controller", + "--cov-branch", + "--cov-report=", + *controller_args, + ) + with session.chdir("spawner"): + spawner_args = [ + a.removeprefix("spawner/") + for a in session.posargs + if a.startswith(("-", "spawner/")) + ] + if not session.posargs or spawner_args: + session.run( + "pytest", + "--cov=rsp_restspawner", + "--cov-branch", + "--cov-report=", + *spawner_args, + ) @nox.session @@ -149,28 +174,29 @@ def update_deps(session: nox.Session) -> None: "--upgrade", "pip-tools", "pip", "setuptools", "wheel", "pre-commit" ) session.run("pre-commit", "autoupdate") - session.run( - "pip-compile", - "--upgrade", - "--resolver=backtracking", - "--build-isolation", - "--allow-unsafe", - "--generate-hashes", - "--output-file", - "controller/requirements/main.txt", - "controller/requirements/main.in", - ) - session.run( - "pip-compile", - "--upgrade", - "--resolver=backtracking", - "--build-isolation", - "--allow-unsafe", - "--generate-hashes", - "--output-file", - "controller/requirements/dev.txt", - "controller/requirements/dev.in", - ) + for subdir in ("controller", "spawner"): + session.run( + "pip-compile", + "--upgrade", + "--resolver=backtracking", + "--build-isolation", + "--allow-unsafe", + "--generate-hashes", + "--output-file", + f"{subdir}/requirements/main.txt", + f"{subdir}/requirements/main.in", + ) + session.run( + "pip-compile", + "--upgrade", + "--resolver=backtracking", + "--build-isolation", + "--allow-unsafe", + "--generate-hashes", + "--output-file", + f"{subdir}/requirements/dev.txt", + f"{subdir}/requirements/dev.in", + ) print("\nTo refresh the development venv, run:\n\n\tnox -s init\n") @@ -179,9 +205,5 @@ def update_deps(session: nox.Session) -> None: def run(session: nox.Session) -> None: """Run the application in development mode.""" _install(session) - with session.chdir("server"): - session.run( - "uvicorn", - "controller.main:app", - "--reload", - ) + with session.chdir("controller"): + session.run("uvicorn", "controller.main:app", "--reload") diff --git a/pyproject.toml b/pyproject.toml index c794867c3..d5612e2bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ ignore = [ "PLR0911", # often many returns is clearer and simpler style "PLR0913", # factory pattern uses constructors with many arguments "PLR2004", # too aggressive about magic values + "PLW0603", # yes global is discouraged but if needed, it's needed "S105", # good idea but too many false positives on non-passwords "S106", # good idea but too many false positives on non-passwords "S107", # good idea but too many false positives on non-passwords @@ -88,6 +89,7 @@ ignore = [ "TD003", # we don't require issues be created for TODOs "TID252", # if we're going to use relative imports, use them always "TRY003", # good general advice but lint is way too aggressive + "TRY301", # sometimes raising exceptions inside try is the best flow # TEMPORARY exclusions while refactoring is in progress. All of these will # be re-enabled once refactoring is complete. @@ -125,7 +127,7 @@ target-version = "py311" ] [tool.ruff.isort] -known-first-party = ["controller", "tests"] +known-first-party = ["controller", "rsp_restspawner", "tests"] split-on-trailing-comma = false [tool.ruff.flake8-bugbear] diff --git a/scripts/docker-tag.sh b/scripts/docker-tag.sh deleted file mode 100755 index 080a9c9b3..000000000 --- a/scripts/docker-tag.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -# Determine the tag for Docker images based on GitHub Actions environment -# variables. - -set -eo pipefail - -if [ -n "$GITHUB_HEAD_REF" ]; then - # For pull requests - echo ${GITHUB_HEAD_REF} | sed -E 's,/,-,g' -else - # For push events - echo ${GITHUB_REF} | sed -E 's,refs/(heads|tags)/,,' | sed -E 's,/,-,g' -fi diff --git a/spawner/LICENSE b/spawner/LICENSE new file mode 120000 index 000000000..ea5b60640 --- /dev/null +++ b/spawner/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/spawner/README.md b/spawner/README.md new file mode 100644 index 000000000..7563055be --- /dev/null +++ b/spawner/README.md @@ -0,0 +1,10 @@ +# rsp-restspawner + +Provides an implementation of the JupyterHub `Spawner` class that makes REST API calls to a Nublado lab controller to manage user lab Kubernetes pods. +This is a client of the Nublado controller and an implementation of the [spawner API](https://jupyterhub.readthedocs.io/en/stable/api/spawner.html). + +Currently, this repository also provides an implementation of the JupyterHub `Authenticator` class that authenticates a user using [Gafaelfawr](https://gafaelfawr.lsst.io/), assuming authentication is configured using [Phalanx](https://phalanx.lsst.io/). +It also builds the Docker image that is used as JupyterHub in a Rubin Science Platform installation. +In the future, these components will be broken into separate directories. + +For more details about this architecture, see [SQR-066](https://sqr-066.lsst.io/). diff --git a/spawner/pyproject.toml b/spawner/pyproject.toml new file mode 100644 index 000000000..91c0f1515 --- /dev/null +++ b/spawner/pyproject.toml @@ -0,0 +1,71 @@ +[project] +# https://packaging.python.org/en/latest/specifications/declaring-project-metadata/ +name = "rsp-restspawner" +description = "JupyterHub spawner using Nublado lab controller REST API." +license = { file = "LICENSE" } +readme = "README.md" +keywords = ["rubin", "lsst"] +# https://pypi.org/classifiers/ +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Natural Language :: English", + "Operating System :: POSIX", + "Typing :: Typed", +] +requires-python = ">=3.10" +dependencies = [] +dynamic = ["version"] + +[project.urls] +Homepage = "https://nublado.lsst.io" +Source = "https://github.com/lsst-sqre/nublado" + +[build-system] +requires = ["setuptools>=61", "wheel", "setuptools_scm[toml]>=6.2"] +build-backend = 'setuptools.build_meta' + +[tool.setuptools_scm] +root = ".." + +[tool.coverage.run] +parallel = true +branch = true +source = ["rsp_restspawner"] + +[tool.coverage.paths] +source = ["src"] + +[tool.coverage.report] +show_missing = true +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if settings.DEBUG", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:" +] + +[tool.pytest.ini_options] +asyncio_mode = "strict" +filterwarnings = [ + # Will probably be fixed with JupyterHub v4. + "ignore:'pipes' is deprecated:DeprecationWarning:jupyterhub.spawner" +] +# The python_files setting is not for test detection (pytest will pick up any +# test files named *_test.py without this setting) but to enable special +# assert processing in any non-test supporting files under tests. We +# conventionally put test support functions under tests.support and may +# sometimes use assert in test fixtures in conftest.py, and pytest only +# enables magical assert processing (showing a full diff on assert failures +# with complex data structures rather than only the assert message) in files +# listed in python_files. +python_files = ["tests/*.py", "tests/*/*.py"] diff --git a/spawner/requirements/dev.in b/spawner/requirements/dev.in new file mode 100644 index 000000000..0be1685d0 --- /dev/null +++ b/spawner/requirements/dev.in @@ -0,0 +1,26 @@ +# -*- conf -*- +# +# Editable development dependencies +# Add direct development, test, and documentation dependencies here, as well +# as implicit dev dependencies with constrained versions. +# +# After editing, update requirements/dev.txt by running: +# make update-deps + +-c main.txt + +coverage[toml] +mypy +pre-commit +pytest +pytest-asyncio +pytest-cov +pytest-sugar +respx +types-PyYAML + +# These dependencies aren't required in Python 3.11 but are required in 3.10. +# Pin them so that we can run tests with Python 3.10 when the dependencies +# have been regenerated with Python 3.11. +exceptiongroup +tomli diff --git a/spawner/requirements/dev.txt b/spawner/requirements/dev.txt new file mode 100644 index 000000000..e7902ce74 --- /dev/null +++ b/spawner/requirements/dev.txt @@ -0,0 +1,297 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --allow-unsafe --generate-hashes --output-file=spawner/requirements/dev.txt spawner/requirements/dev.in +# +anyio==3.7.1 \ + --hash=sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780 \ + --hash=sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5 + # via + # -c spawner/requirements/main.txt + # httpx +certifi==2023.7.22 \ + --hash=sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082 \ + --hash=sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9 + # via + # -c spawner/requirements/main.txt + # httpcore + # httpx +cfgv==3.4.0 \ + --hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \ + --hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560 + # via pre-commit +coverage[toml]==7.3.2 \ + --hash=sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1 \ + --hash=sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63 \ + --hash=sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9 \ + --hash=sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312 \ + --hash=sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3 \ + --hash=sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb \ + --hash=sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25 \ + --hash=sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92 \ + --hash=sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda \ + --hash=sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148 \ + --hash=sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6 \ + --hash=sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216 \ + --hash=sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a \ + --hash=sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640 \ + --hash=sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836 \ + --hash=sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c \ + --hash=sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f \ + --hash=sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2 \ + --hash=sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901 \ + --hash=sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed \ + --hash=sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a \ + --hash=sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074 \ + --hash=sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc \ + --hash=sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84 \ + --hash=sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083 \ + --hash=sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f \ + --hash=sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c \ + --hash=sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c \ + --hash=sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637 \ + --hash=sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2 \ + --hash=sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82 \ + --hash=sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f \ + --hash=sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce \ + --hash=sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef \ + --hash=sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f \ + --hash=sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611 \ + --hash=sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c \ + --hash=sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76 \ + --hash=sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9 \ + --hash=sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce \ + --hash=sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9 \ + --hash=sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf \ + --hash=sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf \ + --hash=sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9 \ + --hash=sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6 \ + --hash=sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2 \ + --hash=sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a \ + --hash=sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a \ + --hash=sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf \ + --hash=sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738 \ + --hash=sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a \ + --hash=sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4 + # via + # -r spawner/requirements/dev.in + # pytest-cov +distlib==0.3.7 \ + --hash=sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057 \ + --hash=sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8 + # via virtualenv +exceptiongroup==1.1.3 \ + --hash=sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9 \ + --hash=sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3 + # via + # -c spawner/requirements/main.txt + # -r spawner/requirements/dev.in +filelock==3.13.1 \ + --hash=sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e \ + --hash=sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c + # via virtualenv +h11==0.14.0 \ + --hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \ + --hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761 + # via + # -c spawner/requirements/main.txt + # httpcore +httpcore==1.0.1 \ + --hash=sha256:c5e97ef177dca2023d0b9aad98e49507ef5423e9f1d94ffe2cfe250aa28e63b0 \ + --hash=sha256:fce1ddf9b606cfb98132ab58865c3728c52c8e4c3c46e2aabb3674464a186e92 + # via + # -c spawner/requirements/main.txt + # httpx +httpx==0.25.1 \ + --hash=sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a \ + --hash=sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0 + # via + # -c spawner/requirements/main.txt + # respx +identify==2.5.31 \ + --hash=sha256:7736b3c7a28233637e3c36550646fc6389bedd74ae84cb788200cc8e2dd60b75 \ + --hash=sha256:90199cb9e7bd3c5407a9b7e81b4abec4bb9d249991c79439ec8af740afc6293d + # via pre-commit +idna==3.4 \ + --hash=sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4 \ + --hash=sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2 + # via + # -c spawner/requirements/main.txt + # anyio + # httpx +iniconfig==2.0.0 \ + --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ + --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + # via pytest +mypy==1.6.1 \ + --hash=sha256:19f905bcfd9e167159b3d63ecd8cb5e696151c3e59a1742e79bc3bcb540c42c7 \ + --hash=sha256:21a1ad938fee7d2d96ca666c77b7c494c3c5bd88dff792220e1afbebb2925b5e \ + --hash=sha256:40b1844d2e8b232ed92e50a4bd11c48d2daa351f9deee6c194b83bf03e418b0c \ + --hash=sha256:41697773aa0bf53ff917aa077e2cde7aa50254f28750f9b88884acea38a16169 \ + --hash=sha256:49ae115da099dcc0922a7a895c1eec82c1518109ea5c162ed50e3b3594c71208 \ + --hash=sha256:4c46b51de523817a0045b150ed11b56f9fff55f12b9edd0f3ed35b15a2809de0 \ + --hash=sha256:4cbe68ef919c28ea561165206a2dcb68591c50f3bcf777932323bc208d949cf1 \ + --hash=sha256:4d01c00d09a0be62a4ca3f933e315455bde83f37f892ba4b08ce92f3cf44bcc1 \ + --hash=sha256:59a0d7d24dfb26729e0a068639a6ce3500e31d6655df8557156c51c1cb874ce7 \ + --hash=sha256:68351911e85145f582b5aa6cd9ad666c8958bcae897a1bfda8f4940472463c45 \ + --hash=sha256:7274b0c57737bd3476d2229c6389b2ec9eefeb090bbaf77777e9d6b1b5a9d143 \ + --hash=sha256:81af8adaa5e3099469e7623436881eff6b3b06db5ef75e6f5b6d4871263547e5 \ + --hash=sha256:82e469518d3e9a321912955cc702d418773a2fd1e91c651280a1bda10622f02f \ + --hash=sha256:8b27958f8c76bed8edaa63da0739d76e4e9ad4ed325c814f9b3851425582a3cd \ + --hash=sha256:8c223fa57cb154c7eab5156856c231c3f5eace1e0bed9b32a24696b7ba3c3245 \ + --hash=sha256:8f57e6b6927a49550da3d122f0cb983d400f843a8a82e65b3b380d3d7259468f \ + --hash=sha256:925cd6a3b7b55dfba252b7c4561892311c5358c6b5a601847015a1ad4eb7d332 \ + --hash=sha256:a43ef1c8ddfdb9575691720b6352761f3f53d85f1b57d7745701041053deff30 \ + --hash=sha256:a8032e00ce71c3ceb93eeba63963b864bf635a18f6c0c12da6c13c450eedb183 \ + --hash=sha256:b96ae2c1279d1065413965c607712006205a9ac541895004a1e0d4f281f2ff9f \ + --hash=sha256:bb8ccb4724f7d8601938571bf3f24da0da791fe2db7be3d9e79849cb64e0ae85 \ + --hash=sha256:bbaf4662e498c8c2e352da5f5bca5ab29d378895fa2d980630656178bd607c46 \ + --hash=sha256:cfd13d47b29ed3bbaafaff7d8b21e90d827631afda134836962011acb5904b71 \ + --hash=sha256:d4473c22cc296425bbbce7e9429588e76e05bc7342da359d6520b6427bf76660 \ + --hash=sha256:d8fbb68711905f8912e5af474ca8b78d077447d8f3918997fecbf26943ff3cbb \ + --hash=sha256:e5012e5cc2ac628177eaac0e83d622b2dd499e28253d4107a08ecc59ede3fc2c \ + --hash=sha256:eb4f18589d196a4cbe5290b435d135dee96567e07c2b2d43b5c4621b6501531a + # via -r spawner/requirements/dev.in +mypy-extensions==1.0.0 \ + --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \ + --hash=sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782 + # via mypy +nodeenv==1.8.0 \ + --hash=sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2 \ + --hash=sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec + # via pre-commit +packaging==23.2 \ + --hash=sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5 \ + --hash=sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7 + # via + # -c spawner/requirements/main.txt + # pytest + # pytest-sugar +platformdirs==3.11.0 \ + --hash=sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3 \ + --hash=sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e + # via virtualenv +pluggy==1.3.0 \ + --hash=sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12 \ + --hash=sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7 + # via pytest +pre-commit==3.5.0 \ + --hash=sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32 \ + --hash=sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660 + # via -r spawner/requirements/dev.in +pytest==7.4.3 \ + --hash=sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac \ + --hash=sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5 + # via + # -r spawner/requirements/dev.in + # pytest-asyncio + # pytest-cov + # pytest-sugar +pytest-asyncio==0.21.1 \ + --hash=sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d \ + --hash=sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b + # via -r spawner/requirements/dev.in +pytest-cov==4.1.0 \ + --hash=sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6 \ + --hash=sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a + # via -r spawner/requirements/dev.in +pytest-sugar==0.9.7 \ + --hash=sha256:8cb5a4e5f8bbcd834622b0235db9e50432f4cbd71fef55b467fe44e43701e062 \ + --hash=sha256:f1e74c1abfa55f7241cf7088032b6e378566f16b938f3f08905e2cf4494edd46 + # via -r spawner/requirements/dev.in +pyyaml==6.0.1 \ + --hash=sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5 \ + --hash=sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc \ + --hash=sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df \ + --hash=sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741 \ + --hash=sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206 \ + --hash=sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27 \ + --hash=sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595 \ + --hash=sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62 \ + --hash=sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98 \ + --hash=sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696 \ + --hash=sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290 \ + --hash=sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9 \ + --hash=sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d \ + --hash=sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6 \ + --hash=sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867 \ + --hash=sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47 \ + --hash=sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486 \ + --hash=sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6 \ + --hash=sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3 \ + --hash=sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007 \ + --hash=sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938 \ + --hash=sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0 \ + --hash=sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c \ + --hash=sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735 \ + --hash=sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d \ + --hash=sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28 \ + --hash=sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4 \ + --hash=sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba \ + --hash=sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8 \ + --hash=sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5 \ + --hash=sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd \ + --hash=sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3 \ + --hash=sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0 \ + --hash=sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515 \ + --hash=sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c \ + --hash=sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c \ + --hash=sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924 \ + --hash=sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34 \ + --hash=sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43 \ + --hash=sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859 \ + --hash=sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673 \ + --hash=sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54 \ + --hash=sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a \ + --hash=sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b \ + --hash=sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab \ + --hash=sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa \ + --hash=sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c \ + --hash=sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585 \ + --hash=sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d \ + --hash=sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f + # via + # -c spawner/requirements/main.txt + # pre-commit +respx==0.20.2 \ + --hash=sha256:07cf4108b1c88b82010f67d3c831dae33a375c7b436e54d87737c7f9f99be643 \ + --hash=sha256:ab8e1cf6da28a5b2dd883ea617f8130f77f676736e6e9e4a25817ad116a172c9 + # via -r spawner/requirements/dev.in +sniffio==1.3.0 \ + --hash=sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101 \ + --hash=sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384 + # via + # -c spawner/requirements/main.txt + # anyio + # httpx +termcolor==2.3.0 \ + --hash=sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475 \ + --hash=sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a + # via pytest-sugar +tomli==2.0.1 \ + --hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \ + --hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f + # via -r spawner/requirements/dev.in +types-pyyaml==6.0.12.12 \ + --hash=sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062 \ + --hash=sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24 + # via -r spawner/requirements/dev.in +typing-extensions==4.8.0 \ + --hash=sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0 \ + --hash=sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef + # via + # -c spawner/requirements/main.txt + # mypy +virtualenv==20.24.6 \ + --hash=sha256:02ece4f56fbf939dbbc33c0715159951d6bf14aaf5457b092e4548e1382455af \ + --hash=sha256:520d056652454c5098a00c0f073611ccbea4c79089331f60bf9d7ba247bb7381 + # via pre-commit + +# The following packages are considered to be unsafe in a requirements file: +setuptools==68.2.2 \ + --hash=sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87 \ + --hash=sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a + # via + # -c spawner/requirements/main.txt + # nodeenv diff --git a/spawner/requirements/main.in b/spawner/requirements/main.in new file mode 100644 index 000000000..042a51805 --- /dev/null +++ b/spawner/requirements/main.in @@ -0,0 +1,36 @@ +# -*- conf -*- +# +# Editable runtime dependencies (equivalent to install_requires) +# Add direct runtime dependencies here, as well as implicit dependencies +# with constrained versions. +# +# After editing, update requirements/main.txt by running: +# make update-deps + +# We want to explicitly control new versions of JupyterHub. Pin this to the +# same version that Dockerfile uses. +jupyterhub==4.0.2 + +# Dependencies used directly by the code added by this package. +httpx +httpx-sse +PyYAML +traitlets + +# Additional dependencies required by our configuration of JupyterHub. +jupyterhub-idle-culler +psycopg2 + +# Zero-to-JupyterHub unconditionally imports this, so we have to have it +# installed even though we're using a different spawner class. +kubernetes_asyncio + +# These dependencies aren't required in Python 3.11 but are required in 3.10. +# Pin them so that we can regenerate dependencies with Python 3.11 and still +# successfully build a Docker image using Python 3.10, which the base +# JupyterHub image currently uses. +exceptiongroup +ruamel.yaml.clib + +# anyio 4 currently conflicts with FastAPI. +anyio<4 diff --git a/spawner/requirements/main.txt b/spawner/requirements/main.txt new file mode 100644 index 000000000..a393c3924 --- /dev/null +++ b/spawner/requirements/main.txt @@ -0,0 +1,1091 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --allow-unsafe --generate-hashes --output-file=spawner/requirements/main.txt spawner/requirements/main.in +# +aiohttp==3.8.6 \ + --hash=sha256:002f23e6ea8d3dd8d149e569fd580c999232b5fbc601c48d55398fbc2e582e8c \ + --hash=sha256:01770d8c04bd8db568abb636c1fdd4f7140b284b8b3e0b4584f070180c1e5c62 \ + --hash=sha256:0912ed87fee967940aacc5306d3aa8ba3a459fcd12add0b407081fbefc931e53 \ + --hash=sha256:0cccd1de239afa866e4ce5c789b3032442f19c261c7d8a01183fd956b1935349 \ + --hash=sha256:0fa375b3d34e71ccccf172cab401cd94a72de7a8cc01847a7b3386204093bb47 \ + --hash=sha256:13da35c9ceb847732bf5c6c5781dcf4780e14392e5d3b3c689f6d22f8e15ae31 \ + --hash=sha256:14cd52ccf40006c7a6cd34a0f8663734e5363fd981807173faf3a017e202fec9 \ + --hash=sha256:16d330b3b9db87c3883e565340d292638a878236418b23cc8b9b11a054aaa887 \ + --hash=sha256:1bed815f3dc3d915c5c1e556c397c8667826fbc1b935d95b0ad680787896a358 \ + --hash=sha256:1d84166673694841d8953f0a8d0c90e1087739d24632fe86b1a08819168b4566 \ + --hash=sha256:1f13f60d78224f0dace220d8ab4ef1dbc37115eeeab8c06804fec11bec2bbd07 \ + --hash=sha256:229852e147f44da0241954fc6cb910ba074e597f06789c867cb7fb0621e0ba7a \ + --hash=sha256:253bf92b744b3170eb4c4ca2fa58f9c4b87aeb1df42f71d4e78815e6e8b73c9e \ + --hash=sha256:255ba9d6d5ff1a382bb9a578cd563605aa69bec845680e21c44afc2670607a95 \ + --hash=sha256:2817b2f66ca82ee699acd90e05c95e79bbf1dc986abb62b61ec8aaf851e81c93 \ + --hash=sha256:2b8d4e166e600dcfbff51919c7a3789ff6ca8b3ecce16e1d9c96d95dd569eb4c \ + --hash=sha256:2d5b785c792802e7b275c420d84f3397668e9d49ab1cb52bd916b3b3ffcf09ad \ + --hash=sha256:3161ce82ab85acd267c8f4b14aa226047a6bee1e4e6adb74b798bd42c6ae1f80 \ + --hash=sha256:33164093be11fcef3ce2571a0dccd9041c9a93fa3bde86569d7b03120d276c6f \ + --hash=sha256:39a312d0e991690ccc1a61f1e9e42daa519dcc34ad03eb6f826d94c1190190dd \ + --hash=sha256:3b2ab182fc28e7a81f6c70bfbd829045d9480063f5ab06f6e601a3eddbbd49a0 \ + --hash=sha256:3c68330a59506254b556b99a91857428cab98b2f84061260a67865f7f52899f5 \ + --hash=sha256:3f0e27e5b733803333bb2371249f41cf42bae8884863e8e8965ec69bebe53132 \ + --hash=sha256:3f5c7ce535a1d2429a634310e308fb7d718905487257060e5d4598e29dc17f0b \ + --hash=sha256:3fd194939b1f764d6bb05490987bfe104287bbf51b8d862261ccf66f48fb4096 \ + --hash=sha256:41bdc2ba359032e36c0e9de5a3bd00d6fb7ea558a6ce6b70acedf0da86458321 \ + --hash=sha256:41d55fc043954cddbbd82503d9cc3f4814a40bcef30b3569bc7b5e34130718c1 \ + --hash=sha256:42c89579f82e49db436b69c938ab3e1559e5a4409eb8639eb4143989bc390f2f \ + --hash=sha256:45ad816b2c8e3b60b510f30dbd37fe74fd4a772248a52bb021f6fd65dff809b6 \ + --hash=sha256:4ac39027011414dbd3d87f7edb31680e1f430834c8cef029f11c66dad0670aa5 \ + --hash=sha256:4d4cbe4ffa9d05f46a28252efc5941e0462792930caa370a6efaf491f412bc66 \ + --hash=sha256:4fcf3eabd3fd1a5e6092d1242295fa37d0354b2eb2077e6eb670accad78e40e1 \ + --hash=sha256:5d791245a894be071d5ab04bbb4850534261a7d4fd363b094a7b9963e8cdbd31 \ + --hash=sha256:6c43ecfef7deaf0617cee936836518e7424ee12cb709883f2c9a1adda63cc460 \ + --hash=sha256:6c5f938d199a6fdbdc10bbb9447496561c3a9a565b43be564648d81e1102ac22 \ + --hash=sha256:6e2f9cc8e5328f829f6e1fb74a0a3a939b14e67e80832975e01929e320386b34 \ + --hash=sha256:713103a8bdde61d13490adf47171a1039fd880113981e55401a0f7b42c37d071 \ + --hash=sha256:71783b0b6455ac8f34b5ec99d83e686892c50498d5d00b8e56d47f41b38fbe04 \ + --hash=sha256:76b36b3124f0223903609944a3c8bf28a599b2cc0ce0be60b45211c8e9be97f8 \ + --hash=sha256:7bc88fc494b1f0311d67f29fee6fd636606f4697e8cc793a2d912ac5b19aa38d \ + --hash=sha256:7ee912f7e78287516df155f69da575a0ba33b02dd7c1d6614dbc9463f43066e3 \ + --hash=sha256:86f20cee0f0a317c76573b627b954c412ea766d6ada1a9fcf1b805763ae7feeb \ + --hash=sha256:89341b2c19fb5eac30c341133ae2cc3544d40d9b1892749cdd25892bbc6ac951 \ + --hash=sha256:8a9b5a0606faca4f6cc0d338359d6fa137104c337f489cd135bb7fbdbccb1e39 \ + --hash=sha256:8d399dade330c53b4106160f75f55407e9ae7505263ea86f2ccca6bfcbdb4921 \ + --hash=sha256:8e31e9db1bee8b4f407b77fd2507337a0a80665ad7b6c749d08df595d88f1cf5 \ + --hash=sha256:90c72ebb7cb3a08a7f40061079817133f502a160561d0675b0a6adf231382c92 \ + --hash=sha256:918810ef188f84152af6b938254911055a72e0f935b5fbc4c1a4ed0b0584aed1 \ + --hash=sha256:93c15c8e48e5e7b89d5cb4613479d144fda8344e2d886cf694fd36db4cc86865 \ + --hash=sha256:96603a562b546632441926cd1293cfcb5b69f0b4159e6077f7c7dbdfb686af4d \ + --hash=sha256:99c5ac4ad492b4a19fc132306cd57075c28446ec2ed970973bbf036bcda1bcc6 \ + --hash=sha256:9c19b26acdd08dd239e0d3669a3dddafd600902e37881f13fbd8a53943079dbc \ + --hash=sha256:9de50a199b7710fa2904be5a4a9b51af587ab24c8e540a7243ab737b45844543 \ + --hash=sha256:9e2ee0ac5a1f5c7dd3197de309adfb99ac4617ff02b0603fd1e65b07dc772e4b \ + --hash=sha256:a2ece4af1f3c967a4390c284797ab595a9f1bc1130ef8b01828915a05a6ae684 \ + --hash=sha256:a3628b6c7b880b181a3ae0a0683698513874df63783fd89de99b7b7539e3e8a8 \ + --hash=sha256:ad1407db8f2f49329729564f71685557157bfa42b48f4b93e53721a16eb813ed \ + --hash=sha256:b04691bc6601ef47c88f0255043df6f570ada1a9ebef99c34bd0b72866c217ae \ + --hash=sha256:b0cf2a4501bff9330a8a5248b4ce951851e415bdcce9dc158e76cfd55e15085c \ + --hash=sha256:b2fe42e523be344124c6c8ef32a011444e869dc5f883c591ed87f84339de5976 \ + --hash=sha256:b30e963f9e0d52c28f284d554a9469af073030030cef8693106d918b2ca92f54 \ + --hash=sha256:bb54c54510e47a8c7c8e63454a6acc817519337b2b78606c4e840871a3e15349 \ + --hash=sha256:bd111d7fc5591ddf377a408ed9067045259ff2770f37e2d94e6478d0f3fc0c17 \ + --hash=sha256:bdf70bfe5a1414ba9afb9d49f0c912dc524cf60141102f3a11143ba3d291870f \ + --hash=sha256:ca80e1b90a05a4f476547f904992ae81eda5c2c85c66ee4195bb8f9c5fb47f28 \ + --hash=sha256:caf486ac1e689dda3502567eb89ffe02876546599bbf915ec94b1fa424eeffd4 \ + --hash=sha256:ccc360e87341ad47c777f5723f68adbb52b37ab450c8bc3ca9ca1f3e849e5fe2 \ + --hash=sha256:d25036d161c4fe2225d1abff2bd52c34ed0b1099f02c208cd34d8c05729882f0 \ + --hash=sha256:d52d5dc7c6682b720280f9d9db41d36ebe4791622c842e258c9206232251ab2b \ + --hash=sha256:d67f8baed00870aa390ea2590798766256f31dc5ed3ecc737debb6e97e2ede78 \ + --hash=sha256:d76e8b13161a202d14c9584590c4df4d068c9567c99506497bdd67eaedf36403 \ + --hash=sha256:d95fc1bf33a9a81469aa760617b5971331cdd74370d1214f0b3109272c0e1e3c \ + --hash=sha256:de6a1c9f6803b90e20869e6b99c2c18cef5cc691363954c93cb9adeb26d9f3ae \ + --hash=sha256:e1d8cb0b56b3587c5c01de3bf2f600f186da7e7b5f7353d1bf26a8ddca57f965 \ + --hash=sha256:e2a988a0c673c2e12084f5e6ba3392d76c75ddb8ebc6c7e9ead68248101cd446 \ + --hash=sha256:e3f1e3f1a1751bb62b4a1b7f4e435afcdade6c17a4fd9b9d43607cebd242924a \ + --hash=sha256:e6a00ffcc173e765e200ceefb06399ba09c06db97f401f920513a10c803604ca \ + --hash=sha256:e827d48cf802de06d9c935088c2924e3c7e7533377d66b6f31ed175c1620e05e \ + --hash=sha256:ebf3fd9f141700b510d4b190094db0ce37ac6361a6806c153c161dc6c041ccda \ + --hash=sha256:ec00c3305788e04bf6d29d42e504560e159ccaf0be30c09203b468a6c1ccd3b2 \ + --hash=sha256:ec4fd86658c6a8964d75426517dc01cbf840bbf32d055ce64a9e63a40fd7b771 \ + --hash=sha256:efd2fcf7e7b9d7ab16e6b7d54205beded0a9c8566cb30f09c1abe42b4e22bdcb \ + --hash=sha256:f0f03211fd14a6a0aed2997d4b1c013d49fb7b50eeb9ffdf5e51f23cfe2c77fa \ + --hash=sha256:f628dbf3c91e12f4d6c8b3f092069567d8eb17814aebba3d7d60c149391aee3a \ + --hash=sha256:f8ef51e459eb2ad8e7a66c1d6440c808485840ad55ecc3cafefadea47d1b1ba2 \ + --hash=sha256:fc37e9aef10a696a5a4474802930079ccfc14d9f9c10b4662169671ff034b7df \ + --hash=sha256:fdee8405931b0615220e5ddf8cd7edd8592c606a8e4ca2a00704883c396e4479 + # via kubernetes-asyncio +aiosignal==1.3.1 \ + --hash=sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc \ + --hash=sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17 + # via aiohttp +alembic==1.12.1 \ + --hash=sha256:47d52e3dfb03666ed945becb723d6482e52190917fdb47071440cfdba05d92cb \ + --hash=sha256:bca5877e9678b454706347bc10b97cb7d67f300320fa5c3a94423e8266e2823f + # via jupyterhub +anyio==3.7.1 \ + --hash=sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780 \ + --hash=sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5 + # via + # -r spawner/requirements/main.in + # httpx +async-generator==1.10 \ + --hash=sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b \ + --hash=sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144 + # via jupyterhub +async-timeout==4.0.3 \ + --hash=sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f \ + --hash=sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028 + # via aiohttp +attrs==23.1.0 \ + --hash=sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04 \ + --hash=sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015 + # via + # aiohttp + # jsonschema + # referencing +certifi==2023.7.22 \ + --hash=sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082 \ + --hash=sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9 + # via + # httpcore + # httpx + # kubernetes-asyncio + # requests +certipy==0.1.3 \ + --hash=sha256:695704b7716b033375c9a1324d0d30f27110a28895c40151a90ec07ff1032859 \ + --hash=sha256:f272c13bfa9af6b2f3f746329d08adb66af7dd0bbb08fc81175597f25a7284b5 + # via jupyterhub +cffi==1.16.0 \ + --hash=sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc \ + --hash=sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a \ + --hash=sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417 \ + --hash=sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab \ + --hash=sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520 \ + --hash=sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36 \ + --hash=sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743 \ + --hash=sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8 \ + --hash=sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed \ + --hash=sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684 \ + --hash=sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56 \ + --hash=sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324 \ + --hash=sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d \ + --hash=sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235 \ + --hash=sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e \ + --hash=sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088 \ + --hash=sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000 \ + --hash=sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7 \ + --hash=sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e \ + --hash=sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673 \ + --hash=sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c \ + --hash=sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe \ + --hash=sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2 \ + --hash=sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098 \ + --hash=sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8 \ + --hash=sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a \ + --hash=sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0 \ + --hash=sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b \ + --hash=sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896 \ + --hash=sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e \ + --hash=sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9 \ + --hash=sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2 \ + --hash=sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b \ + --hash=sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6 \ + --hash=sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404 \ + --hash=sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f \ + --hash=sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0 \ + --hash=sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4 \ + --hash=sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc \ + --hash=sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936 \ + --hash=sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba \ + --hash=sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872 \ + --hash=sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb \ + --hash=sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614 \ + --hash=sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1 \ + --hash=sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d \ + --hash=sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969 \ + --hash=sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b \ + --hash=sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4 \ + --hash=sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627 \ + --hash=sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956 \ + --hash=sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357 + # via cryptography +charset-normalizer==3.3.2 \ + --hash=sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027 \ + --hash=sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087 \ + --hash=sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786 \ + --hash=sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8 \ + --hash=sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09 \ + --hash=sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185 \ + --hash=sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574 \ + --hash=sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e \ + --hash=sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519 \ + --hash=sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898 \ + --hash=sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269 \ + --hash=sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3 \ + --hash=sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f \ + --hash=sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6 \ + --hash=sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8 \ + --hash=sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a \ + --hash=sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73 \ + --hash=sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc \ + --hash=sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714 \ + --hash=sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2 \ + --hash=sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc \ + --hash=sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce \ + --hash=sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d \ + --hash=sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e \ + --hash=sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6 \ + --hash=sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269 \ + --hash=sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96 \ + --hash=sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d \ + --hash=sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a \ + --hash=sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4 \ + --hash=sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77 \ + --hash=sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d \ + --hash=sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0 \ + --hash=sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed \ + --hash=sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068 \ + --hash=sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac \ + --hash=sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25 \ + --hash=sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8 \ + --hash=sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab \ + --hash=sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26 \ + --hash=sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2 \ + --hash=sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db \ + --hash=sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f \ + --hash=sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5 \ + --hash=sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99 \ + --hash=sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c \ + --hash=sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d \ + --hash=sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811 \ + --hash=sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa \ + --hash=sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a \ + --hash=sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03 \ + --hash=sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b \ + --hash=sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04 \ + --hash=sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c \ + --hash=sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001 \ + --hash=sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458 \ + --hash=sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389 \ + --hash=sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99 \ + --hash=sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985 \ + --hash=sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537 \ + --hash=sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238 \ + --hash=sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f \ + --hash=sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d \ + --hash=sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796 \ + --hash=sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a \ + --hash=sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143 \ + --hash=sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8 \ + --hash=sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c \ + --hash=sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5 \ + --hash=sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5 \ + --hash=sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711 \ + --hash=sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4 \ + --hash=sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6 \ + --hash=sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c \ + --hash=sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7 \ + --hash=sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4 \ + --hash=sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b \ + --hash=sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae \ + --hash=sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12 \ + --hash=sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c \ + --hash=sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae \ + --hash=sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8 \ + --hash=sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887 \ + --hash=sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b \ + --hash=sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4 \ + --hash=sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f \ + --hash=sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5 \ + --hash=sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33 \ + --hash=sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519 \ + --hash=sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561 + # via + # aiohttp + # requests +cryptography==41.0.5 \ + --hash=sha256:0c327cac00f082013c7c9fb6c46b7cc9fa3c288ca702c74773968173bda421bf \ + --hash=sha256:0d2a6a598847c46e3e321a7aef8af1436f11c27f1254933746304ff014664d84 \ + --hash=sha256:227ec057cd32a41c6651701abc0328135e472ed450f47c2766f23267b792a88e \ + --hash=sha256:22892cc830d8b2c89ea60148227631bb96a7da0c1b722f2aac8824b1b7c0b6b8 \ + --hash=sha256:392cb88b597247177172e02da6b7a63deeff1937fa6fec3bbf902ebd75d97ec7 \ + --hash=sha256:3be3ca726e1572517d2bef99a818378bbcf7d7799d5372a46c79c29eb8d166c1 \ + --hash=sha256:573eb7128cbca75f9157dcde974781209463ce56b5804983e11a1c462f0f4e88 \ + --hash=sha256:580afc7b7216deeb87a098ef0674d6ee34ab55993140838b14c9b83312b37b86 \ + --hash=sha256:5a70187954ba7292c7876734183e810b728b4f3965fbe571421cb2434d279179 \ + --hash=sha256:73801ac9736741f220e20435f84ecec75ed70eda90f781a148f1bad546963d81 \ + --hash=sha256:7d208c21e47940369accfc9e85f0de7693d9a5d843c2509b3846b2db170dfd20 \ + --hash=sha256:8254962e6ba1f4d2090c44daf50a547cd5f0bf446dc658a8e5f8156cae0d8548 \ + --hash=sha256:88417bff20162f635f24f849ab182b092697922088b477a7abd6664ddd82291d \ + --hash=sha256:a48e74dad1fb349f3dc1d449ed88e0017d792997a7ad2ec9587ed17405667e6d \ + --hash=sha256:b948e09fe5fb18517d99994184854ebd50b57248736fd4c720ad540560174ec5 \ + --hash=sha256:c707f7afd813478e2019ae32a7c49cd932dd60ab2d2a93e796f68236b7e1fbf1 \ + --hash=sha256:d38e6031e113b7421db1de0c1b1f7739564a88f1684c6b89234fbf6c11b75147 \ + --hash=sha256:d3977f0e276f6f5bf245c403156673db103283266601405376f075c849a0b936 \ + --hash=sha256:da6a0ff8f1016ccc7477e6339e1d50ce5f59b88905585f77193ebd5068f1e797 \ + --hash=sha256:e270c04f4d9b5671ebcc792b3ba5d4488bf7c42c3c241a3748e2599776f29696 \ + --hash=sha256:e886098619d3815e0ad5790c973afeee2c0e6e04b4da90b88e6bd06e2a0b1b72 \ + --hash=sha256:ec3b055ff8f1dce8e6ef28f626e0972981475173d7973d63f271b29c8a2897da \ + --hash=sha256:fba1e91467c65fe64a82c689dc6cf58151158993b13eb7a7f3f4b7f395636723 + # via pyopenssl +exceptiongroup==1.1.3 \ + --hash=sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9 \ + --hash=sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3 + # via -r spawner/requirements/main.in +frozenlist==1.4.0 \ + --hash=sha256:007df07a6e3eb3e33e9a1fe6a9db7af152bbd8a185f9aaa6ece10a3529e3e1c6 \ + --hash=sha256:008eb8b31b3ea6896da16c38c1b136cb9fec9e249e77f6211d479db79a4eaf01 \ + --hash=sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251 \ + --hash=sha256:0c7c1b47859ee2cac3846fde1c1dc0f15da6cec5a0e5c72d101e0f83dcb67ff9 \ + --hash=sha256:0e5c8764c7829343d919cc2dfc587a8db01c4f70a4ebbc49abde5d4b158b007b \ + --hash=sha256:10ff5faaa22786315ef57097a279b833ecab1a0bfb07d604c9cbb1c4cdc2ed87 \ + --hash=sha256:17ae5cd0f333f94f2e03aaf140bb762c64783935cc764ff9c82dff626089bebf \ + --hash=sha256:19488c57c12d4e8095a922f328df3f179c820c212940a498623ed39160bc3c2f \ + --hash=sha256:1a0848b52815006ea6596c395f87449f693dc419061cc21e970f139d466dc0a0 \ + --hash=sha256:1e78fb68cf9c1a6aa4a9a12e960a5c9dfbdb89b3695197aa7064705662515de2 \ + --hash=sha256:261b9f5d17cac914531331ff1b1d452125bf5daa05faf73b71d935485b0c510b \ + --hash=sha256:2b8bcf994563466db019fab287ff390fffbfdb4f905fc77bc1c1d604b1c689cc \ + --hash=sha256:38461d02d66de17455072c9ba981d35f1d2a73024bee7790ac2f9e361ef1cd0c \ + --hash=sha256:490132667476f6781b4c9458298b0c1cddf237488abd228b0b3650e5ecba7467 \ + --hash=sha256:491e014f5c43656da08958808588cc6c016847b4360e327a62cb308c791bd2d9 \ + --hash=sha256:515e1abc578dd3b275d6a5114030b1330ba044ffba03f94091842852f806f1c1 \ + --hash=sha256:556de4430ce324c836789fa4560ca62d1591d2538b8ceb0b4f68fb7b2384a27a \ + --hash=sha256:5833593c25ac59ede40ed4de6d67eb42928cca97f26feea219f21d0ed0959b79 \ + --hash=sha256:6221d84d463fb110bdd7619b69cb43878a11d51cbb9394ae3105d082d5199167 \ + --hash=sha256:6918d49b1f90821e93069682c06ffde41829c346c66b721e65a5c62b4bab0300 \ + --hash=sha256:6c38721585f285203e4b4132a352eb3daa19121a035f3182e08e437cface44bf \ + --hash=sha256:71932b597f9895f011f47f17d6428252fc728ba2ae6024e13c3398a087c2cdea \ + --hash=sha256:7211ef110a9194b6042449431e08c4d80c0481e5891e58d429df5899690511c2 \ + --hash=sha256:764226ceef3125e53ea2cb275000e309c0aa5464d43bd72abd661e27fffc26ab \ + --hash=sha256:7645a8e814a3ee34a89c4a372011dcd817964ce8cb273c8ed6119d706e9613e3 \ + --hash=sha256:76d4711f6f6d08551a7e9ef28c722f4a50dd0fc204c56b4bcd95c6cc05ce6fbb \ + --hash=sha256:7f4f399d28478d1f604c2ff9119907af9726aed73680e5ed1ca634d377abb087 \ + --hash=sha256:88f7bc0fcca81f985f78dd0fa68d2c75abf8272b1f5c323ea4a01a4d7a614efc \ + --hash=sha256:8d0edd6b1c7fb94922bf569c9b092ee187a83f03fb1a63076e7774b60f9481a8 \ + --hash=sha256:901289d524fdd571be1c7be054f48b1f88ce8dddcbdf1ec698b27d4b8b9e5d62 \ + --hash=sha256:93ea75c050c5bb3d98016b4ba2497851eadf0ac154d88a67d7a6816206f6fa7f \ + --hash=sha256:981b9ab5a0a3178ff413bca62526bb784249421c24ad7381e39d67981be2c326 \ + --hash=sha256:9ac08e601308e41eb533f232dbf6b7e4cea762f9f84f6357136eed926c15d12c \ + --hash=sha256:a02eb8ab2b8f200179b5f62b59757685ae9987996ae549ccf30f983f40602431 \ + --hash=sha256:a0c6da9aee33ff0b1a451e867da0c1f47408112b3391dd43133838339e410963 \ + --hash=sha256:a6c8097e01886188e5be3e6b14e94ab365f384736aa1fca6a0b9e35bd4a30bc7 \ + --hash=sha256:aa384489fefeb62321b238e64c07ef48398fe80f9e1e6afeff22e140e0850eef \ + --hash=sha256:ad2a9eb6d9839ae241701d0918f54c51365a51407fd80f6b8289e2dfca977cc3 \ + --hash=sha256:b206646d176a007466358aa21d85cd8600a415c67c9bd15403336c331a10d956 \ + --hash=sha256:b826d97e4276750beca7c8f0f1a4938892697a6bcd8ec8217b3312dad6982781 \ + --hash=sha256:b89ac9768b82205936771f8d2eb3ce88503b1556324c9f903e7156669f521472 \ + --hash=sha256:bd7bd3b3830247580de99c99ea2a01416dfc3c34471ca1298bccabf86d0ff4dc \ + --hash=sha256:bdf1847068c362f16b353163391210269e4f0569a3c166bc6a9f74ccbfc7e839 \ + --hash=sha256:c11b0746f5d946fecf750428a95f3e9ebe792c1ee3b1e96eeba145dc631a9672 \ + --hash=sha256:c5374b80521d3d3f2ec5572e05adc94601985cc526fb276d0c8574a6d749f1b3 \ + --hash=sha256:ca265542ca427bf97aed183c1676e2a9c66942e822b14dc6e5f42e038f92a503 \ + --hash=sha256:ce31ae3e19f3c902de379cf1323d90c649425b86de7bbdf82871b8a2a0615f3d \ + --hash=sha256:ceb6ec0a10c65540421e20ebd29083c50e6d1143278746a4ef6bcf6153171eb8 \ + --hash=sha256:d081f13b095d74b67d550de04df1c756831f3b83dc9881c38985834387487f1b \ + --hash=sha256:d5655a942f5f5d2c9ed93d72148226d75369b4f6952680211972a33e59b1dfdc \ + --hash=sha256:d5a32087d720c608f42caed0ef36d2b3ea61a9d09ee59a5142d6070da9041b8f \ + --hash=sha256:d6484756b12f40003c6128bfcc3fa9f0d49a687e171186c2d85ec82e3758c559 \ + --hash=sha256:dd65632acaf0d47608190a71bfe46b209719bf2beb59507db08ccdbe712f969b \ + --hash=sha256:de343e75f40e972bae1ef6090267f8260c1446a1695e77096db6cfa25e759a95 \ + --hash=sha256:e29cda763f752553fa14c68fb2195150bfab22b352572cb36c43c47bedba70eb \ + --hash=sha256:e41f3de4df3e80de75845d3e743b3f1c4c8613c3997a912dbf0229fc61a8b963 \ + --hash=sha256:e66d2a64d44d50d2543405fb183a21f76b3b5fd16f130f5c99187c3fb4e64919 \ + --hash=sha256:e74b0506fa5aa5598ac6a975a12aa8928cbb58e1f5ac8360792ef15de1aa848f \ + --hash=sha256:f0ed05f5079c708fe74bf9027e95125334b6978bf07fd5ab923e9e55e5fbb9d3 \ + --hash=sha256:f61e2dc5ad442c52b4887f1fdc112f97caeff4d9e6ebe78879364ac59f1663e1 \ + --hash=sha256:fec520865f42e5c7f050c2a79038897b1c7d1595e907a9e08e3353293ffc948e + # via + # aiohttp + # aiosignal +greenlet==3.0.1 \ + --hash=sha256:0a02d259510b3630f330c86557331a3b0e0c79dac3d166e449a39363beaae174 \ + --hash=sha256:0b6f9f8ca7093fd4433472fd99b5650f8a26dcd8ba410e14094c1e44cd3ceddd \ + --hash=sha256:100f78a29707ca1525ea47388cec8a049405147719f47ebf3895e7509c6446aa \ + --hash=sha256:1757936efea16e3f03db20efd0cd50a1c86b06734f9f7338a90c4ba85ec2ad5a \ + --hash=sha256:19075157a10055759066854a973b3d1325d964d498a805bb68a1f9af4aaef8ec \ + --hash=sha256:19bbdf1cce0346ef7341705d71e2ecf6f41a35c311137f29b8a2dc2341374565 \ + --hash=sha256:20107edf7c2c3644c67c12205dc60b1bb11d26b2610b276f97d666110d1b511d \ + --hash=sha256:22f79120a24aeeae2b4471c711dcf4f8c736a2bb2fabad2a67ac9a55ea72523c \ + --hash=sha256:2847e5d7beedb8d614186962c3d774d40d3374d580d2cbdab7f184580a39d234 \ + --hash=sha256:28e89e232c7593d33cac35425b58950789962011cc274aa43ef8865f2e11f46d \ + --hash=sha256:329c5a2e5a0ee942f2992c5e3ff40be03e75f745f48847f118a3cfece7a28546 \ + --hash=sha256:337322096d92808f76ad26061a8f5fccb22b0809bea39212cd6c406f6a7060d2 \ + --hash=sha256:3fcc780ae8edbb1d050d920ab44790201f027d59fdbd21362340a85c79066a74 \ + --hash=sha256:41bdeeb552d814bcd7fb52172b304898a35818107cc8778b5101423c9017b3de \ + --hash=sha256:4eddd98afc726f8aee1948858aed9e6feeb1758889dfd869072d4465973f6bfd \ + --hash=sha256:52e93b28db27ae7d208748f45d2db8a7b6a380e0d703f099c949d0f0d80b70e9 \ + --hash=sha256:55d62807f1c5a1682075c62436702aaba941daa316e9161e4b6ccebbbf38bda3 \ + --hash=sha256:5805e71e5b570d490938d55552f5a9e10f477c19400c38bf1d5190d760691846 \ + --hash=sha256:599daf06ea59bfedbec564b1692b0166a0045f32b6f0933b0dd4df59a854caf2 \ + --hash=sha256:60d5772e8195f4e9ebf74046a9121bbb90090f6550f81d8956a05387ba139353 \ + --hash=sha256:696d8e7d82398e810f2b3622b24e87906763b6ebfd90e361e88eb85b0e554dc8 \ + --hash=sha256:6e6061bf1e9565c29002e3c601cf68569c450be7fc3f7336671af7ddb4657166 \ + --hash=sha256:80ac992f25d10aaebe1ee15df45ca0d7571d0f70b645c08ec68733fb7a020206 \ + --hash=sha256:816bd9488a94cba78d93e1abb58000e8266fa9cc2aa9ccdd6eb0696acb24005b \ + --hash=sha256:85d2b77e7c9382f004b41d9c72c85537fac834fb141b0296942d52bf03fe4a3d \ + --hash=sha256:87c8ceb0cf8a5a51b8008b643844b7f4a8264a2c13fcbcd8a8316161725383fe \ + --hash=sha256:89ee2e967bd7ff85d84a2de09df10e021c9b38c7d91dead95b406ed6350c6997 \ + --hash=sha256:8bef097455dea90ffe855286926ae02d8faa335ed8e4067326257cb571fc1445 \ + --hash=sha256:8d11ebbd679e927593978aa44c10fc2092bc454b7d13fdc958d3e9d508aba7d0 \ + --hash=sha256:91e6c7db42638dc45cf2e13c73be16bf83179f7859b07cfc139518941320be96 \ + --hash=sha256:97e7ac860d64e2dcba5c5944cfc8fa9ea185cd84061c623536154d5a89237884 \ + --hash=sha256:990066bff27c4fcf3b69382b86f4c99b3652bab2a7e685d968cd4d0cfc6f67c6 \ + --hash=sha256:9fbc5b8f3dfe24784cee8ce0be3da2d8a79e46a276593db6868382d9c50d97b1 \ + --hash=sha256:ac4a39d1abae48184d420aa8e5e63efd1b75c8444dd95daa3e03f6c6310e9619 \ + --hash=sha256:b2c02d2ad98116e914d4f3155ffc905fd0c025d901ead3f6ed07385e19122c94 \ + --hash=sha256:b2d3337dcfaa99698aa2377c81c9ca72fcd89c07e7eb62ece3f23a3fe89b2ce4 \ + --hash=sha256:b489c36d1327868d207002391f662a1d163bdc8daf10ab2e5f6e41b9b96de3b1 \ + --hash=sha256:b641161c302efbb860ae6b081f406839a8b7d5573f20a455539823802c655f63 \ + --hash=sha256:b8ba29306c5de7717b5761b9ea74f9c72b9e2b834e24aa984da99cbfc70157fd \ + --hash=sha256:b9934adbd0f6e476f0ecff3c94626529f344f57b38c9a541f87098710b18af0a \ + --hash=sha256:ce85c43ae54845272f6f9cd8320d034d7a946e9773c693b27d620edec825e376 \ + --hash=sha256:cf868e08690cb89360eebc73ba4be7fb461cfbc6168dd88e2fbbe6f31812cd57 \ + --hash=sha256:d2905ce1df400360463c772b55d8e2518d0e488a87cdea13dd2c71dcb2a1fa16 \ + --hash=sha256:d57e20ba591727da0c230ab2c3f200ac9d6d333860d85348816e1dca4cc4792e \ + --hash=sha256:d6a8c9d4f8692917a3dc7eb25a6fb337bff86909febe2f793ec1928cd97bedfc \ + --hash=sha256:d923ff276f1c1f9680d32832f8d6c040fe9306cbfb5d161b0911e9634be9ef0a \ + --hash=sha256:daa7197b43c707462f06d2c693ffdbb5991cbb8b80b5b984007de431493a319c \ + --hash=sha256:dbd4c177afb8a8d9ba348d925b0b67246147af806f0b104af4d24f144d461cd5 \ + --hash=sha256:dc4d815b794fd8868c4d67602692c21bf5293a75e4b607bb92a11e821e2b859a \ + --hash=sha256:e9d21aaa84557d64209af04ff48e0ad5e28c5cca67ce43444e939579d085da72 \ + --hash=sha256:ea6b8aa9e08eea388c5f7a276fabb1d4b6b9d6e4ceb12cc477c3d352001768a9 \ + --hash=sha256:eabe7090db68c981fca689299c2d116400b553f4b713266b130cfc9e2aa9c5a9 \ + --hash=sha256:f2f6d303f3dee132b322a14cd8765287b8f86cdc10d2cb6a6fae234ea488888e \ + --hash=sha256:f33f3258aae89da191c6ebaa3bc517c6c4cbc9b9f689e5d8452f7aedbb913fa8 \ + --hash=sha256:f7bfb769f7efa0eefcd039dd19d843a4fbfbac52f1878b1da2ed5793ec9b1a65 \ + --hash=sha256:f89e21afe925fcfa655965ca8ea10f24773a1791400989ff32f467badfe4a064 \ + --hash=sha256:fa24255ae3c0ab67e613556375a4341af04a084bd58764731972bcbc8baeba36 + # via sqlalchemy +h11==0.14.0 \ + --hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \ + --hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761 + # via httpcore +httpcore==1.0.1 \ + --hash=sha256:c5e97ef177dca2023d0b9aad98e49507ef5423e9f1d94ffe2cfe250aa28e63b0 \ + --hash=sha256:fce1ddf9b606cfb98132ab58865c3728c52c8e4c3c46e2aabb3674464a186e92 + # via httpx +httpx==0.25.1 \ + --hash=sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a \ + --hash=sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0 + # via -r spawner/requirements/main.in +httpx-sse==0.3.1 \ + --hash=sha256:3bb3289b2867f50cbdb2fee3eeeefecb1e86653122e164faac0023f1ffc88aea \ + --hash=sha256:7376dd88732892f9b6b549ac0ad05a8e2341172fe7dcf9f8f9c8050934297316 + # via -r spawner/requirements/main.in +idna==3.4 \ + --hash=sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4 \ + --hash=sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2 + # via + # anyio + # httpx + # requests + # yarl +jinja2==3.1.2 \ + --hash=sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852 \ + --hash=sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61 + # via jupyterhub +jsonschema==4.19.2 \ + --hash=sha256:c9ff4d7447eed9592c23a12ccee508baf0dd0d59650615e847feb6cdca74f392 \ + --hash=sha256:eee9e502c788e89cb166d4d37f43084e3b64ab405c795c03d343a4dbc2c810fc + # via jupyter-telemetry +jsonschema-specifications==2023.7.1 \ + --hash=sha256:05adf340b659828a004220a9613be00fa3f223f2b82002e273dee62fd50524b1 \ + --hash=sha256:c91a50404e88a1f6ba40636778e2ee08f6e24c5613fe4c53ac24578a5a7f72bb + # via jsonschema +jupyter-telemetry==0.1.0 \ + --hash=sha256:1de3e423b23aa40ca4a4238d65c56dda544061ff5aedc3f7647220ed7e3b9589 \ + --hash=sha256:445c613ae3df70d255fe3de202f936bba8b77b4055c43207edf22468ac875314 + # via jupyterhub +jupyterhub==4.0.2 \ + --hash=sha256:2f389e7d3067e1b11bb4091719048eedecee161039fd2e5b025d031f7ab23c62 \ + --hash=sha256:d4e450eed8d90dfbcf0eca08f00f2093a0bce74dc51f7cfc0b7057f602341a1d + # via -r spawner/requirements/main.in +jupyterhub-idle-culler==1.2.1 \ + --hash=sha256:c84e45a51932a34bd95c08b3b6e8330fb0ee3391fa4d0f1ac0d4458a40492fd9 \ + --hash=sha256:d80822982d2590cb876d01849cee028943fdbdcc991c0f02fce45af3f40dd415 + # via -r spawner/requirements/main.in +kubernetes-asyncio==28.2.0 \ + --hash=sha256:31d36b86fa151cb656b8c8de28f1f869fb05b0d7cfbc3a08e60f541ec7077b30 \ + --hash=sha256:9399790ea4a41fafd5cef4017e1d8dc3e16502fe16825be0bb4f07a6deb05345 + # via -r spawner/requirements/main.in +mako==1.2.4 \ + --hash=sha256:c97c79c018b9165ac9922ae4f32da095ffd3c4e6872b45eded42926deea46818 \ + --hash=sha256:d60a3903dc3bb01a18ad6a89cdbe2e4eadc69c0bc8ef1e3773ba53d44c3f7a34 + # via alembic +markupsafe==2.1.3 \ + --hash=sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e \ + --hash=sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e \ + --hash=sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431 \ + --hash=sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686 \ + --hash=sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c \ + --hash=sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559 \ + --hash=sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc \ + --hash=sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb \ + --hash=sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939 \ + --hash=sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c \ + --hash=sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0 \ + --hash=sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4 \ + --hash=sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9 \ + --hash=sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575 \ + --hash=sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba \ + --hash=sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d \ + --hash=sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd \ + --hash=sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3 \ + --hash=sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00 \ + --hash=sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155 \ + --hash=sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac \ + --hash=sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52 \ + --hash=sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f \ + --hash=sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8 \ + --hash=sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b \ + --hash=sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007 \ + --hash=sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24 \ + --hash=sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea \ + --hash=sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198 \ + --hash=sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0 \ + --hash=sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee \ + --hash=sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be \ + --hash=sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2 \ + --hash=sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1 \ + --hash=sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707 \ + --hash=sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6 \ + --hash=sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c \ + --hash=sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58 \ + --hash=sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823 \ + --hash=sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779 \ + --hash=sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636 \ + --hash=sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c \ + --hash=sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad \ + --hash=sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee \ + --hash=sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc \ + --hash=sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2 \ + --hash=sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48 \ + --hash=sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7 \ + --hash=sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e \ + --hash=sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b \ + --hash=sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa \ + --hash=sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5 \ + --hash=sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e \ + --hash=sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb \ + --hash=sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9 \ + --hash=sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57 \ + --hash=sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc \ + --hash=sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc \ + --hash=sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2 \ + --hash=sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11 + # via + # jinja2 + # mako +multidict==6.0.4 \ + --hash=sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9 \ + --hash=sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8 \ + --hash=sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03 \ + --hash=sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710 \ + --hash=sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161 \ + --hash=sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664 \ + --hash=sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569 \ + --hash=sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067 \ + --hash=sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313 \ + --hash=sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706 \ + --hash=sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2 \ + --hash=sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636 \ + --hash=sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49 \ + --hash=sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93 \ + --hash=sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603 \ + --hash=sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0 \ + --hash=sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60 \ + --hash=sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4 \ + --hash=sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e \ + --hash=sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1 \ + --hash=sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60 \ + --hash=sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951 \ + --hash=sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc \ + --hash=sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe \ + --hash=sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95 \ + --hash=sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d \ + --hash=sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8 \ + --hash=sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed \ + --hash=sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2 \ + --hash=sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775 \ + --hash=sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87 \ + --hash=sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c \ + --hash=sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2 \ + --hash=sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98 \ + --hash=sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3 \ + --hash=sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe \ + --hash=sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78 \ + --hash=sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660 \ + --hash=sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176 \ + --hash=sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e \ + --hash=sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988 \ + --hash=sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c \ + --hash=sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c \ + --hash=sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0 \ + --hash=sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449 \ + --hash=sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f \ + --hash=sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde \ + --hash=sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5 \ + --hash=sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d \ + --hash=sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac \ + --hash=sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a \ + --hash=sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9 \ + --hash=sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca \ + --hash=sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11 \ + --hash=sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35 \ + --hash=sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063 \ + --hash=sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b \ + --hash=sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982 \ + --hash=sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258 \ + --hash=sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1 \ + --hash=sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52 \ + --hash=sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480 \ + --hash=sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7 \ + --hash=sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461 \ + --hash=sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d \ + --hash=sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc \ + --hash=sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779 \ + --hash=sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a \ + --hash=sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547 \ + --hash=sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0 \ + --hash=sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171 \ + --hash=sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf \ + --hash=sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d \ + --hash=sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba + # via + # aiohttp + # yarl +oauthlib==3.2.2 \ + --hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \ + --hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918 + # via jupyterhub +packaging==23.2 \ + --hash=sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5 \ + --hash=sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7 + # via jupyterhub +pamela==1.1.0 \ + --hash=sha256:d4b139fe600e192e176a2a368059207a6bffa0e7879879b13f4fcba0163481be \ + --hash=sha256:f4534bba9645665b01adfce0134772b0147faea72c278f67a1a732e7ebd46ec6 + # via jupyterhub +prometheus-client==0.18.0 \ + --hash=sha256:35f7a8c22139e2bb7ca5a698e92d38145bc8dc74c1c0bf56f25cca886a764e17 \ + --hash=sha256:8de3ae2755f890826f4b6479e5571d4f74ac17a81345fe69a6778fdb92579184 + # via jupyterhub +psycopg2==2.9.9 \ + --hash=sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981 \ + --hash=sha256:38a8dcc6856f569068b47de286b472b7c473ac7977243593a288ebce0dc89516 \ + --hash=sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3 \ + --hash=sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa \ + --hash=sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a \ + --hash=sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693 \ + --hash=sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372 \ + --hash=sha256:bac58c024c9922c23550af2a581998624d6e02350f4ae9c5f0bc642c633a2d5e \ + --hash=sha256:c92811b2d4c9b6ea0285942b2e7cac98a59e166d59c588fe5cfe1eda58e72d59 \ + --hash=sha256:d1454bde93fb1e224166811694d600e746430c006fbb031ea06ecc2ea41bf156 \ + --hash=sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024 \ + --hash=sha256:de80739447af31525feddeb8effd640782cf5998e1a4e9192ebdf829717e3913 \ + --hash=sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c + # via -r spawner/requirements/main.in +pycparser==2.21 \ + --hash=sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9 \ + --hash=sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206 + # via cffi +pyopenssl==23.3.0 \ + --hash=sha256:6756834481d9ed5470f4a9393455154bc92fe7a64b7bc6ee2c804e78c52099b2 \ + --hash=sha256:6b2cba5cc46e822750ec3e5a81ee12819850b11303630d575e98108a079c2b12 + # via certipy +python-dateutil==2.8.2 \ + --hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \ + --hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9 + # via + # jupyterhub + # jupyterhub-idle-culler + # kubernetes-asyncio +python-json-logger==2.0.7 \ + --hash=sha256:23e7ec02d34237c5aa1e29a070193a4ea87583bb4e7f8fd06d3de8264c4b2e1c \ + --hash=sha256:f380b826a991ebbe3de4d897aeec42760035ac760345e57b812938dc8b35e2bd + # via jupyter-telemetry +pyyaml==6.0.1 \ + --hash=sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5 \ + --hash=sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc \ + --hash=sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df \ + --hash=sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741 \ + --hash=sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206 \ + --hash=sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27 \ + --hash=sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595 \ + --hash=sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62 \ + --hash=sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98 \ + --hash=sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696 \ + --hash=sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290 \ + --hash=sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9 \ + --hash=sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d \ + --hash=sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6 \ + --hash=sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867 \ + --hash=sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47 \ + --hash=sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486 \ + --hash=sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6 \ + --hash=sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3 \ + --hash=sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007 \ + --hash=sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938 \ + --hash=sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0 \ + --hash=sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c \ + --hash=sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735 \ + --hash=sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d \ + --hash=sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28 \ + --hash=sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4 \ + --hash=sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba \ + --hash=sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8 \ + --hash=sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5 \ + --hash=sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd \ + --hash=sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3 \ + --hash=sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0 \ + --hash=sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515 \ + --hash=sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c \ + --hash=sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c \ + --hash=sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924 \ + --hash=sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34 \ + --hash=sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43 \ + --hash=sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859 \ + --hash=sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673 \ + --hash=sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54 \ + --hash=sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a \ + --hash=sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b \ + --hash=sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab \ + --hash=sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa \ + --hash=sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c \ + --hash=sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585 \ + --hash=sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d \ + --hash=sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f + # via + # -r spawner/requirements/main.in + # kubernetes-asyncio +referencing==0.30.2 \ + --hash=sha256:449b6669b6121a9e96a7f9e410b245d471e8d48964c67113ce9afe50c8dd7bdf \ + --hash=sha256:794ad8003c65938edcdbc027f1933215e0d0ccc0291e3ce20a4d87432b59efc0 + # via + # jsonschema + # jsonschema-specifications +requests==2.31.0 \ + --hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \ + --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1 + # via jupyterhub +rpds-py==0.10.6 \ + --hash=sha256:023574366002bf1bd751ebaf3e580aef4a468b3d3c216d2f3f7e16fdabd885ed \ + --hash=sha256:031f76fc87644a234883b51145e43985aa2d0c19b063e91d44379cd2786144f8 \ + --hash=sha256:052a832078943d2b2627aea0d19381f607fe331cc0eb5df01991268253af8417 \ + --hash=sha256:0699ab6b8c98df998c3eacf51a3b25864ca93dab157abe358af46dc95ecd9801 \ + --hash=sha256:0713631d6e2d6c316c2f7b9320a34f44abb644fc487b77161d1724d883662e31 \ + --hash=sha256:0774a46b38e70fdde0c6ded8d6d73115a7c39d7839a164cc833f170bbf539116 \ + --hash=sha256:0898173249141ee99ffcd45e3829abe7bcee47d941af7434ccbf97717df020e5 \ + --hash=sha256:09586f51a215d17efdb3a5f090d7cbf1633b7f3708f60a044757a5d48a83b393 \ + --hash=sha256:102eac53bb0bf0f9a275b438e6cf6904904908562a1463a6fc3323cf47d7a532 \ + --hash=sha256:10f32b53f424fc75ff7b713b2edb286fdbfc94bf16317890260a81c2c00385dc \ + --hash=sha256:150eec465dbc9cbca943c8e557a21afdcf9bab8aaabf386c44b794c2f94143d2 \ + --hash=sha256:1d7360573f1e046cb3b0dceeb8864025aa78d98be4bb69f067ec1c40a9e2d9df \ + --hash=sha256:1f36a9d751f86455dc5278517e8b65580eeee37d61606183897f122c9e51cef3 \ + --hash=sha256:24656dc36f866c33856baa3ab309da0b6a60f37d25d14be916bd3e79d9f3afcf \ + --hash=sha256:25860ed5c4e7f5e10c496ea78af46ae8d8468e0be745bd233bab9ca99bfd2647 \ + --hash=sha256:26857f0f44f0e791f4a266595a7a09d21f6b589580ee0585f330aaccccb836e3 \ + --hash=sha256:2bb2e4826be25e72013916eecd3d30f66fd076110de09f0e750163b416500721 \ + --hash=sha256:2f6da6d842195fddc1cd34c3da8a40f6e99e4a113918faa5e60bf132f917c247 \ + --hash=sha256:30adb75ecd7c2a52f5e76af50644b3e0b5ba036321c390b8e7ec1bb2a16dd43c \ + --hash=sha256:3339eca941568ed52d9ad0f1b8eb9fe0958fa245381747cecf2e9a78a5539c42 \ + --hash=sha256:34ad87a831940521d462ac11f1774edf867c34172010f5390b2f06b85dcc6014 \ + --hash=sha256:3777cc9dea0e6c464e4b24760664bd8831738cc582c1d8aacf1c3f546bef3f65 \ + --hash=sha256:3953c6926a63f8ea5514644b7afb42659b505ece4183fdaaa8f61d978754349e \ + --hash=sha256:3c4eff26eddac49d52697a98ea01b0246e44ca82ab09354e94aae8823e8bda02 \ + --hash=sha256:40578a6469e5d1df71b006936ce95804edb5df47b520c69cf5af264d462f2cbb \ + --hash=sha256:40f93086eef235623aa14dbddef1b9fb4b22b99454cb39a8d2e04c994fb9868c \ + --hash=sha256:4134aa2342f9b2ab6c33d5c172e40f9ef802c61bb9ca30d21782f6e035ed0043 \ + --hash=sha256:442626328600bde1d09dc3bb00434f5374948838ce75c41a52152615689f9403 \ + --hash=sha256:4a5ee600477b918ab345209eddafde9f91c0acd931f3776369585a1c55b04c57 \ + --hash=sha256:4ce5a708d65a8dbf3748d2474b580d606b1b9f91b5c6ab2a316e0b0cf7a4ba50 \ + --hash=sha256:516a611a2de12fbea70c78271e558f725c660ce38e0006f75139ba337d56b1f6 \ + --hash=sha256:52c215eb46307c25f9fd2771cac8135d14b11a92ae48d17968eda5aa9aaf5071 \ + --hash=sha256:53c43e10d398e365da2d4cc0bcaf0854b79b4c50ee9689652cdc72948e86f487 \ + --hash=sha256:5752b761902cd15073a527b51de76bbae63d938dc7c5c4ad1e7d8df10e765138 \ + --hash=sha256:5e8a78bd4879bff82daef48c14d5d4057f6856149094848c3ed0ecaf49f5aec2 \ + --hash=sha256:5ed505ec6305abd2c2c9586a7b04fbd4baf42d4d684a9c12ec6110deefe2a063 \ + --hash=sha256:5ee97c683eaface61d38ec9a489e353d36444cdebb128a27fe486a291647aff6 \ + --hash=sha256:61fa268da6e2e1cd350739bb61011121fa550aa2545762e3dc02ea177ee4de35 \ + --hash=sha256:64ccc28683666672d7c166ed465c09cee36e306c156e787acef3c0c62f90da5a \ + --hash=sha256:66414dafe4326bca200e165c2e789976cab2587ec71beb80f59f4796b786a238 \ + --hash=sha256:68fe9199184c18d997d2e4293b34327c0009a78599ce703e15cd9a0f47349bba \ + --hash=sha256:6a555ae3d2e61118a9d3e549737bb4a56ff0cec88a22bd1dfcad5b4e04759175 \ + --hash=sha256:6bdc11f9623870d75692cc33c59804b5a18d7b8a4b79ef0b00b773a27397d1f6 \ + --hash=sha256:6cf4393c7b41abbf07c88eb83e8af5013606b1cdb7f6bc96b1b3536b53a574b8 \ + --hash=sha256:6eef672de005736a6efd565577101277db6057f65640a813de6c2707dc69f396 \ + --hash=sha256:734c41f9f57cc28658d98270d3436dba65bed0cfc730d115b290e970150c540d \ + --hash=sha256:73e0a78a9b843b8c2128028864901f55190401ba38aae685350cf69b98d9f7c9 \ + --hash=sha256:775049dfa63fb58293990fc59473e659fcafd953bba1d00fc5f0631a8fd61977 \ + --hash=sha256:7854a207ef77319ec457c1eb79c361b48807d252d94348305db4f4b62f40f7f3 \ + --hash=sha256:78ca33811e1d95cac8c2e49cb86c0fb71f4d8409d8cbea0cb495b6dbddb30a55 \ + --hash=sha256:79edd779cfc46b2e15b0830eecd8b4b93f1a96649bcb502453df471a54ce7977 \ + --hash=sha256:7bf347b495b197992efc81a7408e9a83b931b2f056728529956a4d0858608b80 \ + --hash=sha256:7fde6d0e00b2fd0dbbb40c0eeec463ef147819f23725eda58105ba9ca48744f4 \ + --hash=sha256:81de24a1c51cfb32e1fbf018ab0bdbc79c04c035986526f76c33e3f9e0f3356c \ + --hash=sha256:879fb24304ead6b62dbe5034e7b644b71def53c70e19363f3c3be2705c17a3b4 \ + --hash=sha256:8e7f2219cb72474571974d29a191714d822e58be1eb171f229732bc6fdedf0ac \ + --hash=sha256:9164ec8010327ab9af931d7ccd12ab8d8b5dc2f4c6a16cbdd9d087861eaaefa1 \ + --hash=sha256:945eb4b6bb8144909b203a88a35e0a03d22b57aefb06c9b26c6e16d72e5eb0f0 \ + --hash=sha256:99a57006b4ec39dbfb3ed67e5b27192792ffb0553206a107e4aadb39c5004cd5 \ + --hash=sha256:9e9184fa6c52a74a5521e3e87badbf9692549c0fcced47443585876fcc47e469 \ + --hash=sha256:9ff93d3aedef11f9c4540cf347f8bb135dd9323a2fc705633d83210d464c579d \ + --hash=sha256:a360cfd0881d36c6dc271992ce1eda65dba5e9368575663de993eeb4523d895f \ + --hash=sha256:a5d7ed104d158c0042a6a73799cf0eb576dfd5fc1ace9c47996e52320c37cb7c \ + --hash=sha256:ac17044876e64a8ea20ab132080ddc73b895b4abe9976e263b0e30ee5be7b9c2 \ + --hash=sha256:ad857f42831e5b8d41a32437f88d86ead6c191455a3499c4b6d15e007936d4cf \ + --hash=sha256:b2039f8d545f20c4e52713eea51a275e62153ee96c8035a32b2abb772b6fc9e5 \ + --hash=sha256:b455492cab07107bfe8711e20cd920cc96003e0da3c1f91297235b1603d2aca7 \ + --hash=sha256:b4a9fe992887ac68256c930a2011255bae0bf5ec837475bc6f7edd7c8dfa254e \ + --hash=sha256:b5a53f5998b4bbff1cb2e967e66ab2addc67326a274567697379dd1e326bded7 \ + --hash=sha256:b788276a3c114e9f51e257f2a6f544c32c02dab4aa7a5816b96444e3f9ffc336 \ + --hash=sha256:bddd4f91eede9ca5275e70479ed3656e76c8cdaaa1b354e544cbcf94c6fc8ac4 \ + --hash=sha256:c0503c5b681566e8b722fe8c4c47cce5c7a51f6935d5c7012c4aefe952a35eed \ + --hash=sha256:c1b3cd23d905589cb205710b3988fc8f46d4a198cf12862887b09d7aaa6bf9b9 \ + --hash=sha256:c48f3fbc3e92c7dd6681a258d22f23adc2eb183c8cb1557d2fcc5a024e80b094 \ + --hash=sha256:c63c3ef43f0b3fb00571cff6c3967cc261c0ebd14a0a134a12e83bdb8f49f21f \ + --hash=sha256:c6c45a2d2b68c51fe3d9352733fe048291e483376c94f7723458cfd7b473136b \ + --hash=sha256:caa1afc70a02645809c744eefb7d6ee8fef7e2fad170ffdeacca267fd2674f13 \ + --hash=sha256:cc435d059f926fdc5b05822b1be4ff2a3a040f3ae0a7bbbe672babb468944722 \ + --hash=sha256:cf693eb4a08eccc1a1b636e4392322582db2a47470d52e824b25eca7a3977b53 \ + --hash=sha256:cf71343646756a072b85f228d35b1d7407da1669a3de3cf47f8bbafe0c8183a4 \ + --hash=sha256:d08f63561c8a695afec4975fae445245386d645e3e446e6f260e81663bfd2e38 \ + --hash=sha256:d29ddefeab1791e3c751e0189d5f4b3dbc0bbe033b06e9c333dca1f99e1d523e \ + --hash=sha256:d7f5e15c953ace2e8dde9824bdab4bec50adb91a5663df08d7d994240ae6fa31 \ + --hash=sha256:d858532212f0650be12b6042ff4378dc2efbb7792a286bee4489eaa7ba010586 \ + --hash=sha256:d97dd44683802000277bbf142fd9f6b271746b4846d0acaf0cefa6b2eaf2a7ad \ + --hash=sha256:dcdc88b6b01015da066da3fb76545e8bb9a6880a5ebf89e0f0b2e3ca557b3ab7 \ + --hash=sha256:dd609fafdcdde6e67a139898196698af37438b035b25ad63704fd9097d9a3482 \ + --hash=sha256:defa2c0c68734f4a82028c26bcc85e6b92cced99866af118cd6a89b734ad8e0d \ + --hash=sha256:e22260a4741a0e7a206e175232867b48a16e0401ef5bce3c67ca5b9705879066 \ + --hash=sha256:e225a6a14ecf44499aadea165299092ab0cba918bb9ccd9304eab1138844490b \ + --hash=sha256:e3df0bc35e746cce42579826b89579d13fd27c3d5319a6afca9893a9b784ff1b \ + --hash=sha256:e6fcc026a3f27c1282c7ed24b7fcac82cdd70a0e84cc848c0841a3ab1e3dea2d \ + --hash=sha256:e782379c2028a3611285a795b89b99a52722946d19fc06f002f8b53e3ea26ea9 \ + --hash=sha256:e8cdd52744f680346ff8c1ecdad5f4d11117e1724d4f4e1874f3a67598821069 \ + --hash=sha256:e9616f5bd2595f7f4a04b67039d890348ab826e943a9bfdbe4938d0eba606971 \ + --hash=sha256:e98c4c07ee4c4b3acf787e91b27688409d918212dfd34c872201273fdd5a0e18 \ + --hash=sha256:ebdab79f42c5961682654b851f3f0fc68e6cc7cd8727c2ac4ffff955154123c1 \ + --hash=sha256:f0f17f2ce0f3529177a5fff5525204fad7b43dd437d017dd0317f2746773443d \ + --hash=sha256:f4e56860a5af16a0fcfa070a0a20c42fbb2012eed1eb5ceeddcc7f8079214281 + # via + # jsonschema + # referencing +ruamel-yaml==0.18.5 \ + --hash=sha256:61917e3a35a569c1133a8f772e1226961bf5a1198bea7e23f06a0841dea1ab0e \ + --hash=sha256:a013ac02f99a69cdd6277d9664689eb1acba07069f912823177c5eced21a6ada + # via jupyter-telemetry +ruamel-yaml-clib==0.2.8 \ + --hash=sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d \ + --hash=sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001 \ + --hash=sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462 \ + --hash=sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9 \ + --hash=sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b \ + --hash=sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b \ + --hash=sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615 \ + --hash=sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15 \ + --hash=sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b \ + --hash=sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9 \ + --hash=sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675 \ + --hash=sha256:3fcc54cb0c8b811ff66082de1680b4b14cf8a81dce0d4fbf665c2265a81e07a1 \ + --hash=sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899 \ + --hash=sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7 \ + --hash=sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7 \ + --hash=sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312 \ + --hash=sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa \ + --hash=sha256:665f58bfd29b167039f714c6998178d27ccd83984084c286110ef26b230f259f \ + --hash=sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91 \ + --hash=sha256:7048c338b6c86627afb27faecf418768acb6331fc24cfa56c93e8c9780f815fa \ + --hash=sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b \ + --hash=sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3 \ + --hash=sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334 \ + --hash=sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5 \ + --hash=sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3 \ + --hash=sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe \ + --hash=sha256:9eb5dee2772b0f704ca2e45b1713e4e5198c18f515b52743576d196348f374d3 \ + --hash=sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed \ + --hash=sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337 \ + --hash=sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880 \ + --hash=sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d \ + --hash=sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248 \ + --hash=sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d \ + --hash=sha256:b5edda50e5e9e15e54a6a8a0070302b00c518a9d32accc2346ad6c984aacd279 \ + --hash=sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf \ + --hash=sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512 \ + --hash=sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069 \ + --hash=sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb \ + --hash=sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942 \ + --hash=sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d \ + --hash=sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31 \ + --hash=sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92 \ + --hash=sha256:d92f81886165cb14d7b067ef37e142256f1c6a90a65cd156b063a43da1708cfd \ + --hash=sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5 \ + --hash=sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28 \ + --hash=sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d \ + --hash=sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1 \ + --hash=sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2 \ + --hash=sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875 \ + --hash=sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412 + # via + # -r spawner/requirements/main.in + # ruamel-yaml +six==1.16.0 \ + --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ + --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 + # via + # kubernetes-asyncio + # python-dateutil +sniffio==1.3.0 \ + --hash=sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101 \ + --hash=sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384 + # via + # anyio + # httpx +sqlalchemy==2.0.23 \ + --hash=sha256:0666031df46b9badba9bed00092a1ffa3aa063a5e68fa244acd9f08070e936d3 \ + --hash=sha256:0a8c6aa506893e25a04233bc721c6b6cf844bafd7250535abb56cb6cc1368884 \ + --hash=sha256:0e680527245895aba86afbd5bef6c316831c02aa988d1aad83c47ffe92655e74 \ + --hash=sha256:14aebfe28b99f24f8a4c1346c48bc3d63705b1f919a24c27471136d2f219f02d \ + --hash=sha256:1e018aba8363adb0599e745af245306cb8c46b9ad0a6fc0a86745b6ff7d940fc \ + --hash=sha256:227135ef1e48165f37590b8bfc44ed7ff4c074bf04dc8d6f8e7f1c14a94aa6ca \ + --hash=sha256:31952bbc527d633b9479f5f81e8b9dfada00b91d6baba021a869095f1a97006d \ + --hash=sha256:3e983fa42164577d073778d06d2cc5d020322425a509a08119bdcee70ad856bf \ + --hash=sha256:42d0b0290a8fb0165ea2c2781ae66e95cca6e27a2fbe1016ff8db3112ac1e846 \ + --hash=sha256:42ede90148b73fe4ab4a089f3126b2cfae8cfefc955c8174d697bb46210c8306 \ + --hash=sha256:4895a63e2c271ffc7a81ea424b94060f7b3b03b4ea0cd58ab5bb676ed02f4221 \ + --hash=sha256:4af79c06825e2836de21439cb2a6ce22b2ca129bad74f359bddd173f39582bf5 \ + --hash=sha256:5f94aeb99f43729960638e7468d4688f6efccb837a858b34574e01143cf11f89 \ + --hash=sha256:616fe7bcff0a05098f64b4478b78ec2dfa03225c23734d83d6c169eb41a93e55 \ + --hash=sha256:62d9e964870ea5ade4bc870ac4004c456efe75fb50404c03c5fd61f8bc669a72 \ + --hash=sha256:638c2c0b6b4661a4fd264f6fb804eccd392745c5887f9317feb64bb7cb03b3ea \ + --hash=sha256:63bfc3acc970776036f6d1d0e65faa7473be9f3135d37a463c5eba5efcdb24c8 \ + --hash=sha256:6463aa765cf02b9247e38b35853923edbf2f6fd1963df88706bc1d02410a5577 \ + --hash=sha256:64ac935a90bc479fee77f9463f298943b0e60005fe5de2aa654d9cdef46c54df \ + --hash=sha256:683ef58ca8eea4747737a1c35c11372ffeb84578d3aab8f3e10b1d13d66f2bc4 \ + --hash=sha256:75eefe09e98043cff2fb8af9796e20747ae870c903dc61d41b0c2e55128f958d \ + --hash=sha256:787af80107fb691934a01889ca8f82a44adedbf5ef3d6ad7d0f0b9ac557e0c34 \ + --hash=sha256:7c424983ab447dab126c39d3ce3be5bee95700783204a72549c3dceffe0fc8f4 \ + --hash=sha256:7e0dc9031baa46ad0dd5a269cb7a92a73284d1309228be1d5935dac8fb3cae24 \ + --hash=sha256:87a3d6b53c39cd173990de2f5f4b83431d534a74f0e2f88bd16eabb5667e65c6 \ + --hash=sha256:89a01238fcb9a8af118eaad3ffcc5dedaacbd429dc6fdc43fe430d3a941ff965 \ + --hash=sha256:9585b646ffb048c0250acc7dad92536591ffe35dba624bb8fd9b471e25212a35 \ + --hash=sha256:964971b52daab357d2c0875825e36584d58f536e920f2968df8d581054eada4b \ + --hash=sha256:967c0b71156f793e6662dd839da54f884631755275ed71f1539c95bbada9aaab \ + --hash=sha256:9ca922f305d67605668e93991aaf2c12239c78207bca3b891cd51a4515c72e22 \ + --hash=sha256:a86cb7063e2c9fb8e774f77fbf8475516d270a3e989da55fa05d08089d77f8c4 \ + --hash=sha256:aeb397de65a0a62f14c257f36a726945a7f7bb60253462e8602d9b97b5cbe204 \ + --hash=sha256:b41f5d65b54cdf4934ecede2f41b9c60c9f785620416e8e6c48349ab18643855 \ + --hash=sha256:bd45a5b6c68357578263d74daab6ff9439517f87da63442d244f9f23df56138d \ + --hash=sha256:c14eba45983d2f48f7546bb32b47937ee2cafae353646295f0e99f35b14286ab \ + --hash=sha256:c1bda93cbbe4aa2aa0aa8655c5aeda505cd219ff3e8da91d1d329e143e4aff69 \ + --hash=sha256:c4722f3bc3c1c2fcc3702dbe0016ba31148dd6efcd2a2fd33c1b4897c6a19693 \ + --hash=sha256:c80c38bd2ea35b97cbf7c21aeb129dcbebbf344ee01a7141016ab7b851464f8e \ + --hash=sha256:cabafc7837b6cec61c0e1e5c6d14ef250b675fa9c3060ed8a7e38653bd732ff8 \ + --hash=sha256:cc1d21576f958c42d9aec68eba5c1a7d715e5fc07825a629015fe8e3b0657fb0 \ + --hash=sha256:d0f7fb0c7527c41fa6fcae2be537ac137f636a41b4c5a4c58914541e2f436b45 \ + --hash=sha256:d4041ad05b35f1f4da481f6b811b4af2f29e83af253bf37c3c4582b2c68934ab \ + --hash=sha256:d5578e6863eeb998980c212a39106ea139bdc0b3f73291b96e27c929c90cd8e1 \ + --hash=sha256:e3b5036aa326dc2df50cba3c958e29b291a80f604b1afa4c8ce73e78e1c9f01d \ + --hash=sha256:e599a51acf3cc4d31d1a0cf248d8f8d863b6386d2b6782c5074427ebb7803bda \ + --hash=sha256:f3420d00d2cb42432c1d0e44540ae83185ccbbc67a6054dcc8ab5387add6620b \ + --hash=sha256:f48ed89dd11c3c586f45e9eec1e437b355b3b6f6884ea4a4c3111a3358fd0c18 \ + --hash=sha256:f508ba8f89e0a5ecdfd3761f82dda2a3d7b678a626967608f4273e0dba8f07ac \ + --hash=sha256:fd54601ef9cc455a0c61e5245f690c8a3ad67ddb03d3b91c361d076def0b4c60 + # via + # alembic + # jupyterhub +tornado==6.3.3 \ + --hash=sha256:1bd19ca6c16882e4d37368e0152f99c099bad93e0950ce55e71daed74045908f \ + --hash=sha256:22d3c2fa10b5793da13c807e6fc38ff49a4f6e1e3868b0a6f4164768bb8e20f5 \ + --hash=sha256:502fba735c84450974fec147340016ad928d29f1e91f49be168c0a4c18181e1d \ + --hash=sha256:65ceca9500383fbdf33a98c0087cb975b2ef3bfb874cb35b8de8740cf7f41bd3 \ + --hash=sha256:71a8db65160a3c55d61839b7302a9a400074c9c753040455494e2af74e2501f2 \ + --hash=sha256:7ac51f42808cca9b3613f51ffe2a965c8525cb1b00b7b2d56828b8045354f76a \ + --hash=sha256:7d01abc57ea0dbb51ddfed477dfe22719d376119844e33c661d873bf9c0e4a16 \ + --hash=sha256:805d507b1f588320c26f7f097108eb4023bbaa984d63176d1652e184ba24270a \ + --hash=sha256:9dc4444c0defcd3929d5c1eb5706cbe1b116e762ff3e0deca8b715d14bf6ec17 \ + --hash=sha256:ceb917a50cd35882b57600709dd5421a418c29ddc852da8bcdab1f0db33406b0 \ + --hash=sha256:e7d8db41c0181c80d76c982aacc442c0783a2c54d6400fe028954201a2e032fe + # via + # jupyterhub + # jupyterhub-idle-culler +traitlets==5.13.0 \ + --hash=sha256:9b232b9430c8f57288c1024b34a8f0251ddcc47268927367a0dd3eeaca40deb5 \ + --hash=sha256:baf991e61542da48fe8aef8b779a9ea0aa38d8a54166ee250d5af5ecf4486619 + # via + # -r spawner/requirements/main.in + # jupyter-telemetry + # jupyterhub +typing-extensions==4.8.0 \ + --hash=sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0 \ + --hash=sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef + # via + # alembic + # sqlalchemy +urllib3==2.0.7 \ + --hash=sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84 \ + --hash=sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e + # via + # kubernetes-asyncio + # requests +yarl==1.9.2 \ + --hash=sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571 \ + --hash=sha256:066c163aec9d3d073dc9ffe5dd3ad05069bcb03fcaab8d221290ba99f9f69ee3 \ + --hash=sha256:13414591ff516e04fcdee8dc051c13fd3db13b673c7a4cb1350e6b2ad9639ad3 \ + --hash=sha256:149ddea5abf329752ea5051b61bd6c1d979e13fbf122d3a1f9f0c8be6cb6f63c \ + --hash=sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7 \ + --hash=sha256:1b1bba902cba32cdec51fca038fd53f8beee88b77efc373968d1ed021024cc04 \ + --hash=sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191 \ + --hash=sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea \ + --hash=sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4 \ + --hash=sha256:2c315df3293cd521033533d242d15eab26583360b58f7ee5d9565f15fee1bef4 \ + --hash=sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095 \ + --hash=sha256:3458a24e4ea3fd8930e934c129b676c27452e4ebda80fbe47b56d8c6c7a63a9e \ + --hash=sha256:38a3928ae37558bc1b559f67410df446d1fbfa87318b124bf5032c31e3447b74 \ + --hash=sha256:3da8a678ca8b96c8606bbb8bfacd99a12ad5dd288bc6f7979baddd62f71c63ef \ + --hash=sha256:494053246b119b041960ddcd20fd76224149cfea8ed8777b687358727911dd33 \ + --hash=sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde \ + --hash=sha256:52a25809fcbecfc63ac9ba0c0fb586f90837f5425edfd1ec9f3372b119585e45 \ + --hash=sha256:53338749febd28935d55b41bf0bcc79d634881195a39f6b2f767870b72514caf \ + --hash=sha256:5415d5a4b080dc9612b1b63cba008db84e908b95848369aa1da3686ae27b6d2b \ + --hash=sha256:5610f80cf43b6202e2c33ba3ec2ee0a2884f8f423c8f4f62906731d876ef4fac \ + --hash=sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0 \ + --hash=sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528 \ + --hash=sha256:59723a029760079b7d991a401386390c4be5bfec1e7dd83e25a6a0881859e716 \ + --hash=sha256:5fcd436ea16fee7d4207c045b1e340020e58a2597301cfbcfdbe5abd2356c2fb \ + --hash=sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18 \ + --hash=sha256:63c48f6cef34e6319a74c727376e95626f84ea091f92c0250a98e53e62c77c72 \ + --hash=sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6 \ + --hash=sha256:662e6016409828ee910f5d9602a2729a8a57d74b163c89a837de3fea050c7582 \ + --hash=sha256:674ca19cbee4a82c9f54e0d1eee28116e63bc6fd1e96c43031d11cbab8b2afd5 \ + --hash=sha256:6a5883464143ab3ae9ba68daae8e7c5c95b969462bbe42e2464d60e7e2698368 \ + --hash=sha256:6e7221580dc1db478464cfeef9b03b95c5852cc22894e418562997df0d074ccc \ + --hash=sha256:75df5ef94c3fdc393c6b19d80e6ef1ecc9ae2f4263c09cacb178d871c02a5ba9 \ + --hash=sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be \ + --hash=sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a \ + --hash=sha256:8288d7cd28f8119b07dd49b7230d6b4562f9b61ee9a4ab02221060d21136be80 \ + --hash=sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8 \ + --hash=sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6 \ + --hash=sha256:838162460b3a08987546e881a2bfa573960bb559dfa739e7800ceeec92e64417 \ + --hash=sha256:83fcc480d7549ccebe9415d96d9263e2d4226798c37ebd18c930fce43dfb9574 \ + --hash=sha256:84e0b1599334b1e1478db01b756e55937d4614f8654311eb26012091be109d59 \ + --hash=sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608 \ + --hash=sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82 \ + --hash=sha256:8c56986609b057b4839968ba901944af91b8e92f1725d1a2d77cbac6972b9ed1 \ + --hash=sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3 \ + --hash=sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d \ + --hash=sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8 \ + --hash=sha256:9b3152f2f5677b997ae6c804b73da05a39daa6a9e85a512e0e6823d81cdad7cc \ + --hash=sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac \ + --hash=sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8 \ + --hash=sha256:a74dcbfe780e62f4b5a062714576f16c2f3493a0394e555ab141bf0d746bb955 \ + --hash=sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0 \ + --hash=sha256:ac9bb4c5ce3975aeac288cfcb5061ce60e0d14d92209e780c93954076c7c4367 \ + --hash=sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb \ + --hash=sha256:b03917871bf859a81ccb180c9a2e6c1e04d2f6a51d953e6a5cdd70c93d4e5a2a \ + --hash=sha256:b124e2a6d223b65ba8768d5706d103280914d61f5cae3afbc50fc3dfcc016623 \ + --hash=sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2 \ + --hash=sha256:b7232f8dfbd225d57340e441d8caf8652a6acd06b389ea2d3222b8bc89cbfca6 \ + --hash=sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7 \ + --hash=sha256:b9a4e67ad7b646cd6f0938c7ebfd60e481b7410f574c560e455e938d2da8e0f4 \ + --hash=sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051 \ + --hash=sha256:bf74d08542c3a9ea97bb8f343d4fcbd4d8f91bba5ec9d5d7f792dbe727f88938 \ + --hash=sha256:c027a6e96ef77d401d8d5a5c8d6bc478e8042f1e448272e8d9752cb0aff8b5c8 \ + --hash=sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9 \ + --hash=sha256:c1012fa63eb6c032f3ce5d2171c267992ae0c00b9e164efe4d73db818465fac3 \ + --hash=sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5 \ + --hash=sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9 \ + --hash=sha256:de119f56f3c5f0e2fb4dee508531a32b069a5f2c6e827b272d1e0ff5ac040333 \ + --hash=sha256:e65610c5792870d45d7b68c677681376fcf9cc1c289f23e8e8b39c1485384185 \ + --hash=sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3 \ + --hash=sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560 \ + --hash=sha256:f364d3480bffd3aa566e886587eaca7c8c04d74f6e8933f3f2c996b7f09bee1b \ + --hash=sha256:f3b078dbe227f79be488ffcfc7a9edb3409d018e0952cf13f15fd6512847f3f7 \ + --hash=sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78 \ + --hash=sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7 + # via aiohttp + +# The following packages are considered to be unsafe in a requirements file: +setuptools==68.2.2 \ + --hash=sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87 \ + --hash=sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a + # via kubernetes-asyncio diff --git a/spawner/scripts/install-base-packages.sh b/spawner/scripts/install-base-packages.sh new file mode 100755 index 000000000..620781c1f --- /dev/null +++ b/spawner/scripts/install-base-packages.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# This script updates packages in the base Docker image that's used by both the +# build and runtime images, and gives us a place to install additional +# system-level packages with apt-get. +# +# Based on the blog post: +# https://pythonspeed.com/articles/system-packages-docker/ + +# Bash "strict mode", to help catch problems and bugs in the shell +# script. Every bash script you write should include this. See +# http://redsymbol.net/articles/unofficial-bash-strict-mode/ for +# details. +set -euo pipefail + +# Display each command as it's run. +set -x + +# Tell apt-get we're never going to be able to give manual +# feedback: +export DEBIAN_FRONTEND=noninteractive + +# Update the package listing, so we know what packages exist: +apt-get update + +# Install security updates: +apt-get -y upgrade + +# Example of installing a new package, without unnecessary packages: +apt-get -y install --no-install-recommends git + +# Delete cached files we don't need anymore: +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/spawner/scripts/install-dependency-packages.sh b/spawner/scripts/install-dependency-packages.sh new file mode 100755 index 000000000..3706c9042 --- /dev/null +++ b/spawner/scripts/install-dependency-packages.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# This script installs additional packages used by the dependency image but +# not needed by the runtime image, such as additional packages required to +# build Python dependencies. +# +# Since the base image wipes all the apt caches to clean up the image that +# will be reused by the runtime image, we unfortunately have to do another +# apt-get update here, which wastes some time and network. + +# Bash "strict mode", to help catch problems and bugs in the shell +# script. Every bash script you write should include this. See +# http://redsymbol.net/articles/unofficial-bash-strict-mode/ for +# details. +set -euo pipefail + +# Display each command as it's run. +set -x + +# Tell apt-get we're never going to be able to give manual +# feedback: +export DEBIAN_FRONTEND=noninteractive + +# Update the package listing, so we know what packages exist: +apt-get update + +# Install build-essential because sometimes Python dependencies need to build +# C modules, particularly when upgrading to newer Python versions. libffi-dev +# is sometimes needed to build cffi (a cryptography dependency). libpq-dev +# and python3-dev are required to build psycopg2 +apt-get -y install --no-install-recommends build-essential libffi-dev \ + libpq-dev python3-dev + +# postgresql-client is not *strictly* necessary, but if we're using +# CloudSQL proxy against a Cloud SQL instance that has no public IP +# and a network policy only allowing access to the proxy from the Hub +# pod, this is a much easier way to inspect the DB than an interactive +# python instance. + +apt-get -y install --no-install-recommends postgresql-client + +# Delete cached files we don't need anymore: +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/spawner/src/rsp_restspawner/__init__.py b/spawner/src/rsp_restspawner/__init__.py new file mode 100644 index 000000000..b33101260 --- /dev/null +++ b/spawner/src/rsp_restspawner/__init__.py @@ -0,0 +1,4 @@ +from .auth import GafaelfawrAuthenticator +from .spawner import RSPRestSpawner + +__all__ = ["GafaelfawrAuthenticator", "RSPRestSpawner"] diff --git a/spawner/src/rsp_restspawner/auth.py b/spawner/src/rsp_restspawner/auth.py new file mode 100644 index 000000000..eaabc2318 --- /dev/null +++ b/spawner/src/rsp_restspawner/auth.py @@ -0,0 +1,198 @@ +"""Gafaelfawr authenticator for JupyterHub.""" + +from __future__ import annotations + +from typing import Any + +from jupyterhub.app import JupyterHub +from jupyterhub.auth import Authenticator +from jupyterhub.handlers import BaseHandler, LogoutHandler +from jupyterhub.user import User +from jupyterhub.utils import url_path_join +from tornado.httputil import HTTPHeaders +from tornado.web import HTTPError, RequestHandler + +AuthInfo = dict[str, str | dict[str, str]] +Route = tuple[str, type[BaseHandler]] + + +def _build_auth_info(headers: HTTPHeaders) -> AuthInfo: + """Construct the authentication information for a user. + + Gafaelfawr puts the username in ``X-Auth-Request-User`` and the delegated + notebook token in ``X-Auth-Request-Token``. Use those headers to construct + a valid JupyterHub ``auth_state``. + """ + username = headers.get("X-Auth-Request-User") + token = headers.get("X-Auth-Request-Token") + if not username or not token: + raise HTTPError(401, "User is not authenticated") + return {"name": username, "auth_state": {"token": token}} + + +class GafaelfawrAuthenticator(Authenticator): + """JupyterHub authenticator using Gafaelfawr headers. + + Rather than implement any authentication logic inside of JupyterHub, + authentication is done via an ``auth_request`` handler made by the NGINX + ingress controller. JupyterHub then only needs to read the authentication + results from the headers of the incoming request. + + Normally, the authentication flow for JupyterHub is to send the user to + ``/hub/login`` and display a login form. The submitted values to the form + are then passed to the ``authenticate`` method of the authenticator, which + is responsible for returning authentication information for the user. + That information is then stored in an authentication session and the user + is redirected to whatever page they were trying to go to. + + We however do not want to display an interactive form, since the + authentication information is already present in the headers. We just need + JupyterHub to read it. + + The documented way to do this is to register a custom login handler on a + new route not otherwise used by JupyterHub, and then enable the + ``auto_login`` setting on the configured authenticator. This setting tells + the built-in login page to, instead of presenting a login form, redirect + the user to whatever URL is returned by ``login_url``. In our case, this + will be ``/hub/gafaelfawr/login``, served by `GafaelfawrLoginHandler`. + This simple handler will read the token from the header, retrieve its + metadata, create the session and cookie, and then make the same redirect + call the login form handler would normally have made after the + ``authenticate`` method returned. + + In this model, the ``authenticate`` method is not used, since the login + handler never receives a form submission. + + Notes + ----- + A possible alternative implementation that seems to be supported by the + JupyterHub code would be to not override ``login_url``, set + ``auto_login``, and then override ``get_authenticated_user`` in the + authenticator to read authentication information directly from the request + headers. It looks like an authenticator configured in that way would + authenticate the user "in place" in the handler of whatever page the user + first went to, without any redirects. This would be slightly more + efficient and the code appears to handle it, but the current documentation + (as of 1.5.0) explicitly says to not override ``get_authenticated_user``. + + This implementation therefore takes the well-documented path of a new + handler and a redirect from the built-in login handler, on the theory that + a few extra redirects is a small price to pay for staying within the + supported and expected interface. + """ + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + # Automatically log in rather than prompting the user with a link. + self.auto_login = True + + # Enable secure storage of auth state, which we'll use to stash the + # user's token and pass it to the spawned pod. + self.enable_auth_state = True + + # Refresh the auth state before spawning to ensure we have the user's + # most recent token and group information. + self.refresh_pre_spawn = True + + async def authenticate( + self, handler: RequestHandler, data: dict[str, str] + ) -> str | dict[str, Any] | None: + """Login form authenticator. + + This is not used in our authentication scheme. + """ + raise NotImplementedError + + def get_handlers(self, app: JupyterHub) -> list[Route]: + """Register the header-only login and the logout handlers.""" + return [ + ("/gafaelfawr/login", GafaelfawrLoginHandler), + ("/logout", GafaelfawrLogoutHandler), + ] + + def login_url(self, base_url: str) -> str: + """Override the login URL. + + This must be changed to something other than ``/login`` to trigger + correct behavior when ``auto_login`` is set to true (as it is in our + case). + """ + return url_path_join(base_url, "gafaelfawr/login") + + async def refresh_user( + self, user: User, handler: RequestHandler | None = None + ) -> bool | AuthInfo: + """Optionally refresh the user's token.""" + # If running outside of a Tornado handler, we can't refresh the auth + # state, so assume that it is okay. + if not handler: + return True + + # If there is no X-Auth-Request-Token header, this request did not go + # through the Hub ingress and thus is coming from inside the cluster, + # such as requests to JupyterHub from a JupyterLab instance. Allow + # JupyterHub to use its normal authentication logic. + token = handler.request.headers.get("X-Auth-Request-Token") + if not token: + return True + + # JupyterHub doesn't support changing the username of a user during + # refresh, so if the username doesn't match what we're expecting, + # raise a 401 error and force the user to reauthenticate. This can + # happen if the user's username was changed underneath us. + username = handler.request.headers.get("X-Auth-Request-User") + if not username or user.name != username: + raise HTTPError(401, "Username does not match expected identity") + + # We have a new token. If it doesn't match the token we have stored, + # replace the stored auth state with the new auth state. + auth_state = await user.get_auth_state() + if token == auth_state.get("token"): + return True + else: + return _build_auth_info(handler.request.headers) + + +class GafaelfawrLogoutHandler(LogoutHandler): + """Logout handler for Gafaelfawr authentication. + + A logout should always stop all running servers, and then redirect to the + RSP logout page. + """ + + @property + def shutdown_on_logout(self) -> bool: + """Unconditionally true for Gafaelfawr logout.""" + return True + + async def render_logout_page(self) -> None: + self.redirect("/logout", permanent=False) + + +class GafaelfawrLoginHandler(BaseHandler): + """Login handler for Gafaelfawr authentication. + + This retrieves the authentication token from the headers, makes an API + call to get its metadata, constructs an authentication state, and then + redirects to the next URL. + """ + + async def get(self) -> None: + """Handle GET to the login page.""" + auth_info = _build_auth_info(self.request.headers) + + # Store the ancillary user information in the user database and create + # or return the user object. This call is unfortunately undocumented, + # but it's what BaseHandler calls to record the auth_state information + # after a form-based login. Hopefully this is a stable interface. + user = await self.auth_to_user(auth_info) + + # Tell JupyterHub to set its login cookie (also undocumented). + self.set_login_cookie(user) + + # Redirect to the next URL, which is under the control of JupyterHub + # and opaque to the authenticator. In practice, it will normally be + # whatever URL the user was trying to go to when JupyterHub decided + # they needed to be authenticated. + self.redirect(self.get_next_url(user)) diff --git a/spawner/src/rsp_restspawner/exceptions.py b/spawner/src/rsp_restspawner/exceptions.py new file mode 100644 index 000000000..44f586ea2 --- /dev/null +++ b/spawner/src/rsp_restspawner/exceptions.py @@ -0,0 +1,110 @@ +"""Exceptions for the RSP REST spawner. + +JupyterHub catches all exceptions derived from `Exception` and treats them the +same, so the distinction between exceptions is just for better error reporting +and improved code readability. +""" + +from __future__ import annotations + +from httpx import HTTPError, HTTPStatusError, RequestError + +__all__ = [ + "ControllerWebError", + "InvalidAuthStateError", + "MissingFieldError", + "SpawnFailedError", +] + + +class ControllerWebError(Exception): + """Failure to talk to the lab controller API. + + Parameters + ---------- + message + Exception string value, which is the default Slack message. + method + Method of request. + url + URL of the request. + status + Status code of failure, if any. + reason + Reason string of failure, if any. + body + Body of failure message, if any. + """ + + @classmethod + def from_exception(cls, exc: HTTPError) -> ControllerWebError: + """Create an exception from an httpx exception. + + Parameters + ---------- + exc + Exception from httpx. + + Returns + ------- + ControllerWebError + Newly-constructed exception. + """ + if isinstance(exc, HTTPStatusError): + status = exc.response.status_code + method = exc.request.method + message = f"Status {status} from {method} {exc.request.url}" + return cls( + message, + method=exc.request.method, + url=str(exc.request.url), + status=status, + reason=exc.response.reason_phrase, + body=exc.response.text, + ) + else: + message = f"{type(exc).__name__}: {exc!s}" + if isinstance(exc, RequestError): + return cls( + message, + method=exc.request.method, + url=str(exc.request.url), + ) + else: + return cls(message) + + def __init__( + self, + message: str, + *, + method: str | None = None, + url: str | None = None, + status: int | None = None, + reason: str | None = None, + body: str | None = None, + ) -> None: + self.message = message + self.method = method + self.url = url + self.status = status + self.reason = reason + self.body = body + super().__init__(message) + + def __str__(self) -> str: + result = self.message + if self.body: + result += f"\nBody:\n{self.body}\n" + return result + + +class InvalidAuthStateError(Exception): + """The JupyterHub auth state for the user contains no token.""" + + +class MissingFieldError(Exception): + """The reply from the lab controller is missing a required field.""" + + +class SpawnFailedError(Exception): + """The lab controller reports that the spawn failed.""" diff --git a/spawner/src/rsp_restspawner/spawner.py b/spawner/src/rsp_restspawner/spawner.py new file mode 100644 index 000000000..5cdecb0a7 --- /dev/null +++ b/spawner/src/rsp_restspawner/spawner.py @@ -0,0 +1,695 @@ +"""Spawner class that uses a REST API to a separate Kubernetes service.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable, Coroutine +from dataclasses import dataclass +from datetime import timedelta +from enum import Enum +from functools import wraps +from pathlib import Path +from typing import Any, Concatenate, ParamSpec, TypeVar + +from httpx import AsyncClient, HTTPError, Response +from httpx_sse import ServerSentEvent, aconnect_sse +from jupyterhub.spawner import Spawner +from traitlets import Unicode, default + +from .exceptions import ( + ControllerWebError, + InvalidAuthStateError, + MissingFieldError, + SpawnFailedError, +) + +P = ParamSpec("P") +T = TypeVar("T") + +__all__ = [ + "LabStatus", + "RSPRestSpawner", +] + +_CLIENT: AsyncClient | None = None +"""Cached global HTTP client so that we can share a connection pool.""" + + +class LabStatus(str, Enum): + """Possible status conditions of a user's pod per the lab controller. + + This is not directly equivalent to pod phases. It is instead intended to + capture the status of the lab from an infrastructure standpoint, + reflecting the current intent of the controller. Most notably, labs that + have stopped running for any reason (failure or success) use the + terminated status. The failed status is reserved for failed Kubernetes + operations or missing or invalid Kubernetes objects. + + Keep this in sync with the status values reported by the status endpoint + of the lab controller. + """ + + PENDING = "pending" + RUNNING = "running" + TERMINATING = "terminating" + TERMINATED = "terminated" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class SpawnEvent: + """JupyterHub spawning event.""" + + progress: int + """Percentage of progress, from 0 to 100.""" + + message: str + """Event description.""" + + severity: str + """Log message severity.""" + + complete: bool = False + """Whether the event indicated spawning is done.""" + + failed: bool = False + """Whether the event indicated spawning failed.""" + + @classmethod + def from_sse(cls, sse: ServerSentEvent, progress: int) -> SpawnEvent: + """Convert from a server-sent event from the lab controller. + + Parameters + ---------- + sse + Event from the lab controller. + progress + Current progress percentage, if the event doesn't specify one. + """ + try: + data = sse.json() + if not (set(data.keys()) <= {"message", "progress"}): + raise ValueError("Invalid key in SSE data") + if "progress" in data: + progress = int(data["progress"]) + if progress < 0 or progress > 100: + raise ValueError(f"Invalid progress value {progress}") + except Exception: + data = {"message": sse.data} + data["progress"] = progress + + if sse.event == "complete": + data["progress"] = 75 + return cls(**data, severity="info", complete=True) + elif sse.event in ("info", "error"): + return cls(**data, severity=sse.event) + elif sse.event == "failed": + return cls(**data, severity="error", failed=True) + else: + return cls(**data, severity="unknown") + + def to_dict(self) -> dict[str, int | str]: + """Convert to the dictionary expected by JupyterHub.""" + return { + "progress": self.progress, + "message": f"[{self.severity}] {self.message}", + } + + +def _convert_exception( + f: Callable[Concatenate[RSPRestSpawner, P], Coroutine[None, None, T]] +) -> Callable[Concatenate[RSPRestSpawner, P], Coroutine[None, None, T]]: + """Convert ``httpx`` exceptions to `ControllerWebError`.""" + + @wraps(f) + async def wrapper( + spawner: RSPRestSpawner, *args: P.args, **kwargs: P.kwargs + ) -> T: + try: + return await f(spawner, *args, **kwargs) + except HTTPError as e: + # JupyterHub appears to swallow the backtrace of the original + # exception even though we reference it in a from clause, so + # explicitly log the original exception before raising the + # translated one. + spawner.log.exception("Exception raised in REST spawner") + raise ControllerWebError.from_exception(e) from e + + return wrapper + + +class RSPRestSpawner(Spawner): + """Spawner class that sends requests to the RSP lab controller. + + Rather than having JupyterHub spawn labs directly and therefore need + Kubernetes permissions to manage every resource that a user's lab + environment may need, the Rubin Science Platform manages all labs in a + separate privileged lab controller process. JupyterHub makes RESTful HTTP + requests to that service using either its own credentials or the + credentials of the user. + + See `SQR-066 `__ for the full design. + + Notes + ----- + This class uses a single process-global shared `httpx.AsyncClient` to make + all of its HTTP requests, rather than using one per instantiation of the + spawner class. Each user gets their own spawner, so this approach allows + all requests to share a connection pool. + + This client is created on first use and never shut down. To be strictly + correct, it should be closed properly when the JupyterHub process is + exiting, but we haven't yet figured out how to hook into the appropriate + part of the JupyterHub lifecycle to do that. + """ + + admin_token_path = Unicode( + "/etc/gafaelfawr/token", + help=""" + Path to the Gafaelfawr token for JupyterHub itself. + + This token will be used to authenticate to the lab controller routes + that JupyterHub is allowed to call directly such as to get lab status + and delete a lab. + """, + ).tag(config=True) + + controller_url = Unicode( + "http://localhost:8080/nublado", + help=""" + Base URL for the Nublado lab controller. + + All URLs for talking to the Nublado lab controller will be constructed + relative to this base URL. + """, + ).tag(config=True) + + # Do not preserve any of JupyterHub's environment variables in the default + # environment for labs. + @default("env_keep") + def _env_keep_default(self) -> list[str]: + return [] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + # Holds the events from a spawn in progress. + self._events: list[SpawnEvent] = [] + + # Triggers used to notify listeners of new events. Each listener gets + # its own trigger. + self._triggers: list[asyncio.Event] = [] + + # Holds the future representing a spawn in progress, used by the + # progress method to know when th spawn is finished and it should + # exit. + self._start_future: asyncio.Task[str] | None = None + + @property + def _client(self) -> AsyncClient: + """Shared `httpx.AsyncClient`.""" + global _CLIENT + if not _CLIENT: + _CLIENT = AsyncClient(timeout=60) + return _CLIENT + + async def get_url(self) -> str: + """Determine the URL of a running lab. + + Returns + ------- + str + URL of the lab if we can retrieve it from the lab controller, + otherwise the saved URL in the spawner object. + + Notes + ----- + JupyterHub recommends implementing this if the spawner has some + independent way to retrieve the lab URL, since it allows JupyterHub to + recover if it was killed in the middle of spawning a lab and that + spawn finished successfully while JupyterHub was down. This method is + only called if `poll` returns `None`. + + JupyterHub does not appear to do any error handling of failures of + this method, so it should not raise an exception, just fall back on + the stored URL and let the probe fail if that lab does not exist. + """ + try: + return await self._get_internal_url() + except MissingFieldError: + # This is normal if the lab is currently being spawned or deleted + # when JupyterHub asks for its URL. Tell JupyterHub to use the + # stored URL. + msg = ( + f"Lab for {self.user.name} has no URL (possibly still" + " spawning), falling back on stored URL" + ) + self.log.info(msg) + return await super().get_url() + except Exception: + msg = ( + f"Unable to get URL of running lab for {self.user.name}," + " falling back on stored URL" + ) + self.log.exception(msg) + return await super().get_url() + + @_convert_exception + async def options_form(self, spawner: Spawner) -> str: + """Retrieve the options form for this user from the lab controller. + + Parameters + ---------- + spawner + Another copy of the spawner (not used). It's not clear why + JupyterHub passes this into this method. + + Raises + ------ + ControllerWebError + Raised on failure to talk to the lab controller or a failure + response from the lab controller. + InvalidAuthStateError + Raised if there is no ``token`` attribute in the user's + authentication state. This should always be provided by + `~rsp_restspawner.auth.GafaelfawrAuthenticator`. + """ + r = await self._client.get( + self._controller_url("lab-form", self.user.name), + headers=await self._user_authorization(), + ) + r.raise_for_status() + return r.text + + @_convert_exception + async def poll(self) -> int | None: + """Check if the pod is running. + + Pods that are currently being terminated are reported as not running, + since we want to allow the user to immediately begin spawning a lab. + If they outrace the pod termination, we'll just join the wait for the + lab termination to complete. + + Returns + ------- + int or None + If the pod is starting, running, or terminating, return `None`. + If the pod does not exist, is being terminated, or was + successfully terminated, return 0. If the pod exists in a failed + state, return 1. + + Raises + ------ + ControllerWebError + Raised on failure to talk to the lab controller or a failure + response from the lab controller. + + Notes + ----- + In theory, this is supposed to be the exit status of the Jupyter lab + process. This isn't something we know in the classic sense since the + lab is a Kubernetes pod. We only know that something failed if the + record of the lab is hanging around in a failed state, so use a simple + non-zero exit status for that. Otherwise, we have no way to + distinguish between a pod that was shut down without error and a pod + that was stopped, so use an exit status of 0 in both cases. + """ + r = await self._client.get( + self._controller_url("labs", self.user.name), + headers=self._admin_authorization(), + ) + if r.status_code == 404: + return 0 + else: + r.raise_for_status() + result = r.json() + if result["status"] == LabStatus.FAILED: + return 1 + elif result["status"] in (LabStatus.TERMINATING, LabStatus.TERMINATED): + return 0 + else: + return None + + async def progress(self) -> AsyncIterator[dict[str, int | str]]: + """Monitor the progress of a spawn. + + This method is the internal implementation of the progress API. It + provides an iterator of spawn events and then ends when the spawn + succeeds or fails. + + Yields + ------ + dict of str to str or int + Dictionary representing the event with fields ``progress``, + containing an integer completion percentage, and ``message``, + containing a human-readable description of the event. + + Notes + ----- + This method must never raise exceptions, since those will be treated + as unhandled exceptions by JupyterHub. If anything fails, just stop + the iterator. It doesn't do any HTTP calls itself, just monitors the + events created by `start`. + + Uses the internal ``_start_future`` attribute to track when the + related `start` method has completed. + """ + next_event = 0 + complete = False + + # Insert a trigger into the trigger list that will be notified by the + # in-progress spawn. + trigger = asyncio.Event() + self._triggers.append(trigger) + + # Capture the current future and event stream in a local variable so + # that we consistently monitor the same invocation of start. If that + # one aborts and someone kicks off another one, we want to keep + # following the first one until it completes, not switch streams to + # the second one. + start_future = self._start_future + events = self._events + + # We were apparently called before start was called, so there's + # nothing to report. + if not start_future: + return + + while not complete: + trigger.clear() + if start_future.done(): + # Indicate that we're done, but continue to execute the rest + # of the loop. We want to process any events received before + # the spawner finishes and report them before ending the + # stream. + complete = True + + # This logic tries to ensure that we don't repeat events even + # though start will be adding more events while we're working. + len_events = len(events) + for i in range(next_event, len_events): + yield events[i].to_dict() + next_event = len_events + + # Wait until we're notified that there are new events or we time + # out on the spawn. This is not the correct timeout (start_timeout + # is a bound on the total time, not each event). It's just an + # arbitrary timeout to ensure we don't wait forever, which is + # guaranteed to be longer than a spawn can take. + if not complete: + try: + await asyncio.wait_for(trigger.wait(), self.start_timeout) + except TimeoutError: + complete = True + + def start(self) -> asyncio.Task[str]: + """Start the user's pod. + + Initiates the pod start operation and then waits for the pod to spawn + by watching the event stream, converting those events into the format + expected by JupyterHub and returned by `progress`. Returns only when + the pod is running and JupyterHub should start waiting for the lab + process to start responding. + + Returns + ------- + asyncio.Task + Running task monitoring the progress of the spawn. This task will + be started before it is returned. When the task is complete, it + will return the cluster-internal URL of the running Jupyter lab + process. + + Notes + ----- + The actual work is done in `_start`. This is a tiny wrapper to do + bookkeeping on the event stream and record the running task so that + `progress` can notice when the task is complete and return. + + It is tempting to only initiate the pod spawn here, return + immediately, and then let JupyterHub follow progress via the + `progress` API. However, this is not what JupyterHub is expecting. + The entire spawn process must happen before the `start` method returns + for the configured timeouts to work properly; once `start` has + returned, JupyterHub only allows a much shorter timeout for the lab to + fully start. + + In addition, JupyterHub handles exceptions from `start` and correctly + recognizes that the pod has failed to start, but exceptions from + `progress` are treated as uncaught exceptions and cause the UI to + break. Therefore, `progress` must never fail and all operations that + may fail need to be done in `start`. + """ + self._start_future = asyncio.create_task(self._start()) + return self._start_future + + @_convert_exception + async def _start(self) -> str: + """Spawn the user's lab. + + This is the core of the work of `start`. Ask the lab controller to + create the lab and monitor its progress, generating events that are + stored in the ``_events`` attribute for `progress`. + + JupyterHub will automatically call stop on failed spawns, so we don't + need to do that ourselves. + + Returns + ------- + str + Cluster-internal URL of the running Jupyter lab process. + + Raises + ------ + ControllerWebError + Raised on failure to talk to the lab controller or a failure + response from the lab controller. + InvalidAuthStateError + Raised if there is no ``token`` attribute in the user's + authentication state. This should always be provided by + `~rsp_restspawner.auth.GafaelfawrAuthenticator`. + MissingFieldError + Raised if the response from the lab controller is invalid. + SpawnFailedError + Raised if the lab controller said that the spawn failed. + + Notes + ----- + JupyterHub itself arranges for two spawns for the same spawner object + to not be running at the same time, so we ignore that possibility. + """ + progress = 0 + + # Clear the event list (by replacing the previous list so that any + # running progress calls see the old list, not the new one), and + # notify any existing triggers and then clear the trigger list. + self._events = [] + for trigger in self._triggers: + trigger.set() + self._triggers = [] + + # Ask the Nublado lab controller to do the spawn and monitor its + # progress until complete. + try: + r = await self._create_lab() + + # 409 (Conflict) indicates the user either already has a running + # pod (possibly in terminating status) or another spawn is already + # in progress. + # + # Ideally, we would reuse the running pod, but unfortunately at + # this point JupyterHub has already invalidated its OpenID Connect + # credentials, so we'll be unable to talk to it. We therefore have + # to delete it and recreate it. If the pod was already running + # (including when a delete was in progress), the stop should + # succeed. If a spawn was in progress, the stop should abort that + # spawn and clean up any remnants. If the lab was in terminating + # status, our stop call should join the stop call already in + # progress and complete when it does. + if r.status_code == 409: + event = SpawnEvent( + progress=1, + message="Deleting existing orphaned lab", + severity="warning", + ) + self._events.append(event) + await self.stop() + r = await self._create_lab() + + # Any remaining errors should fail lab creation with an exception. + r.raise_for_status() + + # The spawn is now in progress. Monitor the events endpoint until + # we get a completion or failure event. + timeout = timedelta(seconds=self.start_timeout) + async for sse in self._get_progress_events(timeout): + if sse.event == "ping": + # Sent by sse-starlette to keep the connection alive. + continue + event = SpawnEvent.from_sse(sse, progress) + if event.progress: + progress = event.progress + self._events.append(event) + if event.complete: + break + if event.failed: + raise SpawnFailedError(event.message) + + # Return the internal URL of the spawned pod. + return await self._get_internal_url() + + finally: + # Ensure that we set all the triggers just before we exit so that + # none of the progress calls will get stranded waiting for a lock. + for trigger in self._triggers: + trigger.set() + + @_convert_exception + async def stop(self) -> None: + """Delete any running pod for the user. + + If the pod does not exist, treat that as success. + + Raises + ------ + ControllerWebError + Raised on failure to talk to the lab controller or a failure + response from the lab controller. + """ + r = await self._client.delete( + self._controller_url("labs", self.user.name), + timeout=300.0, + headers=self._admin_authorization(), + ) + if r.status_code == 404: + # Nothing to delete, treat that as success. + return + else: + r.raise_for_status() + + def _controller_url(self, *components: str) -> str: + """Build a URL to the Nublado lab controller. + + Parameters + ---------- + *components + Path component of the URL. + + Returns + ------- + str + URL to the lab controller using the configured base URL. + """ + return self.controller_url + "/spawner/v1/" + "/".join(components) + + async def _create_lab(self) -> Response: + """Send the request to create the lab. + + Returns + ------- + httpx.Response + Response from the Nublado lab controller. + + Raises + ------ + httpx.HTTPError + Raised if the call to the Nublado lab controller failed. + """ + return await self._client.post( + self._controller_url("labs", self.user.name, "create"), + headers=await self._user_authorization(), + json={ + "options": self.options_from_form(self.user_options), + "env": self.get_env(), + }, + timeout=self.start_timeout, + ) + + async def _get_internal_url(self) -> str: + """Get the cluster-internal URL of a user's pod. + + Raises + ------ + httpx.HTTPError + Raised on failure to talk to the lab controller or a failure + response from the lab controller. + MissingFieldError + Raised if the response from the lab controller is invalid. + """ + r = await self._client.get( + self._controller_url("labs", self.user.name), + headers=self._admin_authorization(), + ) + r.raise_for_status() + url = r.json().get("internal_url") + if not url: + msg = f"Invalid lab status for {self.user.name}" + raise MissingFieldError(msg) + return url + + async def _get_progress_events( + self, timeout: timedelta + ) -> AsyncIterator[ServerSentEvent]: + """Get server-sent events for the user's pod-spawning status. + + Parameters + ---------- + timeout + Timeout for the request. + + Yields + ------ + ServerSentEvent + Next event from the lab controller's event stream. + + Raises + ------ + httpx.HTTPError + Raised on failure to talk to the lab controller or a failure + response from the lab controller. + InvalidAuthStateError + Raised if there is no ``token`` attribute in the user's + authentication state. This should always be provided by + `~rsp_restspawner.auth.GafaelfawrAuthenticator`. + """ + url = self._controller_url("labs", self.user.name, "events") + kwargs = { + "timeout": timeout.total_seconds(), + "headers": await self._user_authorization(), + } + async with aconnect_sse(self._client, "GET", url, **kwargs) as source: + async for sse in source.aiter_sse(): + yield sse + + def _admin_authorization(self) -> dict[str, str]: + """Create authorization headers for auth as JupyterHub itself. + + Returns + ------- + dict of str to str + Suitable headers for authenticating to the lab controller as the + JupyterHub pod. + """ + path = Path(self.admin_token_path) + token = path.read_text().strip() + return {"Authorization": f"Bearer {token}"} + + async def _user_authorization(self) -> dict[str, str]: + """Create authorization headers for auth as the user. + + Returns + ------- + dict of str to str + Suitable headers for authenticating to the lab controller as the + user. + + Raises + ------ + InvalidAuthStateError + Raised if there is no ``token`` attribute in the user's + authentication state. This should always be provided by + `~rsp_restspawner.auth.GafaelfawrAuthenticator`. + """ + auth_state = await self.user.get_auth_state() + if "token" not in auth_state: + raise InvalidAuthStateError("No token in user auth state") + return {"Authorization": "Bearer " + auth_state["token"]} diff --git a/spawner/tests/__init.py__ b/spawner/tests/__init.py__ new file mode 100644 index 000000000..e69de29bb diff --git a/spawner/tests/__init__.py b/spawner/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/spawner/tests/auth_test.py b/spawner/tests/auth_test.py new file mode 100644 index 000000000..0a34357e3 --- /dev/null +++ b/spawner/tests/auth_test.py @@ -0,0 +1,109 @@ +"""Tests for the Gafaelfawr authenticator. + +Most of the authenticator machinery is deeply entangled with JupyterHub and +therefore can't be tested easily (and is also kept as simple as possible). +This tests the logic that's sufficiently separable to run in a test harness. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from tornado import web +from tornado.httputil import HTTPHeaders + +from rsp_restspawner import GafaelfawrAuthenticator +from rsp_restspawner.auth import ( + GafaelfawrLoginHandler, + GafaelfawrLogoutHandler, + _build_auth_info, +) + + +@pytest.mark.asyncio +async def test_authenticator() -> None: + authenticator = GafaelfawrAuthenticator() + assert authenticator.get_handlers(MagicMock()) == [ + ("/gafaelfawr/login", GafaelfawrLoginHandler), + ("/logout", GafaelfawrLogoutHandler), + ] + + assert authenticator.login_url("/hub") == "/hub/gafaelfawr/login" + + # No request, just return True. + assert await authenticator.refresh_user(MagicMock()) is True + handler = MagicMock() + handler.request.headers = HTTPHeaders() + + # No headers, internal call, just return True and let JupyterHub do its + # normal thing. + assert await authenticator.refresh_user(MagicMock(), handler) is True + + # Token matches, return true. + user = MagicMock() + user.name = "rachel" + user.get_auth_state = AsyncMock() + user.get_auth_state.return_value = {"token": "token-of-affection"} + assert await authenticator.refresh_user(user, handler) is True + + # Token doesn't match, missing header, raise an error. + handler.request.headers = HTTPHeaders( + {"X-Auth-Request-Token": "token-of-affection"} + ) + user.get_auth_state.return_value = {"token": "blahblahblah"} + with pytest.raises(web.HTTPError): + await authenticator.refresh_user(user, handler) + + # Username doesn't match, raise an error. JupyterHub doesn't allow + # changing usernames in an authentication refresh, so we need to punt the + # user out entirely and force them to log in again. + handler.request.headers = HTTPHeaders( + { + "X-Auth-Request-User": "rachel", + "X-Auth-Request-Token": "token-of-affection", + } + ) + user.name = "wrench" + with pytest.raises(web.HTTPError): + await authenticator.refresh_user(user, handler) + + # Token doesn't match, proper headers, return the new auth state. + user.name = "rachel" + assert await authenticator.refresh_user(user, handler) == { + "name": "rachel", + "auth_state": {"token": "token-of-affection"}, + } + + +@pytest.mark.asyncio +async def test_login_handler() -> None: + """Test the core functionality of the login handler. + + We unfortunately can't test it directly because mocking out the guts of + Tornado and JupyterHub is too tedious and fragile. But all the important + work happens in a helper function anyway. + """ + with pytest.raises(web.HTTPError): + _build_auth_info(HTTPHeaders()) + + # One or the other header is missing. + headers = HTTPHeaders({"X-Auth-Request-User": "rachel"}) + with pytest.raises(web.HTTPError): + _build_auth_info(headers) + headers = HTTPHeaders({"X-Auth-Request-Token": "token-of-affection"}) + with pytest.raises(web.HTTPError): + _build_auth_info(headers) + + # Test with proper headers. + headers = HTTPHeaders( + { + "X-Auth-Request-User": "rachel", + "X-Auth-Request-Token": "token-of-affection", + } + ) + auth_state = _build_auth_info(headers) + assert auth_state == { + "name": "rachel", + "auth_state": {"token": "token-of-affection"}, + } diff --git a/spawner/tests/conftest.py b/spawner/tests/conftest.py new file mode 100644 index 000000000..71cff3ab5 --- /dev/null +++ b/spawner/tests/conftest.py @@ -0,0 +1,42 @@ +"""Fixtures for tests of the Nublado JupyterHub.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import respx + +from rsp_restspawner.spawner import RSPRestSpawner + +from .support.controller import MockLabController, register_mock_lab_controller +from .support.jupyterhub import MockHub, MockUser + + +@pytest.fixture +def mock_lab_controller(respx_mock: respx.Router) -> MockLabController: + url = "https://rsp.example.org/nublado" + admin_token = (Path(__file__).parent / "data" / "admin-token").read_text() + return register_mock_lab_controller( + respx_mock, + url, + user_token="token-of-affection", + admin_token=admin_token.strip(), + ) + + +@pytest.fixture +def spawner(mock_lab_controller: MockLabController) -> RSPRestSpawner: + """Add spawner state that normally comes from JupyterHub.""" + result = RSPRestSpawner() + result.admin_token_path = str( + Path(__file__).parent / "data" / "admin-token" + ) + result.controller_url = mock_lab_controller.base_url + result.hub = MockHub() + result.user = MockUser( + name="rachel", + auth_state={"token": "token-of-affection"}, + url="http://lab.nublado-rachel:8888", + ) + return result diff --git a/spawner/tests/data/admin-token b/spawner/tests/data/admin-token new file mode 100644 index 000000000..9e083b96d --- /dev/null +++ b/spawner/tests/data/admin-token @@ -0,0 +1 @@ +token-of-authority diff --git a/spawner/tests/spawner_test.py b/spawner/tests/spawner_test.py new file mode 100644 index 000000000..6b5af20e4 --- /dev/null +++ b/spawner/tests/spawner_test.py @@ -0,0 +1,183 @@ +"""Tests for the REST spawner class.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta + +import pytest + +from rsp_restspawner import RSPRestSpawner +from rsp_restspawner.exceptions import SpawnFailedError +from rsp_restspawner.spawner import LabStatus + +from .support.controller import MockLabController + + +async def collect_progress( + spawner: RSPRestSpawner, +) -> list[dict[str, int | str]]: + """Gather progress from a spawner and return it as a list when done.""" + return [m async for m in spawner.progress()] + + +@pytest.mark.asyncio +async def test_start(spawner: RSPRestSpawner) -> None: + user = spawner.user.name + assert await spawner.start() == f"http://lab.nublado-{user}:8888" + + # Calling start again while it is running will return a 409, which should + # trigger deletion of the lab followed by its recreation. This turns into + # the same apparent behavior at the API level. (The details of this are + # tested later.) + assert await spawner.start() == f"http://lab.nublado-{user}:8888" + + +@pytest.mark.asyncio +async def test_stop(spawner: RSPRestSpawner) -> None: + user = spawner.user.name + assert await spawner.start() == f"http://lab.nublado-{user}:8888" + assert await spawner.poll() is None + await spawner.stop() + assert await spawner.poll() == 0 + + # Delete a nonexistent lab. The lab controller will return 404, but the + # spawner should swallow it. + await spawner.stop() + + +@pytest.mark.asyncio +async def test_poll( + spawner: RSPRestSpawner, mock_lab_controller: MockLabController +) -> None: + assert await spawner.poll() == 0 + mock_lab_controller.set_status(spawner.user.name, LabStatus.PENDING) + assert await spawner.poll() is None + mock_lab_controller.set_status(spawner.user.name, LabStatus.RUNNING) + assert await spawner.poll() is None + mock_lab_controller.set_status(spawner.user.name, LabStatus.TERMINATING) + assert await spawner.poll() == 0 + mock_lab_controller.set_status(spawner.user.name, LabStatus.TERMINATED) + assert await spawner.poll() == 0 + mock_lab_controller.set_status(spawner.user.name, LabStatus.FAILED) + assert await spawner.poll() == 1 + await spawner.stop() + assert await spawner.poll() == 0 + + +@pytest.mark.asyncio +async def test_get_url(spawner: RSPRestSpawner) -> None: + user = spawner.user.name + assert await spawner.start() == f"http://lab.nublado-{user}:8888" + assert await spawner.get_url() == f"http://lab.nublado-{user}:8888" + + +@pytest.mark.asyncio +async def test_options_form(spawner: RSPRestSpawner) -> None: + expected = f"

This is some lab form for {spawner.user.name}

" + assert await spawner.options_form(spawner) == expected + + +@pytest.mark.asyncio +async def test_progress(spawner: RSPRestSpawner) -> None: + await spawner.start() + user = spawner.user.name + expected = [ + {"progress": 2, "message": "[info] Lab creation initiated"}, + {"progress": 45, "message": "[info] Pod requested"}, + { + "progress": 75, + "message": f"[info] Pod successfully spawned for {user}", + }, + ] + index = 0 + async for message in spawner.progress(): + assert message == expected[index] + index += 1 + assert index == len(expected) + + +@pytest.mark.asyncio +async def test_progress_conflict(spawner: RSPRestSpawner) -> None: + await spawner.start() + + # Start it a second time, which should trigger deleting the old lab. + await spawner.start() + user = spawner.user.name + expected = [ + {"progress": 1, "message": "[warning] Deleting existing orphaned lab"}, + {"progress": 2, "message": "[info] Lab creation initiated"}, + {"progress": 45, "message": "[info] Pod requested"}, + { + "progress": 75, + "message": f"[info] Pod successfully spawned for {user}", + }, + ] + index = 0 + async for message in spawner.progress(): + assert message == expected[index] + index += 1 + assert index == len(expected) + + +@pytest.mark.asyncio +async def test_progress_multiple( + spawner: RSPRestSpawner, mock_lab_controller: MockLabController +) -> None: + """Test multiple progress listeners for the same spawn.""" + mock_lab_controller.delay = timedelta(milliseconds=750) + user = spawner.user.name + expected = [ + {"progress": 2, "message": "[info] Lab creation initiated"}, + {"progress": 45, "message": "[info] Pod requested"}, + { + "progress": 75, + "message": f"[info] Pod successfully spawned for {user}", + }, + ] + + results = await asyncio.gather( + spawner.start(), + collect_progress(spawner), + collect_progress(spawner), + collect_progress(spawner), + ) + url = results[0] + assert url == f"http://lab.nublado-{user}:8888" + for events in results[1:]: + assert events == expected + + +@pytest.mark.asyncio +async def test_spawn_failure( + spawner: RSPRestSpawner, mock_lab_controller: MockLabController +) -> None: + """Test error handling when a spawn fails. + + Also tests invalid JSON in the event body. In those cases, the raw event + body should be treated as the message. + """ + mock_lab_controller.delay = timedelta(milliseconds=750) + mock_lab_controller.fail_during_spawn = True + user = spawner.user.name + expected = [ + {"progress": 2, "message": "[info] Lab creation initiated"}, + {"progress": 45, "message": "[info] Pod requested"}, + {"progress": 45, "message": "[unknown] This is not JSON"}, + {"progress": 45, "message": "[error] Something is going wrong"}, + {"progress": 45, "message": '[info] {"invalid": "value"}'}, + { + "progress": 45, + "message": '[info] {"message": "Blah", "progress": "Happy!"}', + }, + { + "progress": 45, + "message": f"[error] Some random failure for {user}", + }, + ] + + results = await asyncio.gather( + spawner.start(), collect_progress(spawner), return_exceptions=True + ) + assert isinstance(results[0], SpawnFailedError) + assert results[1] == expected diff --git a/spawner/tests/support/__init__.py b/spawner/tests/support/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/spawner/tests/support/controller.py b/spawner/tests/support/controller.py new file mode 100644 index 000000000..130fd9ed9 --- /dev/null +++ b/spawner/tests/support/controller.py @@ -0,0 +1,227 @@ +"""Mock responses from jupyterlab-controller.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from datetime import UTC, datetime, timedelta + +import respx +from httpx import AsyncByteStream, Request, Response + +from rsp_restspawner.spawner import LabStatus + +__all__ = [ + "MockLabController", + "register_mock_lab_controller", +] + + +class MockProgress(AsyncByteStream): + """Generator that produces progress events for a lab spawn. + + An instantiation of this object is suitable for passing as the stream + argument to an `httpx.Response`. + + Parameters + ---------- + user + Name of user for which progress events should be generated. + delay + Delay by this long between events. + fail_during_spawn + Whether to emit a failure message instead of a completion message. + """ + + def __init__( + self, user: str, delay: timedelta, *, fail_during_spawn: bool = False + ) -> None: + self._user = user + self._delay = delay + self._fail_during_spawn = fail_during_spawn + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield b"event: info\r\n" + yield b'data: {"message": "Lab creation initiated", "progress": 2}\r\n' + yield b"\r\n" + + await asyncio.sleep(self._delay.total_seconds()) + + # sse-starlette sends these ping events periodically to keep the + # connection alive. We should just ignore them. + yield b"event: ping\r\n" + yield b"data: " + str(datetime.now(tz=UTC)).encode() + b"\r\n" + yield b"\r\n" + + yield b"event: info\r\n" + yield b'data: {"message": "Pod requested", "progress": 45}\r\n' + yield b"\r\n" + + await asyncio.sleep(self._delay.total_seconds()) + + if self._fail_during_spawn: + yield b"event: blahblah\r\n" + yield b"data: This is not JSON\r\n" + yield b"\r\n" + + yield b"event: error\r\n" + yield b'data: {"message": "Something is going wrong"}\r\n' + yield b"\r\n" + + yield b"event: info\r\n" + yield b'data: {"invalid": "value"}\r\n' + yield b"\r\n" + + yield b"event: info\r\n" + yield b'data: {"message": "Blah", "progress": "Happy!"}\r\n' + yield b"\r\n" + + yield b"event: failed\r\n" + msg = f"Some random failure for {self._user}" + yield b'data: {"message": "' + msg.encode() + b'"}\r\n' + yield b"\r\n" + else: + yield b"event: complete\r\n" + msg = f"Pod successfully spawned for {self._user}" + yield b'data: {"message": "' + msg.encode() + b'"}\r\n' + yield b"\r\n" + + +class MockLabController: + """Mock Nublado lab controller. + + This is an extremely simplified version of the lab controller API + specified in `SQR-066 `__. + + Attributes + ---------- + base_url + Base URL with which the mock was configured. + delay + Set this to the desired delay between server-sent events. + + Parameters + ---------- + base_url + Base URL where the mock is installed, used for constructing redirects. + user_token + User token expected for routes requiring user authentication. + admin_token + JupyterHub token expected for routes only it can use. + """ + + def __init__( + self, base_url: str, user_token: str, admin_token: str + ) -> None: + self.base_url = base_url + self.delay = timedelta(seconds=0) + self.fail_during_spawn = False + self._user_token = user_token + self._admin_token = admin_token + self._url = f"{base_url}/spawner/v1" + self._lab_status: dict[str, LabStatus] = {} + + def create(self, request: Request, user: str) -> Response: + self._check_authorization(request) + if self._lab_status.get(user): + return Response(status_code=409) + if self.fail_during_spawn: + self._lab_status[user] = LabStatus.FAILED + else: + self._lab_status[user] = LabStatus.RUNNING + location = f"{self._url}/{user}" + return Response(status_code=201, headers={"Location": location}) + + def delete(self, request: Request, user: str) -> Response: + self._check_authorization(request, admin=True) + if self._lab_status.get(user): + del self._lab_status[user] + return Response(status_code=202) + else: + return Response(status_code=404) + + def events(self, request: Request, user: str) -> Response: + self._check_authorization(request) + if not self._lab_status.get(user): + return Response(status_code=404) + stream = MockProgress( + user, self.delay, fail_during_spawn=self.fail_during_spawn + ) + return Response( + status_code=200, + headers={"Content-Type": "text/event-stream"}, + stream=stream, + ) + + def lab_form(self, request: Request, user: str) -> Response: + self._check_authorization(request) + return Response( + status_code=200, text=f"

This is some lab form for {user}

" + ) + + def set_status(self, user: str, status: LabStatus) -> None: + """Set the lab status for a given user, called by tests.""" + self._lab_status[user] = status + + def status(self, request: Request, user: str) -> Response: + self._check_authorization(request, admin=True) + if not self._lab_status.get(user): + return Response(status_code=404) + return Response( + status_code=200, + json={ + "status": self._lab_status[user], + "internal_url": f"http://lab.nublado-{user}:8888", + }, + ) + + def _check_authorization( + self, request: Request, *, admin: bool = False + ) -> None: + authorization = request.headers["Authorization"] + auth_type, token = authorization.split(None, 1) + assert auth_type.lower() == "bearer" + if admin: + assert token == self._admin_token + else: + assert token == self._user_token + + +def register_mock_lab_controller( + respx_mock: respx.Router, + base_url: str, + *, + user_token: str, + admin_token: str, +) -> MockLabController: + """Mock out a Nublado lab controller. + + Parameters + ---------- + respx_mock + Mock router. + base_url + Base URL for the lab controller. + user_token + User token expected for routes requiring user authentication. + admin_token + JupyterHub token expected for routes only it can use. + + Returns + ------- + MockLabController + The mock JupyterlabController object. + """ + base_labs_url = f"{base_url}/spawner/v1/labs/(?P[^/]*)" + lab_url = f"{base_labs_url}$" + create_url = f"{base_labs_url}/create$" + events_url = f"{base_labs_url}/events$" + lab_form_url = f"{base_url}/spawner/v1/lab-form/(?P[^/]*)$" + + mock = MockLabController(base_url, user_token, admin_token) + respx_mock.get(url__regex=lab_url).mock(side_effect=mock.status) + respx_mock.delete(url__regex=lab_url).mock(side_effect=mock.delete) + respx_mock.post(url__regex=create_url).mock(side_effect=mock.create) + respx_mock.get(url__regex=events_url).mock(side_effect=mock.events) + respx_mock.get(url__regex=lab_form_url).mock(side_effect=mock.lab_form) + return mock diff --git a/spawner/tests/support/jupyterhub.py b/spawner/tests/support/jupyterhub.py new file mode 100644 index 000000000..bd83a125b --- /dev/null +++ b/spawner/tests/support/jupyterhub.py @@ -0,0 +1,38 @@ +"""Mock objects for data normally provided to the spawner by JupyterHub.""" + +from __future__ import annotations + +from dataclasses import dataclass + +__all__ = ["MockHub", "MockUser"] + + +@dataclass +class MockHub: + """The ``hub`` attribute of a spawner. + + These values are used by the parent JupyterHub Spawner class to populate + the default environment. The values are arbitrary -- they just need to + exist -- so don't go to any great lengths to make them "correct." + """ + + api_url = "http://hub.nublado:8081" + public_host = "rsp.example.com" + base_url = "https://rsp.example.com/nb/hub" + + +@dataclass +class MockUser: + """The ``user`` attribute of a spawner. + + This is normally populated by GafaelfawrAuthenticator via the JupyterHub + code, so ``auth_state`` should be set to whatever that authenticator would + normally set it to. + """ + + name: str + auth_state: dict[str, str] + url: str = "https://rsp.example.com/nb/someuser" + + async def get_auth_state(self) -> dict[str, str]: + return self.auth_state