From 491d331e6e0a7915c9e6d9b3287688ac7a3f56ca Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 14 Jul 2017 22:37:53 -0700 Subject: [PATCH] load env vars using python-dotenv --- .gitignore | 2 + .travis.yml | 5 ++ CHANGES | 4 ++ docs/api.rst | 2 + docs/cli.rst | 34 +++++++++++-- docs/installation.rst | 3 ++ flask/app.py | 42 ++++++++++------ flask/cli.py | 101 ++++++++++++++++++++++++++++++++------ setup.py | 2 + tests/test_apps/.env | 3 ++ tests/test_apps/.flaskenv | 3 ++ tests/test_cli.py | 73 ++++++++++++++++++++++++--- tox.ini | 3 +- 13 files changed, 235 insertions(+), 42 deletions(-) create mode 100644 tests/test_apps/.env create mode 100644 tests/test_apps/.flaskenv diff --git a/.gitignore b/.gitignore index fb9baf3540..231c0873f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .DS_Store +.env +.flaskenv *.pyc *.pyo env diff --git a/.travis.yml b/.travis.yml index 9984bc3c23..ed690253ab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -33,6 +33,11 @@ script: cache: - pip +branches: + only: + - master + - /^.*-maintenance$/ + notifications: email: false irc: diff --git a/CHANGES b/CHANGES index dc11d5e41a..a282617e66 100644 --- a/CHANGES +++ b/CHANGES @@ -101,6 +101,9 @@ Major release, unreleased - The ``request.json`` property is no longer deprecated. (`#1421`_) - Support passing an existing ``EnvironBuilder`` or ``dict`` to ``test_client.open``. (`#2412`_) +- The ``flask`` command and ``app.run`` will load environment variables using + from ``.env`` and ``.flaskenv`` files if python-dotenv is installed. + (`#2416`_) .. _#1421: https://github.com/pallets/flask/issues/1421 .. _#1489: https://github.com/pallets/flask/pull/1489 @@ -130,6 +133,7 @@ Major release, unreleased .. _#2385: https://github.com/pallets/flask/issues/2385 .. _#2412: https://github.com/pallets/flask/pull/2412 .. _#2414: https://github.com/pallets/flask/pull/2414 +.. _#2416: https://github.com/pallets/flask/pull/2416 Version 0.12.2 -------------- diff --git a/docs/api.rst b/docs/api.rst index 4173b7bbcd..e24160c42f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -814,6 +814,8 @@ Command Line Interface .. autoclass:: ScriptInfo :members: +.. autofunction:: load_dotenv + .. autofunction:: with_appcontext .. autofunction:: pass_script_info diff --git a/docs/cli.rst b/docs/cli.rst index 52885f43be..b481991d10 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -97,9 +97,8 @@ Custom Commands --------------- If you want to add more commands to the shell script you can do this -easily. Flask uses `click`_ for the command interface which makes -creating custom commands very easy. For instance if you want a shell -command to initialize the database you can do this:: +easily. For instance if you want a shell command to initialize the database you +can do this:: import click from flask import Flask @@ -134,6 +133,35 @@ decorator:: def example(): pass + +.. _dotenv: + +Loading Environment Variables From ``.env`` Files +------------------------------------------------- + +If `python-dotenv`_ is installed, running the :command:`flask` command will set +environment variables defined in the files :file:`.env` and :file:`.flaskenv`. +This can be used to avoid having to set ``FLASK_APP`` manually every time you +open a new terminal, and to set configuration using environment variables +similar to how some deployment services work. + +Variables set on the command line are used over those set in :file:`.env`, +which are used over those set in :file:`.flaskenv`. :file:`.flaskenv` should be +used for public variables, such as ``FLASK_APP``, while :file:`.env` should not +be committed to your repository so that it can set private variables. + +Directories are scanned upwards from the directory you call :command:`flask` +from to locate the files. The current working directory will be set to the +location of the file, with the assumption that that is the top level project +directory. + +The files are only loaded by the :command:`flask` command or calling +:meth:`~flask.Flask.run`. If you would like to load these files when running in +production, you should call :func:`~flask.cli.load_dotenv` manually. + +.. _python-dotenv: https://github.com/theskumar/python-dotenv#readme + + Factory Functions ----------------- diff --git a/docs/installation.rst b/docs/installation.rst index cd869b9ab0..0ae05f067a 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -41,9 +41,12 @@ use them if you install them. * `SimpleJSON`_ is a fast JSON implementation that is compatible with Python's ``json`` module. It is preferred for JSON operations if it is installed. +* `python-dotenv`_ enables support for :ref:`dotenv` when running ``flask`` + commands. .. _Blinker: https://pythonhosted.org/blinker/ .. _SimpleJSON: https://simplejson.readthedocs.io/ +.. _python-dotenv: https://github.com/theskumar/python-dotenv#readme Virtual environments -------------------- diff --git a/flask/app.py b/flask/app.py index 9dfc37836f..afa5fd1cf2 100644 --- a/flask/app.py +++ b/flask/app.py @@ -820,7 +820,9 @@ def _reconfigure_for_run_debug(self, debug): self.debug = debug self.jinja_env.auto_reload = self.templates_auto_reload - def run(self, host=None, port=None, debug=None, **options): + def run( + self, host=None, port=None, debug=None, load_dotenv=True, **options + ): """Runs the application on a local development server. Do not use ``run()`` in a production setting. It is not intended to @@ -849,30 +851,40 @@ def run(self, host=None, port=None, debug=None, **options): won't catch any exceptions because there won't be any to catch. + :param host: the hostname to listen on. Set this to ``'0.0.0.0'`` to + have the server available externally as well. Defaults to + ``'127.0.0.1'`` or the host in the ``SERVER_NAME`` config variable + if present. + :param port: the port of the webserver. Defaults to ``5000`` or the + port defined in the ``SERVER_NAME`` config variable if present. + :param debug: if given, enable or disable debug mode. See + :attr:`debug`. + :param load_dotenv: Load the nearest :file:`.env` and :file:`.flaskenv` + files to set environment variables. Will also change the working + directory to the directory containing the first file found. + :param options: the options to be forwarded to the underlying Werkzeug + server. See :func:`werkzeug.serving.run_simple` for more + information. + + .. versionchanged:: 1.0 + If installed, python-dotenv will be used to load environment + variables from :file:`.env` and :file:`.flaskenv` files. + .. versionchanged:: 0.10 The default port is now picked from the ``SERVER_NAME`` variable. - :param host: the hostname to listen on. Set this to ``'0.0.0.0'`` to - have the server available externally as well. Defaults to - ``'127.0.0.1'`` or the host in the ``SERVER_NAME`` config - variable if present. - :param port: the port of the webserver. Defaults to ``5000`` or the - port defined in the ``SERVER_NAME`` config variable if - present. - :param debug: if given, enable or disable debug mode. - See :attr:`debug`. - :param options: the options to be forwarded to the underlying - Werkzeug server. See - :func:`werkzeug.serving.run_simple` for more - information. """ # Change this into a no-op if the server is invoked from the # command line. Have a look at cli.py for more information. - if os.environ.get('FLASK_RUN_FROM_CLI_SERVER') == '1': + if os.environ.get('FLASK_RUN_FROM_CLI') == 'true': from .debughelpers import explain_ignored_app_run explain_ignored_app_run() return + if load_dotenv: + from flask.cli import load_dotenv + load_dotenv() + if debug is not None: self._reconfigure_for_run_debug(bool(debug)) diff --git a/flask/cli.py b/flask/cli.py index 3568c10f07..bea4a29f94 100644 --- a/flask/cli.py +++ b/flask/cli.py @@ -8,6 +8,7 @@ :copyright: (c) 2015 by Armin Ronacher. :license: BSD, see LICENSE for more details. """ +from __future__ import print_function import ast import inspect @@ -22,10 +23,14 @@ import click from . import __version__ -from ._compat import iteritems, reraise +from ._compat import getargspec, iteritems, reraise from .globals import current_app from .helpers import get_debug_flag -from ._compat import getargspec + +try: + import dotenv +except ImportError: + dotenv = None class NoAppException(click.UsageError): @@ -394,14 +399,23 @@ class FlaskGroup(AppGroup): For information as of why this is useful see :ref:`custom-scripts`. :param add_default_commands: if this is True then the default run and - shell commands wil be added. + shell commands wil be added. :param add_version_option: adds the ``--version`` option. - :param create_app: an optional callback that is passed the script info - and returns the loaded app. + :param create_app: an optional callback that is passed the script info and + returns the loaded app. + :param load_dotenv: Load the nearest :file:`.env` and :file:`.flaskenv` + files to set environment variables. Will also change the working + directory to the directory containing the first file found. + + .. versionchanged:: 1.0 + If installed, python-dotenv will be used to load environment variables + from :file:`.env` and :file:`.flaskenv` files. """ - def __init__(self, add_default_commands=True, create_app=None, - add_version_option=True, **extra): + def __init__( + self, add_default_commands=True, create_app=None, + add_version_option=True, load_dotenv=True, **extra + ): params = list(extra.pop('params', None) or ()) if add_version_option: @@ -409,6 +423,7 @@ def __init__(self, add_default_commands=True, create_app=None, AppGroup.__init__(self, params=params, **extra) self.create_app = create_app + self.load_dotenv = load_dotenv if add_default_commands: self.add_command(run_command) @@ -472,12 +487,75 @@ def list_commands(self, ctx): return sorted(rv) def main(self, *args, **kwargs): + # Set a global flag that indicates that we were invoked from the + # command line interface. This is detected by Flask.run to make the + # call into a no-op. This is necessary to avoid ugly errors when the + # script that is loaded here also attempts to start a server. + os.environ['FLASK_RUN_FROM_CLI'] = 'true' + + if self.load_dotenv: + load_dotenv() + obj = kwargs.get('obj') + if obj is None: obj = ScriptInfo(create_app=self.create_app) + kwargs['obj'] = obj kwargs.setdefault('auto_envvar_prefix', 'FLASK') - return AppGroup.main(self, *args, **kwargs) + return super(FlaskGroup, self).main(*args, **kwargs) + + +def _path_is_ancestor(path, other): + """Take ``other`` and remove the length of ``path`` from it. Then join it + to ``path``. If it is the original value, ``path`` is an ancestor of + ``other``.""" + return os.path.join(path, other[len(path):].lstrip(os.sep)) == other + + +def load_dotenv(path=None): + """Load "dotenv" files in order of precedence to set environment variables. + + If an env var is already set it is not overwritten, so earlier files in the + list are preferred over later files. + + Changes the current working directory to the location of the first file + found, with the assumption that it is in the top level project directory + and will be where the Python path should import local packages from. + + This is a no-op if `python-dotenv`_ is not installed. + + .. _python-dotenv: https://github.com/theskumar/python-dotenv#readme + + :param path: Load the file at this location instead of searching. + :return: ``True`` if a file was loaded. + + .. versionadded:: 1.0 + """ + + if dotenv is None: + return + + if path is not None: + return dotenv.load_dotenv(path) + + new_dir = None + + for name in ('.env', '.flaskenv'): + path = dotenv.find_dotenv(name, usecwd=True) + + if not path: + continue + + if new_dir is None: + new_dir = os.path.dirname(path) + + dotenv.load_dotenv(path) + + if new_dir and os.getcwd() != new_dir: + os.chdir(new_dir) + + return new_dir is not None # at least one file was located and loaded @click.command('run', short_help='Runs a development server.') @@ -512,13 +590,6 @@ def run_command(info, host, port, reload, debugger, eager_loading, """ from werkzeug.serving import run_simple - # Set a global flag that indicates that we were invoked from the - # command line interface provided server command. This is detected - # by Flask.run to make the call into a no-op. This is necessary to - # avoid ugly errors when the script that is loaded here also attempts - # to start a server. - os.environ['FLASK_RUN_FROM_CLI_SERVER'] = '1' - debug = get_debug_flag() if reload is None: reload = bool(debug) diff --git a/setup.py b/setup.py index acade8464d..ff2f310758 100644 --- a/setup.py +++ b/setup.py @@ -75,8 +75,10 @@ def hello(): 'click>=4.0', ], extras_require={ + 'dotenv': ['python-dotenv'], 'dev': [ 'blinker', + 'python-dotenv', 'greenlet', 'pytest>=3', 'coverage', diff --git a/tests/test_apps/.env b/tests/test_apps/.env new file mode 100644 index 0000000000..13ac34837b --- /dev/null +++ b/tests/test_apps/.env @@ -0,0 +1,3 @@ +FOO=env +SPAM=1 +EGGS=2 diff --git a/tests/test_apps/.flaskenv b/tests/test_apps/.flaskenv new file mode 100644 index 0000000000..59f96af759 --- /dev/null +++ b/tests/test_apps/.flaskenv @@ -0,0 +1,3 @@ +FOO=flaskenv +BAR=bar +EGGS=0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 5fba52290d..c66bd17ed8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,11 +19,31 @@ import click import pytest +from _pytest.monkeypatch import notset from click.testing import CliRunner from flask import Flask, current_app -from flask.cli import AppGroup, FlaskGroup, NoAppException, ScriptInfo, \ - find_best_app, get_version, locate_app, prepare_import, with_appcontext +from flask.cli import AppGroup, FlaskGroup, NoAppException, ScriptInfo, dotenv, \ + find_best_app, get_version, load_dotenv, locate_app, prepare_import, \ + with_appcontext + +cwd = os.getcwd() +test_path = os.path.abspath(os.path.join( + os.path.dirname(__file__), 'test_apps' +)) + + +@pytest.fixture(autouse=True) +def manage_os_environ(monkeypatch): + # can't use monkeypatch.delitem since we don't want to restore a value + os.environ.pop('FLASK_APP', None) + os.environ.pop('FLASK_DEBUG', None) + # use monkeypatch internals to force-delete environ keys + monkeypatch._setitem.extend(( + (os.environ, 'FLASK_APP', notset), + (os.environ, 'FLASK_DEBUG', notset), + (os.environ, 'FLASK_RUN_FROM_CLI', notset), + )) @pytest.fixture @@ -125,12 +145,6 @@ def create_app(foo, bar): pytest.raises(NoAppException, find_best_app, script_info, Module) -cwd = os.getcwd() -test_path = os.path.abspath(os.path.join( - os.path.dirname(__file__), 'test_apps' -)) - - @pytest.mark.parametrize('value,path,result', ( ('test', cwd, 'test'), ('test.py', cwd, 'test'), @@ -414,3 +428,46 @@ def test_all_methods(self, invoke): assert 'GET, HEAD, OPTIONS, POST' not in output output = invoke(['routes', '--all-methods']).output assert 'GET, HEAD, OPTIONS, POST' in output + + +need_dotenv = pytest.mark.skipif( + dotenv is None, reason='dotenv is not installed' +) + + +@need_dotenv +def test_load_dotenv(monkeypatch): + # can't use monkeypatch.delitem since the keys don't exist yet + for item in ('FOO', 'BAR', 'SPAM'): + monkeypatch._setitem.append((os.environ, item, notset)) + + monkeypatch.setenv('EGGS', '3') + monkeypatch.chdir(os.path.join(test_path, 'cliapp', 'inner1')) + load_dotenv() + assert os.getcwd() == test_path + # .flaskenv doesn't overwrite .env + assert os.environ['FOO'] == 'env' + # set only in .flaskenv + assert os.environ['BAR'] == 'bar' + # set only in .env + assert os.environ['SPAM'] == '1' + # set manually, files don't overwrite + assert os.environ['EGGS'] == '3' + + +@need_dotenv +def test_dotenv_path(monkeypatch): + for item in ('FOO', 'BAR', 'EGGS'): + monkeypatch._setitem.append((os.environ, item, notset)) + + cwd = os.getcwd() + load_dotenv(os.path.join(test_path, '.flaskenv')) + assert os.getcwd() == cwd + assert 'FOO' in os.environ + + +def test_dotenv_optional(monkeypatch): + monkeypatch.setattr('flask.cli.dotenv', None) + monkeypatch.chdir(test_path) + load_dotenv() + assert 'FOO' not in os.environ diff --git a/tox.ini b/tox.ini index a38b8ebef5..ff4d7999f4 100644 --- a/tox.ini +++ b/tox.ini @@ -15,6 +15,7 @@ deps = coverage greenlet blinker + python-dotenv lowest: Werkzeug==0.9 lowest: Jinja2==2.4 @@ -67,4 +68,4 @@ skip_install = true deps = detox commands = detox -e py{36,35,34,33,27,26,py},py{36,27,py}-simplejson,py{36,33,27,26,py}-devel,py{36,33,27,26,py}-lowest - tox -e coverage-report + tox -e docs-html,coverage-report