diff --git a/.editorconfig b/.editorconfig
index 824b9c3..ce35108 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -2,7 +2,7 @@
 # https://github.com/zopefoundation/meta/tree/master/config/zope-product
 #
 # EditorConfig Configuration file, for more details see:
-# http://EditorConfig.org
+# https://EditorConfig.org
 # EditorConfig is a convention description, that could be interpreted
 # by multiple editors to enforce common coding conventions for specific
 # file types
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
new file mode 100644
index 0000000..cf37dd3
--- /dev/null
+++ b/.github/workflows/pre-commit.yml
@@ -0,0 +1,36 @@
+# Generated from:
+# https://github.com/zopefoundation/meta/tree/master/config/zope-product
+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:
+    permissions:
+      contents: read
+      pull-requests: write
+    name: linting
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v5
+    - uses: actions/setup-python@v6
+      with:
+        python-version: '3.13'
+    - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd  #v3.0.1
+      with:
+        extra_args: --all-files --show-diff-on-failure
+      env:
+        PRE_COMMIT_COLOR: always
+    - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43  #v1.1.0
+      if: always()
+      with:
+        msg: Apply pre-commit code formatting
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 15d6aaf..5a47ddb 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -12,52 +12,52 @@ on:
 
 jobs:
   build:
+    permissions:
+      contents: read
+      pull-requests: write
     strategy:
       # We want to see all failures:
       fail-fast: false
       matrix:
         os:
-        - ["ubuntu", "ubuntu-20.04"]
+        - ["ubuntu", "ubuntu-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.9",   "docs"]
-        - ["3.9",   "coverage"]
+        - ["3.11", "release-check"]
+        - ["3.10", "py310"]
+        - ["3.11", "py311"]
+        - ["3.12", "py312"]
+        - ["3.13", "py313"]
+        - ["3.14", "py314"]
+        - ["3.11", "docs"]
+        - ["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.config[1] }}
     steps:
-    - uses: actions/checkout@v3
-    - name: Set up Python
-      uses: actions/setup-python@v4
+    - uses: actions/checkout@v5
       with:
-        python-version: ${{ matrix.config[0] }}
-    - name: Pip cache
-      uses: actions/cache@v3
+        persist-credentials: false
+    - name: Install uv + caching
+      # astral/setup-uv@7.1.3
+      uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3
       with:
-        path: ~/.cache/pip
-        key: ${{ runner.os }}-pip-${{ matrix.config[0] }}-${{ hashFiles('setup.*', 'tox.ini') }}
-        restore-keys: |
-          ${{ runner.os }}-pip-${{ matrix.config[0] }}-
-          ${{ runner.os }}-pip-
-    - name: Install dependencies
-      run: |
-        python -m pip install --upgrade pip
-        pip install tox
+        enable-cache: true
+        cache-dependency-glob: |
+          setup.*
+          tox.ini
+        python-version: ${{ matrix.config[0] }}
+        github-token: ${{ secrets.GITHUB_TOKEN }}
     - name: Test
-      run: tox -e ${{ matrix.config[1] }}
+      if: ${{ !startsWith(runner.os, 'Mac') }}
+      run: uvx --with tox-uv tox -e ${{ matrix.config[1] }}
+    - name: Test (macOS)
+      if: ${{ startsWith(runner.os, 'Mac') }}
+      run: uvx --with tox-uv tox -e ${{ matrix.config[1] }}-universal2
     - name: Coverage
       if: matrix.config[1] == 'coverage'
       run: |
-        pip install coveralls
-        coveralls --service=github
+        uvx coveralls --service=github
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.meta.toml b/.meta.toml
index 4981527..8e7a8f1 100644
--- a/.meta.toml
+++ b/.meta.toml
@@ -2,7 +2,7 @@
 # https://github.com/zopefoundation/meta/tree/master/config/zope-product
 [meta]
 template = "zope-product"
-commit-id = "acd8d239"
+commit-id = "9fcd3d67"
 
 [python]
 with-windows = false
@@ -26,6 +26,7 @@ additional-rules = [
 [check-manifest]
 additional-ignores = [
     "docs/_build/html/_static/*",
+    "docs/_build/html/_static/scripts/*",
     ]
 
 [coverage]
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..cdeaf6c
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,28 @@
+# Generated from:
+# https://github.com/zopefoundation/meta/tree/master/config/zope-product
+minimum_pre_commit_version: '3.6'
+repos:
+  - repo: https://github.com/pycqa/isort
+    rev: "7.0.0"
+    hooks:
+    - id: isort
+  - repo: https://github.com/hhatto/autopep8
+    rev: "v2.3.2"
+    hooks:
+    - id: autopep8
+      args: [--in-place, --aggressive, --aggressive]
+  - repo: https://github.com/asottile/pyupgrade
+    rev: v3.21.0
+    hooks:
+    - id: pyupgrade
+      args: [--py310-plus]
+  - repo: https://github.com/isidentical/teyit
+    rev: 0.4.3
+    hooks:
+    - id: teyit
+  - repo: https://github.com/PyCQA/flake8
+    rev: "7.3.0"
+    hooks:
+    - id: flake8
+      additional_dependencies:
+        - flake8-debugger == 4.1.2
diff --git a/CHANGES.rst b/CHANGES.rst
index 8aad410..875c00c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,10 @@ Change log
 ----------------
 
 
+- Add support for Python 3.13, 3.14.
+
+- Drop support for Python 3.7, 3.8, 3.9.
+
 3.1 (2024-01-03)
 ----------------
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index da489e0..bbf4194 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,3 +1,7 @@
+<!--
+Generated from:
+https://github.com/zopefoundation/meta/tree/master/config/zope-product
+-->
 # Contributing to dataflake projects
 
 The projects under the dataflake GitHub organization are open source and
diff --git a/MANIFEST.in b/MANIFEST.in
index f3dc11d..d3e6a36 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 b53eb26..3d5d5f6 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -5,14 +5,13 @@
 
 import datetime
 import os
-import pkginfo
 import sys
+from importlib.metadata import distribution
+
 
-parent = os.path.dirname(os.path.dirname(__file__))
-parent_dir = os.path.abspath(parent)
-pkg_info = pkginfo.Develop(parent_dir)
-pkg_version = pkg_info.version or ''
 year = datetime.datetime.now().year
+sys.path.append(os.path.abspath('../src'))
+rqmt = distribution('Products.LocalFS')
 
 # -- Project information -----------------------------------------------------
 # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
@@ -21,10 +20,8 @@
 copyright = '1999 Jonothan Farr and contributors'
 author = 'Jonothan Farr and contributors'
 
-# The short X.Y version.
-version = pkg_version.replace('.dev0', '')
-# The full version, including alpha/beta/rc tags.
-release = pkg_version
+version = '%s.%s' % tuple(map(int, rqmt.version.split('.')[:2]))
+release = rqmt.version
 
 # -- General configuration ---------------------------------------------------
 # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
@@ -37,5 +34,5 @@
 # -- Options for HTML output -------------------------------------------------
 # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
 
-html_theme = 'sphinx_rtd_theme'
+html_theme = 'furo'
 html_static_path = ['_static']
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 59ed542..3ef203c 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,2 +1,2 @@
-sphinx>5
-.[docs]
+sphinx
+furo
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..3e55adc
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,32 @@
+# Generated from:
+# https://github.com/zopefoundation/meta/tree/master/config/zope-product
+
+[build-system]
+requires = [
+    "setuptools >= 78.1.1,< 81",
+    "wheel",
+]
+build-backend = "setuptools.build_meta"
+
+[tool.coverage.run]
+branch = true
+source = ["Products.LocalFS"]
+
+[tool.coverage.report]
+fail_under = 80
+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 26f5bc7..d5c2915 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,5 @@
 # Generated from:
 # https://github.com/zopefoundation/meta/tree/master/config/zope-product
-[bdist_wheel]
-universal = 0
 
 [flake8]
 doctests = 1
@@ -14,6 +12,7 @@ ignore =
     .meta.toml
     docs/_build/html/_sources/*
     docs/_build/html/_static/*
+    docs/_build/html/_static/scripts/*
 
 [isort]
 force_single_line = True
diff --git a/setup.py b/setup.py
index 79106c8..858155b 100644
--- a/setup.py
+++ b/setup.py
@@ -13,7 +13,6 @@
 
 import os
 
-from setuptools import find_packages
 from setuptools import setup
 
 
@@ -32,23 +31,22 @@ def _read(name):
       description='The Local File System product',
       long_description=_read('README.rst'),
       classifiers=[
-        'Development Status :: 6 - Mature',
-        'Environment :: Web Environment',
-        'Framework :: Zope',
-        'Framework :: Zope :: 5',
-        'Intended Audience :: Developers',
-        'License :: OSI Approved :: BSD License',
-        'Operating System :: OS Independent',
-        'Programming Language :: Python',
-        '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',
-        'Topic :: Internet :: WWW/HTTP :: Site Management',
-        ],
+          'Development Status :: 6 - Mature',
+          'Environment :: Web Environment',
+          'Framework :: Zope',
+          'Framework :: Zope :: 5',
+          'Intended Audience :: Developers',
+          'License :: OSI Approved :: BSD License',
+          'Operating System :: OS Independent',
+          'Programming Language :: Python',
+          'Programming Language :: Python :: 3',
+          'Programming Language :: Python :: 3.10',
+          'Programming Language :: Python :: 3.11',
+          'Programming Language :: Python :: 3.12',
+          'Programming Language :: Python :: 3.13',
+          'Programming Language :: Python :: 3.14',
+          'Topic :: Internet :: WWW/HTTP :: Site Management',
+      ],
       keywords='web application server zope',
       author='Jonothan Farr and contributors',
       author_email='jfarr@speakeasy.org',
@@ -61,18 +59,12 @@ def _read(name):
                             'Products.LocalFS/issues'),
           'Sources': 'https://github.com/dataflake/Products.LocalFS',
       },
-      packages=find_packages('src'),
-      include_package_data=True,
-      namespace_packages=['Products'],
-      package_dir={'': 'src'},
-      zip_safe=False,
-      python_requires='>=3.7',
+      python_requires='>=3.10',
       install_requires=[
-        'setuptools',
-        'Zope >= 5',
-        'Products.PythonScripts',
-        ],
+          'Zope',
+          'Products.PythonScripts',
+      ],
       extras_require={
-          'docs': ['Sphinx', 'sphinx_rtd_theme', 'pkginfo'],
-        },
+          'docs': ['Sphinx', 'furo'],
+      },
       )
diff --git a/src/Products/LocalFS/LocalFS.py b/src/Products/LocalFS/LocalFS.py
index 7fe22c5..7a8c9b0 100644
--- a/src/Products/LocalFS/LocalFS.py
+++ b/src/Products/LocalFS/LocalFS.py
@@ -194,7 +194,7 @@ class LocalDirectory(OFS.CopySupport.CopyContainer, App.Management.Navigation,
         {'label': 'Contents', 'action': 'manage_main'},
         {'label': 'Upload', 'action': 'manage_uploadForm'},
         {'label': 'View', 'action': ''},
-        )
+    )
 
     icon = 'misc_/OFSP/Folder_icon.gif'
 
@@ -396,7 +396,7 @@ def _write_file(self, file, path):
                 outfile.write(file)
                 outfile.close()
             else:
-                blocksize = 8*1024
+                blocksize = 8 * 1024
                 outfile = open(path, 'wb')
                 data = file.read(blocksize)
                 while data:
@@ -455,16 +455,16 @@ def manage_upload(self, file, id='', action='manage_workspace',
             # First check for a UNIX full path. There will be no UNIX path
             # separators in a Windows path.
             if '/' in filename:
-                id = filename[filename.rfind('/')+1:]
+                id = filename[filename.rfind('/') + 1:]
             # Next check for Window separators. If there are no UNIX path
             # separators then it's probably a Windows path and not a random
             # relative UNIX path which happens to have backslashes in it.
             # Lets hope this never happens, anyway. ;)
             elif '\\' in filename:
-                id = filename[filename.rfind('\\')+1:]
+                id = filename[filename.rfind('\\') + 1:]
             # Not sure if we'll ever get a Mac path, but here goes...
             elif ':' in filename:
-                id = filename[filename.rfind(':')+1:]
+                id = filename[filename.rfind(':') + 1:]
             # Else we have a filename with no path components so let's use
             # that for the id.
             else:
@@ -497,7 +497,7 @@ def manage_overwrite(self, file, path, REQUEST=None):
         if user is None or \
            not user.has_permission('Overwrite local files', self):
             raise Unauthorized(
-                    'Sorry, you are not authorized to overwrite files.')
+                'Sorry, you are not authorized to overwrite files.')
         self._write_file(file, path)
 
     def manage_renameObject(self, id, new_id, REQUEST=None):
@@ -1054,7 +1054,7 @@ class LocalFS(LocalDirectory, OFS.PropertyManager.PropertyManager,
              'manage_renameForm', 'manage_renameObject',
              'manage_createDirectory')),
         ('Delete local files', ('manage_delObjects',)),
-        )
+    )
 
     _properties = (
         {'id': 'title', 'type': 'string', 'mode': 'w'},
@@ -1064,7 +1064,7 @@ class LocalFS(LocalDirectory, OFS.PropertyManager.PropertyManager,
         _properties = _properties + (
             {'id': 'username', 'type': 'string', 'mode': 'w'},
             {'id': 'password', 'type': 'string', 'mode': 'w'},
-            )
+        )
     _properties = _properties + (
         {'id': 'default_document', 'type': 'string', 'mode': 'w'},
         {'id': 'type_map', 'type': 'lines', 'mode': 'w'},
diff --git a/src/Products/LocalFS/StreamingFile.py b/src/Products/LocalFS/StreamingFile.py
index 2e419c0..b9ef3bc 100644
--- a/src/Products/LocalFS/StreamingFile.py
+++ b/src/Products/LocalFS/StreamingFile.py
@@ -104,7 +104,7 @@ def __getitem__(self, key):
             if key > size:
                 return b''
 
-            self.file.seek(self.offset+key, 0)
+            self.file.seek(self.offset + key, 0)
             return self.file.read(1)
 
     @property
diff --git a/src/Products/LocalFS/__init__.py b/src/Products/LocalFS/__init__.py
index 95dede0..5b93c49 100644
--- a/src/Products/LocalFS/__init__.py
+++ b/src/Products/LocalFS/__init__.py
@@ -58,4 +58,4 @@ def initialize(context):
         constructors=(LocalFS.manage_addLocalFSForm,
                       LocalFS.manage_addLocalFS),
         icon='www/fs.gif',
-        )
+    )
diff --git a/src/Products/LocalFS/tests/test_StreamingFile.py b/src/Products/LocalFS/tests/test_StreamingFile.py
index 96c0bf4..b2c586f 100644
--- a/src/Products/LocalFS/tests/test_StreamingFile.py
+++ b/src/Products/LocalFS/tests/test_StreamingFile.py
@@ -50,7 +50,7 @@ class StreamingFileTests(unittest.TestCase):
     def setUp(self):
         self.tempfile = tempfile.NamedTemporaryFile(dir=LOCALFS_ROOT,
                                                     delete=True)
-        self.tempfile.write(b'.'*40000)
+        self.tempfile.write(b'.' * 40000)
 
     def tearDown(self):
         self.tempfile.close()
@@ -89,7 +89,7 @@ def test_read(self):
         self.assertEqual(bytes(stream_data), data)
 
         # Read a single byte
-        self.assertEqual(stream_data[BUFFER_SIZE-1], b'.')
+        self.assertEqual(stream_data[BUFFER_SIZE - 1], b'.')
 
         # Try to read beyond chunk size
-        self.assertEqual(stream_data[BUFFER_SIZE+1], b'')
+        self.assertEqual(stream_data[BUFFER_SIZE + 1], b'')
diff --git a/src/Products/LocalFS/utils.py b/src/Products/LocalFS/utils.py
index 90c0462..ebd2791 100644
--- a/src/Products/LocalFS/utils.py
+++ b/src/Products/LocalFS/utils.py
@@ -212,7 +212,7 @@ def _create_builtin_ob(c, id, file, path):
 def _create_ob_from_function(c, id, file, path):
     try:
         i = c.rindex('.')
-        m, c = c[:i], c[i+1:]
+        m, c = c[:i], c[i + 1:]
         m = __import__(m, globals(), locals(), (c,))
         c = getattr(m, c)
         f = getattr(c, 'createSelf').__func__
@@ -225,7 +225,7 @@ def _create_ob_from_function(c, id, file, path):
 def _create_ob_from_factory(c, id, file, path):
     try:
         i = c.rindex('.')
-        m, c = c[:i], c[i+1:]
+        m, c = c[:i], c[i + 1:]
         c = getObject(m, c)
         f = c()
         ob = _wrap_ob(f(id, file), path)
diff --git a/src/Products/__init__.py b/src/Products/__init__.py
deleted file mode 100644
index de40ea7..0000000
--- a/src/Products/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-__import__('pkg_resources').declare_namespace(__name__)
diff --git a/tox.ini b/tox.ini
index 12e9d69..d86805f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,32 +5,41 @@ minversion = 3.18
 envlist =
     release-check
     lint
-    py37
-    py38
-    py39
     py310
     py311
     py312
+    py313
+    py314
     docs
     coverage
 
 [testenv]
 skip_install = true
 deps =
-    zc.buildout >= 3.0.1
-    wheel > 0.37
+    setuptools >= 78.1.1,< 81
+    zc.buildout
+    wheel
 setenv =
-    py312: VIRTUALENV_PIP=23.1.2
-    py312: PIP_REQUIRE_VIRTUALENV=0
 commands_pre =
     {envbindir}/buildout -nc {toxinidir}/buildout.cfg buildout:directory={envdir} buildout:develop={toxinidir} install test
 commands =
     {envbindir}/test {posargs:-cv}
+
+[testenv:setuptools-latest]
+basepython = python3
+deps =
+    git+https://github.com/pypa/setuptools.git\#egg=setuptools
+    zc.buildout
+    wheel
+
+
 [testenv:release-check]
 description = ensure that the distribution is ready to release
 basepython = python3
 skip_install = true
 deps =
+    setuptools >= 78.1.1,< 81
+    wheel
     twine
     build
     check-manifest
@@ -39,34 +48,19 @@ deps =
 commands_pre =
 commands =
     check-manifest
-    check-python-versions
+    check-python-versions --only pyproject.toml,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
-commands_pre =
-    mkdir -p {toxinidir}/parts/flake8
-allowlist_externals =
-    mkdir
-commands =
-    isort --check-only --diff {toxinidir}/src {toxinidir}/setup.py
-    flake8 {toxinidir}/src {toxinidir}/setup.py
+skip_install = true
 deps =
-    flake8
-    isort
-    # Useful flake8 plugins that are Python and Plone specific:
-    flake8-coding
-    flake8-debugger
-    mccabe
-
-[testenv:isort-apply]
-basepython = python3
+    pre-commit
 commands_pre =
-deps =
-    isort
 commands =
-    isort {toxinidir}/src {toxinidir}/setup.py []
+    pre-commit run --all-files --show-diff-on-failure
 
 [testenv:docs]
 basepython = python3
@@ -85,27 +79,9 @@ allowlist_externals =
     mkdir
 deps =
     {[testenv]deps}
-    coverage
+    coverage[toml]
 commands =
     mkdir -p {toxinidir}/parts/htmlcov
     coverage run {envbindir}/test {posargs:-cv}
     coverage html
-    coverage report -m --fail-under=80
-
-[coverage:run]
-branch = True
-source = Products.LocalFS
-
-[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 report
