From f19eb44bf639d812da042db1fb4f475b55cbdc1f Mon Sep 17 00:00:00 2001 From: "Paul J. Dorn" Date: Thu, 10 Apr 2025 15:25:44 +0200 Subject: [PATCH 01/10] update docs --- docs/source/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/settings.rst b/docs/source/settings.rst index e1e91fa76..ff91edf5d 100644 --- a/docs/source/settings.rst +++ b/docs/source/settings.rst @@ -1148,7 +1148,7 @@ change the worker process user. Switch worker process to run as this group. A valid group id (as an integer) or the name of a user that can be -retrieved with a call to ``pwd.getgrnam(value)`` or ``None`` to not +retrieved with a call to ``grp.getgrnam(value)`` or ``None`` to not change the worker processes group. .. _umask: From 2bc7d68bebf0aa5961d984fdf12e3022900ab3dc Mon Sep 17 00:00:00 2001 From: "Paul J. Dorn" Date: Sat, 17 Aug 2024 03:41:46 +0200 Subject: [PATCH 02/10] proc management: not all children die equally --- docs/source/settings.rst | 4 ++++ gunicorn/arbiter.py | 13 ++++++++++--- gunicorn/config.py | 4 ++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/source/settings.rst b/docs/source/settings.rst index ff91edf5d..be425a6a2 100644 --- a/docs/source/settings.rst +++ b/docs/source/settings.rst @@ -1100,6 +1100,10 @@ A filename to use for the PID file. If not set, no PID file will be written. +.. note:: + During master re-exec, a ``.2`` suffix is added to + this path to store the PID of the newly launched master. + .. _worker-tmp-dir: ``worker_tmp_dir`` diff --git a/gunicorn/arbiter.py b/gunicorn/arbiter.py index 646d684ef..e44060088 100644 --- a/gunicorn/arbiter.py +++ b/gunicorn/arbiter.py @@ -60,7 +60,9 @@ def __init__(self, app): self.pidfile = None self.systemd = False self.worker_age = 0 + # old master has != 0 until new master is dead or promoted self.reexec_pid = 0 + # new master has != 0 until old master is dead (until promotion) self.master_pid = 0 self.master_name = "Master" @@ -413,8 +415,10 @@ def reexec(self): master_pid = os.getpid() self.reexec_pid = os.fork() if self.reexec_pid != 0: + # old master return + # new master self.cfg.pre_exec(self) environ = self.cfg.env_orig.copy() @@ -519,7 +523,13 @@ def reap_workers(self): break if self.reexec_pid == wpid: self.reexec_pid = 0 + self.log.info("Master exited before promotion.") + continue else: + worker = self.WORKERS.pop(wpid, None) + if not worker: + self.log.debug("Non-worker subprocess (pid:%s) exited", wpid) + continue # A worker was terminated. If the termination reason was # that it could not boot, we'll shut it down to avoid # infinite start/stop cycles. @@ -554,9 +564,6 @@ def reap_workers(self): msg += " Perhaps out of memory?" self.log.error(msg) - worker = self.WORKERS.pop(wpid, None) - if not worker: - continue worker.tmp.close() self.cfg.child_exit(self, worker) except OSError as e: diff --git a/gunicorn/config.py b/gunicorn/config.py index 07c5aab34..aa3cc8f0c 100644 --- a/gunicorn/config.py +++ b/gunicorn/config.py @@ -1123,6 +1123,10 @@ class Pidfile(Setting): A filename to use for the PID file. If not set, no PID file will be written. + + .. note:: + During master re-exec, a ``.2`` suffix is added to + this path to store the PID of the newly launched master. """ From b993e9531f1366000131bbab7cddd80aca9f9554 Mon Sep 17 00:00:00 2001 From: "Paul J. Dorn" Date: Fri, 23 Aug 2024 15:47:07 +0200 Subject: [PATCH 03/10] systemd: send MAINPID updates on re-exec --- gunicorn/arbiter.py | 21 +++++++++++++++++---- gunicorn/systemd.py | 8 ++++++++ tests/test_arbiter.py | 15 +++++++++------ 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/gunicorn/arbiter.py b/gunicorn/arbiter.py index e44060088..31dbfc5b3 100644 --- a/gunicorn/arbiter.py +++ b/gunicorn/arbiter.py @@ -68,8 +68,11 @@ def __init__(self, app): cwd = util.getcwd() - args = sys.argv[:] - args.insert(0, sys.executable) + if sys.version_info < (3, 10): + args = sys.argv[:] + args.insert(0, sys.executable) + else: + args = sys.orig_argv[:] # init start context self.START_CTX = { @@ -161,7 +164,7 @@ def start(self): self.log.debug("Arbiter booted") self.log.info("Listening at: %s (%s)", listeners_str, self.pid) self.log.info("Using worker: %s", self.cfg.worker_class_str) - systemd.sd_notify("READY=1\nSTATUS=Gunicorn arbiter booted", self.log) + systemd.sd_notify("READY=1\nSTATUS=Gunicorn arbiter booted\n", self.log) # check worker class requirements if hasattr(self.worker_class, "check_config"): @@ -253,7 +256,10 @@ def handle_hup(self): - Gracefully shutdown the old worker processes """ self.log.info("Hang up: %s", self.master_name) + systemd.sd_notify("RELOADING=1\nSTATUS=Gunicorn arbiter reloading..\n", self.log) self.reload() + # possibly premature, newly launched workers might have failed + systemd.sd_notify("READY=1\nSTATUS=Gunicorn arbiter reloaded\n", self.log) def handle_term(self): "SIGTERM handling" @@ -329,6 +335,8 @@ def maybe_promote_master(self): self.pidfile.rename(self.cfg.pidfile) # reset proctitle util._setproctitle("master [%s]" % self.proc_name) + # MAINPID does not change here, it was already set on fork + systemd.sd_notify("READY=1\nMAINPID=%d\nSTATUS=Gunicorn arbiter promoted\n" % (os.getpid(), ), self.log) def wakeup(self): """\ @@ -434,7 +442,10 @@ def reexec(self): os.chdir(self.START_CTX['cwd']) # exec the process using the original environment - os.execvpe(self.START_CTX[0], self.START_CTX['args'], environ) + self.log.debug("exe=%r argv=%r" % (self.START_CTX[0], self.START_CTX['args'])) + # let systemd know are are in control + systemd.sd_notify("READY=1\nMAINPID=%d\nSTATUS=Gunicorn arbiter re-exec\n" % (master_pid, ), self.log) + os.execve(self.START_CTX[0], self.START_CTX['args'], environ) def reload(self): old_address = self.cfg.address @@ -524,6 +535,8 @@ def reap_workers(self): if self.reexec_pid == wpid: self.reexec_pid = 0 self.log.info("Master exited before promotion.") + # let systemd know we are (back) in control + systemd.sd_notify("READY=1\nMAINPID=%d\nSTATUS=Gunicorn arbiter re-exec aborted\n" % (os.getpid(), ), self.log) continue else: worker = self.WORKERS.pop(wpid, None) diff --git a/gunicorn/systemd.py b/gunicorn/systemd.py index 9b1855060..ad1c9de44 100644 --- a/gunicorn/systemd.py +++ b/gunicorn/systemd.py @@ -4,6 +4,7 @@ import os import socket +import time SD_LISTEN_FDS_START = 3 @@ -66,6 +67,13 @@ def sd_notify(state, logger, unset_environment=False): if addr[0] == '@': addr = '\0' + addr[1:] sock.connect(addr) + assert state.endswith("\n") + if "RELOADING" in state: # broad, but systemd man promises tolerating + # wrong clock on some platforms.. but this is only needed on Linux + # nsec = 10**-9 + # usec = 10**-6 + state += "MONOTONIC_USEC=%d\n" % (1_000 * time.monotonic_ns(), ) + logger.debug("sd_notify: %r" % (state, )) sock.sendall(state.encode('utf-8')) except Exception: logger.debug("Exception while invoking sd_notify()", exc_info=True) diff --git a/tests/test_arbiter.py b/tests/test_arbiter.py index 8c1527e26..320e929c8 100644 --- a/tests/test_arbiter.py +++ b/tests/test_arbiter.py @@ -71,24 +71,27 @@ def test_arbiter_stop_does_not_unlink_when_using_reuse_port(close_sockets): @mock.patch('os.getpid') @mock.patch('os.fork') -@mock.patch('os.execvpe') -def test_arbiter_reexec_passing_systemd_sockets(execvpe, fork, getpid): +@mock.patch('os.execve') +@mock.patch('gunicorn.systemd.sd_notify') +def test_arbiter_reexec_passing_systemd_sockets(sd_notify, execve, fork, getpid): arbiter = gunicorn.arbiter.Arbiter(DummyApplication()) arbiter.LISTENERS = [mock.Mock(), mock.Mock()] arbiter.systemd = True fork.return_value = 0 + sd_notify.return_value = None getpid.side_effect = [2, 3] arbiter.reexec() - environ = execvpe.call_args[0][2] + environ = execve.call_args[0][2] assert environ['GUNICORN_PID'] == '2' assert environ['LISTEN_FDS'] == '2' assert environ['LISTEN_PID'] == '3' + sd_notify.assert_called_once() @mock.patch('os.getpid') @mock.patch('os.fork') -@mock.patch('os.execvpe') -def test_arbiter_reexec_passing_gunicorn_sockets(execvpe, fork, getpid): +@mock.patch('os.execve') +def test_arbiter_reexec_passing_gunicorn_sockets(execve, fork, getpid): arbiter = gunicorn.arbiter.Arbiter(DummyApplication()) listener1 = mock.Mock() listener2 = mock.Mock() @@ -98,7 +101,7 @@ def test_arbiter_reexec_passing_gunicorn_sockets(execvpe, fork, getpid): fork.return_value = 0 getpid.side_effect = [2, 3] arbiter.reexec() - environ = execvpe.call_args[0][2] + environ = execve.call_args[0][2] assert environ['GUNICORN_FD'] == '4,5' assert environ['GUNICORN_PID'] == '2' From bc9b147e2fc59f74e782ef9e08e67ddfb70ffb2c Mon Sep 17 00:00:00 2001 From: "Paul J. Dorn" Date: Sun, 8 Sep 2024 23:50:16 +0200 Subject: [PATCH 04/10] systemd: send STOPPING=1 on shutdown --- gunicorn/arbiter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gunicorn/arbiter.py b/gunicorn/arbiter.py index 31dbfc5b3..3735446c5 100644 --- a/gunicorn/arbiter.py +++ b/gunicorn/arbiter.py @@ -350,6 +350,8 @@ def wakeup(self): def halt(self, reason=None, exit_status=0): """ halt arbiter """ + systemd.sd_notify("STOPPING=1\nSTATUS=Gunicorn shutting down..\n", self.log) + self.stop() log_func = self.log.info if exit_status == 0 else self.log.error From 76f761f1d00611b408639ee664759537618b621e Mon Sep 17 00:00:00 2001 From: "Paul J. Dorn" Date: Fri, 4 Apr 2025 22:26:32 +0200 Subject: [PATCH 05/10] systemd: $MAINPID handover for NotifyAccess=main This reverts dup-ing the socket fds on re-exec (and --bind=fd://3, and systemd socket activation) --- docs/source/settings.rst | 5 +++++ gunicorn/arbiter.py | 28 +++++++++++++++++++++++----- gunicorn/config.py | 5 +++++ gunicorn/instrument/statsd.py | 1 + gunicorn/sock.py | 14 +++++++++++--- 5 files changed, 45 insertions(+), 8 deletions(-) diff --git a/docs/source/settings.rst b/docs/source/settings.rst index be425a6a2..1c3bc0bba 100644 --- a/docs/source/settings.rst +++ b/docs/source/settings.rst @@ -1595,6 +1595,11 @@ If the ``PORT`` environment variable is defined, the default is ``['0.0.0.0:$PORT']``. If it is not defined, the default is ``['127.0.0.1:8000']``. +.. note:: + Specifying any fd://FD socket or inheriting any socket from systemd + (LISTEN_FDS) results in other bind addresses to be skipped. + Do not mix fd://FD and systemd socket activation. + .. _backlog: ``backlog`` diff --git a/gunicorn/arbiter.py b/gunicorn/arbiter.py index 3735446c5..6b2f91771 100644 --- a/gunicorn/arbiter.py +++ b/gunicorn/arbiter.py @@ -151,6 +151,7 @@ def start(self): self.systemd = True fds = range(systemd.SD_LISTEN_FDS_START, systemd.SD_LISTEN_FDS_START + listen_fds) + self.log.debug("Inherited sockets from systemd: %r", fds) elif self.master_pid: fds = [] @@ -172,6 +173,10 @@ def start(self): self.cfg.when_ready(self) + # # call `pkill --oldest -TERM -f "gunicorn: master "` instead + # if self.master_pid and self.systemd: + # os.kill(self.master_pid, signal.SIGTERM) + def init_signals(self): """\ Initialize master signal handling. Most of the signals @@ -350,7 +355,12 @@ def wakeup(self): def halt(self, reason=None, exit_status=0): """ halt arbiter """ - systemd.sd_notify("STOPPING=1\nSTATUS=Gunicorn shutting down..\n", self.log) + if self.master_pid != 0: + # if NotifyAccess=main, systemd needs to know old master is in control + systemd.sd_notify("READY=1\nMAINPID=%d\nSTATUS=New arbiter shutdown\n" % (self.master_pid, ), self.log) + elif self.reexec_pid == 0: + # skip setting status if this is merely superseded master stopping + systemd.sd_notify("STOPPING=1\nSTATUS=Shutting down..\n", self.log) self.stop() @@ -425,6 +435,10 @@ def reexec(self): master_pid = os.getpid() self.reexec_pid = os.fork() if self.reexec_pid != 0: + # let systemd know they will be in control after exec() + systemd.sd_notify( + "RELOADING=1\nMAINPID=%d\nSTATUS=Gunicorn arbiter re-exec in forked..\n" % (self.reexec_pid, ), self.log + ) # old master return @@ -437,6 +451,9 @@ def reexec(self): if self.systemd: environ['LISTEN_PID'] = str(os.getpid()) environ['LISTEN_FDS'] = str(len(self.LISTENERS)) + # move socket fds back to 3+N after we duped+closed them + # for idx, lnr in enumerate(self.LISTENERS): + # os.dup2(lnr.fileno(), 3+idx) else: environ['GUNICORN_FD'] = ','.join( str(lnr.fileno()) for lnr in self.LISTENERS) @@ -445,8 +462,10 @@ def reexec(self): # exec the process using the original environment self.log.debug("exe=%r argv=%r" % (self.START_CTX[0], self.START_CTX['args'])) - # let systemd know are are in control - systemd.sd_notify("READY=1\nMAINPID=%d\nSTATUS=Gunicorn arbiter re-exec\n" % (master_pid, ), self.log) + # let systemd know we will be in control after exec() + systemd.sd_notify( + "RELOADING=1\nMAINPID=%d\nSTATUS=Gunicorn arbiter re-exec in progress..\n" % (self.reexec_pid, ), self.log + ) os.execve(self.START_CTX[0], self.START_CTX['args'], environ) def reload(self): @@ -538,8 +557,7 @@ def reap_workers(self): self.reexec_pid = 0 self.log.info("Master exited before promotion.") # let systemd know we are (back) in control - systemd.sd_notify("READY=1\nMAINPID=%d\nSTATUS=Gunicorn arbiter re-exec aborted\n" % (os.getpid(), ), self.log) - continue + systemd.sd_notify("READY=1\nMAINPID=%d\nSTATUS=Old arbiter promoted\n" % (os.getpid(), ), self.log) else: worker = self.WORKERS.pop(wpid, None) if not worker: diff --git a/gunicorn/config.py b/gunicorn/config.py index aa3cc8f0c..01f62b6f6 100644 --- a/gunicorn/config.py +++ b/gunicorn/config.py @@ -616,6 +616,11 @@ class Bind(Setting): If the ``PORT`` environment variable is defined, the default is ``['0.0.0.0:$PORT']``. If it is not defined, the default is ``['127.0.0.1:8000']``. + + .. note:: + Specifying any fd://FD socket or inheriting any socket from systemd + (LISTEN_FDS) results in other bind addresses to be skipped. + Do not mix fd://FD and systemd socket activation. """ diff --git a/gunicorn/instrument/statsd.py b/gunicorn/instrument/statsd.py index 7bc4e6ffd..5657c343e 100644 --- a/gunicorn/instrument/statsd.py +++ b/gunicorn/instrument/statsd.py @@ -35,6 +35,7 @@ def __init__(self, cfg): self.sock = socket.socket(address_family, socket.SOCK_DGRAM) self.sock.connect(cfg.statsd_host) except Exception: + self.sock.close() self.sock = None self.dogstatsd_tags = cfg.dogstatsd_tags diff --git a/gunicorn/sock.py b/gunicorn/sock.py index eb2b6fa9c..38df364cb 100644 --- a/gunicorn/sock.py +++ b/gunicorn/sock.py @@ -24,8 +24,8 @@ def __init__(self, address, conf, log, fd=None): sock = socket.socket(self.FAMILY, socket.SOCK_STREAM) bound = False else: - sock = socket.fromfd(fd, self.FAMILY, socket.SOCK_STREAM) - os.close(fd) + # does not duplicate the fd, this LISTEN_FDS stays at fds 3+N + sock = socket.socket(self.FAMILY, socket.SOCK_STREAM, fileno=fd) bound = True self.sock = self.set_options(sock, bound=bound) @@ -156,6 +156,12 @@ def create_sockets(conf, log, fds=None): fdaddr += list(fds) laddr = [bind for bind in addr if not isinstance(bind, int)] + # LISTEN_FDS=1 + fd://3 + uniq_fdaddr = set() + duped_fdaddr = {fd for fd in fdaddr if fd in uniq_fdaddr or uniq_fdaddr.add(fd)} + if duped_fdaddr: + log.warning("Binding with fd:// is unsupported with systemd/re-exec.") + # check ssl config early to raise the error on startup # only the certfile is needed since it can contains the keyfile if conf.certfile and not os.path.exists(conf.certfile): @@ -167,9 +173,11 @@ def create_sockets(conf, log, fds=None): # sockets are already bound if fdaddr: for fd in fdaddr: - sock = socket.fromfd(fd, socket.AF_UNIX, socket.SOCK_STREAM) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM, fileno=fd) sock_name = sock.getsockname() sock_type = _sock_type(sock_name) + log.debug("listen: fd %d => fd %d for %s", fd, sock.fileno(), sock.getsockname()) + sock.detach() # only created to call getsockname(), will re-attach shorty listener = sock_type(sock_name, conf, log, fd=fd) listeners.append(listener) From 97b477f874302fa64a6a860f45126f6e01cd1f3a Mon Sep 17 00:00:00 2001 From: "Paul J. Dorn" Date: Thu, 10 Apr 2025 15:22:23 +0200 Subject: [PATCH 06/10] systemd: auto-shutdown old master post USR2 re-exec --- docs/source/signals.rst | 6 ++++++ gunicorn/arbiter.py | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/source/signals.rst b/docs/source/signals.rst index c22ea0362..cea20dfe2 100644 --- a/docs/source/signals.rst +++ b/docs/source/signals.rst @@ -117,3 +117,9 @@ running:: 20859 benoitc 20 0 55748 11m 1500 S 0.0 0.1 0:00.02 gunicorn: worker [test:app] 20860 benoitc 20 0 55748 11m 1500 S 0.0 0.1 0:00.02 gunicorn: worker [test:app] 20861 benoitc 20 0 55748 11m 1500 S 0.0 0.1 0:00.01 gunicorn: worker [test:app] + +If no pidfile is available (``kill -TERM $(cat /var/run/gunicorn.pid)``) then killing +the *oldest* process (``pkill --oldest -TERM -f "gunicorn: master "``) should suffice. + +When running via systemd socket activation, Gunicorn will *automatically* issue the graceful +shutdown of the old master, as part of starting up the new one. diff --git a/gunicorn/arbiter.py b/gunicorn/arbiter.py index 6b2f91771..91766ec66 100644 --- a/gunicorn/arbiter.py +++ b/gunicorn/arbiter.py @@ -173,9 +173,7 @@ def start(self): self.cfg.when_ready(self) - # # call `pkill --oldest -TERM -f "gunicorn: master "` instead - # if self.master_pid and self.systemd: - # os.kill(self.master_pid, signal.SIGTERM) + # systemd: not yet shutting down old master here (wait for workers) def init_signals(self): """\ @@ -343,6 +341,13 @@ def maybe_promote_master(self): # MAINPID does not change here, it was already set on fork systemd.sd_notify("READY=1\nMAINPID=%d\nSTATUS=Gunicorn arbiter promoted\n" % (os.getpid(), ), self.log) + elif self.systemd and len(self.WORKERS) >= 1: + # still attached to old master, but we are ready to take over + # this automates `kill -TERM $(cat /var/run/gunicorn.pid)` + self.log.debug("systemd managed: shutting down old master %d after re-exec", self.master_pid) + os.kill(self.master_pid, signal.SIGTERM) + + def wakeup(self): """\ Wake up the arbiter by writing to the PIPE @@ -464,7 +469,7 @@ def reexec(self): self.log.debug("exe=%r argv=%r" % (self.START_CTX[0], self.START_CTX['args'])) # let systemd know we will be in control after exec() systemd.sd_notify( - "RELOADING=1\nMAINPID=%d\nSTATUS=Gunicorn arbiter re-exec in progress..\n" % (self.reexec_pid, ), self.log + "RELOADING=1\nMAINPID=%d\nSTATUS=Gunicorn arbiter re-exec in progress..\n" % (os.getpid(), ), self.log ) os.execve(self.START_CTX[0], self.START_CTX['args'], environ) From 10aa88c4d0c62e3ea96b83502bfeada9cafc6977 Mon Sep 17 00:00:00 2001 From: "Paul J. Dorn" Date: Thu, 10 Apr 2025 15:50:35 +0200 Subject: [PATCH 07/10] systemd: recommend Type=notify-reload --- docs/source/deploy.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/deploy.rst b/docs/source/deploy.rst index 5f8689793..df3c6a9f6 100644 --- a/docs/source/deploy.rst +++ b/docs/source/deploy.rst @@ -247,7 +247,8 @@ to the newly created unix socket: [Service] # gunicorn can let systemd know when it is ready - Type=notify + # in systemd versions prior to v253 use Type=notify + Type=notify-reload NotifyAccess=main # the specific user that our service will run as User=someuser From 24113970fe59aa59e26c5e71cd58c44679aaa322 Mon Sep 17 00:00:00 2001 From: "Paul J. Dorn" Date: Thu, 10 Apr 2025 16:03:28 +0200 Subject: [PATCH 08/10] systemd: test duplicated getpid() call --- tests/test_arbiter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_arbiter.py b/tests/test_arbiter.py index 320e929c8..a12bb997a 100644 --- a/tests/test_arbiter.py +++ b/tests/test_arbiter.py @@ -79,7 +79,7 @@ def test_arbiter_reexec_passing_systemd_sockets(sd_notify, execve, fork, getpid) arbiter.systemd = True fork.return_value = 0 sd_notify.return_value = None - getpid.side_effect = [2, 3] + getpid.side_effect = [2, 3, 3] # 2 getpid calls in new master arbiter.reexec() environ = execve.call_args[0][2] assert environ['GUNICORN_PID'] == '2' From 6d451bb10ffd65358d175ba448f0293a23f2aac1 Mon Sep 17 00:00:00 2001 From: "Paul J. Dorn" Date: Thu, 10 Apr 2025 16:06:35 +0200 Subject: [PATCH 09/10] style --- gunicorn/arbiter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gunicorn/arbiter.py b/gunicorn/arbiter.py index 91766ec66..c19bb147d 100644 --- a/gunicorn/arbiter.py +++ b/gunicorn/arbiter.py @@ -347,7 +347,6 @@ def maybe_promote_master(self): self.log.debug("systemd managed: shutting down old master %d after re-exec", self.master_pid) os.kill(self.master_pid, signal.SIGTERM) - def wakeup(self): """\ Wake up the arbiter by writing to the PIPE From 1d202f5558abd2bc58e2d9f69bc4eaf6a4e1e381 Mon Sep 17 00:00:00 2001 From: "Paul J. Dorn" Date: Thu, 10 Apr 2025 16:09:30 +0200 Subject: [PATCH 10/10] CI: skip cPython 3.7 (EoL) --- .github/workflows/tox.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index 759800eb1..e96319a2b 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -21,8 +21,7 @@ jobs: - macos-13 # Not testing Windows, because tests need Unix-only fcntl, grp, pwd, etc. python-version: - # CPython <= 3.7 is EoL since 2023-06-27 - - "3.7" + # CPython <= 3.8 is EoL since 2024-10-07 https://peps.python.org/pep-0569/ - "3.8" - "3.9" - "3.10"