From 0cdc409a88b36df5fb166aba1b9328e2892b901c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 19 Jun 2023 09:37:16 +0200 Subject: [PATCH 01/18] ci: add dependabot to bump github actions --- .github/dependabot.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/dependabot.yaml diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 00000000..8e6c3273 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,16 @@ +# dependabot.yaml reference: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# Notes: +# - Status and logs from dependabot are provided at +# https://github.com/jupyterhub/tmpauthenticator/network/updates. +# +version: 2 +updates: + # Maintain dependencies in our GitHub Workflows + - package-ecosystem: github-actions + directory: / + labels: [ci] + schedule: + interval: monthly + time: "05:00" + timezone: Etc/UTC From d10047a49382442bff20abd354f284ecad3f013d Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 19 Jun 2023 09:19:37 +0200 Subject: [PATCH 02/18] ci: rename github workflow test.yml to test.yaml --- .github/workflows/{test.yml => test.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{test.yml => test.yaml} (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yaml similarity index 100% rename from .github/workflows/test.yml rename to .github/workflows/test.yaml From 1d8163b0f5118a04012acccafa16535bd0f0fe7d Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 19 Jun 2023 09:50:32 +0200 Subject: [PATCH 03/18] docs: update readme badges --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0d3c5652..a7941d6c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # batchspawner for Jupyterhub -[![GitHub Workflow Status - Test](https://img.shields.io/github/workflow/status/jupyterhub/batchspawner/Test?logo=github&label=tests)](https://github.com/jupyterhub/batchspawner/actions) -[![Latest PyPI version](https://img.shields.io/pypi/v/batchspawner?logo=pypi&logoColor=white)](https://pypi.python.org/pypi/batchspawner) -[![GitHub](https://img.shields.io/badge/issue_tracking-github-blue?logo=github)](https://github.com/jupyterhub/batchspawner/issues) -[![Discourse](https://img.shields.io/badge/help_forum-discourse-blue?logo=discourse)](https://discourse.jupyter.org/c/jupyterhub) -[![Gitter](https://img.shields.io/badge/social_chat-gitter-blue?logo=gitter)](https://gitter.im/jupyterhub/jupyterhub) +[![Latest PyPI version](https://img.shields.io/pypi/v/batchspawner?logo=pypi)](https://pypi.python.org/pypi/batchspawner) +[![Latest conda-forge version](https://img.shields.io/conda/vn/conda-forge/batchspawner?logo=conda-forge)](https://anaconda.org/conda-forge/batchspawner) +[![GitHub Workflow Status - Test](https://img.shields.io/github/actions/workflow/status/jupyterhub/batchspawner/test.yaml?logo=github&label=tests)](https://github.com/jupyterhub/batchspawner/actions) +[![Test coverage of code](https://codecov.io/gh/jupyterhub/batchspawner/branch/main/graph/badge.svg)](https://codecov.io/gh/jupyterhub/batchspawner) +[![Issue tracking - GitHub](https://img.shields.io/badge/issue_tracking-github-blue?logo=github)](https://github.com/jupyterhub/batchspawner/issues) +[![Help forum - Discourse](https://img.shields.io/badge/help_forum-discourse-blue?logo=discourse)](https://discourse.jupyter.org/c/jupyterhub) [![Contribute](https://img.shields.io/badge/I_want_to_contribute!-grey?logo=jupyter)](https://github.com/jupyterhub/batchspawner/blob/master/CONTRIBUTING.md) This is a custom spawner for [Jupyterhub](https://jupyterhub.readthedocs.io/) that is designed for installations on clusters using batch scheduling software. From a7ea23984d23c4def78919264e6e67d566922e19 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 19 Jun 2023 09:37:06 +0200 Subject: [PATCH 04/18] breaking: require py37+ and jupyterhub 1.5.1+, test jupyterhub 4 --- .github/workflows/test.yaml | 88 ++++++++++++++++--------------------- MANIFEST.in | 1 - pyproject.toml | 3 ++ requirements.txt | 3 -- setup.py | 60 +++++++------------------ 5 files changed, 58 insertions(+), 97 deletions(-) create mode 100644 pyproject.toml delete mode 100644 requirements.txt mode change 100755 => 100644 setup.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 61bb8785..535dee1e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,5 +1,5 @@ # This is a GitHub workflow defining a set of jobs with a set of steps. -# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions +# ref: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions # name: Test @@ -7,15 +7,13 @@ on: pull_request: paths-ignore: - "**.md" - - "**.yml" - - "**.yaml" - - "!.github/workflows/test.yml" + - ".github/workflows/*.yaml" + - "!.github/workflows/test.yaml" push: paths-ignore: - "**.md" - - "**.yml" - - "**.yaml" - - "!.github/workflows/test.yml" + - ".github/workflows/*.yaml" + - "!.github/workflows/test.yaml" branches-ignore: - "dependabot/**" - "pre-commit-ci-update-config" @@ -24,65 +22,55 @@ on: jobs: pytest: - name: "Run pytest" - runs-on: ubuntu-20.04 - continue-on-error: ${{ matrix.allow_failure }} + name: Run pytest + runs-on: ubuntu-22.04 + strategy: - # Keep running even if one variation of the job fail fail-fast: false matrix: - python-version: - - "3.6" - - "3.10" - JHUB_VER: - - "1.0.0" - - "1.5.1" - - "2.3.1" - allow_failure: [false] - - exclude: - # JupyterHub 1.3.0 requires python 3.6+ - - JHUB_VER: "1.3.0" - python-version: "3.5" - # JupyterHub 0.9.6 used a deprecated sqlalchemy feature removed in py3.9 environment - - JHUB_VER: "0.9.6" - python-version: "3.9" include: - - JHUB_VER: "main" - python-version: "3.9" - allow_failure: true - - JHUB_VER: "3.0.0" - python-version: "3.9" - allow_failure: true + # test oldest supported version + - python-version: "3.7" + pip-install-spec: "jupyterhub==1.5.1 sqlalchemy==1.*" + + - python-version: "3.8" + pip-install-spec: "jupyterhub==2.* sqlalchemy==1.*" + - python-version: "3.10" + pip-install-spec: "jupyterhub==3.*" + - python-version: "3.11" + pip-install-spec: "jupyterhub==4.*" + + # test unreleased jupyterhub, failures tolerated + - python-version: "3.X" + pip-install-spec: "git+https://github.com/jupyterhub/jupyterhub" + allow-failure: true steps: - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + - uses: actions/setup-node@v3 + with: + node-version: "18" + - uses: actions/setup-python@v4 with: python-version: "${{ matrix.python-version }}" - - name: Install dependencies + - name: Install Node dependencies run: | - python -m pip install --upgrade pip - python -m pip install pytest - pip install -r requirements.txt - pip list + npm install -g configurable-http-proxy - - name: Install nodejs dependencies + - name: Install Python dependencies run: | - sudo npm install -g configurable-http-proxy + pip install --upgrade pip + pip install ${{ matrix.pip-install-spec }} + pip install -e ".[test]" - # We need to check compatibility with different versions of the JH API, - # including latest development. For that, we also need to pull in the - # development dependencies of that old JH version (but we don't need - # conda/npm for our tests). - - name: install JupyterHub + - name: List dependencies run: | - git clone --quiet --branch ${{ matrix.JHUB_VER }} https://github.com/jupyterhub/jupyterhub.git ./jupyterhub - pip install -r ./jupyterhub/dev-requirements.txt - pip install ./jupyterhub + pip freeze - name: pytest run: | pytest --verbose --color=yes --last-failed --cov batchspawner batchspawner/tests + + # GitHub action reference: https://github.com/codecov/codecov-action + - uses: codecov/codecov-action@v3 diff --git a/MANIFEST.in b/MANIFEST.in index 293d9460..9a2849d3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ include *.md include LICENSE include version.py -include requirements.txt diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..9787c3bd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 31adaa08..00000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -async_generator>=1.8 -jinja2 -jupyterhub>=0.9 diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 index 3e3da4af..fffba56a --- a/setup.py +++ b/setup.py @@ -1,33 +1,14 @@ -#!/usr/bin/env python -# coding: utf-8 - -# Copyright (c) Jupyter Development Team. -# Distributed under the terms of the Modified BSD License. - -# ----------------------------------------------------------------------------- -# Minimal Python version sanity check (from IPython/Jupyterhub) -# ----------------------------------------------------------------------------- - -from __future__ import print_function - -import os -import sys - from setuptools import setup -from glob import glob -pjoin = os.path.join -here = os.path.abspath(os.path.dirname(__file__)) +with open("README.md") as f: + long_description = f.read() # Get the current package version. version_ns = {} -with open(pjoin(here, "version.py")) as f: +with open("version.py") as f: exec(f.read(), {}, version_ns) -with open(pjoin(here, "README.md"), encoding="utf-8") as f: - long_desc = f.read() - -setup_args = dict( +setup( name="batchspawner", entry_points={ "console_scripts": ["batchspawner-singleuser=batchspawner.singleuser:main"], @@ -35,14 +16,13 @@ packages=["batchspawner"], version=version_ns["__version__"], description="""Batchspawner: A spawner for Jupyterhub to spawn notebooks using batch resource managers.""", - long_description=long_desc, + long_description=long_description, long_description_content_type="text/markdown", author="Michael Milligan, Andrea Zonca, Mike Gilbert", author_email="milligan@umn.edu", url="http://jupyter.org", license="BSD", platforms="Linux, Mac OS X", - python_requires="~=3.5", keywords=["Interactive", "Interpreter", "Shell", "Web", "Jupyter"], classifiers=[ "Intended Audience :: Developers", @@ -58,22 +38,16 @@ "About Jupyterhub": "http://jupyterhub.readthedocs.io/en/latest/", "Jupyter Project": "http://jupyter.org", }, + python_requires=">=3.7", + install_require={ + "async_generator>=1.8", + "jinja2", + "jupyterhub>=1.5.1", + }, + extras_require={ + "test": [ + "pytest", + "pytest-cov", + ], + }, ) - -# setuptools requirements -if "setuptools" in sys.modules: - setup_args["install_requires"] = install_requires = [] - with open("requirements.txt") as f: - for line in f.readlines(): - req = line.strip() - if not req or req.startswith(("-e", "#")): - continue - install_requires.append(req) - - -def main(): - setup(**setup_args) - - -if __name__ == "__main__": - main() From c8430a83c0d6594a1ceaca18d8fce01b865ebda4 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 19 Jun 2023 09:55:44 +0200 Subject: [PATCH 05/18] maint: include __version__, add RELEASE.md, use tbump --- MANIFEST.in | 1 - RELEASE.md | 60 ++++++++++++++++++++++++++++++++++++++++ batchspawner/__init__.py | 1 + batchspawner/_version.py | 7 +++++ pyproject.toml | 32 +++++++++++++++++++++ setup.py | 7 +---- version.py | 10 ------- 7 files changed, 101 insertions(+), 17 deletions(-) create mode 100644 RELEASE.md create mode 100644 batchspawner/_version.py delete mode 100644 version.py diff --git a/MANIFEST.in b/MANIFEST.in index 9a2849d3..7d43f806 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,2 @@ include *.md include LICENSE -include version.py diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..b0863e54 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,60 @@ +# How to make a release + +`batchspawner` is a package available on [PyPI] and on [conda-forge]. + +These are the instructions on how to make a release. + +## Pre-requisites + +- Push rights to this GitHub repository + +## Steps to make a release + +1. Create a PR updating `CHANGELOG.md` with [github-activity] and continue when + its merged. + + Advice on this procedure can be found in [this team compass + issue](https://github.com/jupyterhub/team-compass/issues/563). + +2. Checkout main and make sure it is up to date. + + ```shell + git checkout main + git fetch origin main + git reset --hard origin/main + ``` + +3. Update the version, make commits, and push a git tag with `tbump`. + + ```shell + pip install tbump + ``` + + `tbump` will ask for confirmation before doing anything. + + ```shell + # Example versions to set: 1.0.0, 1.0.0b1 + VERSION= + tbump ${VERSION} + ``` + + Following this, the [CI system] will build and publish a release. + +4. Reset the version back to dev, e.g. `1.0.1.dev` after releasing `1.0.0`. + + ```shell + # Example version to set: 1.0.1.dev + NEXT_VERSION= + tbump --no-tag ${NEXT_VERSION}.dev + ``` + +5. Following the release to PyPI, an automated PR should arrive within 24 hours + to [conda-forge/batchspawner-feedstock] with instructions on releasing to + conda-forge. You are welcome to volunteer doing this, but aren't required as + part of making this release to PyPI. + +[github-activity]: https://github.com/executablebooks/github-activity +[pypi]: https://pypi.org/project/batchspawner/ +[ci system]: https://github.com/jupyterhub/batchspawner/actions/workflows/release.yaml +[conda-forge]: https://anaconda.org/conda-forge/batchspawner +[conda-forge/batchspawner-feedstock]: https://github.com/conda-forge/batchspawner-feedstock diff --git a/batchspawner/__init__.py b/batchspawner/__init__.py index 976081ee..8958947d 100644 --- a/batchspawner/__init__.py +++ b/batchspawner/__init__.py @@ -1,2 +1,3 @@ from .batchspawner import * +from ._version import __version__, version_info from . import api diff --git a/batchspawner/_version.py b/batchspawner/_version.py new file mode 100644 index 00000000..528639f8 --- /dev/null +++ b/batchspawner/_version.py @@ -0,0 +1,7 @@ +# __version__ should be updated using tbump, based on configuration in +# pyproject.toml, according to instructions in RELEASE.md. +# +__version__ = "1.3.0.dev" + +# version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev +version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 9787c3bd..b80ea1ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,35 @@ [build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" + + +# tbump is used to simplify and standardize the release process when updating +# the version, making a git commit and tag, and pushing changes. +# +# ref: https://github.com/your-tools/tbump#readme +# +[tool.tbump] +github_url = "https://github.com/jupyterhub/tmpauthenticator" + +[tool.tbump.version] +current = "1.3.0.dev" +regex = ''' + (?P\d+) + \. + (?P\d+) + \. + (?P\d+) + (?P
((a|b|rc)\d+)|)
+    \.?
+    (?P(?<=\.)dev\d*|)
+'''
+
+[tool.tbump.git]
+message_template = "Bump to {new_version}"
+tag_template = "v{new_version}"
+
+[[tool.tbump.file]]
+src = "setup.py"
+
+[[tool.tbump.file]]
+src = "batchspawner/_version.py"
diff --git a/setup.py b/setup.py
index fffba56a..089bcc8d 100644
--- a/setup.py
+++ b/setup.py
@@ -3,18 +3,13 @@
 with open("README.md") as f:
     long_description = f.read()
 
-# Get the current package version.
-version_ns = {}
-with open("version.py") as f:
-    exec(f.read(), {}, version_ns)
-
 setup(
     name="batchspawner",
     entry_points={
         "console_scripts": ["batchspawner-singleuser=batchspawner.singleuser:main"],
     },
     packages=["batchspawner"],
-    version=version_ns["__version__"],
+    version="1.3.0.dev",
     description="""Batchspawner: A spawner for Jupyterhub to spawn notebooks using batch resource managers.""",
     long_description=long_description,
     long_description_content_type="text/markdown",
diff --git a/version.py b/version.py
deleted file mode 100644
index 94724488..00000000
--- a/version.py
+++ /dev/null
@@ -1,10 +0,0 @@
-# Copyright (c) Jupyter Development Team.
-# Distributed under the terms of the Modified BSD License.
-
-version_info = (
-    1,
-    2,
-    0,
-    #    "dev",  # comment-out this line for a release
-)
-__version__ = ".".join(map(str, version_info))

From 33eddfce4a536590023dbe088177ae6a87226771 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Mon, 19 Jun 2023 09:58:00 +0200
Subject: [PATCH 06/18] refactor: put pytest config in pyproject.toml

---
 .github/workflows/test.yaml | 2 +-
 pyproject.toml              | 9 +++++++++
 2 files changed, 10 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 535dee1e..ef7f389b 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -70,7 +70,7 @@ jobs:
 
       - name: pytest
         run: |
-          pytest --verbose --color=yes --last-failed --cov batchspawner batchspawner/tests
+          pytest --cov=batchspawner
 
       # GitHub action reference: https://github.com/codecov/codecov-action
       - uses: codecov/codecov-action@v3
diff --git a/pyproject.toml b/pyproject.toml
index b80ea1ec..04b230e4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,6 +3,15 @@ requires = ["setuptools", "wheel"]
 build-backend = "setuptools.build_meta"
 
 
+# pytest is used for running Python based tests
+#
+# ref: https://docs.pytest.org/en/stable/
+#
+[tool.pytest.ini_options]
+addopts = "--verbose --color=yes --durations=10"
+testpaths = ["batchspawner/tests"]
+
+
 # tbump is used to simplify and standardize the release process when updating
 # the version, making a git commit and tag, and pushing changes.
 #

From 1790683ad154a2d877aa4e211488366824aac836 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Mon, 19 Jun 2023 09:58:28 +0200
Subject: [PATCH 07/18] refactor: put black config in pyproject.toml

---
 .pre-commit-config.yaml |  7 -------
 pyproject.toml          | 14 ++++++++++++++
 2 files changed, 14 insertions(+), 7 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 091dd472..b21a1cd0 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,13 +14,6 @@ repos:
     rev: "23.3.0"
     hooks:
       - id: black
-        args:
-          - --target-version=py36
-          - --target-version=py37
-          - --target-version=py38
-          - --target-version=py39
-          - --target-version=py310
-          - --target-version=py311
 
   # Autoformat: markdown, yaml
   - repo: https://github.com/pre-commit/mirrors-prettier
diff --git a/pyproject.toml b/pyproject.toml
index 04b230e4..7812dce0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,6 +3,20 @@ requires = ["setuptools", "wheel"]
 build-backend = "setuptools.build_meta"
 
 
+# black is used for autoformatting Python code
+#
+# ref: https://black.readthedocs.io/en/stable/
+#
+[tool.black]
+target_version = [
+    "py37",
+    "py38",
+    "py39",
+    "py310",
+    "py311",
+]
+
+
 # pytest is used for running Python based tests
 #
 # ref: https://docs.pytest.org/en/stable/

From 45d4d6ab07bda6476dee10f6666c841cdd4012f2 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Mon, 19 Jun 2023 10:02:24 +0200
Subject: [PATCH 08/18] pre-commit: add hooks pyupgrade, autoflake, isort

---
 .pre-commit-config.yaml | 23 +++++++++++++++++++++++
 pyproject.toml          | 19 +++++++++++++++++++
 2 files changed, 42 insertions(+)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index b21a1cd0..7d36cd52 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -9,6 +9,29 @@
 # - Register git hooks: pre-commit install --install-hooks
 #
 repos:
+  # Autoformat: Python code, syntax patterns are modernized
+  - repo: https://github.com/asottile/pyupgrade
+    rev: v3.3.2
+    hooks:
+      - id: pyupgrade
+        args:
+          - --py38-plus
+
+  # Autoformat: Python code
+  - repo: https://github.com/PyCQA/autoflake
+    rev: v2.1.1
+    hooks:
+      - id: autoflake
+        # args ref: https://github.com/PyCQA/autoflake#advanced-usage
+        args:
+          - --in-place
+
+  # Autoformat: Python code
+  - repo: https://github.com/pycqa/isort
+    rev: 5.12.0
+    hooks:
+      - id: isort
+
   # Autoformat: Python code
   - repo: https://github.com/psf/black
     rev: "23.3.0"
diff --git a/pyproject.toml b/pyproject.toml
index 7812dce0..98553ba6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,6 +3,25 @@ requires = ["setuptools", "wheel"]
 build-backend = "setuptools.build_meta"
 
 
+# autoflake is used for autoformatting Python code
+#
+# ref: https://github.com/PyCQA/autoflake#readme
+#
+[tool.autoflake]
+ignore-init-module-imports = true
+#remove-all-unused-imports = true
+remove-duplicate-keys = true
+remove-unused-variables = true
+
+
+# isort is used for autoformatting Python code
+#
+# ref: https://pycqa.github.io/isort/
+#
+[tool.isort]
+profile = "black"
+
+
 # black is used for autoformatting Python code
 #
 # ref: https://black.readthedocs.io/en/stable/

From 3269efdd51f4592dced395e4b89d9b2b4ef7e97e Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Mon, 19 Jun 2023 10:02:42 +0200
Subject: [PATCH 09/18] maint: simplify flake8 config

---
 .flake8 | 16 +++++++---------
 1 file changed, 7 insertions(+), 9 deletions(-)

diff --git a/.flake8 b/.flake8
index 87f9b977..246a7780 100644
--- a/.flake8
+++ b/.flake8
@@ -1,13 +1,11 @@
+# flake8 is used for linting Python code setup to automatically run with
+# pre-commit.
+#
+# ref: https://flake8.pycqa.org/en/latest/user/configuration.html
+#
 [flake8]
-# Ignore style and complexity
 # E: style errors
 # W: style warnings
 # C: complexity
-# F401: module imported but unused
-# F403: import *
-# F811: redefinition of unused `name` from line `N`
-# F841: local variable assigned but never used
-# E402: module level import not at top of file
-# I100: Import statements are in the wrong order
-# I101: Imported names are in the wrong order. Should be
-ignore = E, W, C, F401, F403, F811, F841, E402, I100, I101, D400
+# D: docstring warnings (unused pydocstyle extension)
+ignore = E, C, W, D

From 8a607cd6bdb78a909c2b8b10be14f93f134a593d Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Mon, 19 Jun 2023 10:11:19 +0200
Subject: [PATCH 10/18] refactor: fix a few details to please flake8

---
 batchspawner/__init__.py            | 6 +++---
 batchspawner/singleuser.py          | 4 ++--
 batchspawner/tests/conftest.py      | 4 ++--
 batchspawner/tests/test_spawners.py | 4 ++--
 4 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/batchspawner/__init__.py b/batchspawner/__init__.py
index 8958947d..4c44387d 100644
--- a/batchspawner/__init__.py
+++ b/batchspawner/__init__.py
@@ -1,3 +1,3 @@
-from .batchspawner import *
-from ._version import __version__, version_info
-from . import api
+from . import api  # noqa
+from ._version import __version__, version_info  # noqa
+from .batchspawner import *  # noqa
diff --git a/batchspawner/singleuser.py b/batchspawner/singleuser.py
index a56d67db..1bd2b49f 100644
--- a/batchspawner/singleuser.py
+++ b/batchspawner/singleuser.py
@@ -27,9 +27,9 @@ def main(argv=None):
     if hub_auth.client_ca:
         kwargs["verify"] = hub_auth.client_ca
 
-    r = requests.post(
+    requests.post(
         url,
-        headers={"Authorization": f"token {hub_auth.api_token}"},
+        headers=headers,
         json={"port": port},
         **kwargs,
     )
diff --git a/batchspawner/tests/conftest.py b/batchspawner/tests/conftest.py
index 46856885..0003d5c6 100644
--- a/batchspawner/tests/conftest.py
+++ b/batchspawner/tests/conftest.py
@@ -2,9 +2,9 @@
 
 # We only use "db" and "io_loop", but we also need event_loop which is used by
 # io_loop to be available with jupyterhub 1+.
-from jupyterhub.tests.conftest import db, io_loop
+from jupyterhub.tests.conftest import db, io_loop  # noqa
 
 try:
-    from jupyterhub.tests.conftest import event_loop
+    from jupyterhub.tests.conftest import event_loop  # noqa
 except:
     pass
diff --git a/batchspawner/tests/test_spawners.py b/batchspawner/tests/test_spawners.py
index 2416680f..9b599fea 100644
--- a/batchspawner/tests/test_spawners.py
+++ b/batchspawner/tests/test_spawners.py
@@ -131,7 +131,7 @@ def test_submit_failure(db, io_loop):
     spawner = new_spawner(db=db)
     assert spawner.get_state() == {}
     spawner.batch_submit_cmd = "cat > /dev/null; true"
-    with pytest.raises(RuntimeError) as e_info:
+    with pytest.raises(RuntimeError):
         io_loop.run_sync(spawner.start, timeout=30)
     assert spawner.job_id == ""
     assert spawner.job_status == ""
@@ -142,7 +142,7 @@ def test_submit_pending_fails(db, io_loop):
     spawner = new_spawner(db=db)
     assert spawner.get_state() == {}
     spawner.batch_query_cmd = "echo xyz"
-    with pytest.raises(RuntimeError) as e_info:
+    with pytest.raises(RuntimeError):
         io_loop.run_sync(spawner.start, timeout=30)
     status = io_loop.run_sync(spawner.query_job_status, timeout=30)
     assert status == JobStatus.NOTFOUND

From 224ec3e9f6940beb8922dc753ae5f7e4586cec56 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Mon, 19 Jun 2023 10:23:07 +0200
Subject: [PATCH 11/18] maint: with py36+ required async_generator isn't needed

---
 batchspawner/batchspawner.py | 20 +++-----------------
 setup.py                     |  1 -
 2 files changed, 3 insertions(+), 18 deletions(-)

diff --git a/batchspawner/batchspawner.py b/batchspawner/batchspawner.py
index 6fabbbf2..6e40dd89 100644
--- a/batchspawner/batchspawner.py
+++ b/batchspawner/batchspawner.py
@@ -16,7 +16,6 @@
   * job names instead of PIDs
 """
 import asyncio
-from async_generator import async_generator, yield_
 import pwd
 import os
 import re
@@ -490,28 +489,15 @@ async def stop(self, now=False):
                 )
             )
 
-    @async_generator
     async def progress(self):
         while True:
             if self.state_ispending():
-                await yield_(
-                    {
-                        "message": "Pending in queue...",
-                    }
-                )
+                yield {"message": "Pending in queue..."}
             elif self.state_isrunning():
-                await yield_(
-                    {
-                        "message": "Cluster job running... waiting to connect",
-                    }
-                )
+                yield {"message": "Cluster job running... waiting to connect"}
                 return
             else:
-                await yield_(
-                    {
-                        "message": "Unknown status...",
-                    }
-                )
+                yield {"message": "Unknown status..."}
             await gen.sleep(1)
 
 
diff --git a/setup.py b/setup.py
index 089bcc8d..6d2a7727 100644
--- a/setup.py
+++ b/setup.py
@@ -35,7 +35,6 @@
     },
     python_requires=">=3.7",
     install_require={
-        "async_generator>=1.8",
         "jinja2",
         "jupyterhub>=1.5.1",
     },

From d442d74a468abb13760e265b0dc2472aed1af28c Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Mon, 19 Jun 2023 15:35:19 +0200
Subject: [PATCH 12/18] maint: switch from tornado.gen.sleep to asyncio.sleep

---
 batchspawner/batchspawner.py | 10 ++++------
 setup.py                     |  1 +
 2 files changed, 5 insertions(+), 6 deletions(-)

diff --git a/batchspawner/batchspawner.py b/batchspawner/batchspawner.py
index 6e40dd89..4146712d 100644
--- a/batchspawner/batchspawner.py
+++ b/batchspawner/batchspawner.py
@@ -26,8 +26,6 @@
 
 from jinja2 import Template
 
-from tornado import gen
-
 from jupyterhub.spawner import Spawner
 from traitlets import Integer, Unicode, Float, Dict, default
 
@@ -448,11 +446,11 @@ async def start(self):
                     " while pending in the queue or died immediately"
                     " after starting."
                 )
-            await gen.sleep(self.startup_poll_interval)
+            await asyncio.sleep(self.startup_poll_interval)
 
         self.ip = self.state_gethost()
         while self.port == 0:
-            await gen.sleep(self.startup_poll_interval)
+            await asyncio.sleep(self.startup_poll_interval)
             # Test framework: For testing, mock_port is set because we
             # don't actually run the single-user server yet.
             if hasattr(self, "mock_port"):
@@ -481,7 +479,7 @@ async def stop(self, now=False):
             status = await self.query_job_status()
             if status not in (JobStatus.RUNNING, JobStatus.UNKNOWN):
                 return
-            await gen.sleep(1.0)
+            await asyncio.sleep(1)
         if self.job_id:
             self.log.warning(
                 "Notebook server job {0} at {1}:{2} possibly failed to terminate".format(
@@ -498,7 +496,7 @@ async def progress(self):
                 return
             else:
                 yield {"message": "Unknown status..."}
-            await gen.sleep(1)
+            await asyncio.sleep(1)
 
 
 class BatchSpawnerRegexStates(BatchSpawnerBase):
diff --git a/setup.py b/setup.py
index 6d2a7727..eb60227e 100644
--- a/setup.py
+++ b/setup.py
@@ -41,6 +41,7 @@
     extras_require={
         "test": [
             "pytest",
+            "pytest-asyncio",
             "pytest-cov",
         ],
     },

From 2b658863e8cbfcc2cd8451eddc4764df74cfcf5c Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Mon, 19 Jun 2023 15:34:13 +0200
Subject: [PATCH 13/18] maint: update tests to use async/await

---
 batchspawner/tests/conftest.py      |  10 +-
 batchspawner/tests/test_spawners.py | 184 +++++++++++++---------------
 pyproject.toml                      |   1 +
 3 files changed, 86 insertions(+), 109 deletions(-)

diff --git a/batchspawner/tests/conftest.py b/batchspawner/tests/conftest.py
index 0003d5c6..0fe4f7ef 100644
--- a/batchspawner/tests/conftest.py
+++ b/batchspawner/tests/conftest.py
@@ -1,10 +1,4 @@
 """Relevant pytest fixtures are re-used from JupyterHub's test suite"""
 
-# We only use "db" and "io_loop", but we also need event_loop which is used by
-# io_loop to be available with jupyterhub 1+.
-from jupyterhub.tests.conftest import db, io_loop  # noqa
-
-try:
-    from jupyterhub.tests.conftest import event_loop  # noqa
-except:
-    pass
+# We use "db" directly, but we also need event_loop
+from jupyterhub.tests.conftest import db, event_loop  # noqa
diff --git a/batchspawner/tests/test_spawners.py b/batchspawner/tests/test_spawners.py
index 9b599fea..653200a9 100644
--- a/batchspawner/tests/test_spawners.py
+++ b/batchspawner/tests/test_spawners.py
@@ -1,19 +1,16 @@
 """Test BatchSpawner and subclasses"""
 
+import asyncio
 import re
-from unittest import mock
-from .. import BatchSpawnerRegexStates, JobStatus
-from traitlets import Unicode
 import time
-import pytest
+
 from jupyterhub import orm
-from tornado import gen
+from jupyterhub.objects import Hub, Server
+from jupyterhub.user import User
+from traitlets import Unicode
+import pytest
 
-try:
-    from jupyterhub.objects import Hub, Server
-    from jupyterhub.user import User
-except:
-    pass
+from .. import BatchSpawnerRegexStates, JobStatus
 
 testhost = "userhost123"
 testjob = "12345"
@@ -34,7 +31,7 @@ class BatchDummy(BatchSpawnerRegexStates):
     cmd_expectlist = None
     out_expectlist = None
 
-    def run_command(self, *args, **kwargs):
+    async def run_command(self, *args, **kwargs):
         """Overwriten run command to test templating and outputs"""
         cmd = args[0]
         # Test that the command matches the expectations
@@ -46,7 +43,7 @@ def run_command(self, *args, **kwargs):
                     run_re.search(cmd) is not None
                 ), "Failed test: re={0} cmd={1}".format(run_re, cmd)
         # Run command normally
-        out = super().run_command(*args, **kwargs)
+        out = await super().run_command(*args, **kwargs)
         # Test that the command matches the expectations
         if self.out_expectlist:
             out_re = self.out_expectlist.pop(0)
@@ -76,43 +73,42 @@ def new_spawner(db, spawner_class=BatchDummy, **kwargs):
     return spawner
 
 
-@pytest.mark.slow
-def test_stress_submit(db, io_loop):
-    for i in range(200):
-        time.sleep(0.01)
-        test_spawner_start_stop_poll(db, io_loop)
-
-
 def check_ip(spawner, value):
     assert spawner.ip == value
 
 
-def test_spawner_start_stop_poll(db, io_loop):
+async def test_spawner_start_stop_poll(db, event_loop):
     spawner = new_spawner(db=db)
 
-    status = io_loop.run_sync(spawner.poll, timeout=5)
+    status = await asyncio.wait_for(spawner.poll(), timeout=5)
     assert status == 1
     assert spawner.job_id == ""
     assert spawner.get_state() == {}
 
-    io_loop.run_sync(spawner.start, timeout=5)
+    await asyncio.wait_for(spawner.start(), timeout=5)
     check_ip(spawner, testhost)
     assert spawner.job_id == testjob
 
-    status = io_loop.run_sync(spawner.poll, timeout=5)
+    status = await asyncio.wait_for(spawner.poll(), timeout=5)
     assert status is None
     spawner.batch_query_cmd = "echo NOPE"
-    io_loop.run_sync(spawner.stop, timeout=5)
-    status = io_loop.run_sync(spawner.poll, timeout=5)
+    await asyncio.wait_for(spawner.stop(), timeout=5)
+    status = await asyncio.wait_for(spawner.poll(), timeout=5)
     assert status == 1
     assert spawner.get_state() == {}
 
 
-def test_spawner_state_reload(db, io_loop):
+async def test_stress_submit(db, event_loop):
+    for i in range(200):
+        time.sleep(0.01)
+        test_spawner_start_stop_poll(db, event_loop)
+
+
+async def test_spawner_state_reload(db, event_loop):
     spawner = new_spawner(db=db)
     assert spawner.get_state() == {}
 
-    io_loop.run_sync(spawner.start, timeout=30)
+    await asyncio.wait_for(spawner.start(), timeout=30)
     check_ip(spawner, testhost)
     assert spawner.job_id == testjob
 
@@ -127,59 +123,59 @@ def test_spawner_state_reload(db, io_loop):
     assert spawner.job_id == testjob
 
 
-def test_submit_failure(db, io_loop):
+async def test_submit_failure(db, event_loop):
     spawner = new_spawner(db=db)
     assert spawner.get_state() == {}
     spawner.batch_submit_cmd = "cat > /dev/null; true"
     with pytest.raises(RuntimeError):
-        io_loop.run_sync(spawner.start, timeout=30)
+        await asyncio.wait_for(spawner.start(), timeout=30)
     assert spawner.job_id == ""
     assert spawner.job_status == ""
 
 
-def test_submit_pending_fails(db, io_loop):
+async def test_submit_pending_fails(db, event_loop):
     """Submission works, but the batch query command immediately fails"""
     spawner = new_spawner(db=db)
     assert spawner.get_state() == {}
     spawner.batch_query_cmd = "echo xyz"
     with pytest.raises(RuntimeError):
-        io_loop.run_sync(spawner.start, timeout=30)
-    status = io_loop.run_sync(spawner.query_job_status, timeout=30)
+        await asyncio.wait_for(spawner.start(), timeout=30)
+    status = await asyncio.wait_for(spawner.query_job_status(), timeout=30)
     assert status == JobStatus.NOTFOUND
     assert spawner.job_id == ""
     assert spawner.job_status == ""
 
 
-def test_poll_fails(db, io_loop):
+async def test_poll_fails(db, event_loop):
     """Submission works, but a later .poll() fails"""
     spawner = new_spawner(db=db)
     assert spawner.get_state() == {}
     # The start is successful:
-    io_loop.run_sync(spawner.start, timeout=30)
+    await asyncio.wait_for(spawner.start(), timeout=30)
     spawner.batch_query_cmd = "echo xyz"
     # Now, the poll fails:
-    io_loop.run_sync(spawner.poll, timeout=30)
+    await asyncio.wait_for(spawner.poll(), timeout=30)
     # .poll() will run self.clear_state() if it's not found:
     assert spawner.job_id == ""
     assert spawner.job_status == ""
 
 
-def test_unknown_status(db, io_loop):
+async def test_unknown_status(db, event_loop):
     """Polling returns an unknown status"""
     spawner = new_spawner(db=db)
     assert spawner.get_state() == {}
     # The start is successful:
-    io_loop.run_sync(spawner.start, timeout=30)
+    await asyncio.wait_for(spawner.start(), timeout=30)
     spawner.batch_query_cmd = "echo UNKNOWN"
     # This poll should not fail:
-    io_loop.run_sync(spawner.poll, timeout=30)
-    status = io_loop.run_sync(spawner.query_job_status, timeout=30)
+    await asyncio.wait_for(spawner.poll(), timeout=30)
+    status = await asyncio.wait_for(spawner.query_job_status(), timeout=30)
     assert status == JobStatus.UNKNOWN
     assert spawner.job_id == "12345"
     assert spawner.job_status != ""
 
 
-def test_templates(db, io_loop):
+async def test_templates(db, event_loop):
     """Test templates in the run_command commands"""
     spawner = new_spawner(db=db)
 
@@ -187,7 +183,7 @@ def test_templates(db, io_loop):
     spawner.cmd_expectlist = [
         re.compile(".*RUN"),
     ]
-    status = io_loop.run_sync(spawner.poll, timeout=5)
+    status = await asyncio.wait_for(spawner.poll(), timeout=5)
     assert status == 1
     assert spawner.job_id == ""
     assert spawner.get_state() == {}
@@ -197,7 +193,7 @@ def test_templates(db, io_loop):
         re.compile(".*echo"),
         re.compile(".*RUN"),
     ]
-    io_loop.run_sync(spawner.start, timeout=5)
+    await asyncio.wait_for(spawner.start(), timeout=5)
     check_ip(spawner, testhost)
     assert spawner.job_id == testjob
 
@@ -205,7 +201,7 @@ def test_templates(db, io_loop):
     spawner.cmd_expectlist = [
         re.compile(".*RUN"),
     ]
-    status = io_loop.run_sync(spawner.poll, timeout=5)
+    status = await asyncio.wait_for(spawner.poll(), timeout=5)
     assert status is None
 
     # Test stopping
@@ -214,67 +210,64 @@ def test_templates(db, io_loop):
         re.compile(".*STOP"),
         re.compile(".*NOPE"),
     ]
-    io_loop.run_sync(spawner.stop, timeout=5)
-    status = io_loop.run_sync(spawner.poll, timeout=5)
+    await asyncio.wait_for(spawner.stop(), timeout=5)
+    status = await asyncio.wait_for(spawner.poll(), timeout=5)
     assert status == 1
     assert spawner.get_state() == {}
 
 
-def test_batch_script(db, io_loop):
+async def test_batch_script(db, event_loop):
     """Test that the batch script substitutes {cmd}"""
 
     class BatchDummyTestScript(BatchDummy):
-        @gen.coroutine
-        def _get_batch_script(self, **subvars):
-            script = yield super()._get_batch_script(**subvars)
+        async def _get_batch_script(self, **subvars):
+            script = await super()._get_batch_script(**subvars)
             assert "singleuser_command" in script
             return script
 
     spawner = new_spawner(db=db, spawner_class=BatchDummyTestScript)
-    # status = io_loop.run_sync(spawner.poll, timeout=5)
-    io_loop.run_sync(spawner.start, timeout=5)
-    # status = io_loop.run_sync(spawner.poll, timeout=5)
-    # io_loop.run_sync(spawner.stop, timeout=5)
+    # status = await asyncio.wait_for(spawner.poll(), timeout=5)
+    await asyncio.wait_for(spawner.start(), timeout=5)
+    # status = await asyncio.wait_for(spawner.poll(), timeout=5)
+    # await asyncio.wait_for(spawner.stop(), timeout=5)
 
 
-def test_exec_prefix(db, io_loop):
+async def test_exec_prefix(db, event_loop):
     """Test that all run_commands have exec_prefix"""
 
     class BatchDummyTestScript(BatchDummy):
         exec_prefix = "PREFIX"
 
-        @gen.coroutine
-        def run_command(self, cmd, *args, **kwargs):
+        async def run_command(self, cmd, *args, **kwargs):
             assert cmd.startswith("PREFIX ")
             cmd = cmd[7:]
             print(cmd)
-            out = yield super().run_command(cmd, *args, **kwargs)
+            out = await super().run_command(cmd, *args, **kwargs)
             return out
 
     spawner = new_spawner(db=db, spawner_class=BatchDummyTestScript)
     # Not running
-    status = io_loop.run_sync(spawner.poll, timeout=5)
+    status = await asyncio.wait_for(spawner.poll(), timeout=5)
     assert status == 1
     # Start
-    io_loop.run_sync(spawner.start, timeout=5)
+    await asyncio.wait_for(spawner.start(), timeout=5)
     assert spawner.job_id == testjob
     # Poll
-    status = io_loop.run_sync(spawner.poll, timeout=5)
+    status = await asyncio.wait_for(spawner.poll(), timeout=5)
     assert status is None
     # Stop
     spawner.batch_query_cmd = "echo NOPE"
-    io_loop.run_sync(spawner.stop, timeout=5)
-    status = io_loop.run_sync(spawner.poll, timeout=5)
+    await asyncio.wait_for(spawner.stop(), timeout=5)
+    status = await asyncio.wait_for(spawner.poll(), timeout=5)
     assert status == 1
 
 
-def run_spawner_script(
-    db, io_loop, spawner, script, batch_script_re_list=None, spawner_kwargs={}
+async def run_spawner_script(
+    db, spawner, script, batch_script_re_list=None, spawner_kwargs={}
 ):
     """Run a spawner script and test that the output and behavior is as expected.
 
     db: same as in this module
-    io_loop: same as in this module
     spawner: the BatchSpawnerBase subclass to test
     script: list of (input_re_to_match, output)
     batch_script_re_list: if given, assert batch script matches all of these
@@ -285,8 +278,7 @@ def run_spawner_script(
     out_list = list(out_list)
 
     class BatchDummyTestScript(spawner):
-        @gen.coroutine
-        def run_command(self, cmd, input=None, env=None):
+        async def run_command(self, cmd, input=None, env=None):
             # Test the input
             run_re = cmd_expectlist.pop(0)
             if run_re:
@@ -310,25 +302,25 @@ def run_command(self, cmd, input=None, env=None):
 
     spawner = new_spawner(db=db, spawner_class=BatchDummyTestScript, **spawner_kwargs)
     # Not running at beginning (no command run)
-    status = io_loop.run_sync(spawner.poll, timeout=5)
+    status = await asyncio.wait_for(spawner.poll(), timeout=5)
     assert status == 1
     # batch_submit_cmd
     # batch_query_cmd    (result=pending)
     # batch_query_cmd    (result=running)
-    io_loop.run_sync(spawner.start, timeout=5)
+    await asyncio.wait_for(spawner.start(), timeout=5)
     assert spawner.job_id == testjob
     check_ip(spawner, testhost)
     # batch_query_cmd
-    status = io_loop.run_sync(spawner.poll, timeout=5)
+    status = await asyncio.wait_for(spawner.poll(), timeout=5)
     assert status is None
     # batch_cancel_cmd
-    io_loop.run_sync(spawner.stop, timeout=5)
+    await asyncio.wait_for(spawner.stop(), timeout=5)
     # batch_poll_cmd
-    status = io_loop.run_sync(spawner.poll, timeout=5)
+    status = await asyncio.wait_for(spawner.poll(), timeout=5)
     assert status == 1
 
 
-def test_torque(db, io_loop):
+async def test_torque(db, event_loop):
     spawner_kwargs = {
         "req_nprocs": "5",
         "req_memory": "5678",
@@ -364,9 +356,8 @@ def test_torque(db, io_loop):
     ]
     from .. import TorqueSpawner
 
-    run_spawner_script(
+    await run_spawner_script(
         db,
-        io_loop,
         TorqueSpawner,
         script,
         batch_script_re_list=batch_script_re_list,
@@ -374,7 +365,7 @@ def test_torque(db, io_loop):
     )
 
 
-def test_moab(db, io_loop):
+async def test_moab(db, event_loop):
     spawner_kwargs = {
         "req_nprocs": "5",
         "req_memory": "5678",
@@ -407,9 +398,8 @@ def test_moab(db, io_loop):
     ]
     from .. import MoabSpawner
 
-    run_spawner_script(
+    await run_spawner_script(
         db,
-        io_loop,
         MoabSpawner,
         script,
         batch_script_re_list=batch_script_re_list,
@@ -417,7 +407,7 @@ def test_moab(db, io_loop):
     )
 
 
-def test_pbs(db, io_loop):
+async def test_pbs(db, event_loop):
     spawner_kwargs = {
         "req_nprocs": "4",
         "req_memory": "10256",
@@ -450,9 +440,8 @@ def test_pbs(db, io_loop):
     ]
     from .. import PBSSpawner
 
-    run_spawner_script(
+    await run_spawner_script(
         db,
-        io_loop,
         PBSSpawner,
         script,
         batch_script_re_list=batch_script_re_list,
@@ -460,7 +449,7 @@ def test_pbs(db, io_loop):
     )
 
 
-def test_slurm(db, io_loop):
+async def test_slurm(db, event_loop):
     spawner_kwargs = {
         "req_runtime": "3-05:10:10",
         "req_nprocs": "5",
@@ -483,9 +472,8 @@ def test_slurm(db, io_loop):
     ]
     from .. import SlurmSpawner
 
-    run_spawner_script(
+    await run_spawner_script(
         db,
-        io_loop,
         SlurmSpawner,
         normal_slurm_script,
         batch_script_re_list=batch_script_re_list,
@@ -510,9 +498,8 @@ def test_slurm(db, io_loop):
 from .. import SlurmSpawner
 
 
-def run_typical_slurm_spawner(
+async def run_typical_slurm_spawner(
     db,
-    io_loop,
     spawner=SlurmSpawner,
     script=normal_slurm_script,
     batch_script_re_list=None,
@@ -523,9 +510,8 @@ def run_typical_slurm_spawner(
     This is useful, for example, for changing options and testing effect
     of batch scripts.
     """
-    return run_spawner_script(
+    return await run_spawner_script(
         db,
-        io_loop,
         spawner,
         script,
         batch_script_re_list=batch_script_re_list,
@@ -533,7 +519,7 @@ def run_typical_slurm_spawner(
     )
 
 
-# def test_gridengine(db, io_loop):
+# async def test_gridengine(db, event_loop):
 #    spawner_kwargs = {
 #        'req_options': 'some_option_asdf',
 #        }
@@ -550,12 +536,12 @@ def run_typical_slurm_spawner(
 #        (re.compile(r'sudo.*qstat'),   ''),
 #        ]
 #    from .. import GridengineSpawner
-#    run_spawner_script(db, io_loop, GridengineSpawner, script,
+#    await run_spawner_script(db, GridengineSpawner, script,
 #                       batch_script_re_list=batch_script_re_list,
 #                       spawner_kwargs=spawner_kwargs)
 
 
-def test_condor(db, io_loop):
+async def test_condor(db, event_loop):
     spawner_kwargs = {
         "req_nprocs": "5",
         "req_memory": "5678",
@@ -580,9 +566,8 @@ def test_condor(db, io_loop):
     ]
     from .. import CondorSpawner
 
-    run_spawner_script(
+    await run_spawner_script(
         db,
-        io_loop,
         CondorSpawner,
         script,
         batch_script_re_list=batch_script_re_list,
@@ -590,7 +575,7 @@ def test_condor(db, io_loop):
     )
 
 
-def test_lfs(db, io_loop):
+async def test_lfs(db, event_loop):
     spawner_kwargs = {
         "req_nprocs": "5",
         "req_memory": "5678",
@@ -619,9 +604,8 @@ def test_lfs(db, io_loop):
     ]
     from .. import LsfSpawner
 
-    run_spawner_script(
+    await run_spawner_script(
         db,
-        io_loop,
         LsfSpawner,
         script,
         batch_script_re_list=batch_script_re_list,
@@ -629,7 +613,7 @@ def test_lfs(db, io_loop):
     )
 
 
-def test_keepvars(db, io_loop):
+async def test_keepvars(db, event_loop):
     # req_keepvars
     spawner_kwargs = {
         "req_keepvars": "ABCDE",
@@ -637,9 +621,8 @@ def test_keepvars(db, io_loop):
     batch_script_re_list = [
         re.compile(r"--export=ABCDE", re.X | re.M),
     ]
-    run_typical_slurm_spawner(
+    await run_typical_slurm_spawner(
         db,
-        io_loop,
         spawner_kwargs=spawner_kwargs,
         batch_script_re_list=batch_script_re_list,
     )
@@ -652,9 +635,8 @@ def test_keepvars(db, io_loop):
     batch_script_re_list = [
         re.compile(r"--export=ABCDE,XYZ", re.X | re.M),
     ]
-    run_typical_slurm_spawner(
+    await run_typical_slurm_spawner(
         db,
-        io_loop,
         spawner_kwargs=spawner_kwargs,
         batch_script_re_list=batch_script_re_list,
     )
diff --git a/pyproject.toml b/pyproject.toml
index 98553ba6..c393d617 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,6 +42,7 @@ target_version = [
 #
 [tool.pytest.ini_options]
 addopts = "--verbose --color=yes --durations=10"
+asyncio_mode = "auto"
 testpaths = ["batchspawner/tests"]
 
 

From 479dcceca0437d71f33139c78da294007e6a3b5f Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Mon, 19 Jun 2023 11:40:18 +0200
Subject: [PATCH 14/18] maint: add indirect test dependency, for
 jupyterhub.tests import

---
 setup.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/setup.py b/setup.py
index eb60227e..4cb98ce1 100644
--- a/setup.py
+++ b/setup.py
@@ -43,6 +43,7 @@
             "pytest",
             "pytest-asyncio",
             "pytest-cov",
+            "notebook",
         ],
     },
 )

From 63c6bdc4ffb76156b287597c1729e04f1b0ba784 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Mon, 19 Jun 2023 13:47:42 +0000
Subject: [PATCH 15/18] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 batchspawner/api.py                 |  3 +-
 batchspawner/batchspawner.py        | 46 +++++++++++------------------
 batchspawner/singleuser.py          |  8 ++---
 batchspawner/tests/test_spawners.py | 36 +++++++++++-----------
 4 files changed, 40 insertions(+), 53 deletions(-)

diff --git a/batchspawner/api.py b/batchspawner/api.py
index d5459d12..e91831ce 100644
--- a/batchspawner/api.py
+++ b/batchspawner/api.py
@@ -1,6 +1,7 @@
 import json
-from tornado import web
+
 from jupyterhub.apihandlers import APIHandler, default_handlers
+from tornado import web
 
 
 class BatchSpawnerAPIHandler(APIHandler):
diff --git a/batchspawner/batchspawner.py b/batchspawner/batchspawner.py
index 4146712d..8661c999 100644
--- a/batchspawner/batchspawner.py
+++ b/batchspawner/batchspawner.py
@@ -16,20 +16,15 @@
   * job names instead of PIDs
 """
 import asyncio
-import pwd
 import os
+import pwd
 import re
-
 import xml.etree.ElementTree as ET
-
 from enum import Enum
 
 from jinja2 import Template
-
-from jupyterhub.spawner import Spawner
-from traitlets import Integer, Unicode, Float, Dict, default
-
-from jupyterhub.spawner import set_user_setuid
+from jupyterhub.spawner import Spawner, set_user_setuid
+from traitlets import Dict, Float, Integer, Unicode, default
 
 
 def format_template(template, *args, **kwargs):
@@ -240,9 +235,7 @@ async def run_command(self, cmd, input=None, env=None):
                 self.log.error(out)
                 self.log.error("Stderr:")
                 self.log.error(eout)
-                raise RuntimeError(
-                    "{} exit status {}: {}".format(cmd, proc.returncode, eout)
-                )
+                raise RuntimeError(f"{cmd} exit status {proc.returncode}: {eout}")
             except asyncio.TimeoutError:
                 self.log.error(
                     "Encountered timeout trying to clean up command, process probably killed already: %s"
@@ -322,7 +315,7 @@ async def query_job_status(self):
         except RuntimeError as e:
             # e.args[0] is stderr from the process
             self.job_status = e.args[0]
-        except Exception as e:
+        except Exception:
             self.log.error("Error querying job " + self.job_id)
             self.job_status = ""
 
@@ -354,13 +347,13 @@ async def cancel_batch_job(self):
 
     def load_state(self, state):
         """load job_id from state"""
-        super(BatchSpawnerBase, self).load_state(state)
+        super().load_state(state)
         self.job_id = state.get("job_id", "")
         self.job_status = state.get("job_status", "")
 
     def get_state(self):
         """add job_id to state"""
-        state = super(BatchSpawnerBase, self).get_state()
+        state = super().get_state()
         if self.job_id:
             state["job_id"] = self.job_id
         if self.job_status:
@@ -369,7 +362,7 @@ def get_state(self):
 
     def clear_state(self):
         """clear job_id state"""
-        super(BatchSpawnerBase, self).clear_state()
+        super().clear_state()
         self.job_id = ""
         self.job_status = ""
 
@@ -415,7 +408,7 @@ async def start(self):
         if self.server:
             self.server.port = self.port
 
-        job = await self.submit_batch_script()
+        await self.submit_batch_script()
 
         # We are called with a timeout, and if the timeout expires this function will
         # be interrupted at the next yield, and self.stop() will be called.
@@ -458,7 +451,7 @@ async def start(self):
 
         self.db.commit()
         self.log.info(
-            "Notebook server job {0} started at {1}:{2}".format(
+            "Notebook server job {} started at {}:{}".format(
                 self.job_id, self.ip, self.port
             )
         )
@@ -482,7 +475,7 @@ async def stop(self, now=False):
             await asyncio.sleep(1)
         if self.job_id:
             self.log.warning(
-                "Notebook server job {0} at {1}:{2} possibly failed to terminate".format(
+                "Notebook server job {} at {}:{} possibly failed to terminate".format(
                     self.job_id, self.ip, self.port
                 )
             )
@@ -799,7 +792,7 @@ def parse_job_id(self, output):
     def state_ispending(self):
         if self.job_status:
             job_info = ET.fromstring(self.job_status).find(
-                ".//job_list[JB_job_number='{0}']".format(self.job_id)
+                f".//job_list[JB_job_number='{self.job_id}']"
             )
             if job_info is not None:
                 return job_info.attrib.get("state") == "pending"
@@ -808,7 +801,7 @@ def state_ispending(self):
     def state_isrunning(self):
         if self.job_status:
             job_info = ET.fromstring(self.job_status).find(
-                ".//job_list[JB_job_number='{0}']".format(self.job_id)
+                f".//job_list[JB_job_number='{self.job_id}']"
             )
             if job_info is not None:
                 return job_info.attrib.get("state") == "running"
@@ -817,13 +810,13 @@ def state_isrunning(self):
     def state_gethost(self):
         if self.job_status:
             queue_name = ET.fromstring(self.job_status).find(
-                ".//job_list[JB_job_number='{0}']/queue_name".format(self.job_id)
+                f".//job_list[JB_job_number='{self.job_id}']/queue_name"
             )
             if queue_name is not None and queue_name.text:
                 return queue_name.text.split("@")[1]
 
         self.log.error(
-            "Spawner unable to match host addr in job {0} with status {1}".format(
+            "Spawner unable to match host addr in job {} with status {}".format(
                 self.job_id, self.job_status
             )
         )
@@ -887,12 +880,7 @@ def parse_job_id(self, output):
         raise Exception(error_msg)
 
     def cmd_formatted_for_batch(self):
-        return (
-            super(CondorSpawner, self)
-            .cmd_formatted_for_batch()
-            .replace('"', '""')
-            .replace("'", "''")
-        )
+        return super().cmd_formatted_for_batch().replace('"', '""').replace("'", "''")
 
 
 class LsfSpawner(BatchSpawnerBase):
@@ -957,7 +945,7 @@ def state_gethost(self):
             return self.job_status.split(" ")[1].strip().split(":")[0]
 
         self.log.error(
-            "Spawner unable to match host addr in job {0} with status {1}".format(
+            "Spawner unable to match host addr in job {} with status {}".format(
                 self.job_id, self.job_status
             )
         )
diff --git a/batchspawner/singleuser.py b/batchspawner/singleuser.py
index 1bd2b49f..f68d238d 100644
--- a/batchspawner/singleuser.py
+++ b/batchspawner/singleuser.py
@@ -1,13 +1,11 @@
 import os
 import sys
-
 from runpy import run_path
 from shutil import which
 
-from jupyterhub.utils import random_port, url_path_join
-from jupyterhub.services.auth import HubAuth
-
 import requests
+from jupyterhub.services.auth import HubAuth
+from jupyterhub.utils import random_port, url_path_join
 
 
 def main(argv=None):
@@ -35,7 +33,7 @@ def main(argv=None):
     )
 
     cmd_path = which(sys.argv[1])
-    sys.argv = sys.argv[1:] + ["--port={}".format(port)]
+    sys.argv = sys.argv[1:] + [f"--port={port}"]
     run_path(cmd_path, run_name="__main__")
 
 
diff --git a/batchspawner/tests/test_spawners.py b/batchspawner/tests/test_spawners.py
index 653200a9..79cd1e46 100644
--- a/batchspawner/tests/test_spawners.py
+++ b/batchspawner/tests/test_spawners.py
@@ -4,11 +4,11 @@
 import re
 import time
 
+import pytest
 from jupyterhub import orm
 from jupyterhub.objects import Hub, Server
 from jupyterhub.user import User
 from traitlets import Unicode
-import pytest
 
 from .. import BatchSpawnerRegexStates, JobStatus
 
@@ -41,7 +41,7 @@ async def run_command(self, *args, **kwargs):
                 print("run:", run_re)
                 assert (
                     run_re.search(cmd) is not None
-                ), "Failed test: re={0} cmd={1}".format(run_re, cmd)
+                ), f"Failed test: re={run_re} cmd={cmd}"
         # Run command normally
         out = await super().run_command(*args, **kwargs)
         # Test that the command matches the expectations
@@ -51,7 +51,7 @@ async def run_command(self, *args, **kwargs):
                 print("out:", out_re)
                 assert (
                     out_re.search(cmd) is not None
-                ), "Failed output: re={0} cmd={1} out={2}".format(out_re, cmd, out)
+                ), f"Failed output: re={out_re} cmd={cmd} out={out}"
         return out
 
 
@@ -282,10 +282,10 @@ async def run_command(self, cmd, input=None, env=None):
             # Test the input
             run_re = cmd_expectlist.pop(0)
             if run_re:
-                print('run: "{}"   [{}]'.format(cmd, run_re))
+                print(f'run: "{cmd}"   [{run_re}]')
                 assert (
                     run_re.search(cmd) is not None
-                ), "Failed test: re={0} cmd={1}".format(run_re, cmd)
+                ), f"Failed test: re={run_re} cmd={cmd}"
             # Test the stdin - will only be the batch script.  For
             # each regular expression in batch_script_re_list, assert that
             # each re in that list matches the batch script.
@@ -294,7 +294,7 @@ async def run_command(self, cmd, input=None, env=None):
                 for match_re in batch_script_re_list:
                     assert (
                         match_re.search(batch_script) is not None
-                    ), "Batch script does not match {}".format(match_re)
+                    ), f"Batch script does not match {match_re}"
             # Return expected output.
             out = out_list.pop(0)
             print("  --> " + out)
@@ -345,11 +345,11 @@ async def test_torque(db, event_loop):
         ),  # pending
         (
             re.compile(r"sudo.*qstat"),
-            "R{}/1".format(testhost),
+            f"R{testhost}/1",
         ),  # running
         (
             re.compile(r"sudo.*qstat"),
-            "R{}/1".format(testhost),
+            f"R{testhost}/1",
         ),  # running
         (re.compile(r"sudo.*qdel"), "STOP"),
         (re.compile(r"sudo.*qstat"), ""),
@@ -387,11 +387,11 @@ async def test_moab(db, event_loop):
         (re.compile(r"sudo.*mdiag"), 'State="Idle"'),  # pending
         (
             re.compile(r"sudo.*mdiag"),
-            'State="Running" AllocNodeList="{}"'.format(testhost),
+            f'State="Running" AllocNodeList="{testhost}"',
         ),  # running
         (
             re.compile(r"sudo.*mdiag"),
-            'State="Running" AllocNodeList="{}"'.format(testhost),
+            f'State="Running" AllocNodeList="{testhost}"',
         ),  # running
         (re.compile(r"sudo.*mjobctl.*-c"), "STOP"),
         (re.compile(r"sudo.*mdiag"), ""),
@@ -429,11 +429,11 @@ async def test_pbs(db, event_loop):
         (re.compile(r"sudo.*qstat"), "job_state = Q"),  # pending
         (
             re.compile(r"sudo.*qstat"),
-            "job_state = R\nexec_host = {}/2*1".format(testhost),
+            f"job_state = R\nexec_host = {testhost}/2*1",
         ),  # running
         (
             re.compile(r"sudo.*qstat"),
-            "job_state = R\nexec_host = {}/2*1".format(testhost),
+            f"job_state = R\nexec_host = {testhost}/2*1",
         ),  # running
         (re.compile(r"sudo.*qdel"), "STOP"),
         (re.compile(r"sudo.*qstat"), ""),
@@ -556,11 +556,11 @@ async def test_condor(db, event_loop):
     script = [
         (
             re.compile(r"sudo.*condor_submit"),
-            "submitted to cluster {}".format(str(testjob)),
+            f"submitted to cluster {str(testjob)}",
         ),
         (re.compile(r"sudo.*condor_q"), "1,"),  # pending
-        (re.compile(r"sudo.*condor_q"), "2, @{}".format(testhost)),  # runing
-        (re.compile(r"sudo.*condor_q"), "2, @{}".format(testhost)),
+        (re.compile(r"sudo.*condor_q"), f"2, @{testhost}"),  # runing
+        (re.compile(r"sudo.*condor_q"), f"2, @{testhost}"),
         (re.compile(r"sudo.*condor_rm"), "STOP"),
         (re.compile(r"sudo.*condor_q"), ""),
     ]
@@ -594,11 +594,11 @@ async def test_lfs(db, event_loop):
     script = [
         (
             re.compile(r"sudo.*bsub"),
-            "Job <{}> is submitted to default queue ".format(str(testjob)),
+            f"Job <{str(testjob)}> is submitted to default queue ",
         ),
         (re.compile(r"sudo.*bjobs"), "PEND "),  # pending
-        (re.compile(r"sudo.*bjobs"), "RUN {}".format(testhost)),  # running
-        (re.compile(r"sudo.*bjobs"), "RUN {}".format(testhost)),
+        (re.compile(r"sudo.*bjobs"), f"RUN {testhost}"),  # running
+        (re.compile(r"sudo.*bjobs"), f"RUN {testhost}"),
         (re.compile(r"sudo.*bkill"), "STOP"),
         (re.compile(r"sudo.*bjobs"), ""),
     ]

From 9d9fac28ee89b3d6845ee0aff9c94f7b4d142a7c Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Sat, 24 Jun 2023 14:37:23 +0200
Subject: [PATCH 16/18] Fix copy-paste mistake in tbump config

---
 pyproject.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyproject.toml b/pyproject.toml
index c393d617..9bf33a0f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,7 +52,7 @@ testpaths = ["batchspawner/tests"]
 # ref: https://github.com/your-tools/tbump#readme
 #
 [tool.tbump]
-github_url = "https://github.com/jupyterhub/tmpauthenticator"
+github_url = "https://github.com/jupyterhub/batchspawner"
 
 [tool.tbump.version]
 current = "1.3.0.dev"

From 8cfc9529f2634e1e37c61747d930b4ff1dd6b9d1 Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Sat, 23 Sep 2023 12:10:40 +0200
Subject: [PATCH 17/18] maint: revert dropping support for py36

For reference note that JupyterHub 3 and 4 requires python 3.7, so
batchspawner of a modern version on python 3.6 will probably use
JupyterHub 1 or 2.
---
 .github/workflows/test.yaml | 11 +++++++----
 pyproject.toml              |  1 +
 setup.py                    |  2 +-
 3 files changed, 9 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index ef7f389b..723f279c 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -23,20 +23,23 @@ on:
 jobs:
   pytest:
     name: Run pytest
-    runs-on: ubuntu-22.04
+    runs-on: ${{ matrix.runs-on || 'ubuntu-22.04' }}
 
     strategy:
       fail-fast: false
       matrix:
         include:
           # test oldest supported version
-          - python-version: "3.7"
+          - python-version: "3.6"
             pip-install-spec: "jupyterhub==1.5.1 sqlalchemy==1.*"
+            runs-on: ubuntu-20.04 # python 3.6 is only available in 20.04
 
-          - python-version: "3.8"
+          - python-version: "3.7"
             pip-install-spec: "jupyterhub==2.* sqlalchemy==1.*"
-          - python-version: "3.10"
+          - python-version: "3.8"
             pip-install-spec: "jupyterhub==3.*"
+          - python-version: "3.10"
+            pip-install-spec: "jupyterhub==4.*"
           - python-version: "3.11"
             pip-install-spec: "jupyterhub==4.*"
 
diff --git a/pyproject.toml b/pyproject.toml
index 9bf33a0f..ed489c80 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,7 @@ profile = "black"
 #
 [tool.black]
 target_version = [
+    "py36",
     "py37",
     "py38",
     "py39",
diff --git a/setup.py b/setup.py
index 4cb98ce1..4f172b10 100644
--- a/setup.py
+++ b/setup.py
@@ -33,7 +33,7 @@
         "About Jupyterhub": "http://jupyterhub.readthedocs.io/en/latest/",
         "Jupyter Project": "http://jupyter.org",
     },
-    python_requires=">=3.7",
+    python_requires=">=3.6",
     install_require={
         "jinja2",
         "jupyterhub>=1.5.1",

From a2f2d61d09547e6e4c40567fd51d997644bb6a9b Mon Sep 17 00:00:00 2001
From: Erik Sundell 
Date: Sat, 23 Sep 2023 12:15:28 +0200
Subject: [PATCH 18/18] maint: exclude tests from codecoverage config

---
 .github/workflows/test.yaml |  2 +-
 pyproject.toml              | 12 +++++++++++-
 2 files changed, 12 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 723f279c..828d2232 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -73,7 +73,7 @@ jobs:
 
       - name: pytest
         run: |
-          pytest --cov=batchspawner
+          pytest
 
       # GitHub action reference: https://github.com/codecov/codecov-action
       - uses: codecov/codecov-action@v3
diff --git a/pyproject.toml b/pyproject.toml
index ed489c80..757224e2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,11 +42,21 @@ target_version = [
 # ref: https://docs.pytest.org/en/stable/
 #
 [tool.pytest.ini_options]
-addopts = "--verbose --color=yes --durations=10"
+addopts = "--verbose --color=yes --durations=10 --cov=batchspawner"
 asyncio_mode = "auto"
 testpaths = ["batchspawner/tests"]
 
 
+# pytest-cov / coverage is used to measure code coverage of tests
+#
+# ref: https://coverage.readthedocs.io/en/stable/config.html
+#
+[tool.coverage.run]
+omit = [
+    "batchspawner/tests/*",
+]
+
+
 # tbump is used to simplify and standardize the release process when updating
 # the version, making a git commit and tag, and pushing changes.
 #