Repository: zopefoundation/RestrictedPython (language: python)

Issue:
Support Python 3.13
Look through the change log of Python 3.13 (once the final version is released in October 2024) for potential issues which need to be handled by RestrictedPython to prevent access to otherwise forbidden data/objects.

There is no need to support new language features of Python 3.13 right now, let's see how they evolve when started being used.

Open tasks:

- [x] make package installable and fix tests
- [x] cut an alpha dev release (7.0a2.dev0)
- [x] security audit after final Python release ([detailed instructions](https://github.com/zopefoundation/RestrictedPython/blob/master/docs/contributing/index.rst#preperations-for-a-new-python-version))
- [x] Prepare for a new Python version after its final release ([detailed instructions](https://github.com/zopefoundation/RestrictedPython/blob/master/docs/contributing/index.rst#preperations-for-a-new-python-version))



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
new file mode 100644
index 0000000..0f06e16
--- /dev/null
+++ b/.github/workflows/pre-commit.yml
@@ -0,0 +1,33 @@
+# Generated from:
+# https://github.com/zopefoundation/meta/tree/master/config/pure-python
+name: pre-commit
+
+on:
+  pull_request:
+  push:
+    branches:
+    - master
+  # Allow to run this workflow manually from the Actions tab
+  workflow_dispatch:
+
+env:
+  FORCE_COLOR: 1
+
+jobs:
+  pre-commit:
+    name: linting
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v4
+    - uses: actions/setup-python@v5
+      with:
+        python-version: 3.x
+    - uses: pre-commit/action@v3.0.1
+      with:
+        extra_args: --all-files --show-diff-on-failure
+      env:
+        PRE_COMMIT_COLOR: always
+    - uses: pre-commit-ci/lite-action@v1.0.2
+      if: always()
+      with:
+        msg: Apply pre-commit code formatting
diff --git a/.meta.toml b/.meta.toml
index af696b7..9814f9a 100644
--- a/.meta.toml
+++ b/.meta.toml
@@ -2,20 +2,20 @@
 # https://github.com/zopefoundation/meta/tree/master/config/pure-python
 [meta]
 template = "pure-python"
-commit-id = "cb0568c7"
+commit-id = "d3455844"
 
 [python]
 with-pypy = false
 with-docs = true
 with-sphinx-doctests = true
 with-windows = true
-with-future-python = true
+with-future-python = false
 with-macos = false
 
 [tox]
 use-flake8 = true
 additional-envlist = [
-    "py39-datetime",
+    "py311-datetime",
     "combined-coverage",
     ]
 testenv-deps = [
@@ -28,7 +28,7 @@ testenv-setenv = [
     ]
 testenv-commands = [
     "python -V",
-    "pytest --cov=src --cov=tests --cov-report= {posargs}",
+    "pytest --cov=src --cov=tests --cov-report= tests {posargs}",
     ]
 testenv-additional = [
     "",
@@ -47,16 +47,15 @@ testenv-additional = [
     "    coverage combine",
     "    coverage html",
     "    coverage report -m --fail-under=100",
-    "depends = py37,py38,py39,py39-datetime,py310,py311,py312,coverage",
+    "depends = py38,py39,py310,py311,py311-datetime,py312,py313,coverage",
     ]
-coverage-basepython = "python3.8"
-coverage-command = "pytest --cov=src --cov=tests --cov-report= {posargs}"
+coverage-command = "pytest --cov=src --cov=tests --cov-report= tests {posargs}"
 coverage-setenv = [
     "COVERAGE_FILE=.coverage",
     ]
 
 [coverage]
-fail-under = 98.4
+fail-under = 97.9
 
 [isort]
 additional-sources = "{toxinidir}/tests"
@@ -82,9 +81,10 @@ additional-ignores = [
     "docs/_build/html/_sources/roadmap/*",
     "docs/_build/html/_sources/upgrade_dependencies/*",
     "docs/_build/html/_sources/usage/*",
+    "docs/_build/html/_static/scripts/*",
     ]
 
 [github-actions]
 additional-config = [
-    "- [\"3.9\",   \"py39-datetime\"]",
+    "- [\"3.11\", \"py311-datetime\"]",
     ]
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..7ab398c
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,28 @@
+# Generated from:
+# https://github.com/zopefoundation/meta/tree/master/config/pure-python
+minimum_pre_commit_version: '3.6'
+repos:
+  - repo: https://github.com/pycqa/isort
+    rev: "5.13.2"
+    hooks:
+    - id: isort
+  - repo: https://github.com/hhatto/autopep8
+    rev: "v2.3.1"
+    hooks:
+    - id: autopep8
+      args: [--in-place, --aggressive, --aggressive]
+  - repo: https://github.com/asottile/pyupgrade
+    rev: v3.17.0
+    hooks:
+    - id: pyupgrade
+      args: [--py38-plus]
+  - repo: https://github.com/isidentical/teyit
+    rev: 0.4.3
+    hooks:
+    - id: teyit
+  - repo: https://github.com/PyCQA/flake8
+    rev: "7.1.1"
+    hooks:
+    - id: flake8
+      additional_dependencies:
+        - flake8-debugger == 4.1.2
diff --git a/CHANGES.rst b/CHANGES.rst
index 43226ba..d2922f6 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,8 +4,9 @@ Changes
 7.4 (unreleased)
 ----------------
 
-- Allow to use the package with Python 3.13 -- Caution: No security
-  audit has been done so far.
+- Allow to use the package with Python 3.13.
+
+- Drop support for Python 3.7.
 
 - Provide new function ``RestrictedPython.Guards.safer_getattr_raise``.
   It is similar to ``safer_getattr`` but handles its parameter
diff --git a/MANIFEST.in b/MANIFEST.in
index 3c482de..7f0aa88 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -5,6 +5,7 @@ include *.rst
 include *.txt
 include buildout.cfg
 include tox.ini
+include .pre-commit-config.yaml
 
 recursive-include docs *.py
 recursive-include docs *.rst
diff --git a/docs/conf.py b/docs/conf.py
index ec291c3..3ea6a2c 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -16,8 +16,13 @@
 # documentation root, use os.path.abspath to make it absolute, like shown here.
 # sys.path.insert(0, os.path.abspath('.'))
 
+import datetime
+
+
 # -- General configuration ------------------------------------------------
 
+year = datetime.datetime.now().year
+
 # If your documentation needs a minimal Sphinx version, state it here.
 # needs_sphinx = '1.0'
 
@@ -37,7 +42,7 @@
 # The suffix(es) of source filenames.
 # You can specify multiple suffix as a list of string:
 # source_suffix = ['.rst', '.md']
-source_suffix = '.rst'
+source_suffix = {'.rst': 'restructuredtext'}
 
 # The encoding of source files.
 # source_encoding = 'utf-8-sig'
@@ -47,7 +52,7 @@
 
 # General information about the project.
 project = 'RestrictedPython'
-copyright = '2017-2023, Zope Foundation and Contributors'
+copyright = f'2017-{year}, Zope Foundation and Contributors'
 author = 'The Zope & Plone developer community'
 
 # The version info for the project you're documenting, acts as replacement for
@@ -55,9 +60,9 @@
 # built documents.
 #
 # The short X.Y version.
-version = '7.0'
+version = '7.4'
 # The full version, including alpha/beta/rc tags.
-release = '7.0'
+release = '7.4'
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
@@ -107,12 +112,12 @@
 # Intersphinx Mapping for Links between different Documentations
 intersphinx_mapping = {
     'python3': ('https://docs.python.org/3', None),
-    'python37': ('https://docs.python.org/3.7', None),
     'python38': ('https://docs.python.org/3.8', None),
     'python39': ('https://docs.python.org/3.9', None),
     'python310': ('https://docs.python.org/3.10', None),
     'python311': ('https://docs.python.org/3.11', None),
     'python312': ('https://docs.python.org/3.12', None),
+    'python313': ('https://docs.python.org/3.13', None),
 }
 
 # Options for sphinx.ext.todo:
@@ -123,7 +128,7 @@
 
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
-html_theme = 'default'
+html_theme = 'furo'
 
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
@@ -274,8 +279,11 @@
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
 man_pages = [
-    (master_doc, 'restrictedpython', 'RestrictedPython Documentation', [author], 1)
-]
+    (master_doc,
+     'restrictedpython',
+     'RestrictedPython Documentation',
+     [author],
+     1)]
 
 # If true, show URL addresses after external links.
 # man_show_urls = False
diff --git a/docs/contributing/ast/python3_6.ast b/docs/contributing/ast/python3_13.ast
old mode 100755
new mode 100644
similarity index 55%
rename from docs/contributing/ast/python3_6.ast
rename to docs/contributing/ast/python3_13.ast
index 8c87864..e23ee03
--- a/docs/contributing/ast/python3_6.ast
+++ b/docs/contributing/ast/python3_13.ast
@@ -1,53 +1,57 @@
--- Python 3.6 AST
--- ASDL's 7 builtin types are:
--- identifier, int, string, bytes, object, singleton, constant
---
--- singleton: None, True or False
--- constant can be None, whereas None means "no value" for object.
-
-module Python version "3.6"
+-- Python 3.13 AST
+-- ASDL's 4 builtin types are:
+-- identifier, int, string, constant
+
+module Python version "3.13"
 {
-    mod = Module(stmt* body)
+    mod = Module(stmt* body, type_ignore* type_ignores)
         | Interactive(stmt* body)
         | Expression(expr body)
-
-        -- not really an actual node but useful in Jython's typesystem.
-        | Suite(stmt* body)
+        | FunctionType(expr* argtypes, expr returns)
 
     stmt = FunctionDef(identifier name,
                        arguments args,
                        stmt* body,
                        expr* decorator_list,
-                       expr? returns)
+                       expr? returns,
+                       string? type_comment,
+                       type_param* type_params)
          | AsyncFunctionDef(identifier name,
                             arguments args,
                             stmt* body,
                             expr* decorator_list,
-                            expr? returns)
+                            expr? returns,
+                            string? type_comment,
+                            type_param* type_params)
 
          | ClassDef(identifier name,
                     expr* bases,
                     keyword* keywords,
                     stmt* body,
-                    expr* decorator_list)
+                    expr* decorator_list,
+                    type_param* type_params)
          | Return(expr? value)
 
          | Delete(expr* targets)
-         | Assign(expr* targets, expr value)
+         | Assign(expr* targets, expr value, string? type_comment)
+         | TypeAlias(expr name, type_param* type_params, expr value)
          | AugAssign(expr target, operator op, expr value)
          -- 'simple' indicates that we annotate simple name without parens
          | AnnAssign(expr target, expr annotation, expr? value, int simple)
 
          -- use 'orelse' because else is a keyword in target languages
-         | For(expr target, expr iter, stmt* body, stmt* orelse)
-         | AsyncFor(expr target, expr iter, stmt* body, stmt* orelse)
+         | For(expr target, expr iter, stmt* body, stmt* orelse, string? type_comment)
+         | AsyncFor(expr target, expr iter, stmt* body, stmt* orelse, string? type_comment)
          | While(expr test, stmt* body, stmt* orelse)
          | If(expr test, stmt* body, stmt* orelse)
-         | With(withitem* items, stmt* body)
-         | AsyncWith(withitem* items, stmt* body)
+         | With(withitem* items, stmt* body, string? type_comment)
+         | AsyncWith(withitem* items, stmt* body, string? type_comment)
+
+         | Match(expr subject, match_case* cases)
 
          | Raise(expr? exc, expr? cause)
          | Try(stmt* body, excepthandler* handlers, stmt* orelse, stmt* finalbody)
+         | TryStar(stmt* body, excepthandler* handlers, stmt* orelse, stmt* finalbody)
          | Assert(expr test, expr? msg)
 
          | Import(alias* names)
@@ -60,12 +64,12 @@ module Python version "3.6"
          | Break
          | Continue
 
-         -- XXX Jython will be different
          -- col_offset is the byte offset in the utf8 string the parser uses
-         attributes (int lineno, int col_offset)
+         attributes (int lineno, int col_offset, int? end_lineno, int? end_col_offset)
 
          -- BoolOp() can use left & right?
     expr = BoolOp(boolop op, expr* values)
+         | NamedExpr(expr target, expr value)
          | BinOp(expr left, operator op, expr right)
          | UnaryOp(unaryop op, expr operand)
          | Lambda(arguments args, expr body)
@@ -83,39 +87,28 @@ module Python version "3.6"
          -- need sequences for compare to distinguish between
          -- x < 4 < 3 and (x < 4) < 3
          | Compare(expr left, cmpop* ops, expr* comparators)
-         | Call(expr func,
-                expr* args,
-                keyword* keywords)
-         | Num(object n) -- a number as a PyObject.
-         | Str(string s) -- need to specify raw, unicode, etc?
-         | FormattedValue(expr value, int? conversion, expr? format_spec)
+         | Call(expr func, expr* args, keyword* keywords)
+         | FormattedValue(expr value, int conversion, expr? format_spec)
          | JoinedStr(expr* values)
-         | Bytes(bytes s)
-         | NameConstant(singleton value)
-         | Ellipsis
-         | Constant(constant value)
+         | Constant(constant value, string? kind)
 
          -- the following expression can appear in assignment context
          | Attribute(expr value, identifier attr, expr_context ctx)
-         | Subscript(expr value, slice slice, expr_context ctx)
+         | Subscript(expr value, expr slice, expr_context ctx)
          | Starred(expr value, expr_context ctx)
          | Name(identifier id, expr_context ctx)
          | List(expr* elts, expr_context ctx)
          | Tuple(expr* elts, expr_context ctx)
 
-         -- col_offset is the byte offset in the utf8 string the parser uses
-         attributes (int lineno, int col_offset)
+         -- can appear only in Subscript
+         | Slice(expr? lower, expr? upper, expr? step)
+
+          -- col_offset is the byte offset in the utf8 string the parser uses
+          attributes (int lineno, int col_offset, int? end_lineno, int? end_col_offset)
 
     expr_context = Load
                  | Store
                  | Del
-                 | AugLoad
-                 | AugStore
-                 | Param
-
-    slice = Slice(expr? lower, expr? upper, expr? step)
-          | ExtSlice(slice* dims)
-          | Index(expr value)
 
     boolop = And
            | Or
@@ -153,23 +146,49 @@ module Python version "3.6"
     comprehension = (expr target, expr iter, expr* ifs, int is_async)
 
     excepthandler = ExceptHandler(expr? type, identifier? name, stmt* body)
-                    attributes (int lineno, int col_offset)
+                    attributes (int lineno, int col_offset, int? end_lineno, int? end_col_offset)
 
-    arguments = (arg* args,
+    arguments = (arg* posonlyargs,
+                 arg* args,
                  arg? vararg,
                  arg* kwonlyargs,
                  expr* kw_defaults,
                  arg? kwarg,
                  expr* defaults)
 
-    arg = (identifier arg, expr? annotation)
-          attributes (int lineno, int col_offset)
+    arg = (identifier arg, expr? annotation, string? type_comment)
+          attributes (int lineno, int col_offset, int? end_lineno, int? end_col_offset)
 
     -- keyword arguments supplied to call (NULL identifier for **kwargs)
     keyword = (identifier? arg, expr value)
+              attributes (int lineno, int col_offset, int? end_lineno, int? end_col_offset)
 
     -- import name with optional 'as' alias.
     alias = (identifier name, identifier? asname)
+            attributes (int lineno, int col_offset, int? end_lineno, int? end_col_offset)
 
     withitem = (expr context_expr, expr? optional_vars)
+
+    match_case = (pattern pattern, expr? guard, stmt* body)
+
+    pattern = MatchValue(expr value)
+            | MatchSingleton(constant value)
+            | MatchSequence(pattern* patterns)
+            | MatchMapping(expr* keys, pattern* patterns, identifier? rest)
+            | MatchClass(expr cls, pattern* patterns, identifier* kwd_attrs, pattern* kwd_patterns)
+
+            | MatchStar(identifier? name)
+            -- The optional "rest" MatchMapping parameter handles capturing extra mapping keys
+
+            | MatchAs(pattern? pattern, identifier? name)
+            | MatchOr(pattern* patterns)
+
+             attributes (int lineno, int col_offset, int end_lineno, int end_col_offset)
+
+    type_ignore = TypeIgnore(int lineno, string tag)
+
+    type_param = TypeVar(identifier name, expr? bound, expr? default_value)
+               | ParamSpec(identifier name, expr? default_value)
+               | TypeVarTuple(identifier name, expr? default_value)
+               attributes (int lineno, int col_offset, int end_lineno, int end_col_offset)
 }
diff --git a/docs/contributing/changes_from312to313.rst b/docs/contributing/changes_from312to313.rst
new file mode 100644
index 0000000..412082a
--- /dev/null
+++ b/docs/contributing/changes_from312to313.rst
@@ -0,0 +1,5 @@
+Changes from Python 3.12 to Python 3.13
+---------------------------------------
+
+.. literalinclude:: ast/python3_13.ast
+   :diff: ast/python3_12.ast
diff --git a/docs/contributing/changes_from36to37.rst b/docs/contributing/changes_from36to37.rst
deleted file mode 100644
index 42160bf..0000000
--- a/docs/contributing/changes_from36to37.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-Changes from Python 3.6 to Python 3.7
--------------------------------------
-
-.. literalinclude:: ast/python3_7.ast
-   :diff: ast/python3_6.ast
diff --git a/docs/contributing/index.rst b/docs/contributing/index.rst
index f88dfa9..c9eef77 100644
--- a/docs/contributing/index.rst
+++ b/docs/contributing/index.rst
@@ -1,7 +1,6 @@
 Contributing
 ============
 
-.. contents::
 
 Contributing to RestrictedPython
 --------------------------------
@@ -99,12 +98,12 @@ A (modified style) Copy of all Abstract Grammar Definitions for the Python versi
 .. toctree::
    :maxdepth: 2
 
-   changes_from36to37
    changes_from37to38
    changes_from38to39
    changes_from39to310
    changes_from310to311
    changes_from311to312
+   changes_from312to313
 
 .. _understand:
 
diff --git a/docs/index.rst b/docs/index.rst
index e840f5d..7e77a28 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -29,8 +29,6 @@ Contents
    usage/index
    usage/api
 
-   upgrade_dependencies/index
-
    roadmap/index
    contributing/index
 
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 2806c16..de8fca9 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,1 +1,2 @@
 Sphinx
+furo
diff --git a/docs/upgrade_dependencies/index.rst b/docs/upgrade_dependencies/index.rst
deleted file mode 100644
index 4d295d1..0000000
--- a/docs/upgrade_dependencies/index.rst
+++ /dev/null
@@ -1,30 +0,0 @@
-Upgrade from 3.x
-----------------
-
-For packages that use RestrictedPython the upgrade path differs on the actual usage.
-If it uses pure RestrictedPython without any additional checks it should be enough to check the imports.
-RestrictedPython did move some of the imports to the base namespace, so you should only import directly from ``RestrictedPython``.
-
-* compile_restricted methods:
-
-  * ``from RestrictedPython import compile_restricted``
-  * ``from RestrictedPython import compile_restricted_eval``
-  * ``from RestrictedPython import compile_restricted_exec``
-  * ``from RestrictedPython import compile_restricted_function``
-  * ``from RestrictedPython import compile_restricted_single``
-
-* predefined built-ins:
-
-  * ``from RestrictedPython import safe_globals``
-  * ``from RestrictedPython import safe_builtins``
-  * ``from RestrictedPython import limited_builtins``
-  * ``from RestrictedPython import utility_builtins``
-
-* helper methods:
-
-  * ``from RestrictedPython import PrintCollector``
-
-Any import from ``RestrictedPython.RCompile`` indicates that there have been advanced checks implemented.
-Those advanced checks where implemented via a ``MutatingWalker``.
-Any checks needs to be reimplemented as a subclass of
-``RestrictingNodeTransformer``.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..75267ab
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,21 @@
+# 
+# Generated from:
+# https://github.com/zopefoundation/meta/tree/master/config/pure-python
+
+[build-system]
+requires = ["setuptools<74"]
+build-backend = "setuptools.build_meta"
+
+[tool.coverage.run]
+branch = true
+source = ["RestrictedPython"]
+
+[tool.coverage.report]
+fail_under = 97.9
+precision = 2
+ignore_errors = true
+show_missing = true
+exclude_lines = ["pragma: no cover", "pragma: nocover", "except ImportError:", "raise NotImplementedError", "if __name__ == '__main__':", "self.fail", "raise AssertionError", "raise unittest.Skip"]
+
+[tool.coverage.html]
+directory = "parts/htmlcov"
diff --git a/setup.cfg b/setup.cfg
index c0470e3..ae12100 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,5 @@
 # Generated from:
 # https://github.com/zopefoundation/meta/tree/master/config/pure-python
-[bdist_wheel]
-universal = 0
 
 [flake8]
 doctests = 1
@@ -19,6 +17,7 @@ ignore =
     docs/_build/html/_sources/roadmap/*
     docs/_build/html/_sources/upgrade_dependencies/*
     docs/_build/html/_sources/usage/*
+    docs/_build/html/_static/scripts/*
 
 [isort]
 force_single_line = True
diff --git a/setup.py b/setup.py
index 79f9bc3..fb92b3d 100644
--- a/setup.py
+++ b/setup.py
@@ -24,11 +24,6 @@ def read(*rnames):
         return f.read()
 
 
-tests_require = [
-    'pytest',
-    'pytest-mock',
-]
-
 setup(name='RestrictedPython',
       version='7.4.dev0',
       url='https://github.com/zopefoundation/RestrictedPython',
@@ -44,18 +39,18 @@ def read(*rnames):
           'Programming Language :: Python',
           'Operating System :: OS Independent',
           'Programming Language :: Python :: 3',
-          'Programming Language :: Python :: 3.7',
           'Programming Language :: Python :: 3.8',
           'Programming Language :: Python :: 3.9',
           'Programming Language :: Python :: 3.10',
           'Programming Language :: Python :: 3.11',
           'Programming Language :: Python :: 3.12',
+          'Programming Language :: Python :: 3.13',
           'Programming Language :: Python :: Implementation :: CPython',
           'Topic :: Security',
       ],
       keywords='restricted execution security untrusted code',
       author='Zope Foundation and Contributors',
-      author_email='zope-dev@zope.org',
+      author_email='zope-dev@zope.dev',
       project_urls={
           "Documentation": "https://restrictedpython.readthedocs.io/",
           "Source": "https://github.com/zopefoundation/RestrictedPython",
@@ -65,10 +60,10 @@ def read(*rnames):
       packages=find_packages('src'),
       package_dir={'': 'src'},
       install_requires=[],
-      python_requires=">=3.7, <3.14",
+      python_requires=">=3.8, <3.14",
       extras_require={
-          'test': tests_require,
-          'docs': ['Sphinx', 'sphinx_rtd_theme'],
+          'test': ['pytest', 'pytest-mock'],
+          'docs': ['Sphinx', 'furo'],
       },
       include_package_data=True,
       zip_safe=False)
diff --git a/src/RestrictedPython/_compat.py b/src/RestrictedPython/_compat.py
index 56fe03d..2d85cc4 100644
--- a/src/RestrictedPython/_compat.py
+++ b/src/RestrictedPython/_compat.py
@@ -3,7 +3,6 @@
 
 
 _version = sys.version_info
-IS_PY38_OR_GREATER = _version.major == 3 and _version.minor >= 8
 IS_PY310_OR_GREATER = _version.major == 3 and _version.minor >= 10
 IS_PY311_OR_GREATER = _version.major == 3 and _version.minor >= 11
 IS_PY312_OR_GREATER = _version.major == 3 and _version.minor >= 12
diff --git a/src/RestrictedPython/transformer.py b/src/RestrictedPython/transformer.py
index 9a205cc..2034205 100644
--- a/src/RestrictedPython/transformer.py
+++ b/src/RestrictedPython/transformer.py
@@ -22,16 +22,6 @@
 import contextlib
 import textwrap
 
-from ._compat import IS_PY38_OR_GREATER
-
-
-# Avoid DeprecationWarnings under Python 3.12 and up
-if IS_PY38_OR_GREATER:
-    astStr = ast.Constant
-    astNum = ast.Constant
-else:  # pragma: no cover
-    astStr = ast.Str
-    astNum = ast.Num
 
 # For AugAssign the operator must be converted to a string.
 IOPERATOR_TO_STR = {
@@ -127,16 +117,14 @@ def copy_locations(new_node, old_node):
     assert 'lineno' in new_node._attributes
     new_node.lineno = old_node.lineno
 
-    if IS_PY38_OR_GREATER:
-        assert 'end_lineno' in new_node._attributes
-        new_node.end_lineno = old_node.end_lineno
+    assert 'end_lineno' in new_node._attributes
+    new_node.end_lineno = old_node.end_lineno
 
     assert 'col_offset' in new_node._attributes
     new_node.col_offset = old_node.col_offset
 
-    if IS_PY38_OR_GREATER:
-        assert 'end_col_offset' in new_node._attributes
-        new_node.end_col_offset = old_node.end_col_offset
+    assert 'end_col_offset' in new_node._attributes
+    new_node.end_col_offset = old_node.end_col_offset
 
     ast.fix_missing_locations(new_node)
 
@@ -280,7 +268,7 @@ def gen_unpack_spec(self, tpl):
         """
         spec = ast.Dict(keys=[], values=[])
 
-        spec.keys.append(astStr('childs'))
+        spec.keys.append(ast.Constant('childs'))
         spec.values.append(ast.Tuple([], ast.Load()))
 
         # starred elements in a sequence do not contribute into the min_len.
@@ -300,12 +288,12 @@ def gen_unpack_spec(self, tpl):
 
             elif isinstance(val, ast.Tuple):
                 el = ast.Tuple([], ast.Load())
-                el.elts.append(astNum(idx - offset))
+                el.elts.append(ast.Constant(idx - offset))
                 el.elts.append(self.gen_unpack_spec(val))
                 spec.values[0].elts.append(el)
 
-        spec.keys.append(astStr('min_len'))
-        spec.values.append(astNum(min_len))
+        spec.keys.append(ast.Constant('min_len'))
+        spec.values.append(ast.Constant(min_len))
 
         return spec
 
@@ -492,9 +480,8 @@ def inject_print_collector(self, node, position=0):
             if isinstance(node, ast.Module):
                 _print.lineno = position
                 _print.col_offset = position
-                if IS_PY38_OR_GREATER:
-                    _print.end_lineno = position
-                    _print.end_col_offset = position
+                _print.end_lineno = position
+                _print.end_col_offset = position
                 ast.fix_missing_locations(_print)
             else:
                 copy_locations(_print, node)
@@ -535,63 +522,22 @@ def node_contents_visit(self, node):
 
     # ast for Literals
 
-    if IS_PY38_OR_GREATER:
-
-        def visit_Constant(self, node):
-            """Allow constant literals with restriction for Ellipsis.
-
-            Constant replaces Num, Str, Bytes, NameConstant and Ellipsis in
-            Python 3.8+.
-            :see: https://docs.python.org/dev/whatsnew/3.8.html#deprecated
-            """
-            if node.value is Ellipsis:
-                # Deny using `...`.
-                # Special handling necessary as ``self.not_allowed(node)``
-                # would return the Error Message:
-                # 'Constant statements are not allowed.'
-                # which is only partial true.
-                self.error(node, 'Ellipsis statements are not allowed.')
-                return
-            return self.node_contents_visit(node)
-
-    else:
-
-        def visit_Num(self, node):
-            """Allow integer numbers without restrictions.
-
-            Replaced by Constant in Python 3.8.
-            """
-            return self.node_contents_visit(node)
-
-        def visit_Str(self, node):
-            """Allow string literals without restrictions.
-
-            Replaced by Constant in Python 3.8.
-            """
-            return self.node_contents_visit(node)
+    def visit_Constant(self, node):
+        """Allow constant literals with restriction for Ellipsis.
 
-        def visit_Bytes(self, node):
-            """Allow bytes literals without restrictions.
-
-            Replaced by Constant in Python 3.8.
-            """
-            return self.node_contents_visit(node)
-
-        def visit_Ellipsis(self, node):
-            """Deny using `...`.
-
-            Replaced by Constant in Python 3.8.
-            """
-            return self.not_allowed(node)
-
-        def visit_NameConstant(self, node):
-            """Allow constant literals (True, False, None) without ...
-
-            restrictions.
-
-            Replaced by Constant in Python 3.8.
-            """
-            return self.node_contents_visit(node)
+        Constant replaces Num, Str, Bytes, NameConstant and Ellipsis in
+        Python 3.8+.
+        :see: https://docs.python.org/dev/whatsnew/3.8.html#deprecated
+        """
+        if node.value is Ellipsis:
+            # Deny using `...`.
+            # Special handling necessary as ``self.not_allowed(node)``
+            # would return the Error Message:
+            # 'Constant statements are not allowed.'
+            # which is only partial true.
+            self.error(node, 'Ellipsis statements are not allowed.')
+            return
+        return self.node_contents_visit(node)
 
     def visit_Interactive(self, node):
         """Allow single mode without restrictions."""
@@ -915,7 +861,7 @@ def visit_Attribute(self, node):
             node = self.node_contents_visit(node)
             new_node = ast.Call(
                 func=ast.Name('_getattr_', ast.Load()),
-                args=[node.value, astStr(node.attr)],
+                args=[node.value, ast.Constant(node.attr)],
                 keywords=[])
 
             copy_locations(new_node, node)
@@ -1119,7 +1065,7 @@ def visit_AugAssign(self, node):
                 value=ast.Call(
                     func=ast.Name('_inplacevar_', ast.Load()),
                     args=[
-                        astStr(IOPERATOR_TO_STR[type(node.op)]),
+                        ast.Constant(IOPERATOR_TO_STR[type(node.op)]),
                         ast.Name(node.target.id, ast.Load()),
                         node.value
                     ],
diff --git a/tox.ini b/tox.ini
index 0adda1e..171abb2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,7 +5,6 @@ minversion = 3.18
 envlist =
     release-check
     lint
-    py37
     py38
     py39
     py310
@@ -14,26 +13,23 @@ envlist =
     py313
     docs
     coverage
-    py39-datetime
+    py311-datetime
     combined-coverage
 
 [testenv]
 usedevelop = true
 package = wheel
 wheel_build_env = .pkg
-pip_pre = py313: true
 deps =
+    setuptools  <74
     datetime: DateTime
     -cconstraints.txt
     pytest-cov
-    Sphinx
 setenv =
     COVERAGE_FILE=.coverage.{envname}
-    py312: VIRTUALENV_PIP=23.1.2
-    py312: PIP_REQUIRE_VIRTUALENV=0
 commands =
     python -V
-    pytest --cov=src --cov=tests --cov-report= {posargs}
+    pytest --cov=src --cov=tests --cov-report= tests {posargs}
     sphinx-build -b doctest -d {envdir}/.cache/doctrees docs {envdir}/.cache/doctest
 extras =
     test
@@ -54,41 +50,43 @@ commands =
     coverage combine
     coverage html
     coverage report -m --fail-under=100
-depends = py37,py38,py39,py39-datetime,py310,py311,py312,coverage
+depends = py38,py39,py310,py311,py311-datetime,py312,py313,coverage
+
+[testenv:setuptools-latest]
+basepython = python3
+deps =
+    git+https://github.com/pypa/setuptools.git\#egg=setuptools
+    datetime: DateTime
+    -cconstraints.txt
+    pytest-cov
+
 [testenv:release-check]
 description = ensure that the distribution is ready to release
 basepython = python3
 skip_install = true
 deps =
+    setuptools <74
     twine
     build
     check-manifest
     check-python-versions >= 0.20.0
     wheel
+commands_pre =
 commands =
     check-manifest
-    check-python-versions
+    check-python-versions --only setup.py,tox.ini,.github/workflows/tests.yml
     python -m build --sdist --no-isolation
     twine check dist/*
 
 [testenv:lint]
+description = This env runs all linters configured in .pre-commit-config.yaml
 basepython = python3
 skip_install = true
 deps =
-    isort
-    flake8
-commands =
-    isort --check-only --diff {toxinidir}/src {toxinidir}/setup.py {toxinidir}/tests
-    flake8 src setup.py tests
-
-[testenv:isort-apply]
-basepython = python3
-skip_install = true
+    pre-commit
 commands_pre =
-deps =
-    isort
 commands =
-    isort {toxinidir}/src {toxinidir}/setup.py {toxinidir}/tests []
+    pre-commit run --all-files --show-diff-on-failure
 
 [testenv:docs]
 basepython = python3
@@ -99,38 +97,19 @@ commands =
     sphinx-build -b doctest -d docs/_build/doctrees docs docs/_build/doctest
 
 [testenv:coverage]
-basepython = python3.8
+basepython = python3
 allowlist_externals =
     mkdir
 deps =
-    coverage
+    coverage[toml]
     datetime: DateTime
     -cconstraints.txt
     pytest-cov
-    Sphinx
 setenv =
     COVERAGE_FILE=.coverage
 commands =
     mkdir -p {toxinidir}/parts/htmlcov
-    pytest --cov=src --cov=tests --cov-report= {posargs}
+    pytest --cov=src --cov=tests --cov-report= tests {posargs}
     coverage run -a -m sphinx -b doctest -d {envdir}/.cache/doctrees docs {envdir}/.cache/doctest
-    coverage html --ignore-errors
-    coverage report --ignore-errors --show-missing --fail-under=98.4
-
-[coverage:run]
-branch = True
-source = RestrictedPython
-
-[coverage:report]
-precision = 2
-exclude_lines =
-    pragma: no cover
-    pragma: nocover
-    except ImportError:
-    raise NotImplementedError
-    if __name__ == '__main__':
-    self.fail
-    raise AssertionError
-
-[coverage:html]
-directory = parts/htmlcov
+    coverage html
+    coverage report


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 4217b1b..6f69029 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -17,39 +17,37 @@ jobs:
       fail-fast: false
       matrix:
         os:
-        - ["ubuntu", "ubuntu-20.04"]
+        - ["ubuntu", "ubuntu-latest"]
         - ["windows", "windows-latest"]
         config:
         # [Python version, tox env]
-        - ["3.9",   "release-check"]
-        - ["3.9",   "lint"]
-        - ["3.7",   "py37"]
-        - ["3.8",   "py38"]
-        - ["3.9",   "py39"]
-        - ["3.10",  "py310"]
-        - ["3.11",  "py311"]
-        - ["3.12",  "py312"]
-        - ["3.13.0-alpha - 3.13.0",  "py313"]
-        - ["3.9",   "docs"]
-        - ["3.9",   "coverage"]
-        - ["3.9",   "py39-datetime"]
+        - ["3.11", "release-check"]
+        - ["3.8", "py38"]
+        - ["3.9", "py39"]
+        - ["3.10", "py310"]
+        - ["3.11", "py311"]
+        - ["3.12", "py312"]
+        - ["3.13", "py313"]
+        - ["3.11", "docs"]
+        - ["3.11", "coverage"]
+        - ["3.11", "py311-datetime"]
         exclude:
-          - { os: ["windows", "windows-latest"], config: ["3.9",   "release-check"] }
-          - { os: ["windows", "windows-latest"], config: ["3.9",   "lint"] }
-          - { os: ["windows", "windows-latest"], config: ["3.9",   "docs"] }
-          - { os: ["windows", "windows-latest"], config: ["3.9",   "coverage"] }
+          - { os: ["windows", "windows-latest"], config: ["3.11", "release-check"] }
+          - { os: ["windows", "windows-latest"], config: ["3.11", "docs"] }
+          - { os: ["windows", "windows-latest"], config: ["3.11", "coverage"] }
 
     runs-on: ${{ matrix.os[1] }}
     if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
     name: ${{ matrix.os[0] }}-${{ matrix.config[1] }}
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.config[0] }}
+        allow-prereleases: true
     - name: Pip cache
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: ~/.cache/pip
         key: ${{ runner.os }}-pip-${{ matrix.config[0] }}-${{ hashFiles('setup.*', 'tox.ini') }}
@@ -61,7 +59,11 @@ jobs:
         python -m pip install --upgrade pip
         pip install tox
     - name: Test
+      if: ${{ !startsWith(runner.os, 'Mac') }}
       run: tox -e ${{ matrix.config[1] }}
+    - name: Test (macOS)
+      if: ${{ startsWith(runner.os, 'Mac') }}
+      run: tox -e ${{ matrix.config[1] }}-universal2
     - name: Coverage
       if: matrix.config[1] == 'coverage'
       run: |
diff --git a/tests/test_NamedExpr.py b/tests/test_NamedExpr.py
index 308d0dd..2da22ad 100644
--- a/tests/test_NamedExpr.py
+++ b/tests/test_NamedExpr.py
@@ -4,14 +4,11 @@
 from ast import NodeTransformer
 from ast import parse
 from unittest import TestCase
-from unittest import skipUnless
 
 from RestrictedPython import compile_restricted
 from RestrictedPython import safe_globals
-from RestrictedPython._compat import IS_PY38_OR_GREATER
 
 
-@skipUnless(IS_PY38_OR_GREATER, "Feature available for Python 3.8+")
 class TestNamedExpr(TestCase):
     def test_works(self):
         code, gs = compile_str("if x:= x + 1: True\n")
diff --git a/tests/test_compile.py b/tests/test_compile.py
index 603ad84..c4ea7de 100644
--- a/tests/test_compile.py
+++ b/tests/test_compile.py
@@ -8,7 +8,6 @@
 from RestrictedPython import compile_restricted_eval
 from RestrictedPython import compile_restricted_exec
 from RestrictedPython import compile_restricted_single
-from RestrictedPython._compat import IS_PY38_OR_GREATER
 from RestrictedPython._compat import IS_PY310_OR_GREATER
 from RestrictedPython._compat import IS_PY311_OR_GREATER
 from tests.helper import restricted_eval
@@ -43,10 +42,8 @@ def test_compile__invalid_syntax():
         compile_restricted(INVALID_ASSINGMENT, '<string>', 'exec')
     if IS_PY310_OR_GREATER:
         assert "SyntaxError: cannot assign to literal here." in str(err.value)
-    elif IS_PY38_OR_GREATER:
-        assert "cannot assign to literal at statement:" in str(err.value)
     else:
-        assert "can't assign to literal at statement:" in str(err.value)
+        assert "cannot assign to literal at statement:" in str(err.value)
 
 
 def test_compile__compile_restricted_exec__1():
diff --git a/tests/test_compile_restricted_function.py b/tests/test_compile_restricted_function.py
index d5b89b3..0bf0437 100644
--- a/tests/test_compile_restricted_function.py
+++ b/tests/test_compile_restricted_function.py
@@ -3,7 +3,6 @@
 from RestrictedPython import PrintCollector
 from RestrictedPython import compile_restricted_function
 from RestrictedPython import safe_builtins
-from RestrictedPython._compat import IS_PY38_OR_GREATER
 from RestrictedPython._compat import IS_PY310_OR_GREATER
 
 
@@ -242,11 +241,7 @@ def test_compile_restricted_function_invalid_syntax():
         assert error_msg.startswith(
             "Line 1: SyntaxError: cannot assign to literal here. Maybe "
         )
-    elif IS_PY38_OR_GREATER:
-        assert error_msg.startswith(
-            "Line 1: SyntaxError: cannot assign to literal at statement:"
-        )
     else:
         assert error_msg.startswith(
-            "Line 1: SyntaxError: can't assign to literal at statement:"
+            "Line 1: SyntaxError: cannot assign to literal at statement:"
         )
diff --git a/tests/transformer/test_dict_comprehension.py b/tests/transformer/test_dict_comprehension.py
index 17b8c45..c573510 100644
--- a/tests/transformer/test_dict_comprehension.py
+++ b/tests/transformer/test_dict_comprehension.py
@@ -1,4 +1,3 @@
-from RestrictedPython._compat import IS_PY38_OR_GREATER
 from tests.helper import restricted_exec
 
 
@@ -28,20 +27,11 @@ def test_dict_comprehension_with_attrs(mocker):
 
     calls = [mocker.call(seq, 'z')]
 
-    # Note: Order changed in PEP 572, starting with Python 3.8.
-    if IS_PY38_OR_GREATER:
-        calls.extend([
-            mocker.call(z[0], 'k'),
-            mocker.call(z[1], 'k'),
-            mocker.call(z[1], 'k'),
-            mocker.call(z[1], 'v'),
-        ])
-    else:
-        calls.extend([
-            mocker.call(z[0], 'k'),
-            mocker.call(z[1], 'k'),
-            mocker.call(z[1], 'v'),
-            mocker.call(z[1], 'k'),
-        ])
+    calls.extend([
+        mocker.call(z[0], 'k'),
+        mocker.call(z[1], 'k'),
+        mocker.call(z[1], 'k'),
+        mocker.call(z[1], 'v'),
+    ])
 
     _getattr_.assert_has_calls(calls)
diff --git a/tests/transformer/test_fstring.py b/tests/transformer/test_fstring.py
index 68b1743..2b31c83 100644
--- a/tests/transformer/test_fstring.py
+++ b/tests/transformer/test_fstring.py
@@ -1,7 +1,4 @@
-import pytest
-
 from RestrictedPython import compile_restricted_exec
-from RestrictedPython._compat import IS_PY38_OR_GREATER
 from RestrictedPython.PrintCollector import PrintCollector
 
 
@@ -37,10 +34,6 @@ def test_visit_invalid_variable_name():
 """
 
 
-@pytest.mark.skipif(
-    not IS_PY38_OR_GREATER,
-    reason="f-string self-documenting expressions added in Python 3.8.",
-)
 def test_f_string_self_documenting_expressions():
     """Checks if f-string self-documenting expressions is checked."""
     result = compile_restricted_exec(