Repository: SCons/scons (language: python)

Issue:
Remove Python 3.6 support
Removes all references to the deprecated 3.6 version, updating documentation and tests to account for the new baseline. This is a deliberately standalone PR, as any functional changes that'd come from it are better suited for evaluation in an isolated environment.

## Contributor Checklist:

* [x] I have created a new test or updated the unit tests to cover the new/changed functionality.
* [x] I have updated `CHANGES.txt` and `RELEASE.txt` (and read the `README.rst`).
* [x] I have updated the appropriate documentation



Discussion / hints:

Fix patch:
diff --git a/CHANGES.txt b/CHANGES.txt
index 478b67d6d0..808e2b96c9 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -78,6 +78,10 @@ RELEASE  VERSION/DATE TO BE FILLED IN LATER
 
   From Thaddeus Crews:
     - Ruff/Mypy: Excluded items now synced.
+    - Ruff: Linter includes new rules - `FA`, `UP006`, `UP007`, and `UP037` - to
+      detect and upgrade legacy type-hint syntax.
+    - Removed "SCons.Util.sctyping.py", as the functionality can now be substituted
+      via top-level `from __future__ import annotations`.
 
   From Alex James:
     - On Darwin, PermissionErrors are now handled while trying to access
diff --git a/RELEASE.txt b/RELEASE.txt
index ae1b90be55..8ca51871b7 100644
--- a/RELEASE.txt
+++ b/RELEASE.txt
@@ -179,6 +179,12 @@ DEVELOPMENT
 
 - Ruff/Mypy: Excluded items now synced.
 
+- Ruff: Linter includes new rules - `FA`, `UP006`, `UP007`, and `UP037` - to
+  detect and upgrade legacy type-hint syntax.
+
+- Removed "SCons.Util.sctyping.py", as the functionality can now be substituted
+  via top-level `from __future__ import annotations`.
+
 Thanks to the following contributors listed below for their contributions to this release.
 ==========================================================================================
 .. code-block:: text
diff --git a/SCons/Action.py b/SCons/Action.py
index 567f66cef4..6022b19b38 100644
--- a/SCons/Action.py
+++ b/SCons/Action.py
@@ -100,6 +100,8 @@
 
 """
 
+from __future__ import annotations
+
 import inspect
 import os
 import pickle
@@ -109,7 +111,7 @@
 from abc import ABC, abstractmethod
 from collections import OrderedDict
 from subprocess import DEVNULL, PIPE
-from typing import List, Optional, Tuple
+from typing import TYPE_CHECKING
 
 import SCons.Debug
 import SCons.Errors
@@ -120,7 +122,9 @@
 from SCons.Debug import logInstanceCreation
 from SCons.Subst import SUBST_CMD, SUBST_RAW, SUBST_SIG
 from SCons.Util import is_String, is_List
-from SCons.Util.sctyping import ExecutorType
+
+if TYPE_CHECKING:
+    from SCons.Executor import Executor
 
 class _null:
     pass
@@ -481,9 +485,7 @@ def _do_create_action(act, kw):
     return None
 
 
-# TODO: from __future__ import annotations once we get to Python 3.7 base,
-#   to avoid quoting the defined-later classname
-def _do_create_list_action(act, kw) -> "ListAction":
+def _do_create_list_action(act, kw) -> ListAction:
     """A factory for list actions.
 
     Convert the input list *act* into Actions and then wrap them in a
@@ -529,7 +531,7 @@ def __call__(
         show=_null,
         execute=_null,
         chdir=_null,
-        executor: Optional[ExecutorType] = None,
+        executor: Executor | None = None,
     ):
         raise NotImplementedError
 
@@ -541,15 +543,15 @@ def no_batch_key(self, env, target, source):
 
     batch_key = no_batch_key
 
-    def genstring(self, target, source, env, executor: Optional[ExecutorType] = None) -> str:
+    def genstring(self, target, source, env, executor: Executor | None = None) -> str:
         return str(self)
 
     @abstractmethod
-    def get_presig(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_presig(self, target, source, env, executor: Executor | None = None):
         raise NotImplementedError
 
     @abstractmethod
-    def get_implicit_deps(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_implicit_deps(self, target, source, env, executor: Executor | None = None):
         raise NotImplementedError
 
     def get_contents(self, target, source, env):
@@ -601,10 +603,10 @@ def presub_lines(self, env):
         self.presub_env = None      # don't need this any more
         return lines
 
-    def get_varlist(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_varlist(self, target, source, env, executor: Executor | None = None):
         return self.varlist
 
-    def get_targets(self, env, executor: Optional[ExecutorType]):
+    def get_targets(self, env, executor: Executor | None):
         """
         Returns the type of targets ($TARGETS, $CHANGED_TARGETS) used
         by this action.
@@ -658,7 +660,7 @@ def __call__(self, target, source, env,
                                show=_null,
                                execute=_null,
                                chdir=_null,
-                               executor: Optional[ExecutorType] = None):
+                               executor: Executor | None = None):
         if not is_List(target):
             target = [target]
         if not is_List(source):
@@ -742,10 +744,10 @@ def __call__(self, target, source, env,
     # an ABC like parent ActionBase, but things reach in and use it. It's
     # not just unittests or we could fix it up with a concrete subclass there.
 
-    def get_presig(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_presig(self, target, source, env, executor: Executor | None = None):
         raise NotImplementedError
 
-    def get_implicit_deps(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_implicit_deps(self, target, source, env, executor: Executor | None = None):
         raise NotImplementedError
 
 
@@ -1010,7 +1012,7 @@ def __str__(self) -> str:
         return str(self.cmd_list)
 
 
-    def process(self, target, source, env, executor=None, overrides: Optional[dict] = None) -> Tuple[List, bool, bool]:
+    def process(self, target, source, env, executor: Executor | None = None, overrides: dict | None = None) -> tuple[list, bool, bool]:
         if executor:
             result = env.subst_list(self.cmd_list, SUBST_CMD, executor=executor, overrides=overrides)
         else:
@@ -1031,7 +1033,7 @@ def process(self, target, source, env, executor=None, overrides: Optional[dict]
             pass
         return result, ignore, silent
 
-    def strfunction(self, target, source, env, executor: Optional[ExecutorType] = None, overrides: Optional[dict] = None) -> str:
+    def strfunction(self, target, source, env, executor: Executor | None = None, overrides: dict | None = None) -> str:
         if self.cmdstr is None:
             return None
         if self.cmdstr is not _null:
@@ -1046,7 +1048,7 @@ def strfunction(self, target, source, env, executor: Optional[ExecutorType] = No
             return ''
         return _string_from_cmd_list(cmd_list[0])
 
-    def execute(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def execute(self, target, source, env, executor: Executor | None = None):
         """Execute a command action.
 
         This will handle lists of commands as well as individual commands,
@@ -1108,7 +1110,7 @@ def execute(self, target, source, env, executor: Optional[ExecutorType] = None):
                                                command=cmd_line)
         return 0
 
-    def get_presig(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_presig(self, target, source, env, executor: Executor | None = None):
         """Return the signature contents of this action's command line.
 
         This strips $(-$) and everything in between the string,
@@ -1123,7 +1125,7 @@ def get_presig(self, target, source, env, executor: Optional[ExecutorType] = Non
             return env.subst_target_source(cmd, SUBST_SIG, executor=executor)
         return env.subst_target_source(cmd, SUBST_SIG, target, source)
 
-    def get_implicit_deps(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_implicit_deps(self, target, source, env, executor: Executor | None = None):
         """Return the implicit dependencies of this action's command line."""
         icd = env.get('IMPLICIT_COMMAND_DEPENDENCIES', True)
         if is_String(icd) and icd[:1] == '$':
@@ -1145,7 +1147,7 @@ def get_implicit_deps(self, target, source, env, executor: Optional[ExecutorType
         # lightweight dependency scanning.
         return self._get_implicit_deps_lightweight(target, source, env, executor)
 
-    def _get_implicit_deps_lightweight(self, target, source, env, executor: Optional[ExecutorType]):
+    def _get_implicit_deps_lightweight(self, target, source, env, executor: Executor | None):
         """
         Lightweight dependency scanning involves only scanning the first entry
         in an action string, even if it contains &&.
@@ -1166,7 +1168,7 @@ def _get_implicit_deps_lightweight(self, target, source, env, executor: Optional
                     res.append(env.fs.File(d))
         return res
 
-    def _get_implicit_deps_heavyweight(self, target, source, env, executor: Optional[ExecutorType],
+    def _get_implicit_deps_heavyweight(self, target, source, env, executor: Executor | None,
                                        icd_int):
         """
         Heavyweight dependency scanning involves scanning more than just the
@@ -1234,7 +1236,7 @@ def __init__(self, generator, kw) -> None:
         self.varlist = kw.get('varlist', ())
         self.targets = kw.get('targets', '$TARGETS')
 
-    def _generate(self, target, source, env, for_signature, executor: Optional[ExecutorType] = None):
+    def _generate(self, target, source, env, for_signature, executor: Executor | None = None):
         # ensure that target is a list, to make it easier to write
         # generator functions:
         if not is_List(target):
@@ -1265,11 +1267,11 @@ def __str__(self) -> str:
     def batch_key(self, env, target, source):
         return self._generate(target, source, env, 1).batch_key(env, target, source)
 
-    def genstring(self, target, source, env, executor: Optional[ExecutorType] = None) -> str:
+    def genstring(self, target, source, env, executor: Executor | None = None) -> str:
         return self._generate(target, source, env, 1, executor).genstring(target, source, env)
 
     def __call__(self, target, source, env, exitstatfunc=_null, presub=_null,
-                 show=_null, execute=_null, chdir=_null, executor: Optional[ExecutorType] = None):
+                 show=_null, execute=_null, chdir=_null, executor: Executor | None = None):
         act = self._generate(target, source, env, 0, executor)
         if act is None:
             raise SCons.Errors.UserError(
@@ -1281,7 +1283,7 @@ def __call__(self, target, source, env, exitstatfunc=_null, presub=_null,
             target, source, env, exitstatfunc, presub, show, execute, chdir, executor
         )
 
-    def get_presig(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_presig(self, target, source, env, executor: Executor | None = None):
         """Return the signature contents of this action's command line.
 
         This strips $(-$) and everything in between the string,
@@ -1289,13 +1291,13 @@ def get_presig(self, target, source, env, executor: Optional[ExecutorType] = Non
         """
         return self._generate(target, source, env, 1, executor).get_presig(target, source, env)
 
-    def get_implicit_deps(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_implicit_deps(self, target, source, env, executor: Executor | None = None):
         return self._generate(target, source, env, 1, executor).get_implicit_deps(target, source, env)
 
-    def get_varlist(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_varlist(self, target, source, env, executor: Executor | None = None):
         return self._generate(target, source, env, 1, executor).get_varlist(target, source, env, executor)
 
-    def get_targets(self, env, executor: Optional[ExecutorType]):
+    def get_targets(self, env, executor: Executor | None):
         return self._generate(None, None, env, 1, executor).get_targets(env, executor)
 
 
@@ -1341,22 +1343,22 @@ def _generate_cache(self, env):
             raise SCons.Errors.UserError("$%s value %s cannot be used to create an Action." % (self.var, repr(c)))
         return gen_cmd
 
-    def _generate(self, target, source, env, for_signature, executor: Optional[ExecutorType] = None):
+    def _generate(self, target, source, env, for_signature, executor: Executor | None = None):
         return self._generate_cache(env)
 
     def __call__(self, target, source, env, *args, **kw):
         c = self.get_parent_class(env)
         return c.__call__(self, target, source, env, *args, **kw)
 
-    def get_presig(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_presig(self, target, source, env, executor: Executor | None = None):
         c = self.get_parent_class(env)
         return c.get_presig(self, target, source, env)
 
-    def get_implicit_deps(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_implicit_deps(self, target, source, env, executor: Executor | None = None):
         c = self.get_parent_class(env)
         return c.get_implicit_deps(self, target, source, env)
 
-    def get_varlist(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_varlist(self, target, source, env, executor: Executor | None = None):
         c = self.get_parent_class(env)
         return c.get_varlist(self, target, source, env, executor)
 
@@ -1389,7 +1391,7 @@ def function_name(self):
             except AttributeError:
                 return "unknown_python_function"
 
-    def strfunction(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def strfunction(self, target, source, env, executor: Executor | None = None):
         if self.cmdstr is None:
             return None
         if self.cmdstr is not _null:
@@ -1430,7 +1432,7 @@ def __str__(self) -> str:
             return str(self.execfunction)
         return "%s(target, source, env)" % name
 
-    def execute(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def execute(self, target, source, env, executor: Executor | None = None):
         exc_info = (None,None,None)
         try:
             if executor:
@@ -1461,14 +1463,14 @@ def execute(self, target, source, env, executor: Optional[ExecutorType] = None):
             # more information about this issue.
             del exc_info
 
-    def get_presig(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_presig(self, target, source, env, executor: Executor | None = None):
         """Return the signature contents of this callable action."""
         try:
             return self.gc(target, source, env)
         except AttributeError:
             return self.funccontents
 
-    def get_implicit_deps(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_implicit_deps(self, target, source, env, executor: Executor | None = None):
         return []
 
 class ListAction(ActionBase):
@@ -1485,7 +1487,7 @@ def list_of_actions(x):
         self.varlist = ()
         self.targets = '$TARGETS'
 
-    def genstring(self, target, source, env, executor: Optional[ExecutorType] = None) -> str:
+    def genstring(self, target, source, env, executor: Executor | None = None) -> str:
         return '\n'.join([a.genstring(target, source, env) for a in self.list])
 
     def __str__(self) -> str:
@@ -1495,7 +1497,7 @@ def presub_lines(self, env):
         return SCons.Util.flatten_sequence(
             [a.presub_lines(env) for a in self.list])
 
-    def get_presig(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_presig(self, target, source, env, executor: Executor | None = None):
         """Return the signature contents of this action list.
 
         Simple concatenation of the signatures of the elements.
@@ -1503,7 +1505,7 @@ def get_presig(self, target, source, env, executor: Optional[ExecutorType] = Non
         return b"".join([bytes(x.get_contents(target, source, env)) for x in self.list])
 
     def __call__(self, target, source, env, exitstatfunc=_null, presub=_null,
-                 show=_null, execute=_null, chdir=_null, executor: Optional[ExecutorType] = None):
+                 show=_null, execute=_null, chdir=_null, executor: Executor | None = None):
         if executor:
             target = executor.get_all_targets()
             source = executor.get_all_sources()
@@ -1514,13 +1516,13 @@ def __call__(self, target, source, env, exitstatfunc=_null, presub=_null,
                 return stat
         return 0
 
-    def get_implicit_deps(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_implicit_deps(self, target, source, env, executor: Executor | None = None):
         result = []
         for act in self.list:
             result.extend(act.get_implicit_deps(target, source, env))
         return result
 
-    def get_varlist(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def get_varlist(self, target, source, env, executor: Executor | None = None):
         result = OrderedDict()
         for act in self.list:
             for var in act.get_varlist(target, source, env, executor):
@@ -1586,7 +1588,7 @@ def subst_kw(self, target, source, env):
             kw[key] = self.subst(self.kw[key], target, source, env)
         return kw
 
-    def __call__(self, target, source, env, executor: Optional[ExecutorType] = None):
+    def __call__(self, target, source, env, executor: Executor | None = None):
         args = self.subst_args(target, source, env)
         kw = self.subst_kw(target, source, env)
         return self.parent.actfunc(*args, **kw)
diff --git a/SCons/ActionTests.py b/SCons/ActionTests.py
index 39798809e9..497de1869d 100644
--- a/SCons/ActionTests.py
+++ b/SCons/ActionTests.py
@@ -27,6 +27,8 @@
 # contents, so try to minimize changes by defining them here, before we
 # even import anything.
 
+from __future__ import annotations
+
 def GlobalFunc() -> None:
     pass
 
@@ -43,13 +45,15 @@ def __call__(self) -> None:
 import unittest
 from unittest import mock
 from subprocess import PIPE
-from typing import Optional
+from typing import TYPE_CHECKING
 
 import SCons.Action
 import SCons.Environment
 import SCons.Errors
 from SCons.Action import scons_subproc_run
-from SCons.Util.sctyping import ExecutorType
+
+if TYPE_CHECKING:
+    from SCons.Executor import Executor
 
 import TestCmd
 
@@ -1701,11 +1705,11 @@ def __call__(self, target, source, env) -> int:
         c = test.read(outfile, 'r')
         assert c == "class1b\n", c
 
-        def build_it(target, source, env, executor: Optional[ExecutorType] = None, self=self) -> int:
+        def build_it(target, source, env, executor: Executor | None = None, self=self) -> int:
             self.build_it = 1
             return 0
 
-        def string_it(target, source, env, executor: Optional[ExecutorType] = None, self=self):
+        def string_it(target, source, env, executor: Executor | None = None, self=self):
             self.string_it = 1
             return None
 
diff --git a/SCons/Builder.py b/SCons/Builder.py
index 3efcc8271d..c81d104143 100644
--- a/SCons/Builder.py
+++ b/SCons/Builder.py
@@ -99,10 +99,11 @@
 
 """
 
+from __future__ import annotations
+
 import os
 from collections import UserDict, UserList
 from contextlib import suppress
-from typing import Optional
 
 import SCons.Action
 import SCons.Debug
@@ -112,7 +113,7 @@
 import SCons.Warnings
 from SCons.Debug import logInstanceCreation
 from SCons.Errors import InternalError, UserError
-from SCons.Util.sctyping import ExecutorType
+from SCons.Executor import Executor
 
 class _Null:
     pass
@@ -591,7 +592,7 @@ def _execute(self, env, target, source, overwarn={}, executor_kw={}):
         # build this particular list of targets from this particular list of
         # sources.
 
-        executor: Optional[ExecutorType] = None
+        executor: Executor | None = None
         key = None
 
         if self.multi:
diff --git a/SCons/BuilderTests.py b/SCons/BuilderTests.py
index b66f52439e..adfa648280 100644
--- a/SCons/BuilderTests.py
+++ b/SCons/BuilderTests.py
@@ -21,6 +21,8 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
+from __future__ import annotations
+
 import SCons.compat
 
 # Define a null function for use as a builder action.
@@ -31,6 +33,7 @@ def Func() -> None:
     pass
 
 from collections import UserList
+from typing import TYPE_CHECKING
 import io
 import os.path
 import re
@@ -45,7 +48,9 @@ def Func() -> None:
 import SCons.Errors
 import SCons.Subst
 import SCons.Util
-from SCons.Util.sctyping import ExecutorType
+
+if TYPE_CHECKING:
+    from SCons.Executor import Executor
 
 sys.stdout = io.StringIO()
 
@@ -185,9 +190,9 @@ def generate_build_env(self, env):
         return env
     def get_build_env(self):
         return self.executor.get_build_env()
-    def set_executor(self, executor: ExecutorType) -> None:
+    def set_executor(self, executor: Executor) -> None:
         self.executor = executor
-    def get_executor(self, create: int=1) -> ExecutorType:
+    def get_executor(self, create: int=1) -> Executor:
         return self.executor
 
 class MyNode(MyNode_without_target_from_source):
diff --git a/SCons/Defaults.py b/SCons/Defaults.py
index a5d49fc76f..d971d060f6 100644
--- a/SCons/Defaults.py
+++ b/SCons/Defaults.py
@@ -31,12 +31,14 @@
 from distutils.msvccompiler.
 """
 
+from __future__ import annotations
+
 import os
 import shutil
 import stat
 import sys
 import time
-from typing import List, Callable
+from typing import Callable
 
 import SCons.Action
 import SCons.Builder
@@ -467,8 +469,8 @@ def _stripixes(
     prefix: str,
     items,
     suffix: str,
-    stripprefixes: List[str],
-    stripsuffixes: List[str],
+    stripprefixes: list[str],
+    stripsuffixes: list[str],
     env,
     literal_prefix: str = "",
     c: Callable[[list], list] = None,
@@ -547,7 +549,7 @@ def _stripixes(
     return c(prefix, stripped, suffix, env)
 
 
-def processDefines(defs) -> List[str]:
+def processDefines(defs) -> list[str]:
     """Return list of strings for preprocessor defines from *defs*.
 
     Resolves the different forms ``CPPDEFINES`` can be assembled in:
diff --git a/SCons/Environment.py b/SCons/Environment.py
index 0c14468406..62926f56a5 100644
--- a/SCons/Environment.py
+++ b/SCons/Environment.py
@@ -30,6 +30,8 @@
 are construction variables used to initialize the Environment.
 """
 
+from __future__ import annotations
+
 import copy
 import os
 import sys
@@ -37,7 +39,7 @@
 import shlex
 from collections import UserDict, UserList, deque
 from subprocess import PIPE, DEVNULL
-from typing import Callable, Collection, Optional, Sequence, Union
+from typing import TYPE_CHECKING, Callable, Collection, Sequence
 
 import SCons.Action
 import SCons.Builder
@@ -76,7 +78,9 @@
     to_String_for_subst,
     uniquer_hashables,
 )
-from SCons.Util.sctyping import ExecutorType
+
+if TYPE_CHECKING:
+    from SCons.Executor import Executor
 
 class _Null:
     pass
@@ -698,7 +702,7 @@ def gvars(self):
     def lvars(self):
         return {}
 
-    def subst(self, string, raw: int=0, target=None, source=None, conv=None, executor: Optional[ExecutorType] = None, overrides: Optional[dict] = None):
+    def subst(self, string, raw: int=0, target=None, source=None, conv=None, executor: Executor | None = None, overrides: dict | None = None):
         """Recursively interpolates construction variables from the
         Environment into the specified string, returning the expanded
         result.  Construction variables are specified by a $ prefix
@@ -724,7 +728,7 @@ def subst_kw(self, kw, raw: int=0, target=None, source=None):
             nkw[k] = v
         return nkw
 
-    def subst_list(self, string, raw: int=0, target=None, source=None, conv=None, executor: Optional[ExecutorType] = None, overrides: Optional[dict] = None):
+    def subst_list(self, string, raw: int=0, target=None, source=None, conv=None, executor: Executor | None = None, overrides: dict | None = None):
         """Calls through to SCons.Subst.scons_subst_list().
 
         See the documentation for that function.
@@ -901,7 +905,7 @@ def ParseFlags(self, *flags) -> dict:
             'RPATH'         : [],
         }
 
-        def do_parse(arg: Union[str, Sequence]) -> None:
+        def do_parse(arg: str | Sequence) -> None:
             if not arg:
                 return
 
@@ -1798,7 +1802,7 @@ def default(self, obj):
         raise ValueError("Unsupported serialization format: %s." % fmt)
 
 
-    def FindIxes(self, paths: Sequence[str], prefix: str, suffix: str) -> Optional[str]:
+    def FindIxes(self, paths: Sequence[str], prefix: str, suffix: str) -> str | None:
         """Search *paths* for a path that has *prefix* and *suffix*.
 
         Returns on first match.
@@ -2079,7 +2083,7 @@ def _find_toolpath_dir(self, tp):
         return self.fs.Dir(self.subst(tp)).srcnode().get_abspath()
 
     def Tool(
-        self, tool: Union[str, Callable], toolpath: Optional[Collection[str]] = None, **kwargs
+        self, tool: str | Callable, toolpath: Collection[str] | None = None, **kwargs
     ) -> Callable:
         """Find and run tool module *tool*.
 
@@ -2615,7 +2619,7 @@ class OverrideEnvironment(Base):
     ``OverrideEnvironment``.
     """
 
-    def __init__(self, subject, overrides: Optional[dict] = None) -> None:
+    def __init__(self, subject, overrides: dict | None = None) -> None:
         if SCons.Debug.track_instances: logInstanceCreation(self, 'Environment.OverrideEnvironment')
         overrides = {} if overrides is None else overrides
         # set these directly via __dict__ to avoid trapping by __setattr__
diff --git a/SCons/Errors.py b/SCons/Errors.py
index a2efc97088..af77971032 100644
--- a/SCons/Errors.py
+++ b/SCons/Errors.py
@@ -26,11 +26,15 @@
 Used to handle internal and user errors in SCons.
 """
 
+from __future__ import annotations
+
 import shutil
-from typing import Optional
+from typing import TYPE_CHECKING
 
 from SCons.Util.sctypes import to_String, is_String
-from SCons.Util.sctyping import ExecutorType
+
+if TYPE_CHECKING:
+    from SCons.Executor import Executor
 
 # Note that not all Errors are defined here, some are at the point of use
 
@@ -75,7 +79,7 @@ class BuildError(Exception):
 
     def __init__(self,
                  node=None, errstr: str="Unknown error", status: int=2, exitstatus: int=2,
-                 filename=None, executor: Optional[ExecutorType] = None, action=None, command=None,
+                 filename=None, executor: Executor | None = None, action=None, command=None,
                  exc_info=(None, None, None)) -> None:
 
         # py3: errstr should be string and not bytes.
diff --git a/SCons/Executor.py b/SCons/Executor.py
index 1b054b4ba8..53eb5cbb2f 100644
--- a/SCons/Executor.py
+++ b/SCons/Executor.py
@@ -23,8 +23,9 @@
 
 """Execute actions with specific lists of target and source Nodes."""
 
+from __future__ import annotations
+
 import collections
-from typing import Dict
 
 import SCons.Errors
 import SCons.Memoize
@@ -32,7 +33,6 @@
 from SCons.compat import NoSlotsPyPy
 import SCons.Debug
 from SCons.Debug import logInstanceCreation
-from SCons.Util.sctyping import ExecutorType
 
 class Batch:
     """Remembers exact association between targets
@@ -550,12 +550,12 @@ def get_implicit_deps(self):
 
 
 
-_batch_executors: Dict[str, ExecutorType] = {}
+_batch_executors: dict[str, Executor] = {}
 
-def GetBatchExecutor(key: str) -> ExecutorType:
+def GetBatchExecutor(key: str) -> Executor:
     return _batch_executors[key]
 
-def AddBatchExecutor(key: str, executor: ExecutorType) -> None:
+def AddBatchExecutor(key: str, executor: Executor) -> None:
     assert key not in _batch_executors
     _batch_executors[key] = executor
 
diff --git a/SCons/Node/FS.py b/SCons/Node/FS.py
index 3cd7720c0f..bdecffcfbd 100644
--- a/SCons/Node/FS.py
+++ b/SCons/Node/FS.py
@@ -30,6 +30,8 @@
 that can be used by scripts or modules looking for the canonical default.
 """
 
+from __future__ import annotations
+
 import codecs
 import fnmatch
 import importlib.util
@@ -40,7 +42,6 @@
 import sys
 import time
 from itertools import chain
-from typing import Optional
 
 import SCons.Action
 import SCons.Debug
@@ -1492,7 +1493,7 @@ def Repository(self, *dirs) -> None:
                 d = self.Dir(d)
             self.Top.addRepository(d)
 
-    def PyPackageDir(self, modulename) -> Optional[Dir]:
+    def PyPackageDir(self, modulename) -> Dir | None:
         r"""Locate the directory of Python module *modulename*.
 
         For example 'SCons' might resolve to
@@ -3190,7 +3191,7 @@ def exists(self):
     # SIGNATURE SUBSYSTEM
     #
 
-    def get_max_drift_csig(self) -> Optional[str]:
+    def get_max_drift_csig(self) -> str | None:
         """
         Returns the content signature currently stored for this node
         if it's been unmodified longer than the max_drift value, or the
diff --git a/SCons/Node/FSTests.py b/SCons/Node/FSTests.py
index 83ceef28c7..9ae8c03e10 100644
--- a/SCons/Node/FSTests.py
+++ b/SCons/Node/FSTests.py
@@ -21,6 +21,8 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
+from __future__ import annotations
+
 import SCons.compat
 import os
 import os.path
@@ -29,7 +31,7 @@
 import unittest
 import shutil
 import stat
-from typing import Optional
+from typing import TYPE_CHECKING
 
 from TestCmd import TestCmd, IS_WINDOWS, IS_ROOT
 
@@ -38,7 +40,9 @@
 import SCons.Util
 import SCons.Warnings
 import SCons.Environment
-from SCons.Util.sctyping import ExecutorType
+
+if TYPE_CHECKING:
+    from SCons.Executor import Executor
 
 built_it = None
 
@@ -320,7 +324,7 @@ class MkdirAction(Action):
             def __init__(self, dir_made) -> None:
                 self.dir_made = dir_made
 
-            def __call__(self, target, source, env, executor: Optional[ExecutorType] = None) -> None:
+            def __call__(self, target, source, env, executor: Executor | None = None) -> None:
                 if executor:
                     target = executor.get_all_targets()
                     source = executor.get_all_sources()
@@ -2491,7 +2495,7 @@ def collect(self, args):
                             result += a
                         return result
 
-                    def signature(self, executor: ExecutorType):
+                    def signature(self, executor: Executor):
                         return self.val + 222
 
                 self.module = M(val)
@@ -3582,7 +3586,7 @@ class MkdirAction(Action):
             def __init__(self, dir_made) -> None:
                 self.dir_made = dir_made
 
-            def __call__(self, target, source, env, executor: Optional[ExecutorType] = None) -> None:
+            def __call__(self, target, source, env, executor: Executor | None = None) -> None:
                 if executor:
                     target = executor.get_all_targets()
                     source = executor.get_all_sources()
diff --git a/SCons/Node/NodeTests.py b/SCons/Node/NodeTests.py
index 70c8551b13..6c7437d600 100644
--- a/SCons/Node/NodeTests.py
+++ b/SCons/Node/NodeTests.py
@@ -21,17 +21,21 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
+from __future__ import annotations
+
 import SCons.compat
 
 import collections
 import re
 import unittest
-from typing import Optional
+from typing import TYPE_CHECKING
 
 import SCons.Errors
 import SCons.Node
 import SCons.Util
-from SCons.Util.sctyping import ExecutorType
+
+if TYPE_CHECKING:
+    from SCons.Executor import Executor
 
 
 built_it = None
@@ -64,7 +68,7 @@ class MyAction(MyActionBase):
     def __init__(self) -> None:
         self.order = 0
 
-    def __call__(self, target, source, env, executor: Optional[ExecutorType] = None) -> int:
+    def __call__(self, target, source, env, executor: Executor | None = None) -> int:
         global built_it, built_target, built_source, built_args, built_order
         if executor:
             target = executor.get_all_targets()
diff --git a/SCons/Node/__init__.py b/SCons/Node/__init__.py
index 00bf4ac3b1..8ae991ecf7 100644
--- a/SCons/Node/__init__.py
+++ b/SCons/Node/__init__.py
@@ -40,18 +40,19 @@
 
 """
 
+from __future__ import annotations
+
 import collections
 import copy
 from itertools import chain, zip_longest
-from typing import Optional
 
 import SCons.Debug
 import SCons.Executor
 import SCons.Memoize
 from SCons.compat import NoSlotsPyPy
 from SCons.Debug import logInstanceCreation, Trace
+from SCons.Executor import Executor
 from SCons.Util import hash_signature, is_List, UniqueList, render_tree
-from SCons.Util.sctyping import ExecutorType
 
 print_duplicate = 0
 
@@ -636,11 +637,11 @@ def get_build_scanner_path(self, scanner):
         """Fetch the appropriate scanner path for this node."""
         return self.get_executor().get_build_scanner_path(scanner)
 
-    def set_executor(self, executor: ExecutorType) -> None:
+    def set_executor(self, executor: Executor) -> None:
         """Set the action executor for this node."""
         self.executor = executor
 
-    def get_executor(self, create: int=1) -> ExecutorType:
+    def get_executor(self, create: int=1) -> Executor:
         """Fetch the action executor for this node.  Create one if
         there isn't already one, and requested to do so."""
         try:
diff --git a/SCons/SConf.py b/SCons/SConf.py
index d2e09be34e..c22355665c 100644
--- a/SCons/SConf.py
+++ b/SCons/SConf.py
@@ -31,6 +31,8 @@
 libraries are installed, if some command line options are supported etc.
 """
 
+from __future__ import annotations
+
 import SCons.compat
 
 import atexit
@@ -39,7 +41,6 @@
 import re
 import sys
 import traceback
-from typing import Tuple
 
 import SCons.Action
 import SCons.Builder
@@ -265,7 +266,7 @@ def failed(self):
             sys.excepthook(*self.exc_info())
         return SCons.Taskmaster.Task.failed(self)
 
-    def collect_node_states(self) -> Tuple[bool, bool, bool]:
+    def collect_node_states(self) -> tuple[bool, bool, bool]:
         # returns (is_up_to_date, cached_error, cachable)
         # where is_up_to_date is True if the node(s) are up_to_date
         #       cached_error  is True if the node(s) are up_to_date, but the
diff --git a/SCons/Script/Main.py b/SCons/Script/Main.py
index 04b420a3bf..7f04d00243 100644
--- a/SCons/Script/Main.py
+++ b/SCons/Script/Main.py
@@ -31,6 +31,8 @@
 it goes here.
 """
 
+from __future__ import annotations
+
 import SCons.compat
 
 import importlib.util
@@ -42,7 +44,6 @@
 import traceback
 import platform
 import threading
-from typing import Optional, List, TYPE_CHECKING
 
 import SCons.CacheDir
 import SCons.Debug
@@ -552,7 +553,7 @@ def SetOption(name: str, value):
     """Set the value of an option - Public API."""
     return OptionsParser.values.set_option(name, value)
 
-def DebugOptions(json: Optional[str] = None) -> None:
+def DebugOptions(json: str | None = None) -> None:
     """Specify options to SCons debug logic - Public API.
 
     Currently only *json* is supported, which changes the JSON file
@@ -681,8 +682,8 @@ def _scons_internal_error() -> None:
     sys.exit(2)
 
 def _SConstruct_exists(
-    dirname: str, repositories: List[str], filelist: List[str]
-) -> Optional[str]:
+    dirname: str, repositories: list[str], filelist: list[str]
+) -> str | None:
     """Check that an SConstruct file exists in a directory.
 
     Arguments:
@@ -1424,7 +1425,7 @@ def _exec_main(parser, values) -> None:
         class SConsPdb(pdb.Pdb):
             """Specialization of Pdb to help find SConscript files."""
 
-            def lookupmodule(self, filename: str) -> Optional[str]:
+            def lookupmodule(self, filename: str) -> str | None:
                 """Helper function for break/clear parsing -- SCons version.
 
                 Translates (possibly incomplete) file or module name
diff --git a/SCons/Script/SConsOptions.py b/SCons/Script/SConsOptions.py
index 08531814f5..ef27b70669 100644
--- a/SCons/Script/SConsOptions.py
+++ b/SCons/Script/SConsOptions.py
@@ -21,13 +21,14 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
+from __future__ import annotations
+
 import gettext
 import optparse
 import re
 import shutil
 import sys
 import textwrap
-from typing import Optional
 
 import SCons.Node.FS
 import SCons.Platform.virtualenv
@@ -318,7 +319,7 @@ class SConsBadOptionError(optparse.BadOptionError):
     """
     # TODO why is 'parser' needed? Not called in current code base.
 
-    def __init__(self, opt_str: str, parser: Optional["SConsOptionParser"] = None) -> None:
+    def __init__(self, opt_str: str, parser: SConsOptionParser | None = None) -> None:
         self.opt_str = opt_str
         self.parser = parser
 
diff --git a/SCons/Script/SConscript.py b/SCons/Script/SConscript.py
index a2ef3b9d57..7cc9bea5af 100644
--- a/SCons/Script/SConscript.py
+++ b/SCons/Script/SConscript.py
@@ -23,6 +23,8 @@
 
 """This module defines the Python API provided to SConscript files."""
 
+from __future__ import annotations
+
 import SCons
 import SCons.Action
 import SCons.Builder
@@ -45,7 +47,6 @@
 import sys
 import traceback
 import time
-from typing import Tuple
 
 class SConscriptReturn(Exception):
     pass
@@ -386,7 +387,7 @@ class SConsEnvironment(SCons.Environment.Base):
     # Private methods of an SConsEnvironment.
     #
     @staticmethod
-    def _get_major_minor_revision(version_string: str) -> Tuple[int, int, int]:
+    def _get_major_minor_revision(version_string: str) -> tuple[int, int, int]:
         """Split a version string into major, minor and (optionally)
         revision parts.
 
@@ -485,7 +486,7 @@ def Default(self, *targets) -> None:
         SCons.Script._Set_Default_Targets(self, targets)
 
     @staticmethod
-    def GetSConsVersion() -> Tuple[int, int, int]:
+    def GetSConsVersion() -> tuple[int, int, int]:
         """Return the current SCons version.
 
         .. versionadded:: 4.8.0
diff --git a/SCons/Subst.py b/SCons/Subst.py
index b04ebe50cd..4d6b249c6c 100644
--- a/SCons/Subst.py
+++ b/SCons/Subst.py
@@ -23,10 +23,11 @@
 
 """SCons string substitution."""
 
+from __future__ import annotations
+
 import collections
 import re
 from inspect import signature, Parameter
-from typing import Optional
 
 import SCons.Errors
 from SCons.Util import is_String, is_Sequence
@@ -807,7 +808,7 @@ def _remove_list(list):
 _space_sep = re.compile(r'[\t ]+(?![^{]*})')
 
 
-def scons_subst(strSubst, env, mode=SUBST_RAW, target=None, source=None, gvars={}, lvars={}, conv=None, overrides: Optional[dict] = None):
+def scons_subst(strSubst, env, mode=SUBST_RAW, target=None, source=None, gvars={}, lvars={}, conv=None, overrides: dict | None = None):
     """Expand a string or list containing construction variable
     substitutions.
 
@@ -889,7 +890,7 @@ def scons_subst(strSubst, env, mode=SUBST_RAW, target=None, source=None, gvars={
 
     return result
 
-def scons_subst_list(strSubst, env, mode=SUBST_RAW, target=None, source=None, gvars={}, lvars={}, conv=None, overrides: Optional[dict] = None):
+def scons_subst_list(strSubst, env, mode=SUBST_RAW, target=None, source=None, gvars={}, lvars={}, conv=None, overrides: dict | None = None):
     """Substitute construction variables in a string (or list or other
     object) and separate the arguments into a command list.
 
diff --git a/SCons/Tool/FortranCommon.py b/SCons/Tool/FortranCommon.py
index f221d15ec7..dc9dcddf6e 100644
--- a/SCons/Tool/FortranCommon.py
+++ b/SCons/Tool/FortranCommon.py
@@ -23,9 +23,10 @@
 
 """Routines for setting up Fortran, common to all dialects."""
 
+from __future__ import annotations
+
 import re
 import os.path
-from typing import Tuple, List
 
 import SCons.Scanner.Fortran
 import SCons.Tool
@@ -96,7 +97,7 @@ def ShFortranEmitter(target, source, env) -> Tuple:
     return SharedObjectEmitter(target, source, env)
 
 
-def ComputeFortranSuffixes(suffixes: List[str], ppsuffixes: List[str]) -> None:
+def ComputeFortranSuffixes(suffixes: list[str], ppsuffixes: list[str]) -> None:
     """Update the suffix lists to reflect the platform requirements.
 
     If upper-cased suffixes can be distinguished from lower, those are
@@ -119,7 +120,7 @@ def ComputeFortranSuffixes(suffixes: List[str], ppsuffixes: List[str]) -> None:
 
 def CreateDialectActions(
     dialect: str,
-) -> Tuple[CommandAction, CommandAction, CommandAction, CommandAction]:
+) -> tuple[CommandAction, CommandAction, CommandAction, CommandAction]:
     """Create dialect specific actions."""
     CompAction = Action(f'${dialect}COM ', cmdstr=f'${dialect}COMSTR')
     CompPPAction = Action(f'${dialect}PPCOM ', cmdstr=f'${dialect}PPCOMSTR')
@@ -131,8 +132,8 @@ def CreateDialectActions(
 def DialectAddToEnv(
     env,
     dialect: str,
-    suffixes: List[str],
-    ppsuffixes: List[str],
+    suffixes: list[str],
+    ppsuffixes: list[str],
     support_mods: bool = False,
 ) -> None:
     """Add dialect specific construction variables.
diff --git a/SCons/Tool/JavaCommon.py b/SCons/Tool/JavaCommon.py
index c7e62b88ce..0bcb0eaa5e 100644
--- a/SCons/Tool/JavaCommon.py
+++ b/SCons/Tool/JavaCommon.py
@@ -23,11 +23,12 @@
 
 """Common routines for processing Java. """
 
+from __future__ import annotations
+
 import os
 import re
 import glob
 from pathlib import Path
-from typing import List
 
 import SCons.Util
 
@@ -491,7 +492,7 @@ def parse_java_file(fn, version=default_java_version):
         return os.path.split(fn)
 
 
-def get_java_install_dirs(platform, version=None) -> List[str]:
+def get_java_install_dirs(platform, version=None) -> list[str]:
     """ Find possible java jdk installation directories.
 
     Returns a list for use as `default_paths` when looking up actual
@@ -540,7 +541,7 @@ def win32getvnum(java):
     return []
 
 
-def get_java_include_paths(env, javac, version) -> List[str]:
+def get_java_include_paths(env, javac, version) -> list[str]:
     """Find java include paths for JNI building.
 
     Cannot be called in isolation - `javac` refers to an already detected
diff --git a/SCons/Tool/__init__.py b/SCons/Tool/__init__.py
index faa92a78b2..a7bc927ebc 100644
--- a/SCons/Tool/__init__.py
+++ b/SCons/Tool/__init__.py
@@ -33,10 +33,11 @@
 tool specifications.
 """
 
+from __future__ import annotations
+
 import sys
 import os
 import importlib.util
-from typing import Optional
 
 import SCons.Builder
 import SCons.Errors
@@ -824,7 +825,7 @@ def tool_list(platform, env):
     return [x for x in tools if x]
 
 
-def find_program_path(env, key_program, default_paths=None, add_path: bool=False) -> Optional[str]:
+def find_program_path(env, key_program, default_paths=None, add_path: bool=False) -> str | None:
     """
     Find the location of a tool using various means.
 
diff --git a/SCons/Tool/jar.py b/SCons/Tool/jar.py
index 1967294f08..13bdca0518 100644
--- a/SCons/Tool/jar.py
+++ b/SCons/Tool/jar.py
@@ -28,8 +28,9 @@
 selection method.
 """
 
+from __future__ import annotations
+
 import os
-from typing import List
 
 import SCons.Node
 import SCons.Node.FS
@@ -41,7 +42,7 @@
 from SCons.Tool.JavaCommon import get_java_install_dirs
 
 
-def jarSources(target, source, env, for_signature) -> List[str]:
+def jarSources(target, source, env, for_signature) -> list[str]:
     """Only include sources that are not a manifest file."""
     try:
         env['JARCHDIR']
diff --git a/SCons/Tool/lex.py b/SCons/Tool/lex.py
index 527f91c294..5e77ea692d 100644
--- a/SCons/Tool/lex.py
+++ b/SCons/Tool/lex.py
@@ -31,9 +31,10 @@
 selection method.
 """
 
+from __future__ import annotations
+
 import os.path
 import sys
-from typing import Optional
 
 import SCons.Action
 import SCons.Tool
@@ -95,7 +96,7 @@ def lexEmitter(target, source, env) -> tuple:
     return target, source
 
 
-def get_lex_path(env, append_paths: bool=False) -> Optional[str]:
+def get_lex_path(env, append_paths: bool=False) -> str | None:
     """
     Returns the path to the lex tool, searching several possible names.
 
@@ -162,7 +163,7 @@ def generate(env) -> None:
     env['_LEX_TABLES'] = '${LEX_TABLES_FILE and "--tables-file=" + str(LEX_TABLES_FILE)}'
 
 
-def exists(env) -> Optional[str]:
+def exists(env) -> str | None:
     if sys.platform == 'win32':
         return get_lex_path(env)
     else:
diff --git a/SCons/Tool/ninja/Methods.py b/SCons/Tool/ninja/Methods.py
index 5c56e49085..ff006c072e 100644
--- a/SCons/Tool/ninja/Methods.py
+++ b/SCons/Tool/ninja/Methods.py
@@ -21,10 +21,12 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
+from __future__ import annotations
+
 import os
 import shlex
 import textwrap
-from typing import Optional
+from typing import TYPE_CHECKING
 
 import SCons
 from SCons.Subst import SUBST_CMD
@@ -32,7 +34,9 @@
 from SCons.Tool.ninja.Globals import __NINJA_RULE_MAPPING
 from SCons.Tool.ninja.Utils import get_targets_sources, get_dependencies, get_order_only, get_outputs, get_inputs, \
     get_rule, get_path, generate_command, get_command_env, get_comstr
-from SCons.Util.sctyping import ExecutorType
+
+if TYPE_CHECKING:
+    from SCons.Executor import Executor
 
 
 def register_custom_handler(env, name, handler) -> None:
@@ -78,7 +82,7 @@ def set_build_node_callback(env, node, callback) -> None:
         node.attributes.ninja_build_callback = callback
 
 
-def get_generic_shell_command(env, node, action, targets, sources, executor: Optional[ExecutorType] = None):
+def get_generic_shell_command(env, node, action, targets, sources, executor: Executor | None = None):
     return (
         "GENERATED_CMD",
         {
@@ -231,7 +235,7 @@ def gen_get_response_file_command(env, rule, tool, tool_is_dynamic: bool=False,
     if "$" in tool:
         tool_is_dynamic = True
 
-    def get_response_file_command(env, node, action, targets, sources, executor: Optional[ExecutorType] = None):
+    def get_response_file_command(env, node, action, targets, sources, executor: Executor | None = None):
         if hasattr(action, "process"):
             cmd_list, _, _ = action.process(targets, sources, env, executor=executor)
             cmd_list = [str(c).replace("$", "$$") for c in cmd_list[0]]
diff --git a/SCons/Tool/ninja/Utils.py b/SCons/Tool/ninja/Utils.py
index cdce92895c..24d439ef5e 100644
--- a/SCons/Tool/ninja/Utils.py
+++ b/SCons/Tool/ninja/Utils.py
@@ -20,17 +20,22 @@
 # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+from __future__ import annotations
+
 import os
 import shutil
 from os.path import join as joinpath
 from collections import OrderedDict
-from typing import Optional
+from typing import TYPE_CHECKING
 
 import SCons
 from SCons.Action import get_default_ENV, _string_from_cmd_list
 from SCons.Script import AddOption
 from SCons.Util import is_List, flatten_sequence
-from SCons.Util.sctyping import ExecutorType
+
+if TYPE_CHECKING:
+    from SCons.Executor import Executor
 
 class NinjaExperimentalWarning(SCons.Warnings.WarningOnByDefault):
     pass
@@ -349,7 +354,7 @@ def get_comstr(env, action, targets, sources):
     return action.genstring(targets, sources, env)
 
 
-def generate_command(env, node, action, targets, sources, executor: Optional[ExecutorType] = None):
+def generate_command(env, node, action, targets, sources, executor: Executor | None = None):
     # Actions like CommandAction have a method called process that is
     # used by SCons to generate the cmd_line they need to run. So
     # check if it's a thing like CommandAction and call it if we can.
diff --git a/SCons/Tool/yacc.py b/SCons/Tool/yacc.py
index 7a4ddfc54e..bfd82f6053 100644
--- a/SCons/Tool/yacc.py
+++ b/SCons/Tool/yacc.py
@@ -34,9 +34,10 @@
 selection method.
 """
 
+from __future__ import annotations
+
 import os.path
 import sys
-from typing import Optional
 
 import SCons.Defaults
 import SCons.Tool
@@ -68,7 +69,7 @@ def _yaccEmitter(target, source, env, ysuf, hsuf) -> tuple:
 
     # If -d is specified on the command line, yacc will emit a .h
     # or .hpp file with the same base name as the .c or .cpp output file.
-    # if '-d' in flags: 
+    # if '-d' in flags:
     # or  bison options -H, --header, --defines (obsolete)
     if "-d" in flags or "-H" in flags or "--header" in flags or "--defines" in flags:
         target.append(targetBase + env.subst(hsuf, target=target, source=source))
@@ -76,7 +77,7 @@ def _yaccEmitter(target, source, env, ysuf, hsuf) -> tuple:
     # If -g is specified on the command line, yacc will emit a graph
     # file with the same base name as the .c or .cpp output file.
     # TODO: should this be handled like -v? i.e. a side effect, not target
-    # if "-g" in flags:  
+    # if "-g" in flags:
     # or bison option --graph
     if "-g" in flags or "--graph" in flags:
         target.append(targetBase + env.subst("$YACC_GRAPH_FILE_SUFFIX"))
@@ -134,7 +135,7 @@ def yyEmitter(target, source, env) -> tuple:
     return _yaccEmitter(target, source, env, ['.yy'], '$YACCHXXFILESUFFIX')
 
 
-def get_yacc_path(env, append_paths: bool=False) -> Optional[str]:
+def get_yacc_path(env, append_paths: bool=False) -> str | None:
     """
     Returns the path to the yacc tool, searching several possible names.
 
@@ -200,7 +201,7 @@ def generate(env) -> None:
     env['_YACC_GRAPH'] = '${YACC_GRAPH_FILE and "--graph=" + str(YACC_GRAPH_FILE)}'
 
 
-def exists(env) -> Optional[str]:
+def exists(env) -> str | None:
     if 'YACC' in env:
         return env.Detect(env['YACC'])
 
diff --git a/SCons/Util/UtilTests.py b/SCons/Util/UtilTests.py
index b1c01086e4..0f457c3ce0 100644
--- a/SCons/Util/UtilTests.py
+++ b/SCons/Util/UtilTests.py
@@ -21,6 +21,8 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
+from __future__ import annotations
+
 import functools
 import hashlib
 import io
@@ -30,7 +32,6 @@
 import unittest.mock
 import warnings
 from collections import UserDict, UserList, UserString, namedtuple
-from typing import Union
 
 import TestCmd
 
@@ -533,8 +534,8 @@ def test_get_native_path(self) -> None:
 
     def test_PrependPath(self) -> None:
         """Test prepending to a path"""
-        p1: Union[list, str] = r'C:\dir\num\one;C:\dir\num\two'
-        p2: Union[list, str] = r'C:\mydir\num\one;C:\mydir\num\two'
+        p1: list | str = r'C:\dir\num\one;C:\dir\num\two'
+        p2: list | str = r'C:\mydir\num\one;C:\mydir\num\two'
         # have to include the pathsep here so that the test will work on UNIX too.
         p1 = PrependPath(p1, r'C:\dir\num\two', sep=';')
         p1 = PrependPath(p1, r'C:\dir\num\three', sep=';')
@@ -545,14 +546,14 @@ def test_PrependPath(self) -> None:
         assert p2 == r'C:\mydir\num\one;C:\mydir\num\three;C:\mydir\num\two', p2
 
         # check (only) first one is kept if there are dupes in new
-        p3: Union[list, str] = r'C:\dir\num\one'
+        p3: list | str = r'C:\dir\num\one'
         p3 = PrependPath(p3, r'C:\dir\num\two;C:\dir\num\three;C:\dir\num\two', sep=';')
         assert p3 == r'C:\dir\num\two;C:\dir\num\three;C:\dir\num\one', p3
 
     def test_AppendPath(self) -> None:
         """Test appending to a path."""
-        p1: Union[list, str] = r'C:\dir\num\one;C:\dir\num\two'
-        p2: Union[list, str] = r'C:\mydir\num\one;C:\mydir\num\two'
+        p1: list | str = r'C:\dir\num\one;C:\dir\num\two'
+        p2: list | str = r'C:\mydir\num\one;C:\mydir\num\two'
         # have to include the pathsep here so that the test will work on UNIX too.
         p1 = AppendPath(p1, r'C:\dir\num\two', sep=';')
         p1 = AppendPath(p1, r'C:\dir\num\three', sep=';')
@@ -563,13 +564,13 @@ def test_AppendPath(self) -> None:
         assert p2 == r'C:\mydir\num\two;C:\mydir\num\three;C:\mydir\num\one', p2
 
         # check (only) last one is kept if there are dupes in new
-        p3: Union[list, str] = r'C:\dir\num\one'
+        p3: list | str = r'C:\dir\num\one'
         p3 = AppendPath(p3, r'C:\dir\num\two;C:\dir\num\three;C:\dir\num\two', sep=';')
         assert p3 == r'C:\dir\num\one;C:\dir\num\three;C:\dir\num\two', p3
 
     def test_PrependPathPreserveOld(self) -> None:
         """Test prepending to a path while preserving old paths"""
-        p1: Union[list, str] = r'C:\dir\num\one;C:\dir\num\two'
+        p1: list | str = r'C:\dir\num\one;C:\dir\num\two'
         # have to include the pathsep here so that the test will work on UNIX too.
         p1 = PrependPath(p1, r'C:\dir\num\two', sep=';', delete_existing=False)
         p1 = PrependPath(p1, r'C:\dir\num\three', sep=';')
@@ -577,7 +578,7 @@ def test_PrependPathPreserveOld(self) -> None:
 
     def test_AppendPathPreserveOld(self) -> None:
         """Test appending to a path while preserving old paths"""
-        p1: Union[list, str] = r'C:\dir\num\one;C:\dir\num\two'
+        p1: list | str = r'C:\dir\num\one;C:\dir\num\two'
         # have to include the pathsep here so that the test will work on UNIX too.
         p1 = AppendPath(p1, r'C:\dir\num\one', sep=';', delete_existing=False)
         p1 = AppendPath(p1, r'C:\dir\num\three', sep=';')
diff --git a/SCons/Util/__init__.py b/SCons/Util/__init__.py
index 28565da797..0398c1fad7 100644
--- a/SCons/Util/__init__.py
+++ b/SCons/Util/__init__.py
@@ -49,6 +49,8 @@
 #       )
 # (issue filed on this upstream, for now just be aware)
 
+from __future__ import annotations
+
 import copy
 import hashlib
 import logging
@@ -59,7 +61,7 @@
 from collections import UserDict, UserList, deque
 from contextlib import suppress
 from types import MethodType, FunctionType
-from typing import Optional, Union, Any, List
+from typing import Any
 from logging import Formatter
 
 # Util split into a package. Make sure things that used to work
@@ -203,11 +205,11 @@ def __str__(self) -> str:
     def __iter__(self):
         return iter(self.data)
 
-    def __call__(self, *args, **kwargs) -> 'NodeList':
+    def __call__(self, *args, **kwargs) -> NodeList:
         result = [x(*args, **kwargs) for x in self.data]
         return self.__class__(result)
 
-    def __getattr__(self, name) -> 'NodeList':
+    def __getattr__(self, name) -> NodeList:
         """Returns a NodeList of `name` from each member."""
         result = [getattr(x, name) for x in self.data]
         return self.__class__(result)
@@ -254,8 +256,8 @@ def render_tree(
     root,
     child_func,
     prune: bool = False,
-    margin: List[bool] = [False],
-    visited: Optional[dict] = None,
+    margin: list[bool] = [False],
+    visited: dict | None = None,
 ) -> str:
     """Render a tree of nodes into an ASCII tree view.
 
@@ -323,8 +325,8 @@ def print_tree(
     child_func,
     prune: bool = False,
     showtags: int = 0,
-    margin: List[bool] = [False],
-    visited: Optional[dict] = None,
+    margin: list[bool] = [False],
+    visited: dict | None = None,
     lastChild: bool = False,
     singleLineDraw: bool = False,
 ) -> None:
@@ -694,7 +696,7 @@ def RegOpenKeyEx(root, key):
 
 if sys.platform == 'win32':
 
-    def WhereIs(file, path=None, pathext=None, reject=None) -> Optional[str]:
+    def WhereIs(file, path=None, pathext=None, reject=None) -> str | None:
         if path is None:
             try:
                 path = os.environ['PATH']
@@ -731,7 +733,7 @@ def WhereIs(file, path=None, pathext=None, reject=None) -> Optional[str]:
 
 elif os.name == 'os2':
 
-    def WhereIs(file, path=None, pathext=None, reject=None) -> Optional[str]:
+    def WhereIs(file, path=None, pathext=None, reject=None) -> str | None:
         if path is None:
             try:
                 path = os.environ['PATH']
@@ -763,7 +765,7 @@ def WhereIs(file, path=None, pathext=None, reject=None) -> Optional[str]:
 
 else:
 
-    def WhereIs(file, path=None, pathext=None, reject=None) -> Optional[str]:
+    def WhereIs(file, path=None, pathext=None, reject=None) -> str | None:
         import stat  # pylint: disable=import-outside-toplevel
 
         if path is None:
diff --git a/SCons/Util/envs.py b/SCons/Util/envs.py
index 2640ef5c19..9c97da6e9e 100644
--- a/SCons/Util/envs.py
+++ b/SCons/Util/envs.py
@@ -9,10 +9,12 @@
 that don't need the specifics of the Environment class.
 """
 
+from __future__ import annotations
+
 import re
 import os
 from types import MethodType, FunctionType
-from typing import Union, Callable, Optional, Any
+from typing import Callable, Any
 
 from .sctypes import is_List, is_Tuple, is_String
 
@@ -22,8 +24,8 @@ def PrependPath(
     newpath,
     sep=os.pathsep,
     delete_existing: bool = True,
-    canonicalize: Optional[Callable] = None,
-) -> Union[list, str]:
+    canonicalize: Callable | None = None,
+) -> list | str:
     """Prepend *newpath* path elements to *oldpath*.
 
     Will only add any particular path once (leaving the first one it
@@ -112,8 +114,8 @@ def AppendPath(
     newpath,
     sep=os.pathsep,
     delete_existing: bool = True,
-    canonicalize: Optional[Callable] = None,
-) -> Union[list, str]:
+    canonicalize: Callable | None = None,
+) -> list | str:
     """Append *newpath* path elements to *oldpath*.
 
     Will only add any particular path once (leaving the last one it
@@ -239,7 +241,7 @@ class MethodWrapper:
     a new underlying object being copied (without which we wouldn't need
     to save that info).
     """
-    def __init__(self, obj: Any, method: Callable, name: Optional[str] = None) -> None:
+    def __init__(self, obj: Any, method: Callable, name: str | None = None) -> None:
         if name is None:
             name = method.__name__
         self.object = obj
@@ -275,7 +277,7 @@ def clone(self, new_object):
 #   is not needed, the remaining bit is now used inline in AddMethod.
 
 
-def AddMethod(obj, function: Callable, name: Optional[str] = None) -> None:
+def AddMethod(obj, function: Callable, name: str | None = None) -> None:
     """Add a method to an object.
 
     Adds *function* to *obj* if *obj* is a class object.
@@ -314,7 +316,7 @@ def AddMethod(obj, function: Callable, name: Optional[str] = None) -> None:
             function.__code__, function.__globals__, name, function.__defaults__
         )
 
-    method: Union[MethodType, MethodWrapper, Callable]
+    method: MethodType | MethodWrapper | Callable
 
     if hasattr(obj, '__class__') and obj.__class__ is not type:
         # obj is an instance, so it gets a bound method.
diff --git a/SCons/Util/filelock.py b/SCons/Util/filelock.py
index 8ebf3889fe..730f48607b 100644
--- a/SCons/Util/filelock.py
+++ b/SCons/Util/filelock.py
@@ -30,9 +30,10 @@
 #   The lock attributes could probably be made opaque. Showed one visible
 #     in the example above, but not sure the benefit of that.
 
+from __future__ import annotations
+
 import os
 import time
-from typing import Optional
 
 
 class SConsLockFailure(Exception):
@@ -75,8 +76,8 @@ class FileLock:
     def __init__(
         self,
         file: str,
-        timeout: Optional[int] = None,
-        delay: Optional[float] = 0.05,
+        timeout: int | None = None,
+        delay: float | None = 0.05,
         writer: bool = False,
     ) -> None:
         if timeout is not None and delay is None:
@@ -90,7 +91,7 @@ def __init__(
         # Our simple first guess is just put it where the file is.
         self.file = file
         self.lockfile = f"{file}.lock"
-        self.lock: Optional[int] = None
+        self.lock: int | None = None
         self.timeout = 999999 if timeout == 0 else timeout
         self.delay = 0.0 if delay is None else delay
         self.writer = writer
@@ -128,7 +129,7 @@ def release_lock(self) -> None:
             os.unlink(self.lockfile)
             self.lock = None
 
-    def __enter__(self) -> "FileLock":
+    def __enter__(self) -> FileLock:
         """Context manager entry: acquire lock if not holding."""
         if not self.lock:
             self.acquire_lock()
diff --git a/SCons/Util/hashes.py b/SCons/Util/hashes.py
index 566897abbe..016354c6e5 100644
--- a/SCons/Util/hashes.py
+++ b/SCons/Util/hashes.py
@@ -8,10 +8,11 @@
 Routines for working with content and signature hashes.
 """
 
+from __future__ import annotations
+
 import functools
 import hashlib
 import sys
-from typing import Optional, Union
 
 from .sctypes import to_bytes
 
diff --git a/SCons/Util/sctypes.py b/SCons/Util/sctypes.py
index 765458e130..b95e395c80 100644
--- a/SCons/Util/sctypes.py
+++ b/SCons/Util/sctypes.py
@@ -6,13 +6,13 @@
 
 Routines which check types and do type conversions.
 """
+from __future__ import annotations
 
 import codecs
 import os
 import pprint
 import re
 import sys
-from typing import Optional, Union
 
 from collections import UserDict, UserList, UserString, deque
 from collections.abc import MappingView, Iterable
@@ -56,20 +56,24 @@
 if sys.version_info >= (3, 13):
     from typing import TypeAlias, TypeIs
 
-    DictTypeRet: TypeAlias = TypeIs[Union[dict, UserDict]]
-    ListTypeRet: TypeAlias = TypeIs[Union[list, UserList, deque]]
-    SequenceTypeRet: TypeAlias = TypeIs[Union[list, tuple, deque, UserList, MappingView]]
+    DictTypeRet: TypeAlias = TypeIs[dict | UserDict]
+    ListTypeRet: TypeAlias = TypeIs[list | UserList | deque]
+    SequenceTypeRet: TypeAlias = TypeIs[list | tuple | deque | UserList | MappingView]
     TupleTypeRet: TypeAlias = TypeIs[tuple]
-    StringTypeRet: TypeAlias = TypeIs[Union[str, UserString]]
+    StringTypeRet: TypeAlias = TypeIs[str | UserString]
 elif sys.version_info >= (3, 10):
     from typing import TypeAlias, TypeGuard
 
-    DictTypeRet: TypeAlias = TypeGuard[Union[dict, UserDict]]
-    ListTypeRet: TypeAlias = TypeGuard[Union[list, UserList, deque]]
-    SequenceTypeRet: TypeAlias = TypeGuard[Union[list, tuple, deque, UserList, MappingView]]
+    DictTypeRet: TypeAlias = TypeGuard[dict | UserDict]
+    ListTypeRet: TypeAlias = TypeGuard[list | UserList | deque]
+    SequenceTypeRet: TypeAlias = TypeGuard[list | tuple | deque | UserList | MappingView]
     TupleTypeRet: TypeAlias = TypeGuard[tuple]
-    StringTypeRet: TypeAlias = TypeGuard[Union[str, UserString]]
+    StringTypeRet: TypeAlias = TypeGuard[str | UserString]
 else:
+    # Because we have neither `TypeAlias` class nor `type` keyword pre-3.10,
+    # the boolean fallback type has to be wrapped in the legacy `Union` class.
+    from typing import Union
+
     DictTypeRet = Union[bool, bool]
     ListTypeRet = Union[bool, bool]
     SequenceTypeRet = Union[bool, bool]
@@ -354,7 +358,7 @@ def get_os_env_bool(name: str, default: bool=False) -> bool:
 _get_env_var = re.compile(r'^\$([_a-zA-Z]\w*|{[_a-zA-Z]\w*})$')
 
 
-def get_environment_var(varstr) -> Optional[str]:
+def get_environment_var(varstr) -> str | None:
     """Return undecorated construction variable string.
 
     Determine if *varstr* looks like a reference
diff --git a/SCons/Util/sctyping.py b/SCons/Util/sctyping.py
deleted file mode 100644
index 5da5eb9fe7..0000000000
--- a/SCons/Util/sctyping.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# SPDX-License-Identifier: MIT
-#
-# Copyright The SCons Foundation
-
-"""Various SCons type aliases.
-
-For representing complex types across the entire repo without risking
-circular dependencies, we take advantage of TYPE_CHECKING to import
-modules in an tool-only environment. This allows us to introduce
-hinting that resolves as expected in IDEs without clashing at runtime.
-
-For consistency, it's recommended to ALWAYS use these aliases in a
-type-hinting context, even if the type is actually expected to be
-resolved in a given file.
-"""
-
-from typing import Union, TYPE_CHECKING
-
-if TYPE_CHECKING:
-    import SCons.Executor
-
-
-# Because we don't have access to TypeAlias until 3.10, we have to utilize
-# 'Union' for all aliases. As it expects at least two entries, anything that
-# is only represented with a single type needs to list itself twice.
-ExecutorType = Union["SCons.Executor.Executor", "SCons.Executor.Executor"]
-
-
-# Local Variables:
-# tab-width:4
-# indent-tabs-mode:nil
-# End:
-# vim: set expandtab tabstop=4 shiftwidth=4:
diff --git a/SCons/Variables/BoolVariable.py b/SCons/Variables/BoolVariable.py
index 815a4b7865..573a0244e7 100644
--- a/SCons/Variables/BoolVariable.py
+++ b/SCons/Variables/BoolVariable.py
@@ -32,7 +32,9 @@
         ...
 """
 
-from typing import Callable, Tuple, Union
+from __future__ import annotations
+
+from typing import Callable
 
 import SCons.Errors
 
@@ -42,7 +44,7 @@
 FALSE_STRINGS = ('n', 'no', 'false', 'f', '0', 'off', 'none')
 
 
-def _text2bool(val: Union[str, bool]) -> bool:
+def _text2bool(val: str | bool) -> bool:
     """Convert boolean-like string to boolean.
 
     If *val* looks like it expresses a bool-like value, based on
@@ -83,7 +85,7 @@ def _validator(key: str, val, env) -> None:
         raise SCons.Errors.UserError(msg) from None
 
 # lint: W0622: Redefining built-in 'help' (redefined-builtin)
-def BoolVariable(key, help: str, default) -> Tuple[str, str, str, Callable, Callable]:
+def BoolVariable(key, help: str, default) -> tuple[str, str, str, Callable, Callable]:
     """Return a tuple describing a boolean SCons Variable.
 
     The input parameters describe a boolean variable, using a string
diff --git a/SCons/Variables/EnumVariable.py b/SCons/Variables/EnumVariable.py
index 3698e470dc..f154a133b7 100644
--- a/SCons/Variables/EnumVariable.py
+++ b/SCons/Variables/EnumVariable.py
@@ -43,7 +43,9 @@
         ...
 """
 
-from typing import Callable, List, Optional, Tuple
+from __future__ import annotations
+
+from typing import Callable
 
 import SCons.Errors
 
@@ -69,10 +71,10 @@ def EnumVariable(
     key,
     help: str,
     default: str,
-    allowed_values: List[str],
-    map: Optional[dict] = None,
+    allowed_values: list[str],
+    map: dict | None = None,
     ignorecase: int = 0,
-) -> Tuple[str, str, str, Callable, Callable]:
+) -> tuple[str, str, str, Callable, Callable]:
     """Return a tuple describing an enumaration SCons Variable.
 
     The input parameters describe a variable with only predefined values
diff --git a/SCons/Variables/ListVariable.py b/SCons/Variables/ListVariable.py
index 2c79ee7f15..4ea7dc304c 100644
--- a/SCons/Variables/ListVariable.py
+++ b/SCons/Variables/ListVariable.py
@@ -53,9 +53,11 @@
 # Known Bug: This should behave like a Set-Type, but does not really,
 # since elements can occur twice.
 
+from __future__ import annotations
+
 import collections
 import functools
-from typing import Callable, List, Optional, Tuple, Union
+from typing import Callable
 
 import SCons.Util
 
@@ -75,7 +77,7 @@ class _ListVariable(collections.UserList):
     """
 
     def __init__(
-        self, initlist: Optional[list] = None, allowedElems: Optional[list] = None
+        self, initlist: list | None = None, allowedElems: list | None = None
     ) -> None:
         if initlist is None:
             initlist = []
@@ -179,11 +181,11 @@ def _validator(key, val, env) -> None:
 def ListVariable(
     key,
     help: str,
-    default: Union[str, List[str]],
-    names: List[str],
-    map: Optional[dict] = None,
-    validator: Optional[Callable] = None,
-) -> Tuple[str, str, str, None, Callable]:
+    default: str | list[str],
+    names: list[str],
+    map: dict | None = None,
+    validator: Callable | None = None,
+) -> tuple[str, str, str, None, Callable]:
     """Return a tuple describing a list variable.
 
     The input parameters describe a list variable, where the values
diff --git a/SCons/Variables/PackageVariable.py b/SCons/Variables/PackageVariable.py
index 7271cfbf8f..2ecedfe77a 100644
--- a/SCons/Variables/PackageVariable.py
+++ b/SCons/Variables/PackageVariable.py
@@ -50,9 +50,11 @@
         ...  # build with x11 ...
 """
 
+from __future__ import annotations
+
 import os
 import functools
-from typing import Callable, Optional, Tuple, Union
+from typing import Callable
 
 import SCons.Errors
 
@@ -61,7 +63,7 @@
 ENABLE_STRINGS = ('1', 'yes', 'true',  'on', 'enable', 'search')
 DISABLE_STRINGS = ('0', 'no',  'false', 'off', 'disable')
 
-def _converter(val: Union[str, bool], default: str) -> Union[str, bool]:
+def _converter(val: str | bool, default: str) -> str | bool:
     """Convert a package variable.
 
     Returns *val* if it looks like a path string, and ``False`` if it
@@ -108,8 +110,8 @@ def _validator(key: str, val, env, searchfunc) -> None:
 
 # lint: W0622: Redefining built-in 'help' (redefined-builtin)
 def PackageVariable(
-    key: str, help: str, default, searchfunc: Optional[Callable] = None
-) -> Tuple[str, str, str, Callable, Callable]:
+    key: str, help: str, default, searchfunc: Callable | None = None
+) -> tuple[str, str, str, Callable, Callable]:
     """Return a tuple describing a package list SCons Variable.
 
     The input parameters describe a 'package list' variable. Returns
diff --git a/SCons/Variables/PathVariable.py b/SCons/Variables/PathVariable.py
index 43904e62c7..f840a95d26 100644
--- a/SCons/Variables/PathVariable.py
+++ b/SCons/Variables/PathVariable.py
@@ -71,10 +71,11 @@
     )
 """
 
+from __future__ import annotations
 
 import os
 import os.path
-from typing import Callable, Optional, Tuple
+from typing import Callable
 
 import SCons.Errors
 import SCons.Util
@@ -141,8 +142,8 @@ def PathExists(key: str, val, env) -> None:
 
     # lint: W0622: Redefining built-in 'help' (redefined-builtin)
     def __call__(
-        self, key: str, help: str, default, validator: Optional[Callable] = None
-    ) -> Tuple[str, str, str, Callable, None]:
+        self, key: str, help: str, default, validator: Callable | None = None
+    ) -> tuple[str, str, str, Callable, None]:
         """Return a tuple describing a path list SCons Variable.
 
         The input parameters describe a 'path list' variable. Returns
diff --git a/SCons/Variables/__init__.py b/SCons/Variables/__init__.py
index 28325266fb..2d160072a5 100644
--- a/SCons/Variables/__init__.py
+++ b/SCons/Variables/__init__.py
@@ -23,10 +23,12 @@
 
 """Adds user-friendly customizable variables to an SCons build."""
 
+from __future__ import annotations
+
 import os.path
 import sys
 from functools import cmp_to_key
-from typing import Callable, Dict, List, Optional, Sequence, Union
+from typing import Callable, Sequence
 
 import SCons.Errors
 import SCons.Util
@@ -90,16 +92,16 @@ class Variables:
 
     def __init__(
         self,
-        files: Optional[Union[str, Sequence[str]]] = None,
-        args: Optional[dict] = None,
+        files: str | Sequence[str | None] = None,
+        args: dict | None = None,
         is_global: bool = False,
     ) -> None:
-        self.options: List[Variable] = []
+        self.options: list[Variable] = []
         self.args = args if args is not None else {}
         if not SCons.Util.is_Sequence(files):
             files = [files] if files else []
         self.files: Sequence[str] = files
-        self.unknown: Dict[str, str] = {}
+        self.unknown: dict[str, str] = {}
 
     def __str__(self) -> str:
         """Provide a way to "print" a Variables object."""
@@ -113,11 +115,11 @@ def __str__(self) -> str:
     # lint: W0622: Redefining built-in 'help'
     def _do_add(
         self,
-        key: Union[str, List[str]],
+        key: str | list[str],
         help: str = "",
         default=None,
-        validator: Optional[Callable] = None,
-        converter: Optional[Callable] = None,
+        validator: Callable | None = None,
+        converter: Callable | None = None,
         **kwargs,
     ) -> None:
         """Create a Variable and add it to the list.
@@ -162,7 +164,7 @@ def keys(self) -> list:
             yield option.key
 
     def Add(
-        self, key: Union[str, Sequence], *args, **kwargs,
+        self, key: str | Sequence, *args, **kwargs,
     ) -> None:
         """Add a Build Variable.
 
@@ -218,7 +220,7 @@ def AddVariables(self, *optlist) -> None:
         for opt in optlist:
             self._do_add(*opt)
 
-    def Update(self, env, args: Optional[dict] = None) -> None:
+    def Update(self, env, args: dict | None = None) -> None:
         """Update an environment with the Build Variables.
 
         Args:
@@ -362,7 +364,7 @@ def Save(self, filename, env) -> None:
             msg = f'Error writing options to file: {filename}\n{exc}'
             raise SCons.Errors.UserError(msg) from exc
 
-    def GenerateHelpText(self, env, sort: Union[bool, Callable] = False) -> str:
+    def GenerateHelpText(self, env, sort: bool | Callable = False) -> str:
         """Generate the help text for the Variables object.
 
         Args:
@@ -403,7 +405,7 @@ def FormatVariableHelpText(
         help: str,
         default,
         actual,
-        aliases: Optional[List[str]] = None,
+        aliases: list[str | None] = None,
     ) -> str:
         """Format the help text for a single variable.
 
diff --git a/SCons/Warnings.py b/SCons/Warnings.py
index d604659c40..b9f9cc16f4 100644
--- a/SCons/Warnings.py
+++ b/SCons/Warnings.py
@@ -61,8 +61,10 @@
 framework and it will behave like an ordinary exception.
 """
 
+from __future__ import annotations
+
 import sys
-from typing import Callable, Sequence, Optional
+from typing import Callable, Sequence
 
 import SCons.Errors
 
@@ -75,7 +77,7 @@
 
 # Function to emit the warning. Initialized by SCons/Main.py for regular use;
 # the unit test will set to a capturing version for testing.
-_warningOut: Optional[Callable] = None
+_warningOut: Callable | None = None
 
 
 class SConsWarning(SCons.Errors.UserError):
diff --git a/pyproject.toml b/pyproject.toml
index 1e00257be9..5f3a49e04f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -84,6 +84,15 @@ extend-exclude = [
     "SCons/Tool/docbook/docbook-xsl-1.76.1/",
 ]
 
+[tool.ruff.lint]
+extend-select = [
+    "FA", # Future annotations
+    "UP006", # Use {to} instead of {from} for type annotation
+    "UP007", # Use `X | Y` for type annotations
+    "UP037", # Remove quotes from type annotation
+]
+extend-safe-fixes = ["FA", "UP006", "UP007"]
+
 [tool.ruff.format]
 quote-style = "preserve" # Equivalent to black's "skip-string-normalization"
 


Test patch:
diff --git a/runtest.py b/runtest.py
index 220b490333..3408d68681 100755
--- a/runtest.py
+++ b/runtest.py
@@ -15,6 +15,8 @@
 performs test discovery and processes tests according to options.
 """
 
+from __future__ import annotations
+
 import argparse
 import itertools
 import os
@@ -27,11 +29,11 @@
 from io import StringIO
 from pathlib import Path, PurePath, PureWindowsPath
 from queue import Queue
-from typing import List, TextIO, Optional
+from typing import TextIO
 
 cwd = os.getcwd()
-debug: Optional[str] = None
-scons: Optional[str] = None
+debug: str | None = None
+scons: str | None = None
 catch_output: bool = False
 suppress_output: bool = False
 script = PurePath(sys.argv[0]).name
@@ -43,7 +45,7 @@
 """
 
 # this is currently expected to be global, maybe refactor later?
-unittests: List[str]
+unittests: list[str]
 
 parser = argparse.ArgumentParser(
     usage=usagestr,
diff --git a/test/Variables/PackageVariable.py b/test/Variables/PackageVariable.py
index bc447dd58e..77c04e6088 100644
--- a/test/Variables/PackageVariable.py
+++ b/test/Variables/PackageVariable.py
@@ -27,8 +27,9 @@
 Test the PackageVariable canned Variable type.
 """
 
+from __future__ import annotations
+
 import os
-from typing import List
 
 import TestSCons
 
@@ -36,7 +37,7 @@
 
 SConstruct_path = test.workpath('SConstruct')
 
-def check(expect: List[str]) -> None:
+def check(expect: list[str]) -> None:
     result = test.stdout().split('\n')
     # skip first line and any lines beyond the length of expect
     assert result[1:len(expect)+1] == expect, (result[1:len(expect)+1], expect)
diff --git a/testing/framework/TestCmd.py b/testing/framework/TestCmd.py
index 243745d020..7307078c5e 100644
--- a/testing/framework/TestCmd.py
+++ b/testing/framework/TestCmd.py
@@ -295,6 +295,8 @@
     TestCmd.where_is('foo', 'PATH1;PATH2', '.suffix3;.suffix4')
 """
 
+from __future__ import annotations
+
 __author__ = "Steven Knight <knight at baldmt dot com>"
 __revision__ = "TestCmd.py 1.3.D001 2010/06/03 12:58:27 knight"
 __version__ = "1.3"
@@ -323,7 +325,7 @@
 from collections import UserList, UserString
 from pathlib import Path
 from subprocess import PIPE, STDOUT
-from typing import Callable, Dict, Optional, Union
+from typing import Callable
 
 IS_WINDOWS = sys.platform == 'win32'
 IS_MACOS = sys.platform == 'darwin'
@@ -437,7 +439,7 @@ def clean_up_ninja_daemon(self, result_type) -> None:
 def fail_test(
     self=None,
     condition: bool = True,
-    function: Optional[Callable] = None,
+    function: Callable | None = None,
     skip: int = 0,
     message: str = "",
 ) -> None:
@@ -1044,8 +1046,8 @@ def __init__(
         diff_stdout=None,
         diff_stderr=None,
         combine: bool = False,
-        universal_newlines: Optional[bool] = True,
-        timeout: Optional[float] = None,
+        universal_newlines: bool | None = True,
+        timeout: float | None = None,
     ) -> None:
         self.external = os.environ.get('SCONS_EXTERNAL_TEST', 0)
         self._cwd = os.getcwd()
@@ -1060,7 +1062,7 @@ def __init__(
         self.verbose_set(verbose)
         self.combine = combine
         self.universal_newlines = universal_newlines
-        self.process: Optional[Popen] = None
+        self.process: Popen | None = None
         # Two layers of timeout: one at the test class instance level,
         # one set on an individual start() call (usually via a run() call)
         self.timeout = timeout
@@ -1068,7 +1070,7 @@ def __init__(
         self.set_match_function(match, match_stdout, match_stderr)
         self.set_diff_function(diff, diff_stdout, diff_stderr)
         self._dirlist = []
-        self._preserve: Dict[str, Union[str, bool]] = {
+        self._preserve: dict[str, str | bool] = {
             'pass_test': False,
             'fail_test': False,
             'no_result': False,
@@ -1084,9 +1086,9 @@ def __init__(
             self._preserve['no_result'] = os.environ.get('PRESERVE_NO_RESULT', False)
         self._stdout = []
         self._stderr = []
-        self.status: Optional[int] = None
+        self.status: int | None = None
         self.condition = 'no_result'
-        self.workdir: Optional[str]
+        self.workdir: str | None
         self.workdir_set(workdir)
         self.subdir(subdir)
 
@@ -1254,7 +1256,7 @@ def diff_stdout(self, a, b, *args, **kw):
     def fail_test(
         self,
         condition: bool = True,
-        function: Optional[Callable] = None,
+        function: Callable | None = None,
         skip: int = 0,
         message: str = "",
     )-> None:
@@ -1738,7 +1740,7 @@ def sleep(self, seconds=default_sleep_seconds) -> None:
         """
         time.sleep(seconds)
 
-    def stderr(self, run=None) -> Optional[str]:
+    def stderr(self, run=None) -> str | None:
         """Returns the stored standard error output from a given run.
 
         Args:
@@ -1760,7 +1762,7 @@ def stderr(self, run=None) -> Optional[str]:
         except IndexError:
             return None
 
-    def stdout(self, run=None) -> Optional[str]:
+    def stdout(self, run=None) -> str | None:
         """Returns the stored standard output from a given run.
 
         Args:
diff --git a/testing/framework/TestCommon.py b/testing/framework/TestCommon.py
index f5bb084e68..470dbcb208 100644
--- a/testing/framework/TestCommon.py
+++ b/testing/framework/TestCommon.py
@@ -110,6 +110,8 @@
 
 """
 
+from __future__ import annotations
+
 __author__ = "Steven Knight <knight at baldmt dot com>"
 __revision__ = "TestCommon.py 1.3.D001 2010/06/03 12:58:27 knight"
 __version__ = "1.3"
@@ -121,7 +123,7 @@
 import sysconfig
 
 from collections import UserList
-from typing import Callable, List, Optional, Union
+from typing import Callable
 
 from TestCmd import *
 from TestCmd import __all__
@@ -226,14 +228,14 @@ def separate_files(flist):
             missing.append(f)
     return existing, missing
 
-def contains(seq, subseq, find: Optional[Callable] = None) -> bool:
+def contains(seq, subseq, find: Callable | None = None) -> bool:
     if find is None:
         return subseq in seq
     else:
         f = find(seq, subseq)
         return f not in (None, -1) and f is not False
 
-def find_index(seq, subseq, find: Optional[Callable] = None) -> Optional[int]:
+def find_index(seq, subseq, find: Callable | None = None) -> int | None:
     # Returns either an index of the subseq within the seq, or None.
     # Accepts a function find(seq, subseq), which returns an integer on success
     # and either: None, False, or -1, on failure.
@@ -280,8 +282,8 @@ def __init__(self, **kw) -> None:
 
     def options_arguments(
         self,
-        options: Union[str, List[str]],
-        arguments: Union[str, List[str]],
+        options: str | list[str],
+        arguments: str | list[str],
     ):
         """Merges the "options" keyword argument with the arguments."""
         # TODO: this *doesn't* split unless both are non-empty strings.
@@ -323,7 +325,7 @@ def must_contain(
         file: str,
         required: str,
         mode: str = 'rb',
-        find: Optional[Callable] = None,
+        find: Callable | None = None,
     ) -> None:
         """Ensures specified file contains the required text.
 
@@ -353,7 +355,7 @@ def must_contain(
             print(file_contents)
             self.fail_test()
 
-    def must_contain_all(self, output, input, title: str = "", find: Optional[Callable] = None)-> None:
+    def must_contain_all(self, output, input, title: str = "", find: Callable | None = None)-> None:
         """Ensures that the specified output string (first argument)
         contains all of the specified input as a block (second argument).
 
@@ -376,7 +378,7 @@ def must_contain_all(self, output, input, title: str = "", find: Optional[Callab
             print(output)
             self.fail_test()
 
-    def must_contain_all_lines(self, output, lines, title: str = "", find: Optional[Callable] = None) -> None:
+    def must_contain_all_lines(self, output, lines, title: str = "", find: Callable | None = None) -> None:
         """Ensures that the specified output string (first argument)
         contains all of the specified lines (second argument).
 
@@ -427,7 +429,7 @@ def must_contain_single_instance_of(self, output, lines, title: str = "") -> Non
             sys.stdout.write(output)
             self.fail_test()
 
-    def must_contain_any_line(self, output, lines, title: str = "", find: Optional[Callable] = None) -> None:
+    def must_contain_any_line(self, output, lines, title: str = "", find: Callable | None = None) -> None:
         """Ensures that the specified output string (first argument)
         contains at least one of the specified lines (second argument).
 
@@ -451,7 +453,7 @@ def must_contain_any_line(self, output, lines, title: str = "", find: Optional[C
         sys.stdout.write(output)
         self.fail_test()
 
-    def must_contain_exactly_lines(self, output, expect, title: str = "", find: Optional[Callable] = None) -> None:
+    def must_contain_exactly_lines(self, output, expect, title: str = "", find: Callable | None = None) -> None:
         """Ensures that the specified output string (first argument)
         contains all of the lines in the expected string (second argument)
         with none left over.
@@ -499,7 +501,7 @@ def must_contain_exactly_lines(self, output, expect, title: str = "", find: Opti
         sys.stdout.flush()
         self.fail_test()
 
-    def must_contain_lines(self, lines, output, title: str = "", find: Optional[Callable] = None) -> None:
+    def must_contain_lines(self, lines, output, title: str = "", find: Callable | None = None) -> None:
         # Deprecated; retain for backwards compatibility.
         self.must_contain_all_lines(output, lines, title, find)
 
@@ -540,7 +542,7 @@ def must_match(
         file,
         expect,
         mode: str = 'rb',
-        match: Optional[Callable] = None,
+        match: Callable | None = None,
         message: str = "",
         newline=None,
     ):
@@ -569,7 +571,7 @@ def must_match_file(
         file,
         golden_file,
         mode: str = 'rb',
-        match: Optional[Callable] = None,
+        match: Callable | None = None,
         message: str = "",
         newline=None,
     ) -> None:
@@ -609,7 +611,7 @@ def must_not_contain(self, file, banned, mode: str = 'rb', find = None) -> None:
             print(file_contents)
             self.fail_test()
 
-    def must_not_contain_any_line(self, output, lines, title: str = "", find: Optional[Callable] = None) -> None:
+    def must_not_contain_any_line(self, output, lines, title: str = "", find: Callable | None = None) -> None:
         """Ensures that the specified output string (first argument)
         does not contain any of the specified lines (second argument).
 
@@ -635,7 +637,7 @@ def must_not_contain_any_line(self, output, lines, title: str = "", find: Option
             sys.stdout.write(output)
             self.fail_test()
 
-    def must_not_contain_lines(self, lines, output, title: str = "", find: Optional[Callable] = None) -> None:
+    def must_not_contain_lines(self, lines, output, title: str = "", find: Callable | None = None) -> None:
         self.must_not_contain_any_line(output, lines, title, find)
 
     def must_not_exist(self, *files) -> None:
@@ -768,9 +770,9 @@ def start(self, program = None,
     def finish(
         self,
         popen,
-        stdout: Optional[str] = None,
-        stderr: Optional[str] = '',
-        status: Optional[int] = 0,
+        stdout: str | None = None,
+        stderr: str | None = '',
+        status: int | None = 0,
         **kw,
     ) -> None:
         """Finish and wait for the process being run.
@@ -800,9 +802,9 @@ def run(
         self,
         options=None,
         arguments=None,
-        stdout: Optional[str] = None,
-        stderr: Optional[str] = '',
-        status: Optional[int] = 0,
+        stdout: str | None = None,
+        stderr: str | None = '',
+        status: int | None = 0,
         **kw,
     ) -> None:
         """Runs the program under test, checking that the test succeeded.
diff --git a/testing/framework/TestSCons.py b/testing/framework/TestSCons.py
index 243be753a4..806b596be0 100644
--- a/testing/framework/TestSCons.py
+++ b/testing/framework/TestSCons.py
@@ -34,6 +34,8 @@
 attributes defined in this subclass.
 """
 
+from __future__ import annotations
+
 import os
 import re
 import shutil
@@ -42,7 +44,6 @@
 import subprocess as sp
 import zipfile
 from collections import namedtuple
-from typing import Optional, Tuple
 
 from TestCommon import *
 from TestCommon import __all__, _python_
@@ -865,7 +866,7 @@ def java_where_includes(self, version=None):
             result.append(os.path.join(d, 'linux'))
         return result
 
-    def java_where_java_home(self, version=None) -> Optional[str]:
+    def java_where_java_home(self, version=None) -> str | None:
         """ Find path to what would be JAVA_HOME.
 
         SCons does not read JAVA_HOME from the environment, so deduce it.
@@ -980,7 +981,7 @@ def java_where_java(self, version=None) -> str:
 
         return where_java
 
-    def java_where_javac(self, version=None) -> Tuple[str, str]:
+    def java_where_javac(self, version=None) -> tuple[str, str]:
         """ Find java compiler.
 
         Args: