diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a96908e97..6c6554a54 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -19,6 +19,6 @@ repos:
         exclude: (tests/messages/data/)
       - id: name-tests-test
         args: [ '--django' ]
-        exclude: (tests/messages/data/)
+        exclude: (tests/messages/data/|.*(consts|utils).py)
       - id: requirements-txt-fixer
       - id: trailing-whitespace
diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py
index 0008a9b84..4d61f0163 100644
--- a/babel/messages/frontend.py
+++ b/babel/messages/frontend.py
@@ -40,22 +40,17 @@
 
 log = logging.getLogger('babel')
 
-try:
-    # See: https://setuptools.pypa.io/en/latest/deprecated/distutils-legacy.html
-    from setuptools import Command as _Command
-    distutils_log = log  # "distutils.log → (no replacement yet)"
 
-    try:
-        from setuptools.errors import BaseError, OptionError, SetupError
-    except ImportError:  # Error aliases only added in setuptools 59 (2021-11).
-        OptionError = SetupError = BaseError = Exception
+class BaseError(Exception):
+    pass
 
-except ImportError:
-    from distutils import log as distutils_log
-    from distutils.cmd import Command as _Command
-    from distutils.errors import DistutilsError as BaseError
-    from distutils.errors import DistutilsOptionError as OptionError
-    from distutils.errors import DistutilsSetupError as SetupError
+
+class OptionError(BaseError):
+    pass
+
+
+class SetupError(BaseError):
+    pass
 
 
 def listify_value(arg, split=None):
@@ -100,7 +95,7 @@ def listify_value(arg, split=None):
     return out
 
 
-class Command(_Command):
+class CommandMixin:
     # This class is a small shim between Distutils commands and
     # optparse option parsing in the frontend command line.
 
@@ -128,7 +123,7 @@ class Command(_Command):
     option_choices = {}
 
     #: Log object. To allow replacement in the script command line runner.
-    log = distutils_log
+    log = log
 
     def __init__(self, dist=None):
         # A less strict version of distutils' `__init__`.
@@ -140,24 +135,21 @@ def __init__(self, dist=None):
         self.help = 0
         self.finalized = 0
 
+    def initialize_options(self):
+        pass
 
-class compile_catalog(Command):
-    """Catalog compilation command for use in ``setup.py`` scripts.
-
-    If correctly installed, this command is available to Setuptools-using
-    setup scripts automatically. For projects using plain old ``distutils``,
-    the command needs to be registered explicitly in ``setup.py``::
-
-        from babel.messages.frontend import compile_catalog
+    def ensure_finalized(self):
+        if not self.finalized:
+            self.finalize_options()
+        self.finalized = 1
 
-        setup(
-            ...
-            cmdclass = {'compile_catalog': compile_catalog}
+    def finalize_options(self):
+        raise RuntimeError(
+            f"abstract method -- subclass {self.__class__} must override",
         )
 
-    .. versionadded:: 0.9
-    """
 
+class CompileCatalog(CommandMixin):
     description = 'compile message catalogs to binary MO files'
     user_options = [
         ('domain=', 'D',
@@ -280,6 +272,7 @@ def _make_directory_filter(ignore_patterns):
     """
     Build a directory_filter function based on a list of ignore patterns.
     """
+
     def cli_directory_filter(dirname):
         basename = os.path.basename(dirname)
         return not any(
@@ -287,24 +280,11 @@ def cli_directory_filter(dirname):
             for ignore_pattern
             in ignore_patterns
         )
-    return cli_directory_filter
-
 
-class extract_messages(Command):
-    """Message extraction command for use in ``setup.py`` scripts.
-
-    If correctly installed, this command is available to Setuptools-using
-    setup scripts automatically. For projects using plain old ``distutils``,
-    the command needs to be registered explicitly in ``setup.py``::
-
-        from babel.messages.frontend import extract_messages
+    return cli_directory_filter
 
-        setup(
-            ...
-            cmdclass = {'extract_messages': extract_messages}
-        )
-    """
 
+class ExtractMessages(CommandMixin):
     description = 'extract localizable strings from the project code'
     user_options = [
         ('charset=', None,
@@ -497,6 +477,7 @@ def callback(filename: str, method: str, options: dict):
                 opt_values = ", ".join(f'{k}="{v}"' for k, v in options.items())
                 optstr = f" ({opt_values})"
             self.log.info('extracting messages from %s%s', filepath, optstr)
+
         return callback
 
     def run(self):
@@ -572,38 +553,7 @@ def _get_mappings(self):
         return mappings
 
 
-def check_message_extractors(dist, name, value):
-    """Validate the ``message_extractors`` keyword argument to ``setup()``.
-
-    :param dist: the distutils/setuptools ``Distribution`` object
-    :param name: the name of the keyword argument (should always be
-                 "message_extractors")
-    :param value: the value of the keyword argument
-    :raise `DistutilsSetupError`: if the value is not valid
-    """
-    assert name == 'message_extractors'
-    if not isinstance(value, dict):
-        raise SetupError(
-            'the value of the "message_extractors" '
-            'parameter must be a dictionary'
-        )
-
-
-class init_catalog(Command):
-    """New catalog initialization command for use in ``setup.py`` scripts.
-
-    If correctly installed, this command is available to Setuptools-using
-    setup scripts automatically. For projects using plain old ``distutils``,
-    the command needs to be registered explicitly in ``setup.py``::
-
-        from babel.messages.frontend import init_catalog
-
-        setup(
-            ...
-            cmdclass = {'init_catalog': init_catalog}
-        )
-    """
-
+class InitCatalog(CommandMixin):
     description = 'create a new catalog based on a POT file'
     user_options = [
         ('domain=', 'D',
@@ -678,23 +628,7 @@ def run(self):
             write_po(outfile, catalog, width=self.width)
 
 
-class update_catalog(Command):
-    """Catalog merging command for use in ``setup.py`` scripts.
-
-    If correctly installed, this command is available to Setuptools-using
-    setup scripts automatically. For projects using plain old ``distutils``,
-    the command needs to be registered explicitly in ``setup.py``::
-
-        from babel.messages.frontend import update_catalog
-
-        setup(
-            ...
-            cmdclass = {'update_catalog': update_catalog}
-        )
-
-    .. versionadded:: 0.9
-    """
-
+class UpdateCatalog(CommandMixin):
     description = 'update message catalogs from a POT file'
     user_options = [
         ('domain=', 'D',
@@ -911,10 +845,10 @@ class CommandLineInterface:
     }
 
     command_classes = {
-        'compile': compile_catalog,
-        'extract': extract_messages,
-        'init': init_catalog,
-        'update': update_catalog,
+        'compile': CompileCatalog,
+        'extract': ExtractMessages,
+        'init': InitCatalog,
+        'update': UpdateCatalog,
     }
 
     log = None  # Replaced on instance level
@@ -996,7 +930,7 @@ def _configure_command(self, cmdname, argv):
         cmdinst = cmdclass()
         if self.log:
             cmdinst.log = self.log  # Use our logger, not distutils'.
-        assert isinstance(cmdinst, Command)
+        assert isinstance(cmdinst, CommandMixin)
         cmdinst.initialize_options()
 
         parser = optparse.OptionParser(
@@ -1113,7 +1047,8 @@ def parse_mapping(fileobj, filename=None):
 
     return method_map, options_map
 
-def _parse_spec(s: str) -> tuple[int | None, tuple[int|tuple[int, str], ...]]:
+
+def _parse_spec(s: str) -> tuple[int | None, tuple[int | tuple[int, str], ...]]:
     inds = []
     number = None
     for x in s.split(','):
@@ -1125,6 +1060,7 @@ def _parse_spec(s: str) -> tuple[int | None, tuple[int|tuple[int, str], ...]]:
             inds.append(int(x))
     return number, tuple(inds)
 
+
 def parse_keywords(strings: Iterable[str] = ()):
     """Parse keywords specifications from the given list of strings.
 
@@ -1173,5 +1109,16 @@ def parse_keywords(strings: Iterable[str] = ()):
     return keywords
 
 
+def __getattr__(name: str):
+    # Re-exports for backwards compatibility;
+    # `setuptools_frontend` is the canonical import location.
+    if name in {'check_message_extractors', 'compile_catalog', 'extract_messages', 'init_catalog', 'update_catalog'}:
+        from babel.messages import setuptools_frontend
+
+        return getattr(setuptools_frontend, name)
+
+    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+
 if __name__ == '__main__':
     main()
diff --git a/babel/messages/setuptools_frontend.py b/babel/messages/setuptools_frontend.py
new file mode 100644
index 000000000..2f23fc182
--- /dev/null
+++ b/babel/messages/setuptools_frontend.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+from babel.messages import frontend
+
+try:
+    # See: https://setuptools.pypa.io/en/latest/deprecated/distutils-legacy.html
+    from setuptools import Command
+
+    try:
+        from setuptools.errors import BaseError, OptionError, SetupError
+    except ImportError:  # Error aliases only added in setuptools 59 (2021-11).
+        OptionError = SetupError = BaseError = Exception
+
+except ImportError:
+    from distutils.cmd import Command
+    from distutils.errors import DistutilsSetupError as SetupError
+
+
+def check_message_extractors(dist, name, value):
+    """Validate the ``message_extractors`` keyword argument to ``setup()``.
+
+    :param dist: the distutils/setuptools ``Distribution`` object
+    :param name: the name of the keyword argument (should always be
+                 "message_extractors")
+    :param value: the value of the keyword argument
+    :raise `DistutilsSetupError`: if the value is not valid
+    """
+    assert name == "message_extractors"
+    if not isinstance(value, dict):
+        raise SetupError(
+            'the value of the "message_extractors" parameter must be a dictionary'
+        )
+
+
+class compile_catalog(frontend.CompileCatalog, Command):
+    """Catalog compilation command for use in ``setup.py`` scripts.
+
+    If correctly installed, this command is available to Setuptools-using
+    setup scripts automatically. For projects using plain old ``distutils``,
+    the command needs to be registered explicitly in ``setup.py``::
+
+        from babel.messages.setuptools_frontend import compile_catalog
+
+        setup(
+            ...
+            cmdclass = {'compile_catalog': compile_catalog}
+        )
+
+    .. versionadded:: 0.9
+    """
+
+
+class extract_messages(frontend.ExtractMessages, Command):
+    """Message extraction command for use in ``setup.py`` scripts.
+
+    If correctly installed, this command is available to Setuptools-using
+    setup scripts automatically. For projects using plain old ``distutils``,
+    the command needs to be registered explicitly in ``setup.py``::
+
+        from babel.messages.setuptools_frontend import extract_messages
+
+        setup(
+            ...
+            cmdclass = {'extract_messages': extract_messages}
+        )
+    """
+
+
+class init_catalog(frontend.InitCatalog, Command):
+    """New catalog initialization command for use in ``setup.py`` scripts.
+
+    If correctly installed, this command is available to Setuptools-using
+    setup scripts automatically. For projects using plain old ``distutils``,
+    the command needs to be registered explicitly in ``setup.py``::
+
+        from babel.messages.setuptools_frontend import init_catalog
+
+        setup(
+            ...
+            cmdclass = {'init_catalog': init_catalog}
+        )
+    """
+
+
+class update_catalog(frontend.UpdateCatalog, Command):
+    """Catalog merging command for use in ``setup.py`` scripts.
+
+    If correctly installed, this command is available to Setuptools-using
+    setup scripts automatically. For projects using plain old ``distutils``,
+    the command needs to be registered explicitly in ``setup.py``::
+
+        from babel.messages.setuptools_frontend import update_catalog
+
+        setup(
+            ...
+            cmdclass = {'update_catalog': update_catalog}
+        )
+
+    .. versionadded:: 0.9
+    """
+
+
+COMMANDS = {
+    "compile_catalog": compile_catalog,
+    "extract_messages": extract_messages,
+    "init_catalog": init_catalog,
+    "update_catalog": update_catalog,
+}
diff --git a/conftest.py b/conftest.py
index 3982cef4e..79aeecf81 100644
--- a/conftest.py
+++ b/conftest.py
@@ -2,7 +2,11 @@
 
 from _pytest.doctest import DoctestModule
 
-collect_ignore = ['tests/messages/data', 'setup.py']
+collect_ignore = [
+    'babel/messages/setuptools_frontend.py',
+    'setup.py',
+    'tests/messages/data',
+]
 babel_path = Path(__file__).parent / 'babel'
 
 
diff --git a/setup.py b/setup.py
index 6d43080d2..5233af1c8 100755
--- a/setup.py
+++ b/setup.py
@@ -67,9 +67,6 @@ def run(self):
         # higher.
         # Python 3.9 and later include zoneinfo which replaces pytz
         'pytz>=2015.7; python_version<"3.9"',
-        # https://github.com/python/cpython/issues/95299
-        # https://github.com/python-babel/babel/issues/1031
-        'setuptools; python_version>="3.12"',
     ],
     extras_require={
         'dev': [
@@ -89,13 +86,13 @@ def run(self):
     pybabel = babel.messages.frontend:main
 
     [distutils.commands]
-    compile_catalog = babel.messages.frontend:compile_catalog
-    extract_messages = babel.messages.frontend:extract_messages
-    init_catalog = babel.messages.frontend:init_catalog
-    update_catalog = babel.messages.frontend:update_catalog
+    compile_catalog = babel.messages.setuptools_frontend:compile_catalog
+    extract_messages = babel.messages.setuptools_frontend:extract_messages
+    init_catalog = babel.messages.setuptools_frontend:init_catalog
+    update_catalog = babel.messages.setuptools_frontend:update_catalog
 
     [distutils.setup_keywords]
-    message_extractors = babel.messages.frontend:check_message_extractors
+    message_extractors = babel.messages.setuptools_frontend:check_message_extractors
 
     [babel.checkers]
     num_plurals = babel.messages.checkers:num_plurals
diff --git a/tests/messages/consts.py b/tests/messages/consts.py
new file mode 100644
index 000000000..34509b304
--- /dev/null
+++ b/tests/messages/consts.py
@@ -0,0 +1,12 @@
+import os
+
+TEST_PROJECT_DISTRIBUTION_DATA = {
+    "name": "TestProject",
+    "version": "0.1",
+    "packages": ["project"],
+}
+this_dir = os.path.abspath(os.path.dirname(__file__))
+data_dir = os.path.join(this_dir, 'data')
+project_dir = os.path.join(data_dir, 'project')
+i18n_dir = os.path.join(project_dir, 'i18n')
+pot_file = os.path.join(i18n_dir, 'temp.pot')
diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py
index b28cb0da2..a5f436305 100644
--- a/tests/messages/test_frontend.py
+++ b/tests/messages/test_frontend.py
@@ -18,10 +18,10 @@
 import unittest
 from datetime import datetime, timedelta
 from io import BytesIO, StringIO
+from typing import List
 
 import pytest
 from freezegun import freeze_time
-from setuptools import Distribution
 
 from babel import __version__ as VERSION
 from babel.dates import format_datetime
@@ -29,30 +29,41 @@
 from babel.messages.frontend import (
     BaseError,
     CommandLineInterface,
+    ExtractMessages,
     OptionError,
-    extract_messages,
-    update_catalog,
+    UpdateCatalog,
 )
 from babel.messages.pofile import read_po, write_po
 from babel.util import LOCALTZ
-
-TEST_PROJECT_DISTRIBUTION_DATA = {
-    "name": "TestProject",
-    "version": "0.1",
-    "packages": ["project"],
-}
-
-this_dir = os.path.abspath(os.path.dirname(__file__))
-data_dir = os.path.join(this_dir, 'data')
-project_dir = os.path.join(data_dir, 'project')
-i18n_dir = os.path.join(project_dir, 'i18n')
-pot_file = os.path.join(i18n_dir, 'temp.pot')
+from tests.messages.consts import (
+    TEST_PROJECT_DISTRIBUTION_DATA,
+    data_dir,
+    i18n_dir,
+    pot_file,
+    project_dir,
+    this_dir,
+)
 
 
 def _po_file(locale):
     return os.path.join(i18n_dir, locale, 'LC_MESSAGES', 'messages.po')
 
 
+class Distribution:  # subset of distutils.dist.Distribution
+    def __init__(self, attrs: dict) -> None:
+        self.attrs = attrs
+
+    def get_name(self) -> str:
+        return self.attrs['name']
+
+    def get_version(self) -> str:
+        return self.attrs['version']
+
+    @property
+    def packages(self) -> List[str]:
+        return self.attrs['packages']
+
+
 class CompileCatalogTestCase(unittest.TestCase):
 
     def setUp(self):
@@ -60,7 +71,7 @@ def setUp(self):
         os.chdir(data_dir)
 
         self.dist = Distribution(TEST_PROJECT_DISTRIBUTION_DATA)
-        self.cmd = frontend.compile_catalog(self.dist)
+        self.cmd = frontend.CompileCatalog(self.dist)
         self.cmd.initialize_options()
 
     def tearDown(self):
@@ -86,7 +97,7 @@ def setUp(self):
         os.chdir(data_dir)
 
         self.dist = Distribution(TEST_PROJECT_DISTRIBUTION_DATA)
-        self.cmd = frontend.extract_messages(self.dist)
+        self.cmd = frontend.ExtractMessages(self.dist)
         self.cmd.initialize_options()
 
     def tearDown(self):
@@ -355,7 +366,7 @@ def setUp(self):
         os.chdir(data_dir)
 
         self.dist = Distribution(TEST_PROJECT_DISTRIBUTION_DATA)
-        self.cmd = frontend.init_catalog(self.dist)
+        self.cmd = frontend.InitCatalog(self.dist)
         self.cmd.initialize_options()
 
     def tearDown(self):
@@ -1433,6 +1444,7 @@ def test_parse_keywords_with_t():
         }
     }
 
+
 def test_extract_messages_with_t():
     content = rb"""
 _("1 arg, arg 1")
@@ -1464,27 +1476,6 @@ def configure_cli_command(cmdline):
     return cmdinst
 
 
-def configure_distutils_command(cmdline):
-    """
-    Helper to configure a command class, but not run it just yet.
-
-    This will have strange side effects if you pass in things
-    `distutils` deals with internally.
-
-    :param cmdline: The command line (sans the executable name)
-    :return: Command instance
-    """
-    d = Distribution(attrs={
-        "cmdclass": vars(frontend),
-        "script_args": shlex.split(cmdline),
-    })
-    d.parse_command_line()
-    assert len(d.commands) == 1
-    cmdinst = d.get_command_obj(d.commands[0])
-    cmdinst.ensure_finalized()
-    return cmdinst
-
-
 @pytest.mark.parametrize("split", (False, True))
 @pytest.mark.parametrize("arg_name", ("-k", "--keyword", "--keywords"))
 def test_extract_keyword_args_384(split, arg_name):
@@ -1515,7 +1506,7 @@ def test_extract_keyword_args_384(split, arg_name):
     cmdinst = configure_cli_command(
         f"extract -F babel-django.cfg --add-comments Translators: -o django232.pot {kwarg_text} ."
     )
-    assert isinstance(cmdinst, extract_messages)
+    assert isinstance(cmdinst, ExtractMessages)
     assert set(cmdinst.keywords.keys()) == {'_', 'dgettext', 'dngettext',
                                             'gettext', 'gettext_lazy',
                                             'gettext_noop', 'N_', 'ngettext',
@@ -1526,31 +1517,10 @@ def test_extract_keyword_args_384(split, arg_name):
                                             'ungettext', 'ungettext_lazy'}
 
 
-@pytest.mark.parametrize("kwarg,expected", [
-    ("LW_", ("LW_",)),
-    ("LW_ QQ Q", ("LW_", "QQ", "Q")),
-    ("yiy         aia", ("yiy", "aia")),
-])
-def test_extract_distutils_keyword_arg_388(kwarg, expected):
-    # This is a regression test for https://github.com/python-babel/babel/issues/388
-
-    # Note that distutils-based commands only support a single repetition of the same argument;
-    # hence `--keyword ignored` will actually never end up in the output.
-
-    cmdinst = configure_distutils_command(
-        "extract_messages --no-default-keywords --keyword ignored --keyword '%s' "
-        "--input-dirs . --output-file django233.pot --add-comments Bar,Foo" % kwarg
-    )
-    assert isinstance(cmdinst, extract_messages)
-    assert set(cmdinst.keywords.keys()) == set(expected)
-
-    # Test the comma-separated comment argument while we're at it:
-    assert set(cmdinst.add_comments) == {"Bar", "Foo"}
-
-
 def test_update_catalog_boolean_args():
-    cmdinst = configure_cli_command("update --init-missing --no-wrap -N --ignore-obsolete --previous -i foo -o foo -l en")
-    assert isinstance(cmdinst, update_catalog)
+    cmdinst = configure_cli_command(
+        "update --init-missing --no-wrap -N --ignore-obsolete --previous -i foo -o foo -l en")
+    assert isinstance(cmdinst, UpdateCatalog)
     assert cmdinst.init_missing is True
     assert cmdinst.no_wrap is True
     assert cmdinst.no_fuzzy_matching is True
@@ -1561,25 +1531,25 @@ def test_update_catalog_boolean_args():
 def test_extract_cli_knows_dash_s():
     # This is a regression test for https://github.com/python-babel/babel/issues/390
     cmdinst = configure_cli_command("extract -s -o foo babel")
-    assert isinstance(cmdinst, extract_messages)
+    assert isinstance(cmdinst, ExtractMessages)
     assert cmdinst.strip_comments
 
 
 def test_extract_add_location():
     cmdinst = configure_cli_command("extract -o foo babel --add-location full")
-    assert isinstance(cmdinst, extract_messages)
+    assert isinstance(cmdinst, ExtractMessages)
     assert cmdinst.add_location == 'full'
     assert not cmdinst.no_location
     assert cmdinst.include_lineno
 
     cmdinst = configure_cli_command("extract -o foo babel --add-location file")
-    assert isinstance(cmdinst, extract_messages)
+    assert isinstance(cmdinst, ExtractMessages)
     assert cmdinst.add_location == 'file'
     assert not cmdinst.no_location
     assert not cmdinst.include_lineno
 
     cmdinst = configure_cli_command("extract -o foo babel --add-location never")
-    assert isinstance(cmdinst, extract_messages)
+    assert isinstance(cmdinst, ExtractMessages)
     assert cmdinst.add_location == 'never'
     assert cmdinst.no_location
 
@@ -1603,7 +1573,7 @@ def test_extract_ignore_dirs(monkeypatch, capsys, tmp_path, with_underscore_igno
         # This also tests that multiple arguments are supported.
         cmd += "--ignore-dirs '_*'"
     cmdinst = configure_cli_command(cmd)
-    assert isinstance(cmdinst, extract_messages)
+    assert isinstance(cmdinst, ExtractMessages)
     assert cmdinst.directory_filter
     cmdinst.run()
     pot_content = pot_file.read_text()
diff --git a/tests/messages/test_setuptools_frontend.py b/tests/messages/test_setuptools_frontend.py
new file mode 100644
index 000000000..825d214f2
--- /dev/null
+++ b/tests/messages/test_setuptools_frontend.py
@@ -0,0 +1,102 @@
+import os
+import shlex
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+from tests.messages.consts import data_dir
+
+Distribution = pytest.importorskip("setuptools").Distribution
+
+
+@pytest.mark.parametrize("kwarg,expected", [
+    ("LW_", ("LW_",)),
+    ("LW_ QQ Q", ("LW_", "QQ", "Q")),
+    ("yiy         aia", ("yiy", "aia")),
+])
+def test_extract_distutils_keyword_arg_388(kwarg, expected):
+    from babel.messages import frontend, setuptools_frontend
+
+    # This is a regression test for https://github.com/python-babel/babel/issues/388
+
+    # Note that distutils-based commands only support a single repetition of the same argument;
+    # hence `--keyword ignored` will actually never end up in the output.
+
+    cmdline = (
+        "extract_messages --no-default-keywords --keyword ignored --keyword '%s' "
+        "--input-dirs . --output-file django233.pot --add-comments Bar,Foo" % kwarg
+    )
+    d = Distribution(attrs={
+        "cmdclass": setuptools_frontend.COMMANDS,
+        "script_args": shlex.split(cmdline),
+    })
+    d.parse_command_line()
+    assert len(d.commands) == 1
+    cmdinst = d.get_command_obj(d.commands[0])
+    cmdinst.ensure_finalized()
+    assert isinstance(cmdinst, frontend.ExtractMessages)
+    assert isinstance(cmdinst, setuptools_frontend.extract_messages)
+    assert set(cmdinst.keywords.keys()) == set(expected)
+
+    # Test the comma-separated comment argument while we're at it:
+    assert set(cmdinst.add_comments) == {"Bar", "Foo"}
+
+
+def test_setuptools_commands(tmp_path, monkeypatch):
+    """
+    Smoke-tests all of the setuptools versions of the commands in turn.
+
+    Their full functionality is tested better in `test_frontend.py`.
+    """
+    # Copy the test project to a temporary directory and work there
+    dest = tmp_path / "dest"
+    shutil.copytree(data_dir, dest)
+    monkeypatch.chdir(dest)
+
+    env = os.environ.copy()
+    # When in Tox, we need to hack things a bit so as not to have the
+    # sub-interpreter `sys.executable` use the tox virtualenv's Babel
+    # installation, so the locale data is where we expect it to be.
+    if "BABEL_TOX_INI_DIR" in env:
+        env["PYTHONPATH"] = env["BABEL_TOX_INI_DIR"]
+
+    # Initialize an empty catalog
+    subprocess.check_call([
+        sys.executable,
+        "setup.py",
+        "init_catalog",
+        "-i", os.devnull,
+        "-l", "fi",
+        "-d", "inited",
+    ], env=env)
+    po_file = Path("inited/fi/LC_MESSAGES/messages.po")
+    orig_po_data = po_file.read_text()
+    subprocess.check_call([
+        sys.executable,
+        "setup.py",
+        "extract_messages",
+        "-o", "extracted.pot",
+    ], env=env)
+    pot_file = Path("extracted.pot")
+    pot_data = pot_file.read_text()
+    assert "FooBar, TM" in pot_data  # should be read from setup.cfg
+    assert "bugs.address@email.tld" in pot_data  # should be read from setup.cfg
+    subprocess.check_call([
+        sys.executable,
+        "setup.py",
+        "update_catalog",
+        "-i", "extracted.pot",
+        "-d", "inited",
+    ], env=env)
+    new_po_data = po_file.read_text()
+    assert new_po_data != orig_po_data  # check we updated the file
+    subprocess.check_call([
+        sys.executable,
+        "setup.py",
+        "compile_catalog",
+        "-d", "inited",
+    ], env=env)
+    assert po_file.with_suffix(".mo").exists()
diff --git a/tox.ini b/tox.ini
index dd1b9a6ff..ec0c9cd6f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,8 +1,10 @@
 [tox]
+isolated_build = true
 envlist =
     py{37,38,39,310,311,312}
     pypy3
     py{37,38}-pytz
+    py{311,312}-setuptools
 
 [testenv]
 extras =
@@ -11,10 +13,12 @@ deps =
     backports.zoneinfo;python_version<"3.9"
     tzdata;sys_platform == 'win32'
     pytz: pytz
+    setuptools: setuptools
 allowlist_externals = make
 commands = make clean-cldr test
 setenv =
     PYTEST_FLAGS=--cov=babel --cov-report=xml:{env:COVERAGE_XML_PATH:.coverage_cache}/coverage.{envname}.xml
+    BABEL_TOX_INI_DIR={toxinidir}
 passenv =
     BABEL_*
     PYTEST_*