diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a40f2fc..dbc99b3 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -30,7 +30,6 @@ jobs: fail-fast: false matrix: include: - - python-version: "3.6" - python-version: "3.7" - python-version: "3.8" - python-version: "3.9" @@ -43,15 +42,9 @@ jobs: with: python-version: "${{ matrix.python-version }}" - - name: Install - run: | - pip install -r dev-requirements.txt - pip install -e . + - run: pip install ".[test]" - - name: Test - run: | - pytest --maxfail=3 --log-cli-level=DEBUG --cov=simpervisor tests/ + - run: pytest --cov=simpervisor # GitHub action reference: https://github.com/codecov/codecov-action - uses: codecov/codecov-action@v3 - if: github.ref == 'refs/heads/main' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8347ed4..ad69ba2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: hooks: - id: pyupgrade args: - - --py36-plus + - --py37-plus # Autoformat: Python code - repo: https://github.com/pycqa/isort diff --git a/README.md b/README.md index ae8b820..44ec7f9 100644 --- a/README.md +++ b/README.md @@ -8,4 +8,8 @@ [![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) -Simple Python3 Supervisor library +simpervisor provides the SupervisedProcess class that provides async methods +`start`, `ready`, `terminate`, and `kill` to manage it. As an example of how it +can be used, see [how jupyterhub/jupyter-server-proxy uses it][]. + +[how jupyterhub/jupyter-server-proxy uses it]: https://github.com/jupyterhub/jupyter-server-proxy/blob/969850eb0be2f8d016974104497109e0d13ddc94/jupyter_server_proxy/handlers.py#L650-L660 diff --git a/RELEASE.md b/RELEASE.md index 76ba30d..d4ea1ca 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,100 +1,50 @@ # How to make a release -`simpervisor` is a package [available on -PyPI](https://pypi.org/project/jupyterhub-simpervisor/) and -[conda-forge](https://conda-forge.org/). These are instructions on how to make a -release on PyPI. The PyPI release is done automatically by a GitHub workflow -when a tag is pushed. +`simpervisor` is a package available on [PyPI][] and [conda-forge][]. +These are instructions on how to make a release. -For you to follow along according to these instructions, you need: +## Pre-requisites -- To have push rights to the [simpervisor GitHub - repository](https://github.com/jupyterhub/simpervisor). +- Push rights to [github.com/jupyterhub/simpervisor][] +- Push rights to [conda-forge/simpervisor-feedstock][] ## Steps to make a release -1. Update [CHANGELOG.md](CHANGELOG.md). Doing this can be made easier with the - help of the - [choldgraf/github-activity](https://github.com/choldgraf/github-activity) - utility to list merged PRs and generate a list of contributors. +1. Create a PR updating `CHANGELOG.md` with [github-activity][] and continue + only when its merged. - ```bash - github-activity jupyterhub/simpervisor --output tmp-changelog-prep.md - ``` - -1. Once the changelog is up to date, checkout main and make sure it is up to date and clean. +1. Checkout main and make sure it is up to date. - ```bash - ORIGIN=${ORIGIN:-origin} # set to the canonical remote, e.g. 'upstream' if 'origin' is not the official repo + ```shell git checkout main - git fetch $ORIGIN main - git reset --hard $ORIGIN/main - # WARNING! This next command deletes any untracked files in the repo - git clean -xfd + git fetch origin main + git reset --hard origin/main ``` -1. Set the `version` field in [setup.py](setup.py) appropriately and make a - commit. - - ```bash - git add setup.py - VERSION=... # e.g. 1.2.3 - git commit -m "release $VERSION" - ``` - -1. Reset the `version` field in [setup.py](setup.py) appropriately with an - incremented patch version and a `dev` element, then make a commit. - - ```bash - git add setup.py - git commit -m "back to dev" - ``` +1. Update the version, make commits, and push a git tag with `tbump`. -1. Push your two commits to main. + ```shell + pip install tbump + tbump --dry-run ${VERSION} - ```bash - # first push commits without a tags to ensure the - # commits comes through, because a tag can otherwise - # be pushed all alone without company of rejected - # commits, and we want have our tagged release coupled - # with a specific commit in main - git push $ORIGIN main + # run + tbump ${VERSION} ``` -1. Create a git tag for the pushed release commit and push it. + Following this, the [CI system][] will build and publishe a release. - ```bash - git tag -a $VERSION -m $VERSION HEAD~1 +1. Reset the version back to dev, e.g. `1.0.1.dev` after releasing `1.0.0`. - # then verify you tagged the right commit - git log - - # then push it - git push $ORIGIN refs/tags/$VERSION - ``` - -1. Push your two commits to main along with the annotated tags referencing - commits on main. A GitHub Workflow will trigger automatic deployment of the - pushed tag. - - ```bash - # pushing the commits standalone allows you to - # ensure you don't end up only pushing the tag - # because the commit were rejected but the tag - # wasn't - git push $ORIGIN main - - # if you could push the commits without issues - # go ahead and push the tag also - git push --follow-tags $ORIGIN main + ```shell + tbump --no-tag ${NEXT_VERSION}.dev ``` -1. Verify that [the GitHub - workflow](https://github.com/jupyterhub/simpervisor/actions?query=workflow%3ARelease) - triggers and succeeds and that that PyPI received a [new - release](https://pypi.org/project/simpervisor/). - 1. Following the release to PyPI, an automated PR should arrive to - [conda-forge/simpervisor-feedstock](https://github.com/conda-forge/simpervisor-feedstock), - check for the tests to succeed on this PR and then merge it to successfully - update the package for `conda` on the conda-forge channel. + [conda-forge/simpervisor-feedstock][] with instructions. + +[github-activity]: https://github.com/executablebooks/github-activity +[github.com/jupyterhub/simpervisor]: https://github.com/jupyterhub/simpervisor +[pypi]: https://pypi.org/project/simpervisor/ +[conda-forge]: https://anaconda.org/conda-forge/simpervisor +[conda-forge/simpervisor-feedstock]: https://github.com/conda-forge/simpervisor-feedstock +[ci system]: https://github.com/jupyterhub/simpervisor/actions/workflows/release.yaml diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index 67dbc5e..0000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -aiohttp -pytest -pytest-asyncio -pytest-cov diff --git a/pyproject.toml b/pyproject.toml index ec9ffbd..310df3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,59 @@ +# build-system +# - ref 1: https://peps.python.org/pep-0517/ +# +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + +# hatch ref: https://hatch.pypa.io/latest/ +# +[tool.hatch.version] +path = "simpervisor/_version.py" + + +# project +# - ref 1: https://peps.python.org/pep-0621/ +# - ref 2: https://hatch.pypa.io/latest/config/metadata/#project-metadata +# +[project] +name = "simpervisor" +description = "Simple async process supervisor" +readme = "README.md" +requires-python = ">=3.7" +license = {file = "LICENSE"} +keywords = ["async", "process", "supervisor"] +authors = [ + {name = "Yuvi Panda", email = "yuvipanda@gmail.com"}, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [] +dynamic = ["version"] + +[project.urls] +Documentation = "https://github.com/jupyterhub/simpervisor#readme" +Issues = "https://github.com/jupyterhub/simpervisor/issues" +Source = "https://github.com/jupyterhub/simpervisor" + +[project.optional-dependencies] +test = [ + "aiohttp", + "pytest", + "pytest-asyncio", + "pytest-cov", +] + + # pytest is used for running Python based tests # # ref: https://docs.pytest.org/en/stable/ @@ -13,7 +69,6 @@ asyncio_mode = "auto" # [tool.black] target_version = [ - "py36", "py37", "py38", "py39", @@ -28,3 +83,32 @@ target_version = [ # [tool.isort] profile = "black" + + +# 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/simpervisor" + +[tool.tbump.version] +current = "1.0.0.dev0" +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 = "{new_version}"
+
+[[tool.tbump.file]]
+src = "simpervisor/_version.py"
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 8183238..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[metadata]
-license_files = LICENSE
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 3ea1667..0000000
--- a/setup.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import setuptools
-
-setuptools.setup(
-    name="simpervisor",
-    version="0.4",
-    url="https://github.com/jupyterhub/simpervisor",
-    description="Simple async process supervisor",
-    author="Yuvi Panda",
-    author_email="yuvipanda@gmail.com",
-    license="BSD 3-Clause",
-    packages=setuptools.find_packages(),
-)
diff --git a/simpervisor/__init__.py b/simpervisor/__init__.py
index 1a507bd..27b2a15 100644
--- a/simpervisor/__init__.py
+++ b/simpervisor/__init__.py
@@ -1 +1,2 @@
-from simpervisor.process import KilledProcessError, SupervisedProcess
+from ._version import __version__
+from .process import KilledProcessError, SupervisedProcess
diff --git a/simpervisor/_version.py b/simpervisor/_version.py
new file mode 100644
index 0000000..3592c53
--- /dev/null
+++ b/simpervisor/_version.py
@@ -0,0 +1,4 @@
+# __version__ should be updated using tbump, based on configuration in
+# pyproject.toml, according to instructions in RELEASE.md.
+#
+__version__ = "1.0.0.dev0"
diff --git a/simpervisor/process.py b/simpervisor/process.py
index 955bbc4..416c3b7 100644
--- a/simpervisor/process.py
+++ b/simpervisor/process.py
@@ -6,7 +6,7 @@
 import signal
 import time
 
-from simpervisor import atexitasync
+from .atexitasync import add_handler, remove_handler
 
 
 class KilledProcessError(Exception):
@@ -76,7 +76,7 @@ def _debug_log(self, action, message, extras=None, *args):
     def _handle_signal(self, signal):
         # Child processes should handle SIGTERM / SIGINT & close,
         # which should trigger self._restart_process_if_needed
-        # We don't explicitly reap child processe
+        # We don't explicitly reap child processes
         self.proc.send_signal(signal)
         # Don't restart process after it is reaped
         self._killed = True
@@ -117,7 +117,7 @@ async def start(self):
             )
 
             # This handler is removed when process stops
-            atexitasync.add_handler(self._handle_signal)
+            add_handler(self._handle_signal)
 
     async def _restart_process_if_needed(self):
         """
@@ -128,7 +128,7 @@ async def _restart_process_if_needed(self):
         """
         retcode = await self.proc.wait()
         # FIXME: Do we need to aquire a lock somewhere in this method?
-        atexitasync.remove_handler(self._handle_signal)
+        remove_handler(self._handle_signal)
         self._debug_log(
             "exited", "{} exited with code {}", {"code": retcode}, self.name, retcode
         )
@@ -160,7 +160,7 @@ async def _signal_and_wait(self, signum):
             await self.proc.wait()
             self.running = False
             # Remove signal handler *after* the process is done
-            atexitasync.remove_handler(self._handle_signal)
+            remove_handler(self._handle_signal)
 
     async def terminate(self):
         """
diff --git a/tests/child_scripts/signalprinter.py b/tests/child_scripts/signalprinter.py
index d2be00f..61b583c 100644
--- a/tests/child_scripts/signalprinter.py
+++ b/tests/child_scripts/signalprinter.py
@@ -5,7 +5,7 @@
 import sys
 from functools import partial
 
-from simpervisor import atexitasync
+from simpervisor.atexitasync import add_handler
 
 
 def _handle_sigterm(number, received_signum):
@@ -15,7 +15,7 @@ def _handle_sigterm(number, received_signum):
 
 handlercount = int(sys.argv[1])
 for i in range(handlercount):
-    atexitasync.add_handler(partial(_handle_sigterm, i))
+    add_handler(partial(_handle_sigterm, i))
 
 loop = asyncio.get_event_loop()
 try:
diff --git a/tests/child_scripts/signalsupervisor.py b/tests/child_scripts/signalsupervisor.py
index ffd14fb..0344a42 100644
--- a/tests/child_scripts/signalsupervisor.py
+++ b/tests/child_scripts/signalsupervisor.py
@@ -32,11 +32,7 @@ async def main():
     loop.run_forever()
 finally:
     # Cleanup properly so we get a clean exit
-    try:
-        remaining_tasks = asyncio.all_tasks(loop=loop)
-    except AttributeError:
-        # asyncio.all_tasks was added in 3. Provides reverse compatability.
-        remaining_tasks = asyncio.Task.all_tasks(loop=loop)
+    remaining_tasks = asyncio.all_tasks(loop=loop)
     loop.run_until_complete(asyncio.gather(*remaining_tasks))
     loop.close()
     print("supervisor exiting cleanly")
diff --git a/tests/test_atexitasync.py b/tests/test_atexitasync.py
index e32d731..29fb079 100644
--- a/tests/test_atexitasync.py
+++ b/tests/test_atexitasync.py
@@ -24,11 +24,13 @@ def test_atexitasync(signum, handlercount):
         os.path.dirname(os.path.abspath(__file__)), "child_scripts", "signalprinter.py"
     )
     proc = subprocess.Popen(
-        [sys.executable, signalprinter_file, str(handlercount)], stdout=subprocess.PIPE
+        [sys.executable, signalprinter_file, str(handlercount)],
+        stdout=subprocess.PIPE,
+        text=True,
     )
 
     # Give the process time to register signal handlers
-    time.sleep(0.5)
+    time.sleep(1)
     proc.send_signal(signum)
 
     # Make sure the signal is handled by our handler in the code
@@ -38,7 +40,7 @@ def test_atexitasync(signum, handlercount):
         + "\n"
     )
 
-    assert stdout.decode() == expected_output
+    assert stdout == expected_output
 
     # The code should exit cleanly
     retcode = proc.wait()
diff --git a/tests/test_signals.py b/tests/test_signals.py
index 78653ff..a46b1a2 100644
--- a/tests/test_signals.py
+++ b/tests/test_signals.py
@@ -24,14 +24,16 @@ async def test_sigtermreap(childcount):
     )
 
     proc = subprocess.Popen(
-        [sys.executable, signalsupervisor_file, str(childcount)], stdout=subprocess.PIPE
+        [sys.executable, signalsupervisor_file, str(childcount)],
+        stdout=subprocess.PIPE,
+        text=True,
     )
 
     # Give the signal handlers a bit of time to set up
-    time.sleep(0.5)
+    time.sleep(1)
 
     # Read the child's PID from signalsupervisor
-    child_pids = [int(l) for l in proc.stdout.readline().decode().split(" ")]
+    child_pids = [int(l) for l in proc.stdout.readline().split(" ")]
 
     proc.send_signal(signal.SIGTERM)
     proc.wait()
@@ -46,7 +48,7 @@ async def test_sigtermreap(childcount):
 
     # Test order of exit of child & parent
     assert (
-        stdout.decode()
+        stdout
         == "handler 0 received 15\n" * len(child_pids) + "supervisor exiting cleanly\n"
     )
     # Test that our supervisor also exited cleanly
diff --git a/tests/test_simpervisor.py b/tests/test_simpervisor.py
index f727448..a598edc 100644
--- a/tests/test_simpervisor.py
+++ b/tests/test_simpervisor.py
@@ -7,7 +7,7 @@
 
 import pytest
 
-import simpervisor
+from simpervisor import KilledProcessError, SupervisedProcess
 
 SLEEP_TIME = 0.1
 SLEEP_WAIT_TIME = 0.5
@@ -28,7 +28,7 @@ async def test_start_success():
     """
     Start a process & check its running status
     """
-    proc = simpervisor.SupervisedProcess(
+    proc = SupervisedProcess(
         inspect.currentframe().f_code.co_name, *sleep(0), always_restart=False
     )
     await proc.start()
@@ -41,7 +41,7 @@ async def test_start_always_restarting():
     """
     Start a process & check it restarts even when it succeeds
     """
-    proc = simpervisor.SupervisedProcess(
+    proc = SupervisedProcess(
         inspect.currentframe().f_code.co_name, *sleep(0), always_restart=True
     )
     await proc.start()
@@ -61,7 +61,7 @@ async def test_start_fail_restarting():
     """
     Start a process that fails & make sure it restarts
     """
-    proc = simpervisor.SupervisedProcess(
+    proc = SupervisedProcess(
         inspect.currentframe().f_code.co_name, *sleep(1), always_restart=True
     )
     await proc.start()
@@ -81,7 +81,7 @@ async def test_start_multiple_start():
     """
     Starting the same process multiple times should be a noop
     """
-    proc = simpervisor.SupervisedProcess(
+    proc = SupervisedProcess(
         inspect.currentframe().f_code.co_name, *sleep(0), always_restart=True
     )
     await proc.start()
@@ -100,7 +100,7 @@ async def test_method_after_kill(method):
     """
     Running 'method' on process after it has been killed should throw
     """
-    proc = simpervisor.SupervisedProcess(
+    proc = SupervisedProcess(
         inspect.currentframe().f_code.co_name, *sleep(0), always_restart=True
     )
     await proc.start()
@@ -108,7 +108,7 @@ async def test_method_after_kill(method):
     await proc.kill()
     assert not proc.running
 
-    with pytest.raises(simpervisor.KilledProcessError):
+    with pytest.raises(KilledProcessError):
         await getattr(proc, method)()
 
 
@@ -116,7 +116,7 @@ async def test_kill():
     """
     Test killing processes
     """
-    proc = simpervisor.SupervisedProcess(
+    proc = SupervisedProcess(
         inspect.currentframe().f_code.co_name, *sleep(0), always_restart=True
     )
 
@@ -133,7 +133,7 @@ async def test_terminate():
     """
     Test terminating processes
     """
-    proc = simpervisor.SupervisedProcess(
+    proc = SupervisedProcess(
         inspect.currentframe().f_code.co_name, *sleep(0), always_restart=True
     )