diff --git a/alembic/script/base.py b/alembic/script/base.py index a9b5705d..3f618643 100644 --- a/alembic/script/base.py +++ b/alembic/script/base.py @@ -3,6 +3,7 @@ import os import re import shutil +import sys from dateutil import tz @@ -20,6 +21,8 @@ _default_file_template = "%(rev)s_%(slug)s" _split_on_space_comma = re.compile(r", *|(?: +)") +_split_on_space_comma_colon = re.compile(r", *|(?: +)|\:") + class ScriptDirectory(object): @@ -136,6 +139,12 @@ def from_config(cls, config): if version_locations: version_locations = _split_on_space_comma.split(version_locations) + prepend_sys_path = config.get_main_option("prepend_sys_path") + if prepend_sys_path: + sys.path[:0] = list( + _split_on_space_comma_colon.split(prepend_sys_path) + ) + return ScriptDirectory( util.coerce_resource_to_filename(script_location), file_template=config.get_main_option( diff --git a/alembic/templates/generic/alembic.ini.mako b/alembic/templates/generic/alembic.ini.mako index 281794fb..bf7e5d1c 100644 --- a/alembic/templates/generic/alembic.ini.mako +++ b/alembic/templates/generic/alembic.ini.mako @@ -7,6 +7,10 @@ script_location = ${script_location} # template used to generate migration files # file_template = %%(rev)s_%%(slug)s +# sys.path path, will be prepended to sys.path if present. +# 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. # string value is passed to dateutil.tz.gettz() diff --git a/alembic/templates/multidb/alembic.ini.mako b/alembic/templates/multidb/alembic.ini.mako index 0b0919e0..ec3c5193 100644 --- a/alembic/templates/multidb/alembic.ini.mako +++ b/alembic/templates/multidb/alembic.ini.mako @@ -7,6 +7,10 @@ script_location = ${script_location} # template used to generate migration files # file_template = %%(rev)s_%%(slug)s +# sys.path path, will be prepended to sys.path if present. +# 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. # string value is passed to dateutil.tz.gettz() diff --git a/alembic/templates/pylons/alembic.ini.mako b/alembic/templates/pylons/alembic.ini.mako index 70fead0b..c37397d8 100644 --- a/alembic/templates/pylons/alembic.ini.mako +++ b/alembic/templates/pylons/alembic.ini.mako @@ -7,6 +7,10 @@ script_location = ${script_location} # template used to generate migration files # file_template = %%(rev)s_%%(slug)s +# sys.path path, will be prepended to sys.path if present. +# 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. # string value is passed to dateutil.tz.gettz() diff --git a/docs/build/front.rst b/docs/build/front.rst index 09d7b6bc..a56a85fb 100644 --- a/docs/build/front.rst +++ b/docs/build/front.rst @@ -19,26 +19,14 @@ The most recent published version of this documentation should be at https://ale Installation ============ -Installation of Alembic is typically local to a project setup and it is usually -assumed that an approach like `virtual environments -`_ are used, which would include -that the target project also `has a setup.py script -`_. - -.. note:: - - While the ``alembic`` command line tool runs perfectly fine no matter where - its installed, the rationale for project-local setup is that the Alembic - command line tool runs most of its key operations through a Python file - ``env.py`` that is established as part of a project's setup when the - ``alembic init`` command is run for that project; the purpose of - ``env.py`` is to establish database connectivity and optionally model - definitions for the migration process, the latter of which in particular - usually rely upon being able to import the modules of the project itself. - - -The documentation below is **only one kind of approach to installing Alembic for a -project**; there are many such approaches. The documentation below is +While Alembic can be installed system wide, it's more common that it's +installed local to a `virtual environment +`_ , as it also uses libraries +such as SQLAlchemy and database drivers that are more appropriate for +local installations. + +The documentation below is **only one kind of approach to installing Alembic +for a project**; there are many such approaches. The documentation below is provided only for those users who otherwise have no specific project setup chosen. @@ -64,11 +52,22 @@ proceed through the usage of this command, as in:: $ /path/to/your/project/.venv/bin/alembic init . -Next, we ensure that the local project is also installed, in a development environment -this would be in `editable mode `_:: +The next step is **optional**. If our project itself has a ``setup.py`` +file, we can also install it in the local virtual environment in +`editable mode `_:: $ /path/to/your/project/.venv/bin/pip install -e . +If we don't "install" the project locally, that's fine as well; the default +``alembic.ini`` file includes a directive ``prepend_sys_path = .`` so that the +local path is also in ``sys.path``. This allows us to run the ``alembic`` +command line tool from this directory without our project being "installed" in +that environment. + +.. versionchanged:: 1.5.5 Fixed a long-standing issue where the ``alembic`` + command-line tool would not preserve the default ``sys.path`` of ``.`` + by implementing ``prepend_sys_path`` option. + As a final step, the `virtualenv activate `_ tool can be used so that the ``alembic`` command is available without any path information, within the context of the current shell:: diff --git a/docs/build/tutorial.rst b/docs/build/tutorial.rst index 487eb394..54da1112 100644 --- a/docs/build/tutorial.rst +++ b/docs/build/tutorial.rst @@ -132,6 +132,11 @@ The file generated with the "generic" configuration looks like:: # template used to generate migration files # file_template = %%(rev)s_%%(slug)s + # sys.path path, will be prepended to sys.path if present. + # defaults to the current working directory. + # (new in 1.5.5) + prepend_sys_path = . + # timezone to use when rendering the date # within the migration file as well as the filename. # string value is passed to dateutil.tz.gettz() diff --git a/docs/build/unreleased/797.rst b/docs/build/unreleased/797.rst new file mode 100644 index 00000000..9f01e41b --- /dev/null +++ b/docs/build/unreleased/797.rst @@ -0,0 +1,25 @@ +.. change:: + :tags: bug, environment + :tickets: 797 + + Added new config file option ``prepend_sys_path``, which is a series of + paths that will be prepended to sys.path; the default value in newly + generated alembic.ini files is ".". This fixes a long-standing issue + where for some reason running the alembic command line would not place the + local "." path in sys.path, meaning an application locally present in "." + and importable through normal channels, e.g. python interpreter, pytest, + etc. would not be located by Alembic, even though the ``env.py`` file is + loaded relative to the current path when ``alembic.ini`` contains a + relative path. To enable for existing installations, add the option to the + alembic.ini file as follows:: + + # sys.path path, will be prepended to sys.path if present. + # defaults to the current working directory. + prepend_sys_path = . + + .. seealso:: + + :ref:`installation` - updated documentation reflecting that local + installation of the project is not necessary if running the Alembic cli + from the local path. + diff --git a/tests/test_environment.py b/tests/test_environment.py index 63de6cd8..e90d6fb0 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -1,4 +1,7 @@ #!coding: utf-8 +import os +import sys + from alembic import command from alembic import testing from alembic import util @@ -13,8 +16,10 @@ from alembic.testing import is_true from alembic.testing import mock from alembic.testing.assertions import expect_raises_message +from alembic.testing.env import _get_staging_directory from alembic.testing.env import _no_sql_testing_config from alembic.testing.env import _sqlite_file_db +from alembic.testing.env import _sqlite_testing_config from alembic.testing.env import clear_staging_env from alembic.testing.env import staging_env from alembic.testing.env import write_script @@ -143,6 +148,46 @@ def upgrade(rev, context): ) +class CWDTest(TestBase): + def setUp(self): + self.env = staging_env() + self.cfg = _sqlite_testing_config() + + def tearDown(self): + clear_staging_env() + + @testing.combinations( + ( + ".", + ["."], + ), + ("/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"]), + ) + def test_sys_path_prepend(self, config_value, expected): + self.cfg.set_main_option("prepend_sys_path", config_value) + + script = ScriptDirectory.from_config(self.cfg) + env = EnvironmentContext(self.cfg, script) + + target = os.path.abspath(_get_staging_directory()) + + def assert_(heads, context): + eq_( + [os.path.abspath(p) for p in sys.path[0 : len(expected)]], + [os.path.abspath(p) for p in expected], + ) + return [] + + p = [p for p in sys.path if os.path.abspath(p) != target] + with mock.patch.object(sys, "path", p): + env.configure(url="sqlite://", fn=assert_) + with env: + script.run_env() + + class MigrationTransactionTest(TestBase): __backend__ = True