Repository: dwavesystems/dwave-cloud-client
Pull request #100 (2018-04-10 21:12:22)

Enhance load_config: skip file load, force auto-detection
Extend a set of accepted "special" values for `config_file` param in
`load_config` to include (in addition to `str` for path, `None` for
conditional auto-detection): `False` to skip loading from config files
altogether, and `True` to force auto-detection and loading from files,
disregarding path value from the environment.

For "new config" loading, these values propagate directly to
`Client.from_config` factory.

This facilitates better config isolation for tests.

Addresses #95.


Patch:
diff --git a/dwave/cloud/client.py b/dwave/cloud/client.py
index cbafdbd..a0390e0 100644
--- a/dwave/cloud/client.py
+++ b/dwave/cloud/client.py
@@ -73,13 +73,58 @@ class Client(object):
         and returning the appropriate client instance
         (:class:`dwave.cloud.qpu.Client` or :class:`dwave.cloud.sw.Client`).
 
+        Note:
+            For details on config loading from files and environment, please
+            see :func:`~dwave.cloud.config.load_config`.
+
         Args:
-            TODO
+            config_file (str/None/False/True, default=None):
+                Path to config file. ``None`` for auto-detect, ``False`` to
+                skip loading from any file (including auto-detection), and
+                ``True`` to force auto-detection, disregarding environment value
+                for config file.
+
+            profile (str, default=None):
+                Profile name (config file section name). If undefined it is
+                taken from ``DWAVE_PROFILE`` environment variable, or config
+                file, or first section, or defaults. For details, see
+                :func:`~dwave.cloud.config.load_config`.
+
+            client (str, default=None):
+                Client class (selected by name) to use for accessing the API.
+                Use ``qpu`` to specify the :class:`dwave.cloud.qpu.Client` and
+                ``sw`` for :class:`dwave.cloud.sw.Client`.
+
+            endpoint (str, default=None):
+                API endpoint URL.
+
+            token (str, default=None):
+                API authorization token.
+
+            solver (str, default=None):
+                Default solver to use in :meth:`~dwave.cloud.client.Client.get_solver`.
+                If undefined, you'll have to explicitly specify the solver name/id
+                in all calls to :meth:`~dwave.cloud.client.Client.get_solver`.
+
+            proxy (str, default=None):
+                URL for proxy to use in connections to D-Wave API. Can include
+                username/password, port, scheme, etc. If undefined, client will
+                connect directly to the API (unless you use a system-level proxy).
 
             legacy_config_fallback (bool, default=True):
                 If loading from a ``dwave.conf`` config file fails, try
                 loading the ``.dwrc`` legacy config.
 
+            **kwargs:
+                All remaining keyword arguments are passed-through as-is to the
+                chosen `Client` constructor method.
+
+                A notable custom argument is `permissive_ssl`.
+
+                Note: all user-defined keys from config files are propagated to
+                the `Client` constructor too, and can be overridden with these
+                keyword arguments.
+
         Example:
             Create ``dwave.conf`` in your current directory or
             ``~/.config/dwave/dwave.conf``::
@@ -103,12 +148,6 @@ class Client(object):
 
             :exc:`~dwave.cloud.exceptions.ConfigFileParseError`:
                 Config file parse failed.
-
-        TODO: describe config loading, new config in broad strokes, refer to
-        actual loaders' doc; include examples for config and usage.
-
-        TODO: mention kwargs include `permissive_ssl`, but are not limited to
-        that.
         """
 
         # try loading configuration from a preferred new config subsystem
diff --git a/dwave/cloud/config.py b/dwave/cloud/config.py
index 424f95a..2a1883c 100644
--- a/dwave/cloud/config.py
+++ b/dwave/cloud/config.py
@@ -226,8 +226,19 @@ def load_config(config_file=None, profile=None, client=None,
 
     Args:
 
-        config_file (str, default=None):
-            Path to config file. Auto-detected by default.
+        config_file (str/None/False/True, default=None):
+            Path to config file.
+
+            If undefined (set to ``None``), the name of the config file is
+            taken from ``DWAVE_CONFIG_FILE`` environment variable.
+            If that env var is undefined or empty, the location of configuration
+            files is auto-detected, as described in :func:`load_config_from_files`.
+
+            Config loading from files, including auto-detected ones, can be
+            skipped if `config_file` is set to ``False``.
+
+            Auto-detection is forced (disregarding ``DWAVE_CONFIG_FILE`` env
+            var) by setting `config_file` to ``True``.
 
         profile (str, default=None):
             Profile name (config file section name). If undefined (by default),
@@ -288,38 +299,51 @@ def load_config(config_file=None, profile=None, client=None,
         :exc:`~dwave.cloud.exceptions.ConfigFileParseError`:
             Config file parse failed.
     """
-    if config_file is None:
-        config_file = os.getenv("DWAVE_CONFIG_FILE")
-
-    # auto-detect if not specified with arg or env
-    filenames = [config_file] if config_file else None
-
-    # progressively build config from a file, or a list of auto-detected files
-    # raises ConfigFileReadError/ConfigFileParseError on error
-    config = load_config_from_files(filenames)
-
-    # determine profile name fallback:
-    #  (1) profile key under [defaults],
-    #  (2) first non-[defaults] section
-    first_section = next(iter(config.sections() + [None]))
-    config_defaults = config.defaults()
-    default_profile = config_defaults.get('profile', first_section)
-
-    # select profile from the config
-    if profile is None:
-        profile = os.getenv("DWAVE_PROFILE", default_profile)
-    if profile:
-        try:
-            section = dict(config[profile])
-        except KeyError:
-            raise ValueError("Config profile {!r} not found".format(profile))
-    else:
-        # as the very last resort (unspecified profile name and
-        # no profiles defined in config), try to use [defaults]
-        if config_defaults:
-            section = config_defaults
+
+    def _get_section(filenames, profile):
+        """Load config from a list of `filenames`, returning only section
+        defined with `profile`."""
+
+        # progressively build config from a file, or a list of auto-detected files
+        # raises ConfigFileReadError/ConfigFileParseError on error
+        config = load_config_from_files(filenames)
+
+        # determine profile name fallback:
+        #  (1) profile key under [defaults],
+        #  (2) first non-[defaults] section
+        first_section = next(iter(config.sections() + [None]))
+        config_defaults = config.defaults()
+        default_profile = config_defaults.get('profile', first_section)
+
+        # select profile from the config
+        if profile is None:
+            profile = os.getenv("DWAVE_PROFILE", default_profile)
+        if profile:
+            try:
+                section = dict(config[profile])
+            except KeyError:
+                raise ValueError("Config profile {!r} not found".format(profile))
         else:
-            section = {}
+            # as the very last resort (unspecified profile name and
+            # no profiles defined in config), try to use [defaults]
+            if config_defaults:
+                section = config_defaults
+            else:
+                section = {}
+
+        return section
+
+    if config_file == False:
+        # skip loading from file altogether
+        section = {}
+    elif config_file == True:
+        # force auto-detection, disregarding DWAVE_CONFIG_FILE
+        section = _get_section(None, profile)
+    else:
+        # auto-detect if not specified with arg or env
+        if config_file is None:
+            config_file = os.getenv("DWAVE_CONFIG_FILE")
+        section = _get_section([config_file] if config_file else None, profile)
 
     # override a selected subset of values via env or kwargs,
     # pass-through the rest unmodified


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
index 3f5b3cd..f43e1f1 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -140,7 +140,7 @@ class TestConfig(unittest.TestCase):
         # default values are inherited
         self.assertEqual(config['client'], "qpu")
 
-    def _load_config_from_files(self, asked, provided, data=None):
+    def _load_config_from_files(self, asked, provided=None, data=None):
         self.assertEqual(asked, provided)
         if data is None:
             data = self.config_body
@@ -163,6 +163,41 @@ class TestConfig(unittest.TestCase):
                         partial(self._load_config_from_files, provided=None)):
             self._assert_config_valid(load_config(config_file=None, profile='alpha'))
 
+    def test_config_load_skip_configfiles(self):
+        with mock.patch("dwave.cloud.config.load_config_from_files",
+                        self._load_config_from_files):
+
+            # don't load from file, use arg or env
+            self.assertEqual(load_config(config_file=False)['endpoint'], None)
+            with mock.patch.dict(os.environ, {'DWAVE_API_ENDPOINT': 'test'}):
+                self.assertEqual(load_config(config_file=False)['endpoint'], 'test')
+
+            # specifying a profile doesn't affect outcome
+            self.assertEqual(load_config(config_file=False, profile='alpha')['endpoint'], None)
+            with mock.patch.dict(os.environ, {'DWAVE_API_ENDPOINT': 'test'}):
+                self.assertEqual(load_config(config_file=False, profile='alpha')['endpoint'], 'test')
+            with mock.patch.dict(os.environ, {'DWAVE_PROFILE': 'profile'}):
+                self.assertEqual(load_config(config_file=False, endpoint='test')['endpoint'], 'test')
+
+    def test_config_load_force_autodetection(self):
+        with mock.patch("dwave.cloud.config.load_config_from_files",
+                        partial(self._load_config_from_files, provided=None)):
+
+            # load from file
+            self._assert_config_valid(load_config(config_file=True, profile='alpha'))
+
+            # load from file, even when config_file overridden in env (to path)
+            with mock.patch.dict(os.environ, {'DWAVE_CONFIG_FILE': 'nonexisting'}):
+                self._assert_config_valid(load_config(config_file=True, profile='alpha'))
+                with mock.patch.dict(os.environ, {'DWAVE_PROFILE': 'alpha'}):
+                    self._assert_config_valid(load_config(config_file=True))
+
+            # load from file, even when config_file overridden in env (to None)
+            with mock.patch.dict(os.environ, {'DWAVE_CONFIG_FILE': ''}):
+                self._assert_config_valid(load_config(config_file=True, profile='alpha'))
+                with mock.patch.dict(os.environ, {'DWAVE_PROFILE': 'alpha'}):
+                    self._assert_config_valid(load_config(config_file=True))
+
     def test_config_load_configfile_detect_profile_env(self):
         with mock.patch("dwave.cloud.config.load_config_from_files",
                         partial(self._load_config_from_files, provided=None)):