Repository: dwavesystems/dwave-cloud-client

Issue:
Add config options for defining Leap API endpoint
We currently support:
- `metadata_api_endpoint` (file/kwarg) option and `DWAVE_METADATA_API_ENDPOINT` (env var)
- `endpoint` (file/kwarg) option and `DWAVE_API_ENDPOINT` (env var) for specifying SolverAPI (SAPI) endpoint - legacy names back from the era when cloud-client worked only with SAPI.

To support LeapAPI client, we're lacking:
- `leap_api_endpoint`/`DWAVE_LEAP_API_ENDPOINT` options for specifying Leap API endpoint

Note: previously this issue included #573, but it makes sense to decouple them.

Discussion:

Patch:
diff --git a/dwave/cloud/api/client.py b/dwave/cloud/api/client.py
index aeb093f..f445afb 100644
--- a/dwave/cloud/api/client.py
+++ b/dwave/cloud/api/client.py
@@ -546,7 +546,5 @@ class LeapAPIClient(DWaveAPIClient):
 
     @classmethod
     def from_client_config(cls, config: ClientConfig, **kwargs):
-        # XXX: replace endpoint with `leap_api_endpoint` after we implement
-        # https://github.com/dwavesystems/dwave-cloud-client/issues/569
-        kwargs.setdefault('endpoint', constants.DEFAULT_LEAP_API_ENDPOINT)
+        kwargs.setdefault('endpoint', config.leap_api_endpoint)
         return super().from_client_config(config, **kwargs)
diff --git a/dwave/cloud/client/base.py b/dwave/cloud/client/base.py
index e1f8b9a..4c10d26 100644
--- a/dwave/cloud/client/base.py
+++ b/dwave/cloud/client/base.py
@@ -90,6 +90,7 @@ def _deprecated_config_properties_to_config_map(options):
 
     properties_map = {
         'metadata_api_endpoint': 'metadata_api_endpoint',
+        'leap_api_endpoint': 'leap_api_endpoint',
         'region': 'region',
         'endpoint': 'endpoint',
         'token': 'token',
@@ -220,10 +221,17 @@ class Client(object):
             D-Wave Metadata API endpoint. Central for all regions, used for
             regional SAPI endpoint discovery.
 
+        leap_api_endpoint (str, optional):
+            Leap API endpoint.
+
         defaults (dict, optional):
             Defaults for the client instance that override the class
             :attr:`.Client.DEFAULTS`.
 
+    .. versionchanged:: 0.11.0
+        Added the ``leap_api_endpoint`` parameter and config option (also
+        available via environment variable ``DWAVE_LEAP_API_ENDPOINT``).
+
     Note:
         Default values of all constructor arguments listed above are kept in
         a class variable :attr:`.Client.DEFAULTS`.
@@ -231,10 +239,14 @@ class Client(object):
         Instance-level defaults can be specified via ``defaults`` argument.
 
     .. deprecated:: 0.10.0
-
         Positional arguments in :class:`.Client` constructor are deprecated and
         will be removed in 0.12.0.
 
+    .. deprecated:: 0.11.0
+        Config attributes on :class:`.Client` are deprecated in favor of config
+        model attributes available on ``Client.config`` and will be removed
+        in 0.12.0.
+
     Examples:
         This example directly initializes a :class:`.Client`.
         Direct initialization uses class constructor arguments, the minimum
@@ -271,6 +283,7 @@ class Client(object):
         'client': 'base',
         # constructor (and factory)
         'metadata_api_endpoint': api.constants.DEFAULT_METADATA_API_ENDPOINT,
+        'leap_api_endpoint': api.constants.DEFAULT_LEAP_API_ENDPOINT,
         'region': DEFAULT_API_REGION,
         # NOTE: should we rename endpoint to solver_api_endpoint for clarity?
         'endpoint': None,       # defined via region, resolved on client init
diff --git a/dwave/cloud/config/__init__.py b/dwave/cloud/config/__init__.py
index c128379..dd290fa 100644
--- a/dwave/cloud/config/__init__.py
+++ b/dwave/cloud/config/__init__.py
@@ -56,6 +56,7 @@ Environment variables:
 *   ``DWAVE_API_SOLVER``: Default solver.
 *   ``DWAVE_API_TOKEN``: Solver API authorization token.
 *   ``DWAVE_CONFIG_FILE``: Configuration file path.
+*   ``DWAVE_LEAP_API_ENDPOINT``: Leap API endpoint URL.
 *   ``DWAVE_METADATA_API_ENDPOINT``: Metadata API endpoint URL.
 *   ``DWAVE_PROFILE``: Name of profile (section).
 
@@ -148,9 +149,9 @@ Examples:
 
     >>> from dwave.cloud import Client
     >>> client = Client.from_config()  # doctest: +SKIP
-    >>> print(client.endpoint)      # doctest: +SKIP
+    >>> print(client.config.endpoint)      # doctest: +SKIP
     https://eu-central-1.cloud.dwavesys.com/sapi/v2/
-    >>> print(client.token)  # doctest: +SKIP
+    >>> print(client.config.token)  # doctest: +SKIP
     DEF-987654321987654321987654321
     >>> # code that uses client
     >>> client.close() # doctest: +SKIP
diff --git a/dwave/cloud/config/loaders.py b/dwave/cloud/config/loaders.py
index a5f919d..1431a28 100644
--- a/dwave/cloud/config/loaders.py
+++ b/dwave/cloud/config/loaders.py
@@ -44,6 +44,7 @@ ENV_OPTION_MAP = {
     'DWAVE_API_SOLVER': 'solver',
     'DWAVE_API_PROXY': 'proxy',
     'DWAVE_API_HEADERS': 'headers',
+    'DWAVE_LEAP_API_ENDPOINT': 'leap_api_endpoint',
     'DWAVE_METADATA_API_ENDPOINT': 'metadata_api_endpoint',
 }
 """Map of environment variable names to config options."""
@@ -496,7 +497,8 @@ def load_config(config_file: Optional[Union[str, bool]] = None,
     Environment variables: ``DWAVE_CONFIG_FILE``, ``DWAVE_PROFILE``,
     ``DWAVE_API_CLIENT``, ``DWAVE_API_REGION``, ``DWAVE_API_ENDPOINT``,
     ``DWAVE_API_TOKEN``, ``DWAVE_API_SOLVER``, ``DWAVE_API_PROXY``,
-    ``DWAVE_API_HEADERS``, ``DWAVE_METADATA_API_ENDPOINT``.
+    ``DWAVE_API_HEADERS``, ``DWAVE_LEAP_API_ENDPOINT``,
+    ``DWAVE_METADATA_API_ENDPOINT``.
 
     Environment variables are described in :mod:`dwave.cloud.config`.
 
diff --git a/dwave/cloud/config/models.py b/dwave/cloud/config/models.py
index dc7f6dd..d11db89 100644
--- a/dwave/cloud/config/models.py
+++ b/dwave/cloud/config/models.py
@@ -23,7 +23,7 @@ from pydantic import BaseModel
 
 from dwave.cloud.config.loaders import update_config
 from dwave.cloud.api.constants import (
-    DEFAULT_REGION, DEFAULT_METADATA_API_ENDPOINT)
+    DEFAULT_REGION, DEFAULT_METADATA_API_ENDPOINT, DEFAULT_LEAP_API_ENDPOINT)
 
 __all__ = ['RequestRetryConfig', 'PollingSchedule', 'ClientConfig',
            'validate_config_v1', 'dump_config_v1', 'load_config_v1']
@@ -101,10 +101,11 @@ class PollingSchedule(BaseModel, GetterMixin):
 
 class ClientConfig(BaseModel, GetterMixin):
     # api region
-    metadata_api_endpoint: str = DEFAULT_METADATA_API_ENDPOINT
+    metadata_api_endpoint: Optional[str] = DEFAULT_METADATA_API_ENDPOINT
     region: Optional[str] = DEFAULT_REGION
 
     # resolved api endpoint
+    leap_api_endpoint: Optional[str] = DEFAULT_LEAP_API_ENDPOINT
     endpoint: Optional[str] = None
     token: Optional[str] = None
 
@@ -122,11 +123,11 @@ class ClientConfig(BaseModel, GetterMixin):
     proxy: Optional[str] = None
 
     # specific connection options
-    permissive_ssl: bool = False
-    connection_close: bool = False
+    permissive_ssl: Optional[bool] = False
+    connection_close: Optional[bool] = False
 
     # api request retry params
-    request_retry: RequestRetryConfig = RequestRetryConfig()
+    request_retry: Optional[RequestRetryConfig] = RequestRetryConfig()
     request_timeout: Optional[Union[float, Tuple[float, float]]] = (60.0, 120.0)
 
 
@@ -263,6 +264,7 @@ def dump_config_v1(config: ClientConfig) -> dict:
 _V1_CONFIG_DEFAULTS = {
     'client': 'base',
     'metadata_api_endpoint': DEFAULT_METADATA_API_ENDPOINT,
+    'leap_api_endpoint': DEFAULT_LEAP_API_ENDPOINT,
     'region': DEFAULT_REGION,
     'endpoint': None,
     'token': None,
diff --git a/releasenotes/notes/add-leap-api-endpoint-config-option-28607cc0cb2e360d.yaml b/releasenotes/notes/add-leap-api-endpoint-config-option-28607cc0cb2e360d.yaml
new file mode 100644
index 0000000..7b49c74
--- /dev/null
+++ b/releasenotes/notes/add-leap-api-endpoint-config-option-28607cc0cb2e360d.yaml
@@ -0,0 +1,7 @@
+---
+features:
+  - |
+    Add configuration options for defining Leap API endpoint: ``leap_api_endpoint``
+    option in configuration file or as keyword argument, and
+    ``DWAVE_LEAP_API_ENDPOINT`` environment variable.
+    See `#569  <https://github.com/dwavesystems/dwave-cloud-client/issues/569>`_.


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index 4bc50bf..0fbb19b 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -26,6 +26,7 @@ from contextlib import contextmanager
 from collections import defaultdict
 
 import requests
+from parameterized import parameterized
 from plucky import merge
 
 from dwave.cloud.api import constants, Regions
@@ -186,7 +187,7 @@ class ClientConstruction(unittest.TestCase):
     """Test Client constructor and Client.from_config() factory."""
 
     def setUp(self):
-        # prevent run-time env vars interferring with tests
+        # prevent run-time env vars interfering with tests
         self._env = isolated_environ(empty=True).start()
 
     def tearDown(self):
@@ -209,6 +210,8 @@ class ClientConstruction(unittest.TestCase):
             with dwave.cloud.Client.from_config() as client:
                 self.assertEqual(client.config.region, constants.DEFAULT_REGION)
                 self.assertEqual(client.config.endpoint, constants.DEFAULT_SOLVER_API_ENDPOINT)
+                self.assertEqual(client.config.leap_api_endpoint, constants.DEFAULT_LEAP_API_ENDPOINT)
+                self.assertEqual(client.config.metadata_api_endpoint, constants.DEFAULT_METADATA_API_ENDPOINT)
                 self.assertEqual(client.config.token, 'token')
                 self.assertIsInstance(client, dwave.cloud.client.Client)
                 self.assertNotIsInstance(client, dwave.cloud.sw.Client)
@@ -264,13 +267,16 @@ class ClientConstruction(unittest.TestCase):
                         self.assertEqual(client.config.region, region_code)
                         self.assertEqual(client.config.endpoint, region_endpoint)
 
-    def test_metadata_api_endpoint_from_env_accepted(self):
-        metadata_api_endpoint = 'metadata-endpoint'
-        with isolated_environ(add={'DWAVE_METADATA_API_ENDPOINT': metadata_api_endpoint}):
-            conf = {k: k for k in 'endpoint metadata_api_endpoint token'.split()}
+    @parameterized.expand([
+        ("DWAVE_METADATA_API_ENDPOINT", "metadata-api-endpoint", "metadata_api_endpoint"),
+        ("DWAVE_LEAP_API_ENDPOINT", "leap-api-endpoint", "leap_api_endpoint"),
+    ])
+    def test_metadata_api_endpoint_from_env_accepted(self, env_key, env_val, config_key):
+        with isolated_environ(add={env_key: env_val}):
+            conf = {k: k for k in 'endpoint token'.split()}
             with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
                 with dwave.cloud.Client.from_config() as client:
-                    self.assertEqual(client.config.metadata_api_endpoint, metadata_api_endpoint)
+                    self.assertEqual(client.config[config_key], env_val)
 
     def test_client_type(self):
         conf = {k: k for k in 'endpoint token'.split()}
@@ -389,9 +395,12 @@ class ClientConstruction(unittest.TestCase):
                 # explicit None kwargs do not modify defaults
                 with dwave.cloud.Client(
                         endpoint=None, token=None, solver=None,
-                        connection_close=None, poll_backoff_min=None) as client:
+                        connection_close=None, poll_backoff_min=None,
+                        metadata_api_endpoint=None, leap_api_endpoint=None) as client:
 
                     self.assertEqual(client.config.endpoint, client.DEFAULT_API_ENDPOINT)
+                    self.assertEqual(client.config.leap_api_endpoint, DEFAULTS['leap_api_endpoint'])
+                    self.assertEqual(client.config.metadata_api_endpoint, DEFAULTS['metadata_api_endpoint'])
                     self.assertEqual(client.config.token, token)
                     self.assertEqual(client.config.solver, {})
 
diff --git a/tests/test_config.py b/tests/test_config.py
index d6d5955..c80c5b9 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -22,6 +22,7 @@ from typing import Any, Dict, Callable
 
 from parameterized import parameterized
 
+from dwave.cloud.api import constants
 from dwave.cloud.package_info import __packagename__, __version__
 from dwave.cloud.exceptions import ConfigFileParseError, ConfigFileReadError
 from dwave.cloud.testing import iterable_mock_open
@@ -31,6 +32,7 @@ from dwave.cloud.config import (
 from dwave.cloud.config.models import ClientConfig
 from dwave.cloud.config.models import validate_config_v1, load_config_v1, dump_config_v1
 
+
 class TestConfigParsing(unittest.TestCase):
 
     config_body = """
@@ -393,6 +395,15 @@ class TestConfigParsing(unittest.TestCase):
             with mock.patch.dict(os.environ, {'DWAVE_API_HEADERS': 'test'}):
                 self.assertEqual(load_config(config_file='myfile')['headers'], 'test')
 
+            with mock.patch.dict(os.environ, {'DWAVE_API_REGION': 'test'}):
+                self.assertEqual(load_config(config_file='myfile')['region'], 'test')
+
+            with mock.patch.dict(os.environ, {'DWAVE_METADATA_API_ENDPOINT': 'test'}):
+                self.assertEqual(load_config(config_file='myfile')['metadata_api_endpoint'], 'test')
+
+            with mock.patch.dict(os.environ, {'DWAVE_LEAP_API_ENDPOINT': 'test'}):
+                self.assertEqual(load_config(config_file='myfile')['leap_api_endpoint'], 'test')
+
 
 class TestConfigUtils(unittest.TestCase):
 
@@ -514,6 +525,8 @@ class TestConfigUtils(unittest.TestCase):
 
 class TestConfigModel(unittest.TestCase):
 
+    OMITTED = object()
+
     def _verify(self,
                 raw_config: Dict[str, Any],
                 get_field: Callable[[ClientConfig], Any],
@@ -571,6 +584,23 @@ class TestConfigModel(unittest.TestCase):
                      get_field=lambda config: config.solver,
                      model_value=model_value)
 
+    @parameterized.expand([
+        ("null meta", "metadata_api_endpoint", None, None),
+        ("null leap", "leap_api_endpoint", None, None),
+        ("null sapi", "endpoint", None, None),
+        ("omitted meta", "metadata_api_endpoint", OMITTED, constants.DEFAULT_METADATA_API_ENDPOINT),
+        ("omitted leap", "leap_api_endpoint", OMITTED, constants.DEFAULT_LEAP_API_ENDPOINT),
+        ("omitted sapi", "endpoint", OMITTED, None),
+        ("url meta", "metadata_api_endpoint", "https://metadata.api/v1", "https://metadata.api/v1"),
+        ("url leap", "leap_api_endpoint", "https://leap.api/v1", "https://leap.api/v1"),
+        ("url sapi", "endpoint", "https://solver.api/v1", "https://solver.api/v1"),
+    ])
+    def test_endpoints(self, name, key, raw_value, model_value):
+        raw_config = {} if raw_value is self.OMITTED else {key: raw_value}
+        self._verify(raw_config=raw_config,
+                     get_field=lambda config: config[key],
+                     model_value=model_value)
+
     @parameterized.expand([
         ("cert missing", {"client_cert": None, "client_cert_key": "key"}, ValueError),
         ("invalid headers", {"headers": [1,2,3]}, ValueError),