Repository: grampajoe/happy

Issue:
Add an --env option to pass env overrides
There should be an `--env` option for `happy up` that allows passing env overrides to the `app-setups` endpoint.

Discussion:

Patch:
diff --git a/README.rst b/README.rst
index 1acae81..738e6ed 100644
--- a/README.rst
+++ b/README.rst
@@ -75,6 +75,13 @@ happy can find it later.
   logged in through Heroku CLI, i.e. your token is stored in your ``netrc``
   file.
 
+- ``--env``
+
+  (optional) Environment variable overrides, e.g. ``--env KEY=value``. For
+  multiple variables, this option can be passed more than once. Variable names
+  MUST match one of the names in the ``env`` section of your ``app.json``, or
+  the build will fail with an ``invalid app.json`` message.
+
 - ``--tarball-url``
 
   (optional) URL of the tarball containing app.json. If this is not given,
diff --git a/happy/__init__.py b/happy/__init__.py
index a59c152..b5be08e 100644
--- a/happy/__init__.py
+++ b/happy/__init__.py
@@ -15,13 +15,14 @@ class Happy(object):
         """
         self._api = Heroku(auth_token=auth_token)
 
-    def create(self, tarball_url):
+    def create(self, tarball_url, env=None):
         """Creates a Heroku app-setup build.
 
         :param tarball_url: URL of a tarball containing an ``app.json``.
+        :param env: Dict containing environment variable overrides.
         :returns: A tuple with ``(build_id, app_name)``.
         """
-        data = self._api.create_build(tarball_url=tarball_url)
+        data = self._api.create_build(tarball_url=tarball_url, env=env)
 
         return (data['id'], data['app']['name'])
 
diff --git a/happy/cli.py b/happy/cli.py
index 4636560..76083e4 100644
--- a/happy/cli.py
+++ b/happy/cli.py
@@ -52,7 +52,8 @@ def cli():
 @cli.command(name='up')
 @click.option('--tarball-url', help='URL of the tarball containing app.json.')
 @click.option('--auth-token', help='Heroku API auth token.')
-def up(tarball_url, auth_token):
+@click.option('--env', multiple=True, help='Env override, e.g. KEY=value.')
+def up(tarball_url, auth_token, env):
     """Brings up a Heroku app."""
     tarball_url = tarball_url or _infer_tarball_url()
 
@@ -60,11 +61,18 @@ def up(tarball_url, auth_token):
         click.echo('No tarball URL found.')
         sys.exit(1)
 
+    if env:
+        # Split ["KEY=value", ...] into {"KEY": "value", ...}
+        env = {
+            arg.split('=')[0]: arg.split('=')[1]
+            for arg in env
+        }
+
     happy = Happy(auth_token=auth_token)
 
     click.echo('Creating app... ', nl=False)
 
-    build_id, app_name = happy.create(tarball_url=tarball_url)
+    build_id, app_name = happy.create(tarball_url=tarball_url, env=env)
 
     click.echo(app_name)
 
diff --git a/happy/heroku.py b/happy/heroku.py
index a5f95c9..9684b34 100644
--- a/happy/heroku.py
+++ b/happy/heroku.py
@@ -66,10 +66,11 @@ class Heroku(object):
 
         return response.json()
 
-    def create_build(self, tarball_url):
+    def create_build(self, tarball_url, env=None):
         """Creates an app-setups build. Returns response data as a dict.
 
         :param tarball_url: URL of a tarball containing an ``app.json``.
+        :param env: Dict containing environment variable overrides.
         :returns: Response data as a ``dict``.
         """
         data = {
@@ -78,6 +79,9 @@ class Heroku(object):
             }
         }
 
+        if env:
+            data['overrides'] = {'env': env}
+
         return self.api_request('POST', '/app-setups', data=data)
 
     def check_build_status(self, build_id):


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index b613880..2bfaeb8 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -96,6 +96,26 @@ def test_up_no_tarball_url(happy, runner):
     assert 'no tarball' in result.output.lower()
 
 
+def test_up_env(happy, runner):
+    """Running up --env should pass environment overrides."""
+    with runner.isolated_filesystem():
+        runner.invoke(cli, [
+            'up',
+            '--env',
+            'FART=test',
+            '--env',
+            'BUTT=wow',
+            '--tarball-url=example.com',
+        ])
+
+    args_, kwargs = happy().create.call_args
+
+    assert kwargs['env'] == {
+        'FART': 'test',
+        'BUTT': 'wow',
+    }
+
+
 def test_up_writes_app_name(happy, runner):
     """Running up should write the app name to .happy."""
     with runner.isolated_filesystem():
diff --git a/tests/test_happy.py b/tests/test_happy.py
index a5bdb25..f343079 100644
--- a/tests/test_happy.py
+++ b/tests/test_happy.py
@@ -39,7 +39,20 @@ def test_create(heroku, happy):
     """Should create an app build on Heroku."""
     happy.create(tarball_url='tarball-url')
 
-    heroku().create_build.assert_called_with(tarball_url='tarball-url')
+    heroku().create_build.assert_called_with(
+        env=None,
+        tarball_url='tarball-url',
+    )
+
+
+def test_create_env(heroku, happy):
+    """Should pass env overrides to Heroku.create_build."""
+    happy.create(tarball_url='tarball-url', env={'TEST': 'env'})
+
+    heroku().create_build.assert_called_with(
+        tarball_url='tarball-url',
+        env={'TEST': 'env'},
+    )
 
 
 def test_create_returns_app_name(heroku, happy):
diff --git a/tests/test_heroku.py b/tests/test_heroku.py
index 2ea21f2..5a7a5e5 100644
--- a/tests/test_heroku.py
+++ b/tests/test_heroku.py
@@ -124,6 +124,23 @@ def test_heroku_create_build(api_request):
     assert result == fake_json
 
 
+@mock.patch.object(Heroku, 'api_request')
+def test_heroku_create_build_env(api_request):
+    """Heroku.create_build should send a POST to /app-setups."""
+    heroku = Heroku()
+
+    result = heroku.create_build('tarball-url', env={'HELLO': 'world'})
+
+    api_request.assert_called_with(
+        'POST',
+        '/app-setups',
+        data={
+            'source_blob': {'url': 'tarball-url'},
+            'overrides': {'env': {'HELLO': 'world'}},
+        },
+    )
+
+
 @mock.patch.object(Heroku, 'api_request')
 def test_heroku_check_build_status(api_request):
     """Heroku.check_build_status should send a GET to /app-setups/:id."""