]> git.ipfire.org Git - thirdparty/sqlalchemy/alembic.git/commitdiff
Revendor editor and make dateutil optional
authorCaselIT <cfederico87@gmail.com>
Tue, 15 Jun 2021 20:32:05 +0000 (22:32 +0200)
committerCaselIT <cfederico87@gmail.com>
Tue, 15 Jun 2021 21:38:07 +0000 (23:38 +0200)
Re-implemented the ``python-editor`` dependency as a small internal
function to avoid the need for external dependencies.
The implementation is based on the original
version in 7b91b325ff43a0e9235e0f15b57391fa92352626.

Make the ``python-dateutil`` library an optional dependency.
This library is only required if the ``timezone`` option
is used in the Alembic configuration.
An extra require named ``tz`` is available with
``pip install alembic[tz]`` to install it.

Fixes: #674
Fixes: #856
Change-Id: I07f17b2fea01e3a3d677ce95333fe3e8d8d438fd

18 files changed:
alembic/command.py
alembic/script/base.py
alembic/templates/async/alembic.ini.mako
alembic/templates/generic/alembic.ini.mako
alembic/templates/multidb/alembic.ini.mako
alembic/templates/pylons/alembic.ini.mako
alembic/testing/requirements.py
alembic/util/__init__.py
alembic/util/editor.py [new file with mode: 0644]
alembic/util/pyfiles.py
docs/build/tutorial.rst
docs/build/unreleased/674.rst [new file with mode: 0644]
docs/build/unreleased/856.rst [new file with mode: 0644]
setup.cfg
tests/test_command.py
tests/test_editor.py [new file with mode: 0644]
tests/test_script_production.py
tox.ini

index 6a4a3d0e60dceac186552674958d2f4d117f0861..ada458d3b00d34d4fb5f38fc26c41478968c7c3e 100644 (file)
@@ -587,7 +587,7 @@ def edit(config, rev):
             if not rev:
                 raise util.CommandError("No current revisions")
             for sc in script.get_revisions(rev):
-                util.edit(sc.path)
+                util.open_in_editor(sc.path)
             return []
 
         with EnvironmentContext(config, script, fn=edit_current):
@@ -599,4 +599,4 @@ def edit(config, rev):
                 "No revision files indicated by symbol '%s'" % rev
             )
         for sc in revs:
-            util.edit(sc.path)
+            util.open_in_editor(sc.path)
index 3f618643521f47fa588f1c04472d83035abb810a..723c01161445dde51203b313239533aaf3f82fdb 100644 (file)
@@ -5,14 +5,17 @@ import re
 import shutil
 import sys
 
-from dateutil import tz
-
 from . import revision
 from . import write_hooks
 from .. import util
 from ..runtime import migration
 from ..util import compat
 
+try:
+    from dateutil import tz
+except ImportError:
+    tz = None  # noqa
+
 _sourceless_rev_file = re.compile(r"(?!\.\#|__init__)(.*\.py)(c|o)?$")
 _only_source_rev_file = re.compile(r"(?!\.\#|__init__)(.*\.py)$")
 _legacy_rev = re.compile(r"([a-f0-9]+)\.py$")
@@ -515,6 +518,11 @@ class ScriptDirectory(object):
 
     def _generate_create_date(self):
         if self.timezone is not None:
+            if tz is None:
+                raise util.CommandError(
+                    "The library 'python-dateutil' is required "
+                    "for timezone support"
+                )
             # First, assume correct capitalization
             tzinfo = tz.gettz(self.timezone)
             if tzinfo is None:
index 31de75a41a51c3f0bf520c56859cbc1e74c7d0b7..64af30188c4d3ac859cfc2ccd2cb808d0b62beaf 100644 (file)
@@ -11,8 +11,10 @@ script_location = ${script_location}
 # defaults to the current working directory.
 prepend_sys_path = .
 
-# timezone to use when rendering the date
-# within the migration file as well as the filename.
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the python-dateutil library that can be
+# installed by adding `alembic[tz]` to the pip requirements
 # string value is passed to dateutil.tz.gettz()
 # leave blank for localtime
 # timezone =
index 31de75a41a51c3f0bf520c56859cbc1e74c7d0b7..64af30188c4d3ac859cfc2ccd2cb808d0b62beaf 100644 (file)
@@ -11,8 +11,10 @@ script_location = ${script_location}
 # defaults to the current working directory.
 prepend_sys_path = .
 
-# timezone to use when rendering the date
-# within the migration file as well as the filename.
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the python-dateutil library that can be
+# installed by adding `alembic[tz]` to the pip requirements
 # string value is passed to dateutil.tz.gettz()
 # leave blank for localtime
 # timezone =
index 363be679d71f010da147bce9748fb2861e5c66f1..c3b0ec0fa61e84fb810f0af9176ce6537320faed 100644 (file)
@@ -11,8 +11,10 @@ script_location = ${script_location}
 # defaults to the current working directory.
 prepend_sys_path = .
 
-# timezone to use when rendering the date
-# within the migration file as well as the filename.
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the python-dateutil library that can be
+# installed by adding `alembic[tz]` to the pip requirements
 # string value is passed to dateutil.tz.gettz()
 # leave blank for localtime
 # timezone =
index b0b8fe1da4ab9616d1ee1af3a2d4703a4b6b6e68..aa745a8ad55385552913adf99d70cee5c3b69079 100644 (file)
@@ -11,8 +11,10 @@ script_location = ${script_location}
 # defaults to the current working directory.
 prepend_sys_path = .
 
-# timezone to use when rendering the date
-# within the migration file as well as the filename.
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the python-dateutil library that can be
+# installed by adding `alembic[tz]` to the pip requirements
 # string value is passed to dateutil.tz.gettz()
 # leave blank for localtime
 # timezone =
index 3a5426b64cf0f8ebc9368644f10499bf3ec52b9e..5a1106881bb60cb96cf9cbccd622cfa5cf85d858 100644 (file)
@@ -67,18 +67,6 @@ class SuiteRequirements(Requirements):
     def reflects_fk_options(self):
         return exclusions.closed()
 
-    @property
-    def editor_installed(self):
-        def go():
-            try:
-                import editor  # noqa
-            except ImportError:
-                return False
-            else:
-                return True
-
-        return exclusions.only_if(go, "editor package not installed")
-
     @property
     def sqlalchemy_13(self):
         return exclusions.skip_if(
index cfdab4984b06ce179a4d8ff43f1ae014a7f32566..2a3d8a88ec9607b599faf1d896341641f5cfac31 100644 (file)
@@ -1,4 +1,5 @@
 from .compat import raise_  # noqa
+from .editor import open_in_editor  # noqa
 from .exc import CommandError
 from .langhelpers import _with_legacy_names  # noqa
 from .langhelpers import asbool  # noqa
@@ -19,7 +20,6 @@ from .messaging import status  # noqa
 from .messaging import warn  # noqa
 from .messaging import write_outstream  # noqa
 from .pyfiles import coerce_resource_to_filename  # noqa
-from .pyfiles import edit  # noqa
 from .pyfiles import load_python_file  # noqa
 from .pyfiles import pyc_file_from_path  # noqa
 from .pyfiles import template_to_file  # noqa
diff --git a/alembic/util/editor.py b/alembic/util/editor.py
new file mode 100644 (file)
index 0000000..c27f0f3
--- /dev/null
@@ -0,0 +1,71 @@
+import os
+from os.path import exists
+from os.path import join
+from os.path import splitext
+from subprocess import check_call
+
+from .compat import is_posix
+from .exc import CommandError
+
+
+def open_in_editor(filename, environ=None):
+    """
+    Opens the given file in a text editor. If the environment variable
+    ``EDITOR`` is set, this is taken as preference.
+
+    Otherwise, a list of commonly installed editors is tried.
+
+    If no editor matches, an :py:exc:`OSError` is raised.
+
+    :param filename: The filename to open. Will be passed  verbatim to the
+        editor command.
+    :param environ: An optional drop-in replacement for ``os.environ``. Used
+        mainly for testing.
+    """
+
+    try:
+        editor = _find_editor(environ)
+        check_call([editor, filename])
+    except Exception as exc:
+        raise CommandError("Error executing editor (%s)" % (exc,)) from exc
+
+
+def _find_editor(environ=None):
+    candidates = _default_editors()
+    for i, var in enumerate(("EDITOR", "VISUAL")):
+        if var in environ:
+            user_choice = environ[var]
+            if exists(user_choice):
+                return user_choice
+            if os.sep not in user_choice:
+                candidates.insert(i, user_choice)
+
+    for candidate in candidates:
+        path = _find_executable(candidate, environ)
+        if path is not None:
+            return path
+    raise OSError(
+        "No suitable editor found. Please set the "
+        '"EDITOR" or "VISUAL" environment variables'
+    )
+
+
+def _find_executable(candidate, environ):
+    # Assuming this is on the PATH, we need to determine it's absolute
+    # location. Otherwise, ``check_call`` will fail
+    if not is_posix and splitext(candidate)[1] != ".exe":
+        candidate += ".exe"
+    for path in environ.get("PATH", "").split(os.pathsep):
+        value = join(path, candidate)
+        if exists(value):
+            return value
+    return None
+
+
+def _default_editors():
+    # Look for an editor. Prefer the user's choice by env-var, fall back to
+    # most commonly installed editor (nano/vim)
+    if is_posix:
+        return ["sensible-editor", "editor", "nano", "vim", "code"]
+    else:
+        return ["code.exe", "notepad++.exe", "notepad.exe"]
index 8dfb9db0b3f40677ce1e819df2f48c187e0ff63f..1bc5be36c110c29d981778b33d2c73d6a44e5d35 100644 (file)
@@ -10,7 +10,6 @@ from .compat import has_pep3147
 from .compat import load_module_py
 from .compat import load_module_pyc
 from .compat import py3k
-from .compat import raise_
 from .exc import CommandError
 
 
@@ -75,17 +74,6 @@ def pyc_file_from_path(path):
         return None
 
 
-def edit(path):
-    """Given a source path, run the EDITOR for it"""
-
-    import editor
-
-    try:
-        editor.edit(path)
-    except Exception as exc:
-        raise_(CommandError("Error executing editor (%s)" % (exc,)), from_=exc)
-
-
 def load_python_file(dir_, filename):
     """Load a file from the given path as a Python module."""
 
index 2ef84193c1a80e49e5e91a6cda70012b084a052b..df5d115832d6f149d8b70bd07a1dc0218516fd77 100644 (file)
@@ -137,8 +137,10 @@ The file generated with the "generic" configuration looks like::
     # (new in 1.5.5)
     prepend_sys_path = .
 
-    # timezone to use when rendering the date
-    # within the migration file as well as the filename.
+    # timezone to use when rendering the date within the migration file
+    # as well as the filename.
+    # If specified, requires the python-dateutil library that can be
+    # installed by adding `alembic[tz]` to the pip requirements
     # string value is passed to dateutil.tz.gettz()
     # leave blank for localtime
     # timezone =
@@ -253,7 +255,8 @@ 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.  If ``timezone`` is specified,
+  file's comment as well as within the filename. This option requires installing
+  the ``python-dateutil`` library. If ``timezone`` is specified,
   the create date object is no longer derived from ``datetime.datetime.now()``
   and is instead generated as::
 
diff --git a/docs/build/unreleased/674.rst b/docs/build/unreleased/674.rst
new file mode 100644 (file)
index 0000000..8ed09db
--- /dev/null
@@ -0,0 +1,9 @@
+.. change::
+    :tags: changed
+    :tickets: 674
+
+    Make the ``python-dateutil`` library an optional dependency.
+    This library is only required if the ``timezone`` option
+    is used in the Alembic configuration.
+    An extra require named ``tz`` is available with
+    ``pip install alembic[tz]`` to install it.
diff --git a/docs/build/unreleased/856.rst b/docs/build/unreleased/856.rst
new file mode 100644 (file)
index 0000000..b756fc3
--- /dev/null
@@ -0,0 +1,6 @@
+.. change::
+    :tags: bug, commands
+    :tickets: 856
+
+    Re-implemented the ``python-editor`` dependency as a small internal
+    function to avoid the need for external dependencies.
index 59be53600f03b6ecc041c85e082170602984f471..b168de5b6206857489c75e25171f7e911542d45c 100644 (file)
--- a/setup.cfg
+++ b/setup.cfg
@@ -44,7 +44,9 @@ python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*
 install_requires =
     SQLAlchemy>=1.3.0
     Mako
-    python-editor>=0.3
+
+[options.extras_require]
+tz =
     python-dateutil
 
 [options.packages.find]
index 4114be070efcbdcb2999a1894a5eef6f26540875..a477a1d3aac54122662d1a460f4c22778e8c3395 100644 (file)
@@ -11,12 +11,10 @@ from sqlalchemy import text
 from alembic import __version__
 from alembic import command
 from alembic import config
-from alembic import testing
 from alembic import util
 from alembic.script import ScriptDirectory
 from alembic.testing import assert_raises
 from alembic.testing import assert_raises_message
-from alembic.testing import config as testing_config
 from alembic.testing import eq_
 from alembic.testing import is_false
 from alembic.testing import is_true
@@ -924,7 +922,7 @@ class EditTest(TestBase):
             % (EditTest.cfg.config_args["here"], EditTest.c)
         )
 
-        with mock.patch("alembic.util.edit") as edit:
+        with mock.patch("alembic.util.open_in_editor") as edit:
             command.edit(self.cfg, "head")
             edit.assert_called_with(expected_call_arg)
 
@@ -934,22 +932,10 @@ class EditTest(TestBase):
             % (EditTest.cfg.config_args["here"], EditTest.b)
         )
 
-        with mock.patch("alembic.util.edit") as edit:
+        with mock.patch("alembic.util.open_in_editor") as edit:
             command.edit(self.cfg, self.b[0:3])
             edit.assert_called_with(expected_call_arg)
 
-    @testing_config.requirements.editor_installed
-    @testing.emits_python_deprecation_warning("the imp module is deprecated")
-    def test_edit_with_missing_editor(self):
-        with mock.patch("editor.edit") as edit_mock:
-            edit_mock.side_effect = OSError("file not found")
-            assert_raises_message(
-                util.CommandError,
-                "file not found",
-                util.edit,
-                "/not/a/file.txt",
-            )
-
     def test_edit_no_revs(self):
         assert_raises_message(
             util.CommandError,
@@ -975,7 +961,7 @@ class EditTest(TestBase):
         )
 
         command.stamp(self.cfg, self.b)
-        with mock.patch("alembic.util.edit") as edit:
+        with mock.patch("alembic.util.open_in_editor") as edit:
             command.edit(self.cfg, "current")
             edit.assert_called_with(expected_call_arg)
 
diff --git a/tests/test_editor.py b/tests/test_editor.py
new file mode 100644 (file)
index 0000000..0ec6f5f
--- /dev/null
@@ -0,0 +1,106 @@
+import os
+from os.path import join
+from unittest.mock import patch
+
+from alembic import util
+from alembic.testing import combinations
+from alembic.testing import expect_raises_message
+from alembic.testing.fixtures import TestBase
+
+
+class TestHelpers(TestBase):
+    def common(self, cb, is_posix=True):
+        with patch("alembic.util.editor.check_call") as check_call, patch(
+            "alembic.util.editor.exists"
+        ) as exists, patch(
+            "alembic.util.editor.is_posix",
+            new=is_posix,
+        ), patch(
+            "os.pathsep", new=":" if is_posix else ";"
+        ):
+            cb(check_call, exists)
+
+    @combinations((True,), (False,))
+    def test_edit_with_user_editor(self, posix):
+        def go(check_call, exists):
+            test_environ = {"EDITOR": "myvim", "PATH": "/usr/bin"}
+            executable = join("/usr/bin", "myvim")
+            if not posix:
+                executable += ".exe"
+
+            exists.side_effect = lambda fname: fname == executable
+            util.open_in_editor("myfile", test_environ)
+            check_call.assert_called_with([executable, "myfile"])
+
+        self.common(go, posix)
+
+    @combinations(("EDITOR",), ("VISUAL",))
+    def test_edit_with_user_editor_exists(self, key):
+        def go(check_call, exists):
+            test_environ = {key: "myvim", "PATH": "/usr/bin"}
+            exists.side_effect = lambda fname: fname == "myvim"
+            util.open_in_editor("myfile", test_environ)
+            check_call.assert_called_with(["myvim", "myfile"])
+
+        self.common(go)
+
+    @combinations((True,), (False,))
+    def test_edit_with_user_editor_precedence(self, with_path):
+        def go(check_call, exists):
+            test_environ = {
+                "EDITOR": "myvim",
+                "VISUAL": "myvisual",
+                "PATH": "/usr/bin",
+            }
+            exes = ["myvim", "myvisual"]
+            if with_path:
+                exes = [join("/usr/bin", n) for n in exes]
+            exists.side_effect = lambda fname: fname in exes
+            util.open_in_editor("myfile", test_environ)
+            check_call.assert_called_with([exes[0], "myfile"])
+
+        self.common(go)
+
+    def test_edit_with_user_editor_abs(self):
+        def go(check_call, exists):
+            test_environ = {"EDITOR": "/foo/myvim", "PATH": "/usr/bin"}
+            exists.side_effect = lambda fname: fname == "/usr/bin/foo/myvim"
+            with expect_raises_message(util.CommandError, "EDITOR"):
+                util.open_in_editor("myfile", test_environ)
+
+        self.common(go)
+
+    def test_edit_with_default_editor(self):
+        def go(check_call, exists):
+            test_environ = {"PATH": os.pathsep.join(["/usr/bin", "/bin"])}
+            executable = join("/bin", "vim")
+
+            exists.side_effect = lambda fname: fname == executable
+            util.open_in_editor("myfile", test_environ)
+            check_call.assert_called_with([executable, "myfile"])
+
+        self.common(go)
+
+    def test_edit_with_default_editor_windows(self):
+        def go(check_call, exists):
+            test_environ = {
+                "PATH": os.pathsep.join(
+                    [r"C:\Windows\System32", r"C:\Users\user\bin"]
+                )
+            }
+            executable = join(r"C:\Users\user\bin", "notepad.exe")
+
+            exists.side_effect = lambda fname: fname == executable
+            util.open_in_editor("myfile", test_environ)
+            check_call.assert_called_with([executable, "myfile"])
+
+        self.common(go, False)
+
+    def test_edit_with_missing_editor(self):
+        def go(check_call, exists):
+            test_environ = {}
+            exists.return_value = False
+            with expect_raises_message(util.CommandError, "EDITOR"):
+                util.open_in_editor("myfile", test_environ)
+
+        self.common(go)
index 15d9366a9ec6bd7c94d7a45719551ca894b663a5..2edae26779fdf4ed9b5e22c9bb5c323982bf9843 100644 (file)
@@ -15,6 +15,7 @@ from alembic.script import ScriptDirectory
 from alembic.testing import assert_raises_message
 from alembic.testing import assertions
 from alembic.testing import eq_
+from alembic.testing import expect_raises_message
 from alembic.testing import is_
 from alembic.testing import mock
 from alembic.testing import ne_
@@ -34,6 +35,10 @@ from alembic.testing.env import write_script
 from alembic.testing.fixtures import TestBase
 from alembic.util import CommandError
 
+try:
+    from unittest.mock import patch
+except ImportError:
+    from mock import patch  # noqa
 env, abc, def_ = None, None, None
 
 
@@ -250,6 +255,17 @@ class ScriptNamingTest(TestBase):
             datetime.datetime(2012, 7, 25, 15, 8, 5),
         )
 
+    def test_no_dateutil_module(self):
+        with patch("alembic.script.base.tz", new=None):
+            with expect_raises_message(
+                CommandError, "The library 'python-dateutil' is required"
+            ):
+                self._test_tz(
+                    "utc",
+                    datetime.datetime(2012, 7, 25, 15, 8, 5),
+                    datetime.datetime(2012, 7, 25, 15, 8, 5),
+                )
+
 
 class RevisionCommandTest(TestBase):
     def setUp(self):
diff --git a/tox.ini b/tox.ini
index ec69f94ea09b914f10582beb8b2210909c33f841..596942a8a7c535b3e8ff8879a241a56e348b52dd 100644 (file)
--- a/tox.ini
+++ b/tox.ini
@@ -22,7 +22,6 @@ deps=pytest>4.6
      cov: pytest-cov
      sqlalchemy: sqlalchemy>=1.3.0
      mako
-     python-editor>=0.3
      python-dateutil