Skip to content

Python 3.10 end-of-life (EOL) (2026-10-31) #153

Closed

Description

@jpmckinney

Ends 31 Oct 2026

  • (?<!\d)(?<!\d\.)3\.10(?!\d|', '3\.11')

Excluding files: -changelog.rst,-package-lock.json,-requirements*.txt,-*.jsonl,-*.log,-*.svg

Notable files to change:

  • .python-version
  • pyproject.toml

Activity

  1. added this to the Priority milestone on Feb 10, 2026
  2. changed the title [-]Python 3.10 end-of-life (EOL)[/-] [+]Python 3.10 end-of-life (EOL) (2025-10-31)[/+] on Feb 10, 2026
  3. changed the title [-]Python 3.10 end-of-life (EOL) (2025-10-31)[/-] [+]Python 3.10 end-of-life (EOL) (2026-10-31)[/+] on Feb 10, 2026
  4. jpmckinney commented on Feb 10, 2026

    @jpmckinney
    MemberAuthor

    For a few applications, related: open-contracting/deploy#602

  5. jpmckinney commented on Jul 24, 2026

    @jpmckinney
    MemberAuthor

    If we upgrade past Python 3.12, we can replace see if we can replace os.walk with Path.rglob. When doing so, we should check if it's paired with e.g. .endswith("*.extension") logic, which we can roll into the glob.

  6. jpmckinney commented on Oct 28, 2026

    @jpmckinney
    MemberAuthor

    Plan

    Searched each sibling repository's default branch with the regex above, plus CI matrices ('3.10', '3.11'), requires-python, and version-conditional code.

    1. Packages: require Python 3.11 or later

    Drop the classifier and matrix entry, set requires-python = ">=3.11", apply Ruff fixes, add a changelog entry.

    2. Shared tooling and templates

    3. Applications on 3.10

    All three run on kingfisher-main, in a virtualenv built from the server's default Python (3.10 on Ubuntu 22.04). Separate the apps' Python from the OS (see open-contracting/deploy#602). These first moved to 3.13, which was wrongly taken to be Ubuntu 26.04's version; it's 3.14 (see section 5):

    4. Docker applications to 3.13

    These were on 3.11, below the Ubuntu 26.04 LTS version, which is 3.14 (not 3.13, as first written). Docker images don't depend on the server, so these can be upgraded now. Each updates .python-version (which CI reads), the Dockerfiles and the contributing docs:

    5. Applications to 3.14

    The handbook's minimum for applications is the latest Ubuntu LTS's Python: 3.14 in Ubuntu 26.04 (0d70430 also rewords the policy, and moves cookiecutter-django and the docs' examples to 3.14). The PRs also upgrade compiled packages without 3.14 wheels.

    Deferred

    • os.walk → Path.rglob (comment above) applies once packages require 3.12+, not to this upgrade.

    🤖 Generated with Claude Code

  7. jpmckinney commented on Oct 31, 2026

    @jpmckinney
    MemberAuthor

    Post-upgrade checks (do these next time, before deploying)

    The test suites passed on 3.14, but two runtime failures reached production after deploying:

    • pika 1.3.2 (indirect, via yapw) calls asyncio.get_event_loop(), which raises on Python 3.14 if no loop is set. Every yapw Async/AsyncConsumer worker crashed on startup (data-registry, kingfisher-process, pelican-backend, pelican-frontend). No test starts an async consumer, so CI didn't catch it. Fixed by upgrading to pika 1.4.4.
    • sentry-sdk 2.8.0 raises an internal error on Python 3.13+ when serializing a function frame's locals (copy(frame.f_locals); PEP 667), so unhandled exceptions weren't reported. A module-level exception still reports, so a trivial test passes. Fixed by upgrading to sentry-sdk 2.73.0.

    Checklist:

    • Smoke test every entry point on the new Python, using the commands in the deploy repository's salt/docker_apps/files/*.yaml (workers, management commands, and gunicorn core.wsgi). Use a fresh environment, a throwaway RABBIT_EXCHANGE_NAME (to not consume development messages), PYTHONWARNINGS=default, and SENTRY_DSN pointing at a local HTTP sink. Each worker must stay up, then exit cleanly on SIGINT; each web server must serve a request.
    • Check that Sentry reports an unhandled exception raised inside a function (not at module level), for example by running a worker without RabbitMQ.
    • Audit old pins, including indirect ones: list pins released before the new Python's release that have a newer version, then read the changelogs of those that touch event loops, frames, annotations, pickling or C extensions. Upgrade those first.
    • After deploying, check docker ps for restarting containers (docker inspect --format '{{.RestartCount}}') and check Sentry for new issues.

    This time's smoke test (after the fixes) passed for all 27 entry points. Remaining warnings, to follow up:

    • ResourceWarning: unclosed event loop on shutdown of every async worker. Fixed: yapw 0.3.0 closes the event loop that Pika creates (open-contracting/yapw@05e7361), and the applications use yapw 0.3.0.
    • datetime.utcfromtimestamp() is deprecated: pytz 2021.1 and python-dateutil 2.8.2 (data-registry). Fixed: upgraded to pytz 2026.5 and python-dateutil 3.0.0.
    • SyntaxWarning: "\s" is an invalid escape sequence: django-bootstrap3 24.2 (cove-oc4ids). Fixed: upgraded to django-bootstrap3 26.3.
    • asyncio.iscoroutinefunction is deprecated (removed in 3.16): sentry-sdk 2.63.0 (digitalbuying, kingfisher-collect). Fixed: upgraded to sentry-sdk 2.73.0.
    • ResourceWarning: <psycopg.Connection ...> was deleted while still open on shutdown of pelican-backend's data_item and dataset workers. Fixed: pelican-backend closes all threads' database connections when the consumer stops (open-contracting/pelican-backend@24760b4, open-contracting/pelican-backend@1c3949a).
    Smoke test script
    """
    Start each production command, then interrupt it, and report crashes, warnings and Sentry events.
    
    Run from the directory containing the repositories, with RabbitMQ, PostgreSQL and Redis running locally:
    
        PYTHON=3.14 RUN_SECONDS=20 python smoke.py OUTPUT_DIR [APP[=COMMAND,...]] ...
    
    The commands are from the deploy repository's salt/docker_apps/files/*.yaml files.
    """
    
    import http.server
    import os
    import re
    import signal
    import subprocess
    import sys
    import threading
    import time
    import urllib.request
    from concurrent.futures import ThreadPoolExecutor
    from pathlib import Path
    
    ROOT = Path.cwd()
    PYTHON = os.getenv("PYTHON", "3.14")
    OUT = Path(sys.argv[1])
    RUN_SECONDS = int(os.getenv("RUN_SECONDS", "10"))
    
    APPS = {
        "data-registry": {
            "manage": ["exporter", "coverage", "flattener", "wiper"],
            "web": True,
        },
        "kingfisher-process": {
            "manage": [
                "api_loader",
                "file_worker",
                "checker",
                "compiler",
                "release_compiler",
                "record_compiler",
                "finisher",
                "wiper",
            ],
            "web": True,
        },
        "pelican-backend": {
            "modules": [
                "workers.extract.kingfisher_process",
                "workers.extract.dataset_filter",
                "workers.check.data_item",
                "workers.check.dataset",
                "workers.check.time_based",
                "workers.report",
                "workers.wipe",
            ],
        },
        "pelican-frontend": {"manage": ["export"], "web": True},
        "cove-oc4ids": {"web": True},
        "digitalbuying": {"web": True},
    }
    
    events = {}
    
    
    def sink(port, app):
        class Handler(http.server.BaseHTTPRequestHandler):
            def do_POST(self):
                self.rfile.read(int(self.headers.get("Content-Length", 0)))
                events[app] = events.get(app, 0) + 1
                self.send_response(200)
                self.end_headers()
    
            def log_message(self, *args):
                pass
    
        server = http.server.HTTPServer(("127.0.0.1", port), Handler)
        threading.Thread(target=server.serve_forever, daemon=True).start()
    
    
    def run(app, label, cmd, env, url=None):
        log = OUT / f"{app}--{label.replace(' ', '_')}.log"
        with log.open("w") as f:
            proc = subprocess.Popen(cmd, cwd=ROOT / app, env=env, stdout=f, stderr=subprocess.STDOUT)
            status = None
            time.sleep(RUN_SECONDS / 2)
            if url:
                try:
                    status = urllib.request.urlopen(url, timeout=10).status
                except urllib.error.HTTPError as e:
                    status = e.code
                except Exception as e:  # noqa: BLE001
                    status = type(e).__name__
            time.sleep(RUN_SECONDS / 2)
            alive = proc.poll() is None
            if alive:
                proc.send_signal(signal.SIGINT if not url else signal.SIGTERM)
                try:
                    proc.wait(15)
                except subprocess.TimeoutExpired:
                    proc.kill()
                    proc.wait()
                    alive = "hung on shutdown"
        text = log.read_text(errors="replace")
        tracebacks = len(re.findall(r"^Traceback", text, re.MULTILINE))
        warnings = sorted(
            set(
                re.findall(
                    r"\w*(?:Deprecation|Syntax|Runtime|Resource|Future)Warning: .{0,120}",
                    text,
                )
            )
        )
        last = text.strip().splitlines()[-1][:160] if text.strip() else ""
        return app, label, alive, proc.returncode, status, tracebacks, warnings, last
    
    
    def app_runs(app, spec, port):
        venv = OUT / "venvs" / app
        py = venv / "bin" / "python"
        subprocess.run(["uv", "venv", "-q", "--clear", "--python", PYTHON, str(venv)], check=True)
        subprocess.run(
            ["uv", "pip", "sync", "-q", "--no-config", "--python", str(py), "requirements_dev.txt"],
            cwd=ROOT / app,
            check=True,
        )
        env = {
            **os.environ,
            "RABBIT_EXCHANGE_NAME": f"smoke_{app.replace('-', '_')}",
            "SENTRY_DSN": f"http://key@127.0.0.1:{port}/1",
            "PYTHONWARNINGS": "default",
            "PYTHONUNBUFFERED": "1",
            "ENABLE_CHECKER": "1",
        }
        env.pop("VIRTUAL_ENV", None)
        results = []
        for command in spec.get("manage", []):
            results.append(run(app, command, [str(py), "manage.py", command], env))
        for module in spec.get("modules", []):
            results.append(run(app, module, [str(py), "-m", module], env))
        if spec.get("web"):
            web_port = port + 100
            cmd = [
                str(venv / "bin" / "gunicorn"),
                "core.wsgi",
                "--bind",
                f"127.0.0.1:{web_port}",
                "--threads",
                "2",
            ]
            results.append(run(app, "web", cmd, env, url=f"http://127.0.0.1:{web_port}/"))
        return results
    
    
    def main():
        only = dict(a.split("=", 1) if "=" in a else (a, "") for a in sys.argv[2:])
        for app in [a for a in APPS if only and a not in only]:
            del APPS[app]
        for app, cmds in only.items():
            if cmds:
                for key in ("manage", "modules"):
                    if key in APPS[app]:
                        APPS[app][key] = [c for c in APPS[app][key] if c in cmds.split(",")]
                APPS[app]["web"] = "web" in cmds.split(",")
        OUT.mkdir(parents=True, exist_ok=True)
        ports = {app: 8800 + i for i, app in enumerate(APPS)}
        for app, port in ports.items():
            sink(port, app)
        with ThreadPoolExecutor(len(APPS)) as executor:
            futures = [executor.submit(app_runs, app, spec, ports[app]) for app, spec in APPS.items()]
            for future in futures:
                for (
                    app,
                    label,
                    alive,
                    code,
                    status,
                    tracebacks,
                    warnings,
                    last,
                ) in future.result():
                    ok = alive is True and not tracebacks and status in (None, 200, 301, 302)
                    print(
                        f"{'OK  ' if ok else 'FAIL'} {app} {label}: alive={alive} exit={code} http={status} "
                        f"tracebacks={tracebacks}"
                    )
                    if not ok:
                        print(f"     last: {last}")
                    for w in warnings:
                        print(f"     warning: {w}")
        print("sentry events:", events)
    
    
    main()

    🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions