From d6b0c1af3df98b50c6ec52781aa411592c4e0c32 Mon Sep 17 00:00:00 2001 From: Mike Bayer Date: Sat, 20 Feb 2021 15:48:58 -0500 Subject: [PATCH] Implement sys_path_prepend option 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. Change-Id: If8f4279bd862acca44d46f4b1ab90b0a18098af3 Fixes: #797 --- alembic/script/base.py | 9 +++++ alembic/templates/generic/alembic.ini.mako | 4 ++ alembic/templates/multidb/alembic.ini.mako | 4 ++ alembic/templates/pylons/alembic.ini.mako | 4 ++ docs/build/front.rst | 43 ++++++++++----------- docs/build/tutorial.rst | 5 +++ docs/build/unreleased/797.rst | 25 ++++++++++++ tests/test_environment.py | 45 ++++++++++++++++++++++ 8 files changed, 117 insertions(+), 22 deletions(-) create mode 100644 docs/build/unreleased/797.rst 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