]> git.ipfire.org Git - thirdparty/sqlalchemy/alembic.git/commitdiff
Add file_path_separator to apply to all path splitting
authorMike Werezak <mike.werezak@nrcan-rncan.gc.ca>
Sat, 26 Apr 2025 18:57:24 +0000 (14:57 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 13 May 2025 15:01:08 +0000 (11:01 -0400)
Added new option to the ConfigParser config (typically via ``alembic.ini``)
``path_separator``. This new option supersedes the previous similar
option ``version_path_separator``.   The new ``path_separator`` option
applies to the path splitting mechanism of both the ``version_locations``
option as well as the ``prepend_sys_path`` option, and in newly
rendered ``alembic.ini`` files will use the value ``os``, which means to
use the operating system path separator when splitting these string values
into a list of paths.

The new attribute applies necessary os-dependent path splitting to the
``prepend_sys_path`` option so that windows paths which contain drive
letters with colons are not inadvertently split, whereas previously
os-dependent path splitting were only available for the
``version_locations`` option.

Existing installations that do not have ``path_separator`` present
will continue to make use of ``version_path_separator`` when parsing the
``version_locations`` option, or splitting on spaces / commas if
``version_path_separator`` is also not present.  ``prepend_sys_path`` will
continue to be split on spaces/commas/colons if ``path_separator`` is
not present.   Under all of these fallback conditions, a deprecation
warning is now emitted encouraging to set ``path_separator``.

Pull request courtesy Mike Werezak.

This change also begins to move some of the role of interpreting
of specific config options into the Config object.  This process will
continue as we look to add toml support to config.

Co-authored-by: Mike Bayer <mike_mp@zzzcomputing.com>
Fixes: #1330
Closes: #1331
Pull-request: https://github.com/sqlalchemy/alembic/pull/1331
Pull-request-sha: f16b35d69acddd046cbba8f28e46219c872c151d

Change-Id: Idef47e8a6947210f1eb63c3d16c4be553effa3a2

18 files changed:
alembic/__init__.py
alembic/config.py
alembic/script/base.py
alembic/script/write_hooks.py
alembic/templates/async/alembic.ini.mako
alembic/templates/generic/alembic.ini.mako
alembic/templates/multidb/alembic.ini.mako
alembic/testing/__init__.py
alembic/testing/assertions.py
alembic/testing/env.py
alembic/util/__init__.py
alembic/util/messaging.py
docs/build/branches.rst
docs/build/changelog.rst
docs/build/tutorial.rst
docs/build/unreleased/1330.rst [new file with mode: 0644]
tests/test_config.py
tests/test_environment.py

index 1e751e9b0e2e849cc14221e1912d49c2eca5150a..16270d147db74df4831b4ff6d172fad10ff69a92 100644 (file)
@@ -1,4 +1,4 @@
 from . import context
 from . import op
 
-__version__ = "1.15.3"
+__version__ = "1.16.0"
index 18ab0f162084ea347957abbdc294a4642e1c0045..dc7d3f8118a88c5aae8cf040449fe17eba8f5870 100644 (file)
@@ -5,6 +5,7 @@ from argparse import Namespace
 from configparser import ConfigParser
 import inspect
 import os
+import re
 import sys
 from typing import Any
 from typing import cast
@@ -340,6 +341,119 @@ class Config:
             ),
         )
 
+    def _get_file_separator_char(self, *names: str) -> Optional[str]:
+        for name in names:
+            separator = self.get_main_option(name)
+            if separator is not None:
+                break
+        else:
+            return None
+
+        split_on_path = {
+            "space": " ",
+            "newline": "\n",
+            "os": os.pathsep,
+            ":": ":",
+            ";": ";",
+        }
+
+        try:
+            sep = split_on_path[separator]
+        except KeyError as ke:
+            raise ValueError(
+                "'%s' is not a valid value for %s; "
+                "expected 'space', 'newline', 'os', ':', ';'"
+                % (separator, name)
+            ) from ke
+        else:
+            if name == "version_path_separator":
+                util.warn_deprecated(
+                    "The version_path_separator configuration parameter "
+                    "is deprecated; please use path_separator"
+                )
+            return sep
+
+    def get_version_locations_list(self) -> Optional[list[str]]:
+
+        version_locations_str = self.get_main_option("version_locations")
+
+        if version_locations_str:
+            split_char = self._get_file_separator_char(
+                "path_separator", "version_path_separator"
+            )
+
+            if split_char is None:
+
+                # legacy behaviour for backwards compatibility
+                util.warn_deprecated(
+                    "No path_separator found in configuration; "
+                    "falling back to legacy splitting on spaces/commas "
+                    "for version_locations.  Consider adding "
+                    "path_separator=os to Alembic config."
+                )
+
+                _split_on_space_comma = re.compile(r", *|(?: +)")
+                return _split_on_space_comma.split(version_locations_str)
+            else:
+                return [
+                    x.strip()
+                    for x in version_locations_str.split(split_char)
+                    if x
+                ]
+        else:
+            return None
+
+    def get_prepend_sys_paths_list(self) -> Optional[list[str]]:
+        prepend_sys_path_str = self.get_main_option("prepend_sys_path")
+
+        if prepend_sys_path_str:
+            split_char = self._get_file_separator_char("path_separator")
+
+            if split_char is None:
+
+                # legacy behaviour for backwards compatibility
+                util.warn_deprecated(
+                    "No path_separator found in configuration; "
+                    "falling back to legacy splitting on spaces, commas, "
+                    "and colons for prepend_sys_path.  Consider adding "
+                    "path_separator=os to Alembic config."
+                )
+
+                _split_on_space_comma_colon = re.compile(r", *|(?: +)|\:")
+                return _split_on_space_comma_colon.split(prepend_sys_path_str)
+            else:
+                return [
+                    x.strip()
+                    for x in prepend_sys_path_str.split(split_char)
+                    if x
+                ]
+        else:
+            return None
+
+    def get_hooks_list(self) -> list[PostWriteHookConfig]:
+        _split_on_space_comma = re.compile(r", *|(?: +)")
+
+        hook_config = self.get_section("post_write_hooks", {})
+        names = _split_on_space_comma.split(hook_config.get("hooks", ""))
+
+        hooks: list[PostWriteHookConfig] = []
+        for name in names:
+            if not name:
+                continue
+            opts = {
+                key[len(name) + 1 :]: hook_config[key]
+                for key in hook_config
+                if key.startswith(name + ".")
+            }
+
+            opts["_hook_name"] = name
+            hooks.append(opts)
+
+        return hooks
+
+
+PostWriteHookConfig = Mapping[str, str]
+
 
 class MessagingOptions(TypedDict, total=False):
     quiet: bool
index 30df6ddb2b79d286308be84da2dfe449199312cb..3fa3c2825880a764ff2b8daa1c5eee59c7662654 100644 (file)
@@ -11,7 +11,6 @@ from typing import Any
 from typing import cast
 from typing import Iterator
 from typing import List
-from typing import Mapping
 from typing import Optional
 from typing import Sequence
 from typing import Set
@@ -32,6 +31,7 @@ if TYPE_CHECKING:
     from .revision import Revision
     from ..config import Config
     from ..config import MessagingOptions
+    from ..config import PostWriteHookConfig
     from ..runtime.migration import RevisionStep
     from ..runtime.migration import StampStep
 
@@ -50,9 +50,6 @@ _only_source_rev_file = re.compile(r"(?!\.\#|__init__)(.*\.py)$")
 _legacy_rev = re.compile(r"([a-f0-9]+)\.py$")
 _slug_re = re.compile(r"\w+")
 _default_file_template = "%(rev)s_%(slug)s"
-_split_on_space_comma = re.compile(r", *|(?: +)")
-
-_split_on_space_comma_colon = re.compile(r", *|(?: +)|\:")
 
 
 class ScriptDirectory:
@@ -84,7 +81,7 @@ class ScriptDirectory:
         sourceless: bool = False,
         output_encoding: str = "utf-8",
         timezone: Optional[str] = None,
-        hook_config: Optional[Mapping[str, str]] = None,
+        hooks: list[PostWriteHookConfig] = [],
         recursive_version_locations: bool = False,
         messaging_opts: MessagingOptions = cast(
             "MessagingOptions", util.EMPTY_DICT
@@ -98,7 +95,7 @@ class ScriptDirectory:
         self.output_encoding = output_encoding
         self.revision_map = revision.RevisionMap(self._load_revisions)
         self.timezone = timezone
-        self.hook_config = hook_config
+        self.hooks = hooks
         self.recursive_version_locations = recursive_version_locations
         self.messaging_opts = messaging_opts
 
@@ -168,7 +165,7 @@ class ScriptDirectory:
         script_location = config.get_main_option("script_location")
         if script_location is None:
             raise util.CommandError(
-                "No 'script_location' key " "found in configuration."
+                "No 'script_location' key found in configuration."
             )
         truncate_slug_length: Optional[int]
         tsl = config.get_main_option("truncate_slug_length")
@@ -177,53 +174,9 @@ class ScriptDirectory:
         else:
             truncate_slug_length = None
 
-        version_locations_str = config.get_main_option("version_locations")
-        version_locations: Optional[List[str]]
-        if version_locations_str:
-            version_path_separator = config.get_main_option(
-                "version_path_separator"
-            )
-
-            split_on_path = {
-                None: None,
-                "space": " ",
-                "newline": "\n",
-                "os": os.pathsep,
-                ":": ":",
-                ";": ";",
-            }
-
-            try:
-                split_char: Optional[str] = split_on_path[
-                    version_path_separator
-                ]
-            except KeyError as ke:
-                raise ValueError(
-                    "'%s' is not a valid value for "
-                    "version_path_separator; "
-                    "expected 'space', 'newline', 'os', ':', ';'"
-                    % version_path_separator
-                ) from ke
-            else:
-                if split_char is None:
-                    # legacy behaviour for backwards compatibility
-                    version_locations = _split_on_space_comma.split(
-                        version_locations_str
-                    )
-                else:
-                    version_locations = [
-                        x.strip()
-                        for x in version_locations_str.split(split_char)
-                        if x
-                    ]
-        else:
-            version_locations = None
-
-        prepend_sys_path = config.get_main_option("prepend_sys_path")
+        prepend_sys_path = config.get_prepend_sys_paths_list()
         if prepend_sys_path:
-            sys.path[:0] = list(
-                _split_on_space_comma_colon.split(prepend_sys_path)
-            )
+            sys.path[:0] = prepend_sys_path
 
         rvl = config.get_main_option("recursive_version_locations") == "true"
         return ScriptDirectory(
@@ -234,9 +187,9 @@ class ScriptDirectory:
             truncate_slug_length=truncate_slug_length,
             sourceless=config.get_main_option("sourceless") == "true",
             output_encoding=config.get_main_option("output_encoding", "utf-8"),
-            version_locations=version_locations,
+            version_locations=config.get_version_locations_list(),
             timezone=config.get_main_option("timezone"),
-            hook_config=config.get_section("post_write_hooks", {}),
+            hooks=config.get_hooks_list(),
             recursive_version_locations=rvl,
             messaging_opts=config.messaging_opts,
         )
@@ -763,7 +716,7 @@ class ScriptDirectory:
             **kw,
         )
 
-        post_write_hooks = self.hook_config
+        post_write_hooks = self.hooks
         if post_write_hooks:
             write_hooks._run_hooks(path, post_write_hooks)
 
index 9977147921055b2f5540993188ca495455a2ca7d..64bd3873471dd886c6355e6433a7d929d8a79ae7 100644 (file)
@@ -10,13 +10,14 @@ from typing import Any
 from typing import Callable
 from typing import Dict
 from typing import List
-from typing import Mapping
 from typing import Optional
-from typing import Union
+from typing import TYPE_CHECKING
 
 from .. import util
 from ..util import compat
 
+if TYPE_CHECKING:
+    from ..config import PostWriteHookConfig
 
 REVISION_SCRIPT_TOKEN = "REVISION_SCRIPT_FILENAME"
 
@@ -42,9 +43,7 @@ def register(name: str) -> Callable:
     return decorate
 
 
-def _invoke(
-    name: str, revision: str, options: Mapping[str, Union[str, int]]
-) -> Any:
+def _invoke(name: str, revision: str, options: PostWriteHookConfig) -> Any:
     """Invokes the formatter registered for the given name.
 
     :param name: The name of a formatter in the registry
@@ -63,24 +62,13 @@ def _invoke(
         return hook(revision, options)
 
 
-def _run_hooks(path: str, hook_config: Mapping[str, str]) -> None:
+def _run_hooks(path: str, hooks: list[PostWriteHookConfig]) -> None:
     """Invoke hooks for a generated revision."""
 
-    from .base import _split_on_space_comma
-
-    names = _split_on_space_comma.split(hook_config.get("hooks", ""))
-
-    for name in names:
-        if not name:
-            continue
-        opts = {
-            key[len(name) + 1 :]: hook_config[key]
-            for key in hook_config
-            if key.startswith(name + ".")
-        }
-        opts["_hook_name"] = name
+    for hook in hooks:
+        name = hook["_hook_name"]
         try:
-            type_ = opts["type"]
+            type_ = hook["type"]
         except KeyError as ke:
             raise util.CommandError(
                 f"Key {name}.type is required for post write hook {name!r}"
@@ -89,7 +77,7 @@ def _run_hooks(path: str, hook_config: Mapping[str, str]) -> None:
             with util.status(
                 f"Running post write hook {name!r}", newline=True
             ):
-                _invoke(type_, path, opts)
+                _invoke(type_, path, hook)
 
 
 def _parse_cmdline_options(cmdline_options_str: str, path: str) -> List[str]:
index 7ffd7926bce15473116ffce116ed2e819d7e5a85..1eb347c02c60eef6fd0743d65309e7ba8c90dbaf 100644 (file)
@@ -10,7 +10,8 @@ script_location = ${script_location}
 # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
 
 # sys.path path, will be prepended to sys.path if present.
-# defaults to the current working directory.
+# defaults to the current working directory.  for multiple paths, the path separator
+# is defined by "path_separator" below.
 prepend_sys_path = .
 
 # timezone to use when rendering the date within the migration file
@@ -36,21 +37,36 @@ prepend_sys_path = .
 # version location specification; This defaults
 # to ${script_location}/versions.  When using multiple version
 # directories, initial revisions must be specified with --version-path.
-# The path separator used here should be the separator specified by "version_path_separator" below.
+# The path separator used here should be the separator specified by "path_separator"
+# below.
 # version_locations = %(here)s/bar:%(here)s/bat:${script_location}/versions
 
-# version path separator; As mentioned above, this is the character used to split
-# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
-# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
-# Valid values for version_path_separator are:
+# path_separator; This indicates what character is used to split lists of file
+# paths, including version_locations and prepend_sys_path within configparser
+# files such as alembic.ini.
+# The default rendered in new alembic.ini files is "os", which uses os.pathsep
+# to provide os-dependent path splitting.
 #
-# version_path_separator = :
-# version_path_separator = ;
-# version_path_separator = space
-# version_path_separator = newline
+# Note that in order to support legacy alembic.ini files, this default does NOT
+# take place if path_separator is not present in alembic.ini.  If this
+# option is omitted entirely, fallback logic is as follows:
+#
+# 1. Parsing of the version_locations option falls back to using the legacy
+#    "version_path_separator" key, which if absent then falls back to the legacy
+#    behavior of splitting on spaces and/or commas.
+# 2. Parsing of the prepend_sys_path option falls back to the legacy
+#    behavior of splitting on spaces, commas, or colons.
+#
+# Valid values for path_separator are:
+#
+# path_separator = :
+# path_separator = ;
+# path_separator = space
+# path_separator = newline
 #
 # Use os.pathsep. Default configuration used for new projects.
-version_path_separator = os
+path_separator = os
+
 
 # set to 'true' to search source files recursively
 # in each "version_locations" directory
index 3e211d0d64dfa92f60da2fb568004d554f8b541e..0a5a5754e97f236a3e2a74945788be57e5e94b8c 100644 (file)
@@ -12,9 +12,11 @@ script_location = ${script_location}
 # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
 
 # sys.path path, will be prepended to sys.path if present.
-# defaults to the current working directory.
+# defaults to the current working directory.  for multiple paths, the path separator
+# is defined by "path_separator" below.
 prepend_sys_path = .
 
+
 # timezone to use when rendering the date within the migration file
 # as well as the filename.
 # If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
@@ -38,21 +40,35 @@ prepend_sys_path = .
 # version location specification; This defaults
 # to ${script_location}/versions.  When using multiple version
 # directories, initial revisions must be specified with --version-path.
-# The path separator used here should be the separator specified by "version_path_separator" below.
+# The path separator used here should be the separator specified by "path_separator"
+# below.
 # version_locations = %(here)s/bar:%(here)s/bat:${script_location}/versions
 
-# version path separator; As mentioned above, this is the character used to split
-# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
-# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
-# Valid values for version_path_separator are:
+# path_separator; This indicates what character is used to split lists of file
+# paths, including version_locations and prepend_sys_path within configparser
+# files such as alembic.ini.
+# The default rendered in new alembic.ini files is "os", which uses os.pathsep
+# to provide os-dependent path splitting.
+#
+# Note that in order to support legacy alembic.ini files, this default does NOT
+# take place if path_separator is not present in alembic.ini.  If this
+# option is omitted entirely, fallback logic is as follows:
+#
+# 1. Parsing of the version_locations option falls back to using the legacy
+#    "version_path_separator" key, which if absent then falls back to the legacy
+#    behavior of splitting on spaces and/or commas.
+# 2. Parsing of the prepend_sys_path option falls back to the legacy
+#    behavior of splitting on spaces, commas, or colons.
+#
+# Valid values for path_separator are:
 #
-# version_path_separator = :
-# version_path_separator = ;
-# version_path_separator = space
-# version_path_separator = newline
+# path_separator = :
+# path_separator = ;
+# path_separator = space
+# path_separator = newline
 #
 # Use os.pathsep. Default configuration used for new projects.
-version_path_separator = os
+path_separator = os
 
 # set to 'true' to search source files recursively
 # in each "version_locations" directory
index 003164561bb8da95e02b3a196d60297ee8140d3d..a0cae1d57cd46628da6661da5c18690aecfafb7a 100644 (file)
@@ -12,7 +12,8 @@ script_location = ${script_location}
 # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
 
 # sys.path path, will be prepended to sys.path if present.
-# defaults to the current working directory.
+# defaults to the current working directory.  for multiple paths, the path separator
+# is defined by "path_separator" below.
 prepend_sys_path = .
 
 # timezone to use when rendering the date within the migration file
@@ -38,21 +39,35 @@ prepend_sys_path = .
 # version location specification; This defaults
 # to ${script_location}/versions.  When using multiple version
 # directories, initial revisions must be specified with --version-path.
-# The path separator used here should be the separator specified by "version_path_separator" below.
+# The path separator used here should be the separator specified by "path_separator"
+# below.
 # version_locations = %(here)s/bar:%(here)s/bat:${script_location}/versions
 
-# version path separator; As mentioned above, this is the character used to split
-# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
-# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
-# Valid values for version_path_separator are:
+# path_separator; This indicates what character is used to split lists of file
+# paths, including version_locations and prepend_sys_path within configparser
+# files such as alembic.ini.
+# The default rendered in new alembic.ini files is "os", which uses os.pathsep
+# to provide os-dependent path splitting.
 #
-# version_path_separator = :
-# version_path_separator = ;
-# version_path_separator = space
-# version_path_separator = newline
+# Note that in order to support legacy alembic.ini files, this default does NOT
+# take place if path_separator is not present in alembic.ini.  If this
+# option is omitted entirely, fallback logic is as follows:
+#
+# 1. Parsing of the version_locations option falls back to using the legacy
+#    "version_path_separator" key, which if absent then falls back to the legacy
+#    behavior of splitting on spaces and/or commas.
+# 2. Parsing of the prepend_sys_path option falls back to the legacy
+#    behavior of splitting on spaces, commas, or colons.
+#
+# Valid values for path_separator are:
+#
+# path_separator = :
+# path_separator = ;
+# path_separator = space
+# path_separator = newline
 #
 # Use os.pathsep. Default configuration used for new projects.
-version_path_separator = os
+path_separator = os
 
 # set to 'true' to search source files recursively
 # in each "version_locations" directory
index 0407adfe9ceb5ed7071d8fac48813e664468a71b..809de7e453b1588b374a1a244c34ede7c23179cd 100644 (file)
@@ -15,6 +15,7 @@ from .assertions import assert_raises_message
 from .assertions import emits_python_deprecation_warning
 from .assertions import eq_
 from .assertions import eq_ignore_whitespace
+from .assertions import expect_deprecated
 from .assertions import expect_raises
 from .assertions import expect_raises_message
 from .assertions import expect_sqlalchemy_deprecated
index c08b22286a0fd22d9cd844ff3917f2f7be674dc0..898fbd1677de6a161a8b993703c3cb42804a58ea 100644 (file)
@@ -167,6 +167,10 @@ def emits_python_deprecation_warning(*messages):
     return decorate
 
 
+def expect_deprecated(*messages, **kw):
+    return _expect_warnings(DeprecationWarning, messages, **kw)
+
+
 def expect_sqlalchemy_deprecated(*messages, **kw):
     return _expect_warnings(sa_exc.SADeprecationWarning, messages, **kw)
 
index 9a457b7f8e9d899a07984355459e5f0a9e201021..a97990e6efd21c5292ca89f6d8100873e80d8d19 100644 (file)
@@ -157,6 +157,7 @@ script_location = {dir_}
 sqlalchemy.url = {url}
 sqlalchemy.future = {"true" if sqlalchemy_future else "false"}
 sourceless = {"true" if sourceless else "false"}
+path_separator = space
 version_locations = %(here)s/model1/ %(here)s/model2/ %(here)s/model3/ \
 {extra_version_location}
 
index 786baa2b4fdce4545f6963b92b80275fb348c5d4..1d3a217968bdffa149f47528a502f3cf34b43be2 100644 (file)
@@ -20,6 +20,7 @@ from .messaging import msg as msg
 from .messaging import obfuscate_url_pw as obfuscate_url_pw
 from .messaging import status as status
 from .messaging import warn as warn
+from .messaging import warn_deprecated as warn_deprecated
 from .messaging import write_outstream as write_outstream
 from .pyfiles import coerce_resource_to_filename as coerce_resource_to_filename
 from .pyfiles import load_python_file as load_python_file
index a2dbefa6202b22aa504c2df48bc0d29856b9a9f3..4c08f16e7e180fbd69eb3505c4e1107fe54da0c5 100644 (file)
@@ -81,6 +81,10 @@ def warn(msg: str, stacklevel: int = 2) -> None:
     warnings.warn(msg, UserWarning, stacklevel=stacklevel)
 
 
+def warn_deprecated(msg: str, stacklevel: int = 2) -> None:
+    warnings.warn(msg, DeprecationWarning, stacklevel=stacklevel)
+
+
 def msg(
     msg: str, newline: bool = True, flush: bool = False, quiet: bool = False
 ) -> None:
index 4b74ca0b805bb0ca06ebb33786273d7ed542d27b..3e8bee1e41d921f1671b012085ffa3a37a643d68 100644 (file)
@@ -521,8 +521,8 @@ that module.  So to start out, we can edit ``alembic.ini`` to refer
 to multiple directories;  we'll also state the current ``versions``
 directory as one of them::
 
-  # A separator for the location paths must be defined first.
-  version_path_separator = os  # Use os.pathsep.
+  # A separator for the location paths needs to be defined
+  path_separator = os  # Use os.pathsep.
   # version location specification; this defaults
   # to foo/versions.  When using multiple version
   # directories, initial revisions must be specified with --version-path
index 0f0effe19a0926c56353793670b2b9705a8fea77..32a77a1f7379e9c7cddb748d4fa83b6be4b58935 100644 (file)
@@ -4,7 +4,7 @@ Changelog
 ==========
 
 .. changelog::
-    :version: 1.15.3
+    :version: 1.16.0
     :include_notes_from: unreleased
 
 .. changelog::
index 347e856bd9cf548ce286be8ec05d5f7a10ddb2ab..74b5252a66a076227594db7d092f93808fd2a7e1 100644 (file)
@@ -166,16 +166,33 @@ The file generated with the "generic" configuration looks like::
     # The path separator used here should be the separator specified by "version_path_separator" below.
     # version_locations = %(here)s/bar:%(here)s/bat:${script_location}/versions
 
-    # version path separator; As mentioned above, this is the character used to split
-    # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
-    # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
-    # Valid values for version_path_separator are:
+    # path_separator (New in Alembic 1.16.0, supersedes version_path_separator);
+    # This indicates what character is used to
+    # split lists of file paths, including version_locations and prepend_sys_path
+    # within configparser files such as alembic.ini.
     #
-    # version_path_separator = :
-    # version_path_separator = ;
-    # version_path_separator = space
-    # version_path_separator = newline
-    version_path_separator = os  # Use os.pathsep. Default configuration used for new projects.
+    # The default rendered in new alembic.ini files is "os", which uses os.pathsep
+    # to provide os-dependent path splitting.
+    #
+    # Note that in order to support legacy alembic.ini files, this default does NOT
+    # take place if path_separator is not present in alembic.ini.  If this
+    # option is omitted entirely, fallback logic is as follows:
+    #
+    # 1. Parsing of the version_locations option falls back to using the legacy
+    #    "version_path_separator" key, which if absent then falls back to the legacy
+    #    behavior of splitting on spaces and/or commas.
+    # 2. Parsing of the prepend_sys_path option falls back to the legacy
+    #    behavior of splitting on spaces, commas, or colons.
+    #
+    # Valid values for path_separator are:
+    #
+    # path_separator = :
+    # path_separator = ;
+    # path_separator = space
+    # path_separator = newline
+    #
+    # Use os.pathsep. Default configuration used for new projects.
+    path_separator = os
 
     # set to 'true' to search source files recursively
     # in each "version_locations" directory
@@ -299,7 +316,7 @@ This file contains the following features:
 * ``timezone`` - an optional timezone name (e.g. ``UTC``, ``EST5EDT``, etc.)
   that will be applied to the timestamp which renders inside the migration
   file's comment as well as within the filename. This option requires Python>=3.9
-  or installing the ``backports.zoneinfo`` library and the ``tzdata`` library. 
+  or installing the ``backports.zoneinfo`` library and the ``tzdata`` library.
   If ``timezone`` is specified, the create date object is no longer derived
   from ``datetime.datetime.now()`` and is instead generated as::
 
@@ -341,9 +358,8 @@ This file contains the following features:
   allow revisions to exist in multiple directories simultaneously.
   See :ref:`multiple_bases` for examples.
 
-* ``version_path_separator`` - a separator of ``version_locations`` paths.
-  It should be defined if multiple ``version_locations`` is used.
-  See :ref:`multiple_bases` for examples.
+* ``path_separator`` - a separator character for the ``version_locations``
+  and ``prepend_sys_path`` path lists.  See :ref:`multiple_bases` for examples.
 
 * ``recursive_version_locations`` - when set to 'true', revision files
   are searched recursively in each "version_locations" directory.
diff --git a/docs/build/unreleased/1330.rst b/docs/build/unreleased/1330.rst
new file mode 100644 (file)
index 0000000..736149f
--- /dev/null
@@ -0,0 +1,28 @@
+.. change::
+    :tags: bug, environment
+    :tickets: 1330
+
+    Added new option to the ConfigParser config (typically via ``alembic.ini``)
+    ``path_separator``. This new option supersedes the previous similar
+    option ``version_path_separator``.   The new ``path_separator`` option
+    applies to the path splitting mechanism of both the ``version_locations``
+    option as well as the ``prepend_sys_path`` option, and in newly
+    rendered ``alembic.ini`` files will use the value ``os``, which means to
+    use the operating system path separator when splitting these string values
+    into a list of paths.
+
+    The new attribute applies necessary os-dependent path splitting to the
+    ``prepend_sys_path`` option so that windows paths which contain drive
+    letters with colons are not inadvertently split, whereas previously
+    os-dependent path splitting were only available for the
+    ``version_locations`` option.
+
+    Existing installations that do not have ``path_separator`` present
+    will continue to make use of ``version_path_separator`` when parsing the
+    ``version_locations`` option, or splitting on spaces / commas if
+    ``version_path_separator`` is also not present.  ``prepend_sys_path`` will
+    continue to be split on spaces/commas/colons if ``path_separator`` is
+    not present.   Under all of these fallback conditions, a deprecation
+    warning is now emitted encouraging to set ``path_separator``.
+
+    Pull request courtesy Mike Werezak.
\ No newline at end of file
index 0fad0dda571c8a45384072cd4a7910813b8c8d15..cf85f516dc9c30895c4ffc926869f4e47b000feb 100644 (file)
@@ -1,4 +1,5 @@
 import os
+import sys
 import tempfile
 
 from alembic import config
@@ -176,7 +177,7 @@ class ConfigTest(TestBase):
             "|",
             "/foo|/bar",
             ValueError(
-                "'|' is not a valid value for version_path_separator; "
+                "'|' is not a valid value for path_separator; "
                 "expected 'space', 'newline', 'os', ':', ';'"
             ),
         ),
@@ -187,7 +188,7 @@ class ConfigTest(TestBase):
         cfg = config.Config()
         if separator is not None:
             cfg.set_main_option(
-                "version_path_separator",
+                "path_separator",
                 separator,
             )
         cfg.set_main_option("script_location", tempfile.gettempdir())
@@ -198,9 +199,143 @@ class ConfigTest(TestBase):
             with expect_raises_message(ValueError, message, text_exact=True):
                 ScriptDirectory.from_config(cfg)
         else:
-            s = ScriptDirectory.from_config(cfg)
+            if separator is None:
+                with testing.expect_deprecated(
+                    "No path_separator found in configuration; "
+                    "falling back to legacy splitting on spaces/commas "
+                    "for version_locations"
+                ):
+                    s = ScriptDirectory.from_config(cfg)
+            else:
+                s = ScriptDirectory.from_config(cfg)
+
             eq_(s.version_locations, expected_result)
 
+    @testing.combinations(
+        (
+            "legacy raw string 1",
+            None,
+            "/foo",
+            ["/foo"],
+        ),
+        (
+            "legacy raw string 2",
+            None,
+            "/foo /bar",
+            ["/foo", "/bar"],
+        ),
+        (
+            "legacy raw string 3",
+            "space",
+            "/foo",
+            ["/foo"],
+        ),
+        (
+            "legacy raw string 4",
+            "space",
+            "/foo /bar",
+            ["/foo", "/bar"],
+        ),
+        (
+            "multiline string 1",
+            "newline",
+            " /foo  \n/bar  ",
+            ["/foo", "/bar"],
+        ),
+        (
+            "Linux pathsep 1",
+            ":",
+            "/Project A",
+            ["/Project A"],
+        ),
+        (
+            "Linux pathsep 2",
+            ":",
+            "/Project A:/Project B",
+            ["/Project A", "/Project B"],
+        ),
+        (
+            "Windows pathsep 1",
+            ";",
+            r"C:\Project A",
+            [r"C:\Project A"],
+        ),
+        (
+            "Windows pathsep 2",
+            ";",
+            r"C:\Project A;C:\Project B",
+            [r"C:\Project A", r"C:\Project B"],
+        ),
+        (
+            "os pathsep",
+            "os",
+            r"path_number_one%(sep)spath_number_two%(sep)s"
+            % {"sep": os.pathsep},
+            [r"path_number_one", r"path_number_two"],
+        ),
+        (
+            "invalid pathsep 2",
+            "|",
+            "/foo|/bar",
+            ValueError(
+                "'|' is not a valid value for path_separator; "
+                "expected 'space', 'newline', 'os', ':', ';'"
+            ),
+        ),
+        id_="iaaa",
+        argnames="separator, string_value, expected_result",
+    )
+    def test_prepend_sys_path_locations(
+        self, separator, string_value, expected_result
+    ):
+        cfg = config.Config()
+        if separator is not None:
+            cfg.set_main_option(
+                "path_separator",
+                separator,
+            )
+        cfg.set_main_option("script_location", tempfile.gettempdir())
+        cfg.set_main_option("prepend_sys_path", string_value)
+
+        if isinstance(expected_result, ValueError):
+            message = str(expected_result)
+            with expect_raises_message(ValueError, message, text_exact=True):
+                ScriptDirectory.from_config(cfg)
+        else:
+            restore_path = list(sys.path)
+            try:
+                sys.path.clear()
+
+                if separator is None:
+                    with testing.expect_deprecated(
+                        "No path_separator found in configuration; "
+                        "falling back to legacy splitting on spaces, commas, "
+                        "and colons for prepend_sys_path"
+                    ):
+                        ScriptDirectory.from_config(cfg)
+                else:
+                    ScriptDirectory.from_config(cfg)
+                eq_(sys.path, expected_result)
+            finally:
+                sys.path = restore_path
+
+    def test_version_path_separator_deprecation_warning(self):
+        cfg = config.Config()
+        cfg.set_main_option("script_location", tempfile.gettempdir())
+        cfg.set_main_option("version_path_separator", "space")
+        cfg.set_main_option(
+            "version_locations", "/path/one /path/two /path:/three"
+        )
+        with testing.expect_deprecated(
+            "The version_path_separator configuration parameter is "
+            "deprecated; please use path_separator"
+        ):
+            script = ScriptDirectory.from_config(cfg)
+        eq_(
+            script.version_locations,
+            ["/path/one", "/path/two", "/path:/three"],
+        )
+
 
 class StdoutOutputEncodingTest(TestBase):
     def test_plain(self):
index 5fc70401656c10f71d7ef6df50ccd0d1c7d65905..fb98bf11edba311a245559604925f343682e203d 100644 (file)
@@ -159,17 +159,28 @@ class CWDTest(TestBase):
     @testing.combinations(
         (
             ".",
+            None,
             ["."],
         ),
-        ("/tmp/foo:/tmp/bar", ["/tmp/foo", "/tmp/bar"]),
-        ("/tmp/foo /tmp/bar", ["/tmp/foo", "/tmp/bar"]),
-        ("/tmp/foo,/tmp/bar", ["/tmp/foo", "/tmp/bar"]),
-        (". /tmp/foo", [".", "/tmp/foo"]),
+        ("/tmp/foo:/tmp/bar", None, ["/tmp/foo", "/tmp/bar"]),
+        ("/tmp/foo:/tmp/bar", ":", ["/tmp/foo", "/tmp/bar"]),
+        ("/tmp/foo /tmp/bar", None, ["/tmp/foo", "/tmp/bar"]),
+        ("/tmp/foo,/tmp/bar", None, ["/tmp/foo", "/tmp/bar"]),
+        (". /tmp/foo", None, [".", "/tmp/foo"]),
+        (". /tmp/foo", "space", [".", "/tmp/foo"]),
     )
-    def test_sys_path_prepend(self, config_value, expected):
+    def test_sys_path_prepend(self, config_value, path_separator, expected):
+        if path_separator is not None:
+            self.cfg.set_main_option("path_separator", path_separator)
         self.cfg.set_main_option("prepend_sys_path", config_value)
 
-        script = ScriptDirectory.from_config(self.cfg)
+        if path_separator is None:
+            with testing.expect_deprecated(
+                "No path_separator found in configuration;"
+            ):
+                script = ScriptDirectory.from_config(self.cfg)
+        else:
+            script = ScriptDirectory.from_config(self.cfg)
         env = EnvironmentContext(self.cfg, script)
 
         target = os.path.abspath(_get_staging_directory())