]> git.ipfire.org Git - thirdparty/sqlalchemy/alembic.git/commitdiff
Add "module" post-write hook
authorFrazer McLean <frazer@frazermclean.co.uk>
Tue, 8 Jul 2025 15:01:43 +0000 (11:01 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 8 Jul 2025 17:59:33 +0000 (13:59 -0400)
This hook type is almost identical to the console_scripts hook, except
it's running `python -m black` instead of using black's console_script.

It is mainly useful for tools without console scripts (e.g. ruff), but
has semantics closer to the console_scripts hook in that it finds the
ruff module available to the running interpreter instead of finding
an executable by path.

Pull request courtesy Frazer McLean.

Fixes: #1686
Closes: #1687
Pull-request: https://github.com/sqlalchemy/alembic/pull/1687
Pull-request-sha: 54d29c34642d88e88ab29e46fa4022d227f8aa60
Change-Id: I804a1a7d3f6423ba23bc556b7f4024c401a8787e

alembic/script/write_hooks.py
alembic/templates/async/alembic.ini.mako
alembic/templates/generic/alembic.ini.mako
alembic/templates/multidb/alembic.ini.mako
alembic/templates/pyproject/pyproject.toml.mako
alembic/templates/pyproject_async/pyproject.toml.mako
docs/build/autogenerate.rst
docs/build/tutorial.rst
docs/build/unreleased/1686.rst [new file with mode: 0644]
tests/test_post_write.py

index 50aefbe364502c24eed4cda425232a1454d61319..f40bb35f6a8c8878fe4fe4181f235e9fc010b36b 100644 (file)
@@ -3,6 +3,7 @@
 
 from __future__ import annotations
 
+import importlib.util
 import os
 import shlex
 import subprocess
@@ -112,17 +113,35 @@ def _parse_cmdline_options(cmdline_options_str: str, path: str) -> List[str]:
     return cmdline_options_list
 
 
-@register("console_scripts")
-def console_scripts(
-    path: str, options: dict, ignore_output: bool = False
-) -> None:
+def _get_required_option(options: dict, name: str) -> str:
     try:
-        entrypoint_name = options["entrypoint"]
+        return options[name]
     except KeyError as ke:
         raise util.CommandError(
-            f"Key {options['_hook_name']}.entrypoint is required for post "
+            f"Key {options['_hook_name']}.{name} is required for post "
             f"write hook {options['_hook_name']!r}"
         ) from ke
+
+
+def _run_hook(
+    path: str, options: dict, ignore_output: bool, command: List[str]
+) -> None:
+    cwd: Optional[str] = options.get("cwd", None)
+    cmdline_options_str = options.get("options", "")
+    cmdline_options_list = _parse_cmdline_options(cmdline_options_str, path)
+
+    kw: Dict[str, Any] = {}
+    if ignore_output:
+        kw["stdout"] = kw["stderr"] = subprocess.DEVNULL
+
+    subprocess.run([*command, *cmdline_options_list], cwd=cwd, **kw)
+
+
+@register("console_scripts")
+def console_scripts(
+    path: str, options: dict, ignore_output: bool = False
+) -> None:
+    entrypoint_name = _get_required_option(options, "entrypoint")
     for entry in compat.importlib_metadata_get("console_scripts"):
         if entry.name == entrypoint_name:
             impl: Any = entry
@@ -131,48 +150,27 @@ def console_scripts(
         raise util.CommandError(
             f"Could not find entrypoint console_scripts.{entrypoint_name}"
         )
-    cwd: Optional[str] = options.get("cwd", None)
-    cmdline_options_str = options.get("options", "")
-    cmdline_options_list = _parse_cmdline_options(cmdline_options_str, path)
-
-    kw: Dict[str, Any] = {}
-    if ignore_output:
-        kw["stdout"] = kw["stderr"] = subprocess.DEVNULL
 
-    subprocess.run(
-        [
-            sys.executable,
-            "-c",
-            f"import {impl.module}; {impl.module}.{impl.attr}()",
-        ]
-        + cmdline_options_list,
-        cwd=cwd,
-        **kw,
-    )
+    command = [
+        sys.executable,
+        "-c",
+        f"import {impl.module}; {impl.module}.{impl.attr}()",
+    ]
+    _run_hook(path, options, ignore_output, command)
 
 
 @register("exec")
 def exec_(path: str, options: dict, ignore_output: bool = False) -> None:
-    try:
-        executable = options["executable"]
-    except KeyError as ke:
-        raise util.CommandError(
-            f"Key {options['_hook_name']}.executable is required for post "
-            f"write hook {options['_hook_name']!r}"
-        ) from ke
-    cwd: Optional[str] = options.get("cwd", None)
-    cmdline_options_str = options.get("options", "")
-    cmdline_options_list = _parse_cmdline_options(cmdline_options_str, path)
+    executable = _get_required_option(options, "executable")
+    _run_hook(path, options, ignore_output, command=[executable])
 
-    kw: Dict[str, Any] = {}
-    if ignore_output:
-        kw["stdout"] = kw["stderr"] = subprocess.DEVNULL
 
-    subprocess.run(
-        [
-            executable,
-            *cmdline_options_list,
-        ],
-        cwd=cwd,
-        **kw,
-    )
+@register("module")
+def module(path: str, options: dict, ignore_output: bool = False) -> None:
+    module_name = _get_required_option(options, "module")
+
+    if importlib.util.find_spec(module_name) is None:
+        raise util.CommandError(f"Could not find module {module_name}")
+
+    command = [sys.executable, "-m", module_name]
+    _run_hook(path, options, ignore_output, command)
index 782524e219ff29fcc12c514df77aa14d524563a9..67acc6d05426f6b03b8c650ff79c70ab2216b1b1 100644 (file)
@@ -98,10 +98,16 @@ sqlalchemy.url = driver://user:pass@localhost/dbname
 # black.entrypoint = black
 # black.options = -l 79 REVISION_SCRIPT_FILENAME
 
-# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# hooks = ruff
+# ruff.type = module
+# ruff.module = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Alternatively, use the exec runner to execute a binary found on your PATH
 # hooks = ruff
 # ruff.type = exec
-# ruff.executable = %(here)s/.venv/bin/ruff
+# ruff.executable = ruff
 # ruff.options = check --fix REVISION_SCRIPT_FILENAME
 
 # Logging configuration.  This is also consumed by the user-maintained
index cb4e2bf399b50d3141e7d6ea4b026a3274c203b7..bb93d0e3cf17447b5aa78da5e1d54ba4d966bae6 100644 (file)
@@ -98,10 +98,16 @@ sqlalchemy.url = driver://user:pass@localhost/dbname
 # black.entrypoint = black
 # black.options = -l 79 REVISION_SCRIPT_FILENAME
 
-# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# hooks = ruff
+# ruff.type = module
+# ruff.module = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Alternatively, use the exec runner to execute a binary found on your PATH
 # hooks = ruff
 # ruff.type = exec
-# ruff.executable = %(here)s/.venv/bin/ruff
+# ruff.executable = ruff
 # ruff.options = check --fix REVISION_SCRIPT_FILENAME
 
 # Logging configuration.  This is also consumed by the user-maintained
index 2680ef68a5c444103d0d90ccd2549365af8f630a..a6629839552197a124088e8e46a011446b5165ee 100644 (file)
@@ -106,10 +106,16 @@ sqlalchemy.url = driver://user:pass@localhost/dbname2
 # black.entrypoint = black
 # black.options = -l 79 REVISION_SCRIPT_FILENAME
 
-# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# hooks = ruff
+# ruff.type = module
+# ruff.module = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Alternatively, use the exec runner to execute a binary found on your PATH
 # hooks = ruff
 # ruff.type = exec
-# ruff.executable = %(here)s/.venv/bin/ruff
+# ruff.executable = ruff
 # ruff.options = check --fix REVISION_SCRIPT_FILENAME
 
 # Logging configuration.  This is also consumed by the user-maintained
index cfc56cebc843d0d8baf40a5d08e0fe7ef38bc6d7..e68cef331352ba8cd12bf1a4c9432c526c20ecec 100644 (file)
@@ -67,10 +67,16 @@ prepend_sys_path = [
 # options = "-l 79 REVISION_SCRIPT_FILENAME"
 #
 # [[tool.alembic.post_write_hooks]]
-# lint with attempts to fix using "ruff" - use the exec runner,
-# execute a binary
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# name = "ruff"
+# type = "module"
+# module = "ruff"
+# options = "check --fix REVISION_SCRIPT_FILENAME"
+#
+# [[tool.alembic.post_write_hooks]]
+# Alternatively, use the exec runner to execute a binary found on your PATH
 # name = "ruff"
 # type = "exec"
-# executable = "%(here)s/.venv/bin/ruff"
+# executable = "ruff"
 # options = "check --fix REVISION_SCRIPT_FILENAME"
 
index cfc56cebc843d0d8baf40a5d08e0fe7ef38bc6d7..e68cef331352ba8cd12bf1a4c9432c526c20ecec 100644 (file)
@@ -67,10 +67,16 @@ prepend_sys_path = [
 # options = "-l 79 REVISION_SCRIPT_FILENAME"
 #
 # [[tool.alembic.post_write_hooks]]
-# lint with attempts to fix using "ruff" - use the exec runner,
-# execute a binary
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# name = "ruff"
+# type = "module"
+# module = "ruff"
+# options = "check --fix REVISION_SCRIPT_FILENAME"
+#
+# [[tool.alembic.post_write_hooks]]
+# Alternatively, use the exec runner to execute a binary found on your PATH
 # name = "ruff"
 # type = "exec"
-# executable = "%(here)s/.venv/bin/ruff"
+# executable = "ruff"
 # options = "check --fix REVISION_SCRIPT_FILENAME"
 
index aca8683b719fd75c7ebf5a49543a82ae8681c700..f754386072b12e1095bb2f3407b6571a868a35d3 100644 (file)
@@ -721,15 +721,22 @@ Above, we configure ``hooks`` to be a single post write hook labeled
 configuration for the ``"black"`` post write hook, which includes:
 
 * ``type`` - this is the type of hook we are running.  Alembic includes
-  two hook runners: ``"console_scripts"``, which is specifically a
-  Python function that uses ``subprocess.run()`` to invoke a separate
-  Python script against the revision file; and ``"exec"``, which uses
-  ``subprocess.run()`` to execute an arbitrary binary.  For a custom-written
-  hook function, this configuration variable would refer to the name under
-  which the custom hook was registered; see the next section for an example.
+  three hook runners:
+
+  * ``"console_scripts"``, which is specifically a Python function that uses
+    ``subprocess.run()`` to invoke a separate Python script against the revision file;
+  * ``"exec"``, which uses ``subprocess.run()`` to execute an arbitrary binary; and
+  * ``"module"``, which uses ``subprocess.run()`` to invoke a Python module directly.
+
+  For a custom-written hook function, this configuration variable would
+  refer to the name under which the custom hook was registered; see the
+  next section for an example.
 
 .. versionadded:: 1.12 added new ``exec`` runner
 
+.. versionadded:: 1.16.3 added new ``module`` runner
+
+
 The following configuration option is specific to the ``"console_scripts"``
 hook runner:
 
index 7354c82f501af866f6db052807ed5a57c3b6cedc..eccf8f304b0661b410d62d1103feb5ed9c6e16a6 100644 (file)
@@ -248,10 +248,16 @@ The all-in-one .ini file created by ``generic`` is illustrated below::
     # black.entrypoint = black
     # black.options = -l 79 REVISION_SCRIPT_FILENAME
 
-    # lint with attempts to fix using "ruff" - use the exec runner, execute a binary
+    # lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+    # hooks = ruff
+    # ruff.type = module
+    # ruff.module = ruff
+    # ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+    # Alternatively, use the exec runner to execute a binary found on your PATH
     # hooks = ruff
     # ruff.type = exec
-    # ruff.executable = %(here)s/.venv/bin/ruff
+    # ruff.executable = ruff
     # ruff.options = check --fix REVISION_SCRIPT_FILENAME
 
     # Logging configuration.  This is also consumed by the user-maintained
diff --git a/docs/build/unreleased/1686.rst b/docs/build/unreleased/1686.rst
new file mode 100644 (file)
index 0000000..3c482a4
--- /dev/null
@@ -0,0 +1,11 @@
+.. change::
+    :tags: usecase, autogenerate
+    :tickets: 1686
+
+    Add "module" post-write hook. This hook type is almost identical to the
+    console_scripts hook, except it's running `python -m black` instead of
+    using black's console_script. It is mainly useful for tools without
+    console scripts (e.g. ruff), but has semantics closer to the
+    console_scripts hook in that it finds the ruff module available to the
+    running interpreter instead of finding an executable by path. Pull request
+    courtesy Frazer McLean.
\ No newline at end of file
index b3c277659b46dd76279fb43a30bede5f3d7feb03..fe2afe97f5cb978dedddcaa1e1d5463ec9a79467 100644 (file)
@@ -1,3 +1,4 @@
+import importlib.util
 from pathlib import Path
 import sys
 
@@ -477,3 +478,193 @@ ruff.cwd = /path/to/cwd
         self._run_ruff_with_config(
             input_config, expected_additional_arguments_fn, cwd="/path/to/cwd"
         )
+
+    def test_module_config_missing(self):
+        self.cfg = _no_sql_testing_config(
+            directives=(
+                """
+[post_write_hooks]
+hooks = ruff
+ruff.type = module
+                """
+            )
+        )
+        assert_raises_message(
+            util.CommandError,
+            "Key ruff.module is required for post write hook 'ruff'",
+            command.revision,
+            self.cfg,
+            message="x",
+        )
+
+    def test_module_not_found(self):
+        self.cfg = _no_sql_testing_config(
+            directives=(
+                """
+[post_write_hooks]
+hooks = ruff
+ruff.type = module
+ruff.module = ruff_not_found
+                """
+            )
+        )
+        assert_raises_message(
+            util.CommandError,
+            "Could not find module ruff_not_found",
+            command.revision,
+            self.cfg,
+            message="x",
+        )
+
+    def _run_black_module_with_config(
+        self,
+        input_config,
+        expected_additional_arguments_fn,
+        cwd=None,
+        use_toml=False,
+    ):
+        if use_toml:
+            self.cfg = _no_sql_pyproject_config(directives=input_config)
+        else:
+            self.cfg = _no_sql_testing_config(directives=input_config)
+
+        black_module_spec = importlib.util.find_spec("black")
+
+        importlib_util_find_spec = mock.Mock(return_value=black_module_spec)
+        with (
+            mock.patch(
+                "importlib.util.find_spec",
+                importlib_util_find_spec,
+            ),
+            mock.patch(
+                "alembic.script.write_hooks.subprocess"
+            ) as mock_subprocess,
+        ):
+            rev = command.revision(self.cfg, message="x")
+
+        eq_(importlib_util_find_spec.mock_calls, [mock.call("black")])
+        eq_(
+            mock_subprocess.mock_calls,
+            [
+                mock.call.run(
+                    [
+                        sys.executable,
+                        "-m",
+                        "black",
+                    ]
+                    + expected_additional_arguments_fn(rev.path),
+                    cwd=cwd,
+                )
+            ],
+        )
+
+    @testing.variation("config", ["ini", "toml"])
+    def test_module(self, config):
+        if config.ini:
+            input_config = """
+[post_write_hooks]
+hooks = black
+black.type = module
+black.module = black
+black.options = -l 79
+            """
+        else:
+            input_config = """
+    [[tool.alembic.post_write_hooks]]
+    name = "black"
+    type = "module"
+    module = "black"
+    options = "-l 79"
+    """
+
+        def expected_additional_arguments_fn(rev_path):
+            return [rev_path, "-l", "79"]
+
+        self._run_black_module_with_config(
+            input_config,
+            expected_additional_arguments_fn,
+            use_toml=config.toml,
+        )
+
+    @combinations(True, False, argnames="posix")
+    @testing.variation("config", ["ini", "toml"])
+    def test_module_filename_interpolation(self, posix, config):
+        if config.ini:
+            input_config = """
+[post_write_hooks]
+hooks = black
+black.type = module
+black.module = black
+black.options = arg1 REVISION_SCRIPT_FILENAME 'multi-word arg' \
+    --flag1='REVISION_SCRIPT_FILENAME'
+        """
+        else:
+            input_config = """
+[[tool.alembic.post_write_hooks]]
+name = "black"
+type = "module"
+module = "black"
+options = "arg1 REVISION_SCRIPT_FILENAME 'multi-word arg' --flag1='REVISION_SCRIPT_FILENAME'"
+"""  # noqa: E501
+
+        def expected_additional_arguments_fn(rev_path):
+            if compat.is_posix:
+                return [
+                    "arg1",
+                    rev_path,
+                    "multi-word arg",
+                    "--flag1=" + rev_path,
+                ]
+            else:
+                return [
+                    "arg1",
+                    rev_path,
+                    "'multi-word arg'",
+                    "--flag1='%s'" % rev_path,
+                ]
+
+        with mock.patch("alembic.util.compat.is_posix", posix):
+            self._run_black_module_with_config(
+                input_config,
+                expected_additional_arguments_fn,
+                use_toml=config.toml,
+            )
+
+    def test_module_path_in_config(self):
+        input_config = """
+[post_write_hooks]
+hooks = black
+black.type = module
+black.module = black
+black.options = arg1 REVISION_SCRIPT_FILENAME --config %(here)s/pyproject.toml
+        """
+
+        def expected_additional_arguments_fn(rev_path):
+            return [
+                "arg1",
+                rev_path,
+                "--config",
+                Path(_get_staging_directory(), "pyproject.toml")
+                .absolute()
+                .as_posix(),
+            ]
+
+        self._run_black_module_with_config(
+            input_config, expected_additional_arguments_fn
+        )
+
+    def test_module_black_with_cwd(self):
+        input_config = """
+[post_write_hooks]
+hooks = black
+black.type = module
+black.module = black
+black.cwd = /path/to/cwd
+        """
+
+        def expected_additional_arguments_fn(rev_path):
+            return [rev_path]
+
+        self._run_black_module_with_config(
+            input_config, expected_additional_arguments_fn, cwd="/path/to/cwd"
+        )