Repository: typesafehub/conductr-cli

Issue:
conduct load should support urls
We should extend the `load` sub command so that it can download a remote resource for the purposes of then uploading it to ConductR. Accepting a URL therefore allows us to pull down bundles and their respective configurations from remote locations.

Discussion:

Patch:
diff --git a/conductr_cli/bundle_utils.py b/conductr_cli/bundle_utils.py
index cf11423..c7b72e2 100644
--- a/conductr_cli/bundle_utils.py
+++ b/conductr_cli/bundle_utils.py
@@ -7,5 +7,5 @@ def short_id(bundle_id):
 
 def conf(bundle_path):
     bundle_zip = ZipFile(bundle_path)
-    bundleConf = [bundle_zip.read(name) for name in bundle_zip.namelist() if name.endswith('bundle.conf')]
-    return bundleConf[0].decode('utf-8') if len(bundleConf) == 1 else ''
+    bundle_configuration = [bundle_zip.read(name) for name in bundle_zip.namelist() if name.endswith('bundle.conf')]
+    return bundle_configuration[0].decode('utf-8') if len(bundle_configuration) == 1 else ''
diff --git a/conductr_cli/conduct.py b/conductr_cli/conduct.py
index 3f62279..0aafe51 100755
--- a/conductr_cli/conduct.py
+++ b/conductr_cli/conduct.py
@@ -3,7 +3,8 @@
 
 import argcomplete
 import argparse
-from conductr_cli import conduct_info, conduct_load, conduct_run, conduct_services, conduct_stop, conduct_unload, conduct_version
+from conductr_cli \
+    import conduct_info, conduct_load, conduct_run, conduct_services, conduct_stop, conduct_unload, conduct_version
 import os
 
 
diff --git a/conductr_cli/conduct_info.py b/conductr_cli/conduct_info.py
index 82dbb02..0c35ad9 100644
--- a/conductr_cli/conduct_info.py
+++ b/conductr_cli/conduct_info.py
@@ -12,7 +12,7 @@ def info(args):
     response = requests.get(url)
     conduct_logging.raise_for_status_inc_3xx(response)
 
-    if (args.verbose):
+    if args.verbose:
         conduct_logging.pretty_json(response.text)
 
     data = [
@@ -38,6 +38,6 @@ def calc_column_widths(data):
         for column, value in row.items():
             column_len = len(str(value))
             width_key = column + '_width'
-            if (column_len > column_widths.get(width_key, 0)):
+            if column_len > column_widths.get(width_key, 0):
                 column_widths[width_key] = column_len
     return column_widths
diff --git a/conductr_cli/conduct_load.py b/conductr_cli/conduct_load.py
index 5de7897..3da8ef7 100644
--- a/conductr_cli/conduct_load.py
+++ b/conductr_cli/conduct_load.py
@@ -2,59 +2,64 @@ from pyhocon import ConfigFactory, ConfigTree
 from pyhocon.exceptions import ConfigMissingException
 from conductr_cli import bundle_utils, conduct_url, conduct_logging
 from functools import partial
+from urllib.parse import ParseResult, urlparse, urlunparse
+from urllib.request import urlretrieve
+from pathlib import Path
+
 import json
-import os
-import re
 import requests
 
-
 @conduct_logging.handle_connection_error
 @conduct_logging.handle_http_error
 @conduct_logging.handle_invalid_config
 @conduct_logging.handle_no_file
+@conduct_logging.handle_bad_zip
 def load(args):
     """`conduct load` command"""
 
-    if not os.path.isfile(args.bundle):
-        raise FileNotFoundError(args.bundle)
-
-    if args.configuration is not None and not os.path.isfile(args.configuration):
-        raise FileNotFoundError(args.configuration)
+    print('Retrieving bundle...')
+    bundle_file, bundle_headers = urlretrieve(get_url(args.bundle))
+    if args.configuration is not None:
+        print('Retrieving configuration...')
+    configuration_file, configuration_headers = urlretrieve(get_url(args.configuration)) \
+        if args.configuration is not None else (None, None)
 
-    bundle_conf = ConfigFactory.parse_string(bundle_utils.conf(args.bundle))
-    overlay_bundle_conf = None if args.configuration is None else \
-         ConfigFactory.parse_string(bundle_utils.conf(args.configuration))
+    bundle_conf = ConfigFactory.parse_string(bundle_utils.conf(bundle_file))
+    overlay_bundle_conf = None if configuration_file is None else \
+        ConfigFactory.parse_string(bundle_utils.conf(configuration_file))
 
-    withBundleConfs = partial(applyToConfs, bundle_conf, overlay_bundle_conf)
+    with_bundle_configurations = partial(apply_to_configurations, bundle_conf, overlay_bundle_conf)
 
     url = conduct_url.url('bundles', args)
     files = [
-        ('nrOfCpus', withBundleConfs(ConfigTree.get_string, 'nrOfCpus')),
-        ('memory', withBundleConfs(ConfigTree.get_string, 'memory')),
-        ('diskSpace', withBundleConfs(ConfigTree.get_string, 'diskSpace')),
-        ('roles', ' '.join(withBundleConfs(ConfigTree.get_list, 'roles'))),
-        ('bundleName', withBundleConfs(ConfigTree.get_string, 'name')),
-        ('system', withBundleConfs(ConfigTree.get_string, 'system')),
-        ('bundle', open(args.bundle, 'rb'))
+        ('nrOfCpus', with_bundle_configurations(ConfigTree.get_string, 'nrOfCpus')),
+        ('memory', with_bundle_configurations(ConfigTree.get_string, 'memory')),
+        ('diskSpace', with_bundle_configurations(ConfigTree.get_string, 'diskSpace')),
+        ('roles', ' '.join(with_bundle_configurations(ConfigTree.get_list, 'roles'))),
+        ('bundleName', with_bundle_configurations(ConfigTree.get_string, 'name')),
+        ('system', with_bundle_configurations(ConfigTree.get_string, 'system')),
+        ('bundle', open(bundle_file, 'rb'))
     ]
-    if args.configuration is not None:
-        files.append(('configuration', open(args.configuration, 'rb')))
+    if configuration_file is not None:
+        files.append(('configuration', open(configuration_file, 'rb')))
 
+    print('Loading bundle to ConductR...')
     response = requests.post(url, files=files)
     conduct_logging.raise_for_status_inc_3xx(response)
 
-    if (args.verbose):
+    if args.verbose:
         conduct_logging.pretty_json(response.text)
 
     response_json = json.loads(response.text)
-    bundleId = response_json['bundleId'] if args.long_ids else bundle_utils.short_id(response_json['bundleId'])
+    bundle_id = response_json['bundleId'] if args.long_ids else bundle_utils.short_id(response_json['bundleId'])
 
     print('Bundle loaded.')
-    print('Start bundle with: conduct run{} {}'.format(args.cli_parameters, bundleId))
-    print('Unload bundle with: conduct unload{} {}'.format(args.cli_parameters, bundleId))
+    print('Start bundle with: conduct run{} {}'.format(args.cli_parameters, bundle_id))
+    print('Unload bundle with: conduct unload{} {}'.format(args.cli_parameters, bundle_id))
     print('Print ConductR info with: conduct info{}'.format(args.cli_parameters))
 
-def applyToConfs(base_conf, overlay_conf, method, key):
+
+def apply_to_configurations(base_conf, overlay_conf, method, key):
     if overlay_conf is None:
         return method(base_conf, key)
     else:
@@ -62,3 +67,10 @@ def applyToConfs(base_conf, overlay_conf, method, key):
             return method(overlay_conf, key)
         except ConfigMissingException:
             return method(base_conf, key)
+
+
+def get_url(uri):
+    parsed = urlparse(uri, scheme='file')
+    op = Path(uri)
+    np = str(op.cwd() / op if parsed.scheme == 'file' and op.root == '' else parsed.path)
+    return urlunparse(ParseResult(parsed.scheme, parsed.netloc, np, parsed.params, parsed.query, parsed.fragment))
diff --git a/conductr_cli/conduct_logging.py b/conductr_cli/conduct_logging.py
index a4c94d0..c6d13a4 100644
--- a/conductr_cli/conduct_logging.py
+++ b/conductr_cli/conduct_logging.py
@@ -1,9 +1,12 @@
 import json
 import sys
+import urllib
+
 from pyhocon.exceptions import ConfigException
 from requests import status_codes
 from requests.exceptions import ConnectionError, HTTPError
-
+from urllib.error import URLError
+from zipfile import BadZipFile
 
 # print to stderr
 def error(message, *objs):
@@ -15,7 +18,7 @@ def warning(message, *objs):
 
 
 def connection_error(err, args):
-    error('Unable to contact Typesafe ConductR.')
+    error('Unable to contact ConductR.')
     error('Reason: {}'.format(err.args[0]))
     error('Make sure it can be accessed at {}:{}.'.format(args[0].ip, args[0].port))
 
@@ -74,7 +77,9 @@ def handle_no_file(func):
     def handler(*args, **kwargs):
         try:
             return func(*args, **kwargs)
-        except FileNotFoundError as err:
+        except urllib.error.HTTPError as err:
+            error('Resource not found: {}', err.url)
+        except URLError as err:
             error('File not found: {}', err.args[0])
 
     # Do not change the wrapped function name,
@@ -84,6 +89,20 @@ def handle_no_file(func):
     return handler
 
 
+def handle_bad_zip(func):
+    def handler(*args, **kwargs):
+        try:
+            return func(*args, **kwargs)
+        except BadZipFile as err:
+            error('Problem with the bundle: {}', err.args[0])
+
+    # Do not change the wrapped function name,
+    # so argparse configuration can be tested.
+    handler.__name__ = func.__name__
+
+    return handler
+
+
 def raise_for_status_inc_3xx(response):
     """
     raise status when status code is 3xx
diff --git a/conductr_cli/conduct_run.py b/conductr_cli/conduct_run.py
index e45ead1..779603c 100644
--- a/conductr_cli/conduct_run.py
+++ b/conductr_cli/conduct_run.py
@@ -13,12 +13,12 @@ def run(args):
     response = requests.put(url)
     conduct_logging.raise_for_status_inc_3xx(response)
 
-    if (args.verbose):
+    if args.verbose:
         conduct_logging.pretty_json(response.text)
 
     response_json = json.loads(response.text)
-    bundleId = response_json['bundleId'] if args.long_ids else bundle_utils.short_id(response_json['bundleId'])
+    bundle_id = response_json['bundleId'] if args.long_ids else bundle_utils.short_id(response_json['bundleId'])
 
     print('Bundle run request sent.')
-    print('Stop bundle with: conduct stop{} {}'.format(args.cli_parameters, bundleId))
+    print('Stop bundle with: conduct stop{} {}'.format(args.cli_parameters, bundle_id))
     print('Print ConductR info with: conduct info{}'.format(args.cli_parameters))
diff --git a/conductr_cli/conduct_services.py b/conductr_cli/conduct_services.py
index 2b07544..236033b 100644
--- a/conductr_cli/conduct_services.py
+++ b/conductr_cli/conduct_services.py
@@ -13,7 +13,7 @@ def services(args):
     response = requests.get(url)
     conduct_logging.raise_for_status_inc_3xx(response)
 
-    if (args.verbose):
+    if args.verbose:
         conduct_logging.pretty_json(response.text)
 
     data = sorted([
@@ -39,7 +39,8 @@ def services(args):
                 service_endpoints[url.path] |= {service['service']}
             except KeyError:
                 service_endpoints[url.path] = {service['service']}
-    duplicate_endpoints = [service for (service, endpoint) in service_endpoints.items() if len(endpoint) > 1] if len(service_endpoints) > 0 else []
+    duplicate_endpoints = [service for (service, endpoint) in service_endpoints.items() if len(endpoint) > 1] \
+        if len(service_endpoints) > 0 else []
 
     data.insert(0, {'service': 'SERVICE', 'bundle_id': 'BUNDLE ID', 'bundle_name': 'BUNDLE NAME', 'status': 'STATUS'})
 
diff --git a/conductr_cli/conduct_stop.py b/conductr_cli/conduct_stop.py
index b15457d..fdfae85 100644
--- a/conductr_cli/conduct_stop.py
+++ b/conductr_cli/conduct_stop.py
@@ -13,12 +13,12 @@ def stop(args):
     response = requests.put(url)
     conduct_logging.raise_for_status_inc_3xx(response)
 
-    if (args.verbose):
+    if args.verbose:
         conduct_logging.pretty_json(response.text)
 
     response_json = json.loads(response.text)
-    bundleId = response_json['bundleId'] if args.long_ids else bundle_utils.short_id(response_json['bundleId'])
+    bundle_id = response_json['bundleId'] if args.long_ids else bundle_utils.short_id(response_json['bundleId'])
 
     print('Bundle stop request sent.')
-    print('Unload bundle with: conduct unload{} {}'.format(args.cli_parameters, bundleId))
+    print('Unload bundle with: conduct unload{} {}'.format(args.cli_parameters, bundle_id))
     print('Print ConductR info with: conduct info{}'.format(args.cli_parameters))
diff --git a/conductr_cli/conduct_unload.py b/conductr_cli/conduct_unload.py
index b6bc47b..49d9990 100644
--- a/conductr_cli/conduct_unload.py
+++ b/conductr_cli/conduct_unload.py
@@ -12,7 +12,7 @@ def unload(args):
     response = requests.delete(url)
     conduct_logging.raise_for_status_inc_3xx(response)
 
-    if (args.verbose):
+    if args.verbose:
         conduct_logging.pretty_json(response.text)
 
     print('Bundle unload request sent.')
diff --git a/conductr_cli/conduct_version.py b/conductr_cli/conduct_version.py
index 206d209..0e23b33 100644
--- a/conductr_cli/conduct_version.py
+++ b/conductr_cli/conduct_version.py
@@ -2,5 +2,5 @@ from conductr_cli import __version__
 
 
 # `conduct version` command
-def version(args):
+def version():
     print(__version__)


Test patch:
diff --git a/conductr_cli/test/cli_test_case.py b/conductr_cli/test/cli_test_case.py
index f57eaad..5f44a83 100644
--- a/conductr_cli/test/cli_test_case.py
+++ b/conductr_cli/test/cli_test_case.py
@@ -10,7 +10,7 @@ class CliTestCase():
 
     @property
     def default_connection_error(self):
-        return strip_margin("""|ERROR: Unable to contact Typesafe ConductR.
+        return strip_margin("""|ERROR: Unable to contact ConductR.
                                |ERROR: Reason: test reason
                                |ERROR: Make sure it can be accessed at {}:{}.
                                |""")
@@ -47,7 +47,7 @@ class CliTestCase():
 
 
 def strip_margin(string, marginChar='|'):
-    return '\n'.join([line[line.index(marginChar) + 1:] for line in string.split('\n')])
+    return '\n'.join([line[line.find(marginChar) + 1:] for line in string.split('\n')])
 
 
 def create_temp_bundle_with_contents(contents):
diff --git a/conductr_cli/test/test_conduct_load.py b/conductr_cli/test/test_conduct_load.py
index 8ec2e54..220b4c3 100644
--- a/conductr_cli/test/test_conduct_load.py
+++ b/conductr_cli/test/test_conduct_load.py
@@ -2,6 +2,7 @@ from unittest import TestCase
 from unittest.mock import call, patch, MagicMock
 from conductr_cli.test.cli_test_case import CliTestCase, create_temp_bundle, create_temp_bundle_with_contents, strip_margin
 from conductr_cli import conduct_load
+from urllib.error import URLError
 import shutil
 
 
@@ -52,7 +53,9 @@ class TestConductLoadCommand(TestCase, CliTestCase):
         ('bundle', 1)
     ]
 
-    output_template = """|Bundle loaded.
+    output_template = """|Retrieving bundle...
+                         |{downloading_configuration}Loading bundle to ConductR...
+                         |{verbose}Bundle loaded.
                          |Start bundle with: conduct run{params} {bundle_id}
                          |Unload bundle with: conduct unload{params} {bundle_id}
                          |Print ConductR info with: conduct info{params}
@@ -62,64 +65,84 @@ class TestConductLoadCommand(TestCase, CliTestCase):
     def tearDownClass(cls):
         shutil.rmtree(cls.tmpdir)
 
-    def default_output(self, params='', bundle_id='45e0c47'):
-        return strip_margin(self.output_template.format(**{'params': params, 'bundle_id': bundle_id}))
+    def default_output(self, params='', bundle_id='45e0c47', downloading_configuration='', verbose=''):
+        return strip_margin(self.output_template.format(**{
+            'params': params,
+            'bundle_id': bundle_id,
+            'downloading_configuration': downloading_configuration,
+            'verbose': verbose}))
 
     def test_success(self):
+        urlretrieve_mock = MagicMock(return_value=(self.bundle_file, ()))
         http_method = self.respond_with(200, self.default_response)
         stdout = MagicMock()
-        openMock = MagicMock(return_value=1)
+        open_mock = MagicMock(return_value=1)
 
-        with patch('requests.post', http_method), patch('sys.stdout', stdout), patch('builtins.open', openMock):
+        with patch('conductr_cli.conduct_load.urlretrieve', urlretrieve_mock), \
+             patch('requests.post', http_method), \
+             patch('sys.stdout', stdout), \
+             patch('builtins.open', open_mock):
             conduct_load.load(MagicMock(**self.default_args))
 
-        openMock.assert_called_with(self.bundle_file, 'rb')
+        open_mock.assert_called_with(self.bundle_file, 'rb')
         http_method.assert_called_with(self.default_url, files=self.default_files)
 
         self.assertEqual(self.default_output(), self.output(stdout))
 
     def test_success_verbose(self):
+        urlretrieve_mock = MagicMock(return_value=(self.bundle_file, ()))
         http_method = self.respond_with(200, self.default_response)
         stdout = MagicMock()
-        openMock = MagicMock(return_value=1)
+        open_mock = MagicMock(return_value=1)
 
-        with patch('requests.post', http_method), patch('sys.stdout', stdout), patch('builtins.open', openMock):
+        with patch('conductr_cli.conduct_load.urlretrieve', urlretrieve_mock), \
+             patch('requests.post', http_method), \
+             patch('sys.stdout', stdout), \
+             patch('builtins.open', open_mock):
             args = self.default_args.copy()
             args.update({'verbose': True})
             conduct_load.load(MagicMock(**args))
 
-        openMock.assert_called_with(self.bundle_file, 'rb')
+        open_mock.assert_called_with(self.bundle_file, 'rb')
         http_method.assert_called_with(self.default_url, files=self.default_files)
 
-        self.assertEqual(self.default_response + self.default_output(), self.output(stdout))
+        self.assertEqual(self.default_output(verbose=self.default_response), self.output(stdout))
 
     def test_success_long_ids(self):
+        urlretrieve_mock = MagicMock(return_value=(self.bundle_file, ()))
         http_method = self.respond_with(200, self.default_response)
         stdout = MagicMock()
-        openMock = MagicMock(return_value=1)
+        open_mock = MagicMock(return_value=1)
 
-        with patch('requests.post', http_method), patch('sys.stdout', stdout), patch('builtins.open', openMock):
+        with patch('conductr_cli.conduct_load.urlretrieve', urlretrieve_mock), \
+             patch('requests.post', http_method), \
+             patch('sys.stdout', stdout), \
+             patch('builtins.open', open_mock):
             args = self.default_args.copy()
             args.update({'long_ids': True})
             conduct_load.load(MagicMock(**args))
 
-        openMock.assert_called_with(self.bundle_file, 'rb')
+        open_mock.assert_called_with(self.bundle_file, 'rb')
         http_method.assert_called_with(self.default_url, files=self.default_files)
 
         self.assertEqual(self.default_output(bundle_id='45e0c477d3e5ea92aa8d85c0d8f3e25c'), self.output(stdout))
 
     def test_success_custom_ip_port(self):
+        urlretrieve_mock = MagicMock(return_value=(self.bundle_file, ()))
         http_method = self.respond_with(200, self.default_response)
         stdout = MagicMock()
-        openMock = MagicMock(return_value=1)
+        open_mock = MagicMock(return_value=1)
 
         cli_parameters = ' --ip 127.0.1.1 --port 9006'
-        with patch('requests.post', http_method), patch('sys.stdout', stdout), patch('builtins.open', openMock):
+        with patch('conductr_cli.conduct_load.urlretrieve', urlretrieve_mock),\
+             patch('requests.post', http_method), \
+             patch('sys.stdout', stdout), \
+             patch('builtins.open', open_mock):
             args = self.default_args.copy()
             args.update({'cli_parameters': cli_parameters})
             conduct_load.load(MagicMock(**args))
 
-        openMock.assert_called_with(self.bundle_file, 'rb')
+        open_mock.assert_called_with(self.bundle_file, 'rb')
         http_method.assert_called_with(self.default_url, files=self.default_files)
 
         self.assertEqual(
@@ -127,22 +150,26 @@ class TestConductLoadCommand(TestCase, CliTestCase):
             self.output(stdout))
 
     def test_success_with_configuration(self):
-        http_method = self.respond_with(200, self.default_response)
-        stdout = MagicMock()
-        openMock = MagicMock(return_value=1)
-
         tmpdir, config_file = create_temp_bundle_with_contents({
             'bundle.conf': '{name="overlaid-name"}',
             'config.sh': 'echo configuring'
         })
 
-        with patch('requests.post', http_method), patch('sys.stdout', stdout), patch('builtins.open', openMock):
+        urlretrieve_mock = MagicMock(side_effect=[(self.bundle_file, ()), (config_file, ())])
+        http_method = self.respond_with(200, self.default_response)
+        stdout = MagicMock()
+        open_mock = MagicMock(return_value=1)
+
+        with patch('conductr_cli.conduct_load.urlretrieve', urlretrieve_mock), \
+             patch('requests.post', http_method), \
+             patch('sys.stdout', stdout), \
+             patch('builtins.open', open_mock):
             args = self.default_args.copy()
             args.update({'configuration': config_file})
             conduct_load.load(MagicMock(**args))
 
         self.assertEqual(
-            openMock.call_args_list,
+            open_mock.call_args_list,
             [call(self.bundle_file, 'rb'), call(config_file, 'rb')]
         )
 
@@ -150,17 +177,21 @@ class TestConductLoadCommand(TestCase, CliTestCase):
         expected_files[4] = ('bundleName', 'overlaid-name')
         http_method.assert_called_with(self.default_url, files=expected_files)
 
-        self.assertEqual(self.default_output(), self.output(stdout))
+        self.assertEqual(self.default_output(downloading_configuration='Retrieving configuration...\n'), self.output(stdout))
 
     def test_failure(self):
+        urlretrieve_mock = MagicMock(return_value=(self.bundle_file, ()))
         http_method = self.respond_with(404)
         stderr = MagicMock()
-        openMock = MagicMock(return_value=1)
+        open_mock = MagicMock(return_value=1)
 
-        with patch('requests.post', http_method), patch('sys.stderr', stderr), patch('builtins.open', openMock):
+        with patch('conductr_cli.conduct_load.urlretrieve', urlretrieve_mock), \
+             patch('requests.post', http_method), \
+             patch('sys.stderr', stderr), \
+             patch('builtins.open', open_mock):
             conduct_load.load(MagicMock(**self.default_args))
 
-        openMock.assert_called_with(self.bundle_file, 'rb')
+        open_mock.assert_called_with(self.bundle_file, 'rb')
         http_method.assert_called_with(self.default_url, files=self.default_files)
 
         self.assertEqual(
@@ -169,14 +200,18 @@ class TestConductLoadCommand(TestCase, CliTestCase):
             self.output(stderr))
 
     def test_failure_invalid_address(self):
+        urlretrieve_mock = MagicMock(return_value=(self.bundle_file, ()))
         http_method = self.raise_connection_error('test reason')
         stderr = MagicMock()
-        openMock = MagicMock(return_value=1)
+        open_mock = MagicMock(return_value=1)
 
-        with patch('requests.post', http_method), patch('sys.stderr', stderr), patch('builtins.open', openMock):
+        with patch('conductr_cli.conduct_load.urlretrieve', urlretrieve_mock), \
+             patch('requests.post', http_method), \
+             patch('sys.stderr', stderr), \
+             patch('builtins.open', open_mock):
             conduct_load.load(MagicMock(**self.default_args))
 
-        openMock.assert_called_with(self.bundle_file, 'rb')
+        open_mock.assert_called_with(self.bundle_file, 'rb')
         http_method.assert_called_with(self.default_url, files=self.default_files)
 
         self.assertEqual(
@@ -295,9 +330,10 @@ class TestConductLoadCommand(TestCase, CliTestCase):
         shutil.rmtree(tmpdir)
 
     def test_failure_no_bundle(self):
+        urlretrieve_mock = MagicMock(side_effect=URLError('no_such.bundle'))
         stderr = MagicMock()
 
-        with patch('sys.stderr', stderr):
+        with patch('conductr_cli.conduct_load.urlretrieve', urlretrieve_mock), patch('sys.stderr', stderr):
             args = self.default_args.copy()
             args.update({'bundle': 'no_such.bundle'})
             conduct_load.load(MagicMock(**args))
@@ -308,9 +344,11 @@ class TestConductLoadCommand(TestCase, CliTestCase):
             self.output(stderr))
 
     def test_failure_no_configuration(self):
+        urlretrieve_mock = MagicMock(side_effect=[(self.bundle_file, ()), URLError('no_such.conf')])
+
         stderr = MagicMock()
 
-        with patch('sys.stderr', stderr):
+        with patch('conductr_cli.conduct_load.urlretrieve', urlretrieve_mock), patch('sys.stderr', stderr):
             args = self.default_args.copy()
             args.update({'configuration': 'no_such.conf'})
             conduct_load.load(MagicMock(**args))