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):
"No revision files indicated by symbol '%s'" % rev
)
for sc in revs:
- util.edit(sc.path)
+ util.open_in_editor(sc.path)
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$")
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:
# 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 =
# 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 =
# 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 =
# 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 =
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(
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
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
--- /dev/null
+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"]
from .compat import load_module_py
from .compat import load_module_pyc
from .compat import py3k
-from .compat import raise_
from .exc import CommandError
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."""
# (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 =
* ``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::
--- /dev/null
+.. 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.
--- /dev/null
+.. change::
+ :tags: bug, commands
+ :tickets: 856
+
+ Re-implemented the ``python-editor`` dependency as a small internal
+ function to avoid the need for external dependencies.
install_requires =
SQLAlchemy>=1.3.0
Mako
- python-editor>=0.3
+
+[options.extras_require]
+tz =
python-dateutil
[options.packages.find]
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
% (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)
% (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,
)
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)
--- /dev/null
+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)
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_
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
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):
cov: pytest-cov
sqlalchemy: sqlalchemy>=1.3.0
mako
- python-editor>=0.3
python-dateutil