Repository: PrefectHQ/prefect

Issue:
Remove Python 3.7 support 
### First check

- [X] I am a contributor to the Prefect codebase

### Description

 Remove Python 3.7 tests and new Docker image builds and update docs

Python 3.7 reached [EOL](https://devguide.python.org/versions/) June 27, 2023. 

Prefect provided a [future warning](https://github.com/PrefectHQ/prefect/pull/9469) that we would no longer be supporting 3.7 after the EOL date was reached.

 

### Impact

To reduce test run time and developer attention to 3.7-related issues, 3.7 tests and Docker image builds should be removed and a few spots in the docs should be updated.

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -35,7 +35,7 @@
         ],
     },
     # Requirements
-    python_requires=">=3.7",
+    python_requires=">=3.8",
     install_requires=install_requires,
     extras_require={"dev": dev_requires},
     classifiers=[
@@ -44,7 +44,6 @@
         "Intended Audience :: System Administrators",
         "License :: OSI Approved :: Apache Software License",
         "Programming Language :: Python :: 3 :: Only",
-        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
diff --git a/src/prefect/__init__.py b/src/prefect/__init__.py
--- a/src/prefect/__init__.py
+++ b/src/prefect/__init__.py
@@ -23,8 +23,8 @@
 if sys.version_info < (3, 8):
     warnings.warn(
         (
-            "Prefect will drop support for Python 3.7 when it reaches end-of-life on 27"
-            " Jun 2023. To use new versions of Prefect after that date, you will need"
+            "Prefect dropped support for Python 3.7 when it reached end-of-life"
+            " . To use new versions of Prefect, you will need"
             " to upgrade to Python 3.8+. See https://devguide.python.org/versions/ for "
             " more details."
         ),
diff --git a/src/prefect/utilities/compat.py b/src/prefect/utilities/compat.py
--- a/src/prefect/utilities/compat.py
+++ b/src/prefect/utilities/compat.py
@@ -4,9 +4,9 @@
 # Please organize additions to this file by version
 
 import asyncio
-import os
-import shutil
 import sys
+from shutil import copytree
+from signal import raise_signal
 
 if sys.version_info < (3, 10):
     import importlib_metadata
@@ -27,149 +27,5 @@ async def asyncio_to_thread(fn, *args, **kwargs):
 else:
     from asyncio import to_thread as asyncio_to_thread
 
-if sys.version_info < (3, 8):
-    import stat
-
-    def copytree(src, dst, symlinks=False, ignore=None, *args, **kwargs):
-        """
-        Replicates the behavior of
-            `shutil.copytree(src=src, dst=dst, ignore=ignore, dirs_exist_ok=True)`
-        in a python 3.7 compatible manner.
-
-        Source for the logic: Cyrille Pontvieux at https://stackoverflow.com/a/22331852
-        """
-        if not os.path.exists(dst):
-            os.makedirs(dst)
-            shutil.copystat(src, dst)
-        elements = os.listdir(src)
-
-        if ignore:
-            exclude = ignore(src, elements)
-            elements = [el for el in elements if el not in exclude]
-
-        for item in elements:
-            source_path = os.path.join(src, item)
-            destination_path = os.path.join(dst, item)
-            if symlinks and os.path.islink(source_path):
-                if os.path.lexists(destination_path):
-                    os.remove(destination_path)
-                os.symlink(os.readlink(source_path), destination_path)
-                try:
-                    st = os.lstat(source_path)
-                    mode = stat.S_IMODE(st.st_mode)
-                    os.lchmod(destination_path, mode)
-                except Exception:
-                    pass  # lchmod not available
-            elif os.path.isdir(source_path):
-                copytree(source_path, destination_path, symlinks, ignore)
-            else:
-                shutil.copy2(source_path, destination_path)
-
-else:
-    from shutil import copytree
-
-if sys.version_info < (3, 8):
-
-    def raise_signal(signal: int):
-        os.kill(os.getpid(), signal)
-
-else:
-    from signal import raise_signal
-
-if sys.version_info < (3, 8) and sys.platform != "win32":
-    # https://docs.python.org/3/library/asyncio-policy.html#asyncio.ThreadedChildWatcher
-    # `ThreadedChildWatcher` is the default child process watcher for Python 3.8+ but it
-    # does not exist in Python 3.7. This backport allows us to ensure consistent
-    # behavior when spawning async child processes without dropping support for 3.7.
-    import itertools
-    import logging
-    import threading
-    import time
-    import warnings
-
-    logger = logging.getLogger()
-
-    # Python 3.7 preferred API
-    _get_running_loop = getattr(asyncio, "get_running_loop", asyncio.get_event_loop)
-
-    class ThreadedChildWatcher(asyncio.AbstractChildWatcher):
-        def __init__(self):
-            self._pid_counter = itertools.count(0)
-            self._threads = {}
-
-        def is_active(self):
-            return True
-
-        def close(self):
-            pass
-
-        def __enter__(self):
-            return self
-
-        def __exit__(self, exc_type, exc_val, exc_tb):
-            pass
-
-        def __del__(self, _warn=warnings.warn):
-            threads = [t for t in list(self._threads.values()) if t.is_alive()]
-            if threads:
-                _warn(
-                    f"{self.__class__} has registered but not finished child processes",
-                    ResourceWarning,
-                    source=self,
-                )
-
-        def add_child_handler(self, pid, callback, *args):
-            loop = _get_running_loop()
-            thread = threading.Thread(
-                target=self._do_waitpid,
-                name=f"waitpid-{next(self._pid_counter)}",
-                args=(loop, pid, callback, args),
-                daemon=True,
-            )
-            self._threads[pid] = thread
-            thread.start()
-
-        def remove_child_handler(self, pid):
-            # asyncio never calls remove_child_handler() !!!
-            # The method is no-op but is implemented because
-            # abstract base class requires it
-            return True
-
-        def attach_loop(self, loop):
-            pass
-
-        def _do_waitpid(self, loop, expected_pid, callback, args):
-            assert expected_pid > 0
-
-            try:
-                pid, status = os.waitpid(expected_pid, 0)
-            except ChildProcessError:
-                # The child process is already reaped
-                # (may happen if waitpid() is called elsewhere).
-                pid = expected_pid
-                returncode = 255
-                logger.warning(
-                    "Unknown child process pid %d, will report returncode 255", pid
-                )
-            else:
-                if os.WIFSIGNALED(status):
-                    returncode = -os.WTERMSIG(status)
-                elif os.WIFEXITED(status):
-                    returncode = os.WEXITSTATUS(status)
-                else:
-                    returncode = status
-
-                if loop.get_debug():
-                    logger.debug(
-                        "process %s exited with returncode %s", expected_pid, returncode
-                    )
-
-            if loop.is_closed():
-                logger.warning("Loop %r that handles pid %r is closed", loop, pid)
-            else:
-                loop.call_soon_threadsafe(callback, pid, returncode, *args)
-
-            self._threads.pop(expected_pid)
-
-elif sys.platform != "win32":
+if sys.platform != "win32":
     from asyncio import ThreadedChildWatcher


Test patch:
diff --git a/src/prefect/testing/standard_test_suites/task_runners.py b/src/prefect/testing/standard_test_suites/task_runners.py
--- a/src/prefect/testing/standard_test_suites/task_runners.py
+++ b/src/prefect/testing/standard_test_suites/task_runners.py
@@ -1,6 +1,5 @@
 import asyncio
 import os
-import sys
 import time
 from abc import ABC, abstractmethod
 from functools import partial
@@ -464,10 +463,6 @@ def get_sleep_time(self):
             # CI machines are slow
             sleep_time += 3
 
-        if sys.version_info < (3, 8):
-            # Python 3.7 is slower
-            sleep_time += 0.5
-
         return sleep_time
 
     def test_sync_tasks_run_sequentially_with_sequential_task_runners(
diff --git a/tests/public/flows/test_flow_crashes.py b/tests/public/flows/test_flow_crashes.py
--- a/tests/public/flows/test_flow_crashes.py
+++ b/tests/public/flows/test_flow_crashes.py
@@ -7,7 +7,6 @@
 interrupts.
 """
 import asyncio
-import sys
 import os
 
 import signal
@@ -33,12 +32,6 @@ async def assert_flow_run_crashed(flow_run: FlowRun, expected_message: str):
         await flow_run.state.result()
 
 
-@pytest.mark.skipif(
-    # Python 3.7 has raises a `CancelledError` that is captured by our typical failure
-    # handling which is probably better than a crash anyway
-    sys.version_info < (3, 8),
-    reason="The flow is reported as FAILED on Python 3.7",
-)
 async def test_anyio_cancellation_crashes_flow(prefect_client):
     started = asyncio.Future()
     flow_run_id = None
@@ -61,11 +54,6 @@ async def my_flow():
     )
 
 
-@pytest.mark.skipif(
-    # See `test_anyio_cancellation_crashes_flow`
-    sys.version_info < (3, 8),
-    reason="The flow is reported as FAILED on Python 3.7",
-)
 async def test_anyio_cancellation_crashes_flow_with_timeout_configured(prefect_client):
     """
     Our timeout cancellation mechanisms for async flows can overlap with AnyIO
@@ -93,11 +81,6 @@ async def my_flow():
     )
 
 
-@pytest.mark.skipif(
-    # See `test_anyio_cancellation_crashes_flow`
-    sys.version_info < (3, 8),
-    reason="The flow is reported as FAILED on Python 3.7",
-)
 async def test_anyio_cancellation_crashes_parent_and_child_flow(prefect_client):
     child_started = asyncio.Future()
     parent_started = asyncio.Future()
@@ -134,11 +117,6 @@ async def parent_flow():
     )
 
 
-@pytest.mark.skipif(
-    # See `test_anyio_cancellation_crashes_flow`
-    sys.version_info < (3, 8),
-    reason="The flow is reported as FAILED on Python 3.7",
-)
 @pytest.mark.xfail  # The child cannot be reported as crashed due to client closure
 async def test_anyio_cancellation_crashes_child_flow(prefect_client):
     child_started = asyncio.Future()
diff --git a/tests/software/test_conda.py b/tests/software/test_conda.py
--- a/tests/software/test_conda.py
+++ b/tests/software/test_conda.py
@@ -264,7 +264,7 @@ def test_from_file(self, tmp_path):
                 channels:
                 - defaults
                 dependencies:
-                - python=3.7.11
+                - python=3.10.11
                 - readline
                 - sqlite=3.37.2=h707629a_0
                 - pip:
@@ -280,7 +280,7 @@ def test_from_file(self, tmp_path):
             PipRequirement("foo==0.8.0"),
             PipRequirement("bar"),
         ]
-        assert reqs.python_version == "3.7.11"
+        assert reqs.python_version == "3.10.11"
         assert reqs.conda_requirements == [
             CondaRequirement("readline"),
             CondaRequirement("sqlite=3.37.2=h707629a_0"),
@@ -295,7 +295,7 @@ def test_from_file_unsupported_subtype(self, tmp_path):
                 channels:
                 - defaults
                 dependencies:
-                - python=3.7.11
+                - python=3.10.11
                 - readline
                 - sqlite=3.37.2=h707629a_0
                 - ohno:
@@ -320,7 +320,7 @@ def test_from_file_duplicate_subtype(self, tmp_path):
                 channels:
                 - defaults
                 dependencies:
-                - python=3.7.11
+                - python=3.10.11
                 - readline
                 - sqlite=3.37.2=h707629a_0
                 - pip: