Repository: theskumar/python-dotenv
Pull request #105 (2018-04-02 13:01:46)

WIP: Added support for calling arbitrary script with .env variables provided
Hi,

I've started working on a feature that'll allow to use the CLI like this:

```bash
$ dotenv foo
```
which will parse the `.env` file and calls `foo` in a subprocess with the environment variables from `.env` merged with the ones from `os.environ`. This is similar to the behavior of [ruby-dotenv](https://github.com/bkeepers/dotenv).

For now everything is in `dotenv/__main__` so you could easily add another `console_script` in `setup.py`. I've stopped here as I think it would probably better to merge it in the existing `dotenv` script, however I think the **default** action should be as described above, and the existing options from the `dotenv` script should be optional. Please let me know what you think and if and how to proceed.

theskumar: Thanks, for making this feature move.

As for the integration, I would prefer to merge this to existing cli. This could be yet another command for running commands.

As for the API, it make sense to have it as:

```
dotenv run <path-to-executable> [-f <path-of-dotenv>]
```

This way it doesn't try to overwrite/conflict any existing commands. This format is also consistent with `pipenv run` command. Thoughts?
venthur: Sure, if click can deal with something like
```bash
$ dotenv run ls -lah -f <path-to-dotenv>
```
or
```bash
$ dotenv run git status -f <path-to-dotenv>
```
and is able to associate all options an params to the `run` part. Unfortunately I don't have much experience with click so I have to investigate.

Also, feel free to update this branch according to your suggestion if you like.
theskumar: This is a valid point and I'll also have to look into it. Thinking out loud, I think the solution around confusing API could be allowing `-f <path-to-env>` before the `run` and everything after `run` can be passed as sub-command as it is.

```
dotenv [-f /path/to/.env] run <executable>
```
venthur: This should do the trick. It works with:
`dotenv run -f .foo ls -lah` and stuff like `dotenv run -f .foo git log --graph --oneline`
venthur: Ok I think I'm done. I won't start a fight with coveralls, I believe I've covered all relevant cases. Please review.

Patch:
diff --git a/README.md b/README.md
index 3a08de3..1ec2c03 100644
--- a/README.md
+++ b/README.md
@@ -192,6 +192,7 @@ Options:
 Commands:
   get    Retrive the value for the given key.
   list   Display all the stored key/value.
+  run    Run command with environment variables from .env file present
   set    Store the given key/value.
   unset  Removes the given key.
 ```
diff --git a/dotenv/cli.py b/dotenv/cli.py
index dd7c241..687be9a 100644
--- a/dotenv/cli.py
+++ b/dotenv/cli.py
@@ -8,7 +8,7 @@ except ImportError:
                      'Run pip install "python-dotenv[cli]" to fix this.')
     sys.exit(1)
 
-from .main import dotenv_values, get_key, set_key, unset_key
+from .main import dotenv_values, get_key, set_key, unset_key, run_command
 
 
 @click.group()
@@ -78,5 +78,19 @@ def unset(ctx, key):
         exit(1)
 
 
+@cli.command(context_settings={'ignore_unknown_options': True})
+@click.pass_context
+@click.argument('commandline', nargs=-1, type=click.UNPROCESSED)
+def run(ctx, commandline):
+    """Run command with environment variables present."""
+    file = ctx.obj['FILE']
+    dotenv_as_dict = dotenv_values(file)
+    if not commandline:
+        click.echo('No command given.')
+        exit(1)
+    ret = run_command(commandline, dotenv_as_dict)
+    exit(ret)
+
+
 if __name__ == "__main__":
     cli()
diff --git a/dotenv/main.py b/dotenv/main.py
index 3d1bd72..cdb5d9d 100644
--- a/dotenv/main.py
+++ b/dotenv/main.py
@@ -7,6 +7,7 @@ import io
 import os
 import re
 import sys
+from subprocess import Popen, PIPE, STDOUT
 import warnings
 from collections import OrderedDict
 
@@ -253,3 +254,46 @@ def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False):
 def dotenv_values(dotenv_path=None, stream=None, verbose=False):
     f = dotenv_path or stream or find_dotenv()
     return DotEnv(f, verbose=verbose).dict()
+
+
+def run_command(command, env):
+    """Run command in sub process.
+
+    Runs the command in a sub process with the variables from `env`
+    added in the current environment variables.
+
+    Parameters
+    ----------
+    command: List[str]
+        The command and it's parameters
+    env: Dict
+        The additional environment variables
+
+    Returns
+    -------
+    int
+        The return code of the command
+
+    """
+    # copy the current environment variables and add the vales from
+    # `env`
+    cmd_env = os.environ.copy()
+    cmd_env.update(env)
+
+    p = Popen(command,
+              stdin=PIPE,
+              stdout=PIPE,
+              stderr=STDOUT,
+              universal_newlines=True,
+              bufsize=0,
+              shell=False,
+              env=cmd_env)
+    try:
+        out, _ = p.communicate()
+        print(out)
+    except Exception:
+        warnings.warn('An error occured, running the command:')
+        out, _ = p.communicate()
+        warnings.warn(out)
+
+    return p.returncode


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 19fb6ac..ac3844d 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -192,3 +192,28 @@ def test_get_key_with_interpolation_of_unset_variable(cli):
         assert stored_value == 'BAR'
         del(environ['NOT_SET'])
         sh.rm(dotenv_path)
+
+
+def test_run(cli):
+    with cli.isolated_filesystem():
+        sh.touch(dotenv_path)
+        sh.cd(here)
+        dotenv.set_key(dotenv_path, 'FOO', 'BAR')
+        result = sh.dotenv('run', 'printenv', 'FOO').strip()
+        assert result == 'BAR'
+
+
+def test_run_with_other_env(cli, dotenv_file):
+    cli.invoke(dotenv_cli, ['--file', dotenv_file, 'set', 'FOO', "BAR"])
+    result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'run', 'printenv', 'FOO'])
+    assert result.output.strip() == 'BAR'
+
+
+def test_run_without_cmd(cli):
+    result = cli.invoke(dotenv_cli, ['run'])
+    assert result.exit_code != 0
+
+
+def test_run_with_invalid_cmd(cli):
+    result = cli.invoke(dotenv_cli, ['run', 'i_do_not_exist'])
+    assert result.exit_code != 0