Repository: dwavesystems/dwave-cloud-client
Pull request #458 (2021-02-12 23:37:53)

Update CLI
Add `--client` to all relevant subcommands.

Fix `--all` on `dwave solvers` (#457).


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index f142333..2812ad5 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -24,6 +24,7 @@ jobs:
           name: Install requirements
           command: |
             . env/bin/activate
+            pip install -U pip
             pip install -r requirements.txt -r tests/requirements.txt
             pip install wheel twine
 
diff --git a/dwave/cloud/cli.py b/dwave/cloud/cli.py
index bb833df..e29cd87 100644
--- a/dwave/cloud/cli.py
+++ b/dwave/cloud/cli.py
@@ -16,19 +16,19 @@ import os
 import sys
 import ast
 import json
-import datetime
 import subprocess
 import pkg_resources
 
+from functools import partial
+from timeit import default_timer as timer
+
 import click
 import requests.exceptions
-from timeit import default_timer as timer
-from datetime import datetime, timedelta
 
 import dwave.cloud
 from dwave.cloud import Client
 from dwave.cloud.utils import (
-    default_text_input, click_info_switch, generate_random_ising_problem,
+    default_text_input, generate_random_ising_problem,
     datetime_to_timestamp, utcnow, strtrunc, CLIError, set_loglevel,
     get_contrib_packages, user_agent)
 from dwave.cloud.coders import bqm_as_file
@@ -56,6 +56,65 @@ def show_platform(ctx, param, value):
         click.echo(user_agent())
         sys.exit()
 
+def deprecated_option(msg=None, update=None):
+    """Generate click callback function that will print a deprecation notice
+    to stderr with a customized message and copy option value to new option.
+
+    Note: if you provide the `update` option name, make sure that option is
+    processed before the deprecated one (set `is_eager`).
+    """
+
+    def _print_deprecation(ctx, param, value, msg=None, update=None):
+        if msg is None:
+            msg = "DeprecationWarning: The following options are deprecated: {opts!r}."
+        if value and not ctx.resilient_parsing:
+            click.echo(click.style(msg.format(opts=param.opts), fg="red"), err=True)
+            if update:
+                ctx.params[update] = value
+
+    # click seems to strip closure variables in calls to `callback`,
+    # so we pass `msg` and `update` via partial application
+    return partial(_print_deprecation, msg=msg, update=update)
+
+
+CONFIG_FILE_DEPRECATION_MSG = (
+    "DeprecationWarning: Use of '-c' for '--config-file' has been deprecated "
+    "in favor of '-f' and it will be removed in 0.10.0")
+
+
+def config_file_options(fn):
+    """Decorate `fn` with `--config-file` and `--profile` options."""
+
+    fn = click.option(
+        '--config-file', '-f', default=None, is_eager=True,
+        type=click.Path(exists=True, dir_okay=False),
+        help='Configuration file path')(fn)
+    fn = click.option(
+        '-c', default=None, expose_value=False,
+        type=click.Path(exists=True, dir_okay=False),
+        help="[Deprecated in favor of '-f']",
+        callback=deprecated_option(CONFIG_FILE_DEPRECATION_MSG, update='config_file'))(fn)
+    fn = click.option(
+        '--profile', '-p', default=None,
+        help='Connection profile (section) name')(fn)
+
+    return fn
+
+
+def solver_options(fn):
+    """Decorate `fn` with `--client` and `--solver` options."""
+
+    fn = click.option(
+        '--client', 'client_type', default=None,
+        type=click.Choice(['base', 'qpu', 'sw', 'hybrid'], case_sensitive=False),
+        help='Client type used (default: from config)')(fn)
+    fn = click.option(
+        '--solver', '-s', 'solver_def', default=None,
+        help='Feature-based solver filter (default: from config)')(fn)
+
+    return fn
+
+
 @click.group()
 @click.version_option(prog_name=__title__, version=__version__)
 @click.option('--debug', is_flag=True, callback=enable_logging,
@@ -64,7 +123,7 @@ def show_platform(ctx, param, value):
               help='Enable trace-level debug logging.')
 @click.option('--log', 'loglevel', metavar='LEVEL', callback=enable_loglevel,
               help='Set custom numeric or symbolic log level.')
-@click.option('--platform', is_flag=True, callback=show_platform,
+@click.option('--platform', is_flag=True, is_eager=True, callback=show_platform,
               help='Show the platform tags and exit.')
 def cli(debug=False, trace=False, loglevel=None, platform=False):
     """D-Wave Cloud Client interactive configuration tool."""
@@ -97,10 +156,7 @@ def ls(system, user, local, include_missing):
 
 
 @config.command()
-@click.option('--config-file', '-c', default=None,
-              type=click.Path(exists=True, dir_okay=False), help='Configuration file path')
-@click.option('--profile', '-p', default=None,
-              help='Connection profile (section) name')
+@config_file_options
 def inspect(config_file, profile):
     """Inspect existing configuration/profile."""
 
@@ -119,10 +175,7 @@ def inspect(config_file, profile):
 
 
 @config.command()
-@click.option('--config-file', '-c', default=None,
-              type=click.Path(exists=False, dir_okay=False), help='Configuration file path')
-@click.option('--profile', '-p', default=None,
-              help='Connection profile (section) name')
+@config_file_options
 def create(config_file, profile):
     """Create and/or update cloud client configuration file."""
     return _config_create(config_file, profile)
@@ -202,8 +255,8 @@ def _config_create(config_file, profile):
     return 0
 
 
-def _ping(config_file, profile, solver_def, sampling_params, request_timeout,
-          polling_timeout, output):
+def _ping(config_file, profile, client_type, solver_def, sampling_params,
+          request_timeout, polling_timeout, output):
     """Helper method for the ping command that uses `output()` for info output
     and raises `CLIError()` on handled errors.
 
@@ -218,7 +271,8 @@ def _ping(config_file, profile, solver_def, sampling_params, request_timeout,
             raise CLIError("sampling parameters required as JSON-encoded "
                            "map of param names to values", code=99)
 
-    config = dict(config_file=config_file, profile=profile, solver=solver_def)
+    config = dict(config_file=config_file, profile=profile,
+                  client=client_type, solver=solver_def)
     if request_timeout is not None:
         config.update(request_timeout=request_timeout)
     if polling_timeout is not None:
@@ -292,11 +346,8 @@ def _ping(config_file, profile, solver_def, sampling_params, request_timeout,
 
 
 @cli.command()
-@click.option('--config-file', '-c', default=None,
-              type=click.Path(exists=True, dir_okay=False), help='Configuration file path')
-@click.option('--profile', '-p', default=None,
-              help='Connection profile (section) name')
-@click.option('--solver', '-s', 'solver_def', default=None, help='Feature-based solver definition')
+@config_file_options
+@solver_options
 @click.option('--sampling-params', '-m', default=None, help='Sampling parameters (JSON encoded)')
 @click.option('--request-timeout', default=None, type=float,
               help='Connection and read timeouts (in seconds) for all API requests')
@@ -304,7 +355,7 @@ def _ping(config_file, profile, solver_def, sampling_params, request_timeout,
               help='Problem polling timeout in seconds (time-to-solution timeout)')
 @click.option('--json', 'json_output', default=False, is_flag=True,
               help='JSON output')
-def ping(config_file, profile, solver_def, sampling_params, json_output,
+def ping(config_file, profile, client_type, solver_def, sampling_params, json_output,
          request_timeout, polling_timeout):
     """Ping the QPU by submitting a single-qubit problem."""
 
@@ -321,7 +372,7 @@ def ping(config_file, profile, solver_def, sampling_params, json_output,
             click.echo(json.dumps(info))
 
     try:
-        _ping(config_file, profile, solver_def, sampling_params,
+        _ping(config_file, profile, client_type, solver_def, sampling_params,
               request_timeout, polling_timeout, output)
     except CLIError as error:
         output("Error: {error} (code: {code})", error=str(error), code=error.code)
@@ -334,16 +385,13 @@ def ping(config_file, profile, solver_def, sampling_params, json_output,
 
 
 @cli.command()
-@click.option('--config-file', '-c', default=None,
-              type=click.Path(exists=True, dir_okay=False), help='Configuration file path')
-@click.option('--profile', '-p', default=None, help='Connection profile name')
-@click.option('--solver', '-s', 'solver_def', default=None,
-              help='Feature-based solver filter (default: from config)')
+@config_file_options
+@solver_options
 @click.option('--list', '-l', 'list_solvers', default=False, is_flag=True,
               help='Print filtered list of solver names, one per line')
 @click.option('--all', '-a', 'list_all', default=False, is_flag=True,
               help='Ignore solver filter (list/print all solvers)')
-def solvers(config_file, profile, solver_def, list_solvers, list_all):
+def solvers(config_file, profile, client_type, solver_def, list_solvers, list_all):
     """Get solver details.
 
     Solver filter is inherited from environment or the specified configuration
@@ -351,10 +399,12 @@ def solvers(config_file, profile, solver_def, list_solvers, list_all):
     """
 
     if list_all:
+        client_type = 'base'
         solver_def = '{}'
 
     with Client.from_config(
-            config_file=config_file, profile=profile, solver=solver_def) as client:
+            config_file=config_file, profile=profile,
+            client=client_type, solver=solver_def) as client:
 
         try:
             solvers = client.get_solvers(**client.default_solver)
@@ -384,12 +434,8 @@ def solvers(config_file, profile, solver_def, list_solvers, list_all):
 
 
 @cli.command()
-@click.option('--config-file', '-c', default=None,
-              type=click.Path(exists=True, dir_okay=False), help='Configuration file path')
-@click.option('--profile', '-p', default=None,
-              help='Connection profile (section) name')
-@click.option('--solver', '-s', 'solver_def', default=None,
-              help='Feature-based solver filter')
+@config_file_options
+@solver_options
 @click.option('--biases', '-h', default=None,
               help='List/dict of biases for Ising model problem formulation')
 @click.option('--couplings', '-j', default=None,
@@ -400,8 +446,8 @@ def solvers(config_file, profile, solver_def, list_solvers, list_all):
               help='Number of reads/samples')
 @click.option('--verbose', '-v', default=False, is_flag=True,
               help='Increase output verbosity')
-def sample(config_file, profile, solver_def, biases, couplings, random_problem,
-           num_reads, verbose):
+def sample(config_file, profile, client_type, solver_def, biases, couplings,
+           random_problem, num_reads, verbose):
     """Submit Ising-formulated problem and return samples."""
 
     # TODO: de-dup wrt ping
@@ -411,7 +457,8 @@ def sample(config_file, profile, solver_def, biases, couplings, random_problem,
 
     try:
         client = Client.from_config(
-            config_file=config_file, profile=profile, solver=solver_def)
+            config_file=config_file, profile=profile,
+            client=client_type, solver=solver_def)
     except Exception as e:
         click.echo("Invalid configuration: {}".format(e))
         return 1
@@ -467,21 +514,20 @@ def sample(config_file, profile, solver_def, biases, couplings, random_problem,
 
 
 @cli.command()
-@click.option('--config-file', '-c', default=None,
-              type=click.Path(exists=True, dir_okay=False), help='Configuration file path')
-@click.option('--profile', '-p', default=None,
-              help='Connection profile (section) name')
+@config_file_options
+@solver_options
 @click.option('--problem-id', '-i', default=None,
               help='Problem ID (optional)')
-@click.option('--format', '-f', default='dimodbqm',
+@click.option('--format', '--fmt', default='dimodbqm',
               type=click.Choice(['coo', 'dimodbqm'], case_sensitive=False),
               help='Problem data encoding')
 @click.argument('input_file', metavar='FILE', type=click.File('rb'))
-def upload(config_file, profile, problem_id, format, input_file):
+def upload(config_file, profile, client_type, solver_def, problem_id, format, input_file):
     """Multipart problem upload with cold restart support."""
 
     try:
-        client = Client.from_config(config_file=config_file, profile=profile)
+        client = Client.from_config(
+            config_file=config_file, profile=profile, client=client_type)
     except Exception as e:
         click.echo("Invalid configuration: {}".format(e))
         return 1
diff --git a/dwave/cloud/utils.py b/dwave/cloud/utils.py
index 1f1a1c4..dc734be 100644
--- a/dwave/cloud/utils.py
+++ b/dwave/cloud/utils.py
@@ -39,7 +39,7 @@ except ImportError:  # pragma: no cover
     _numpy = False
 
 __all__ = ['evaluate_ising', 'uniform_iterator', 'uniform_get',
-           'default_text_input', 'click_info_switch', 'datetime_to_timestamp',
+           'default_text_input', 'datetime_to_timestamp',
            'datetime_to_timestamp', 'utcnow', 'epochnow', 'tictoc',
            'hasinstance', 'exception_chain', 'is_caused_by']
 
@@ -190,34 +190,6 @@ def default_text_input(prompt, default=None, optional=True):
     return input_with_default(prompt, default, optional)
 
 
-def click_info_switch(f):
-    """Decorator to create eager Click info switch option, as described in:
-    http://click.pocoo.org/6/options/#callbacks-and-eager-options.
-
-    Takes a no-argument function and abstracts the boilerplate required by
-    Click (value checking, exit on done).
-
-    Example:
-
-        @click.option('--my-option', is_flag=True, callback=my_option,
-                    expose_value=False, is_eager=True)
-        def test():
-            pass
-
-        @click_info_switch
-        def my_option()
-            click.echo('some info related to my switch')
-    """
-
-    @wraps(f)
-    def wrapped(ctx, param, value):
-        if not value or ctx.resilient_parsing:
-            return
-        f()
-        ctx.exit()
-    return wrapped
-
-
 def datetime_to_timestamp(dt):
     """Convert timezone-aware `datetime` to POSIX timestamp and
     return seconds since UNIX epoch.


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 7c07618..c44dde5 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -19,6 +19,7 @@ import unittest
 from unittest import mock
 
 from click.testing import CliRunner
+from parameterized import parameterized
 
 from dwave.cloud.cli import cli
 from dwave.cloud.config import load_config
@@ -176,9 +177,15 @@ class TestCli(unittest.TestCase):
             self.assertIn('endpoint = 1', result.output)
             self.assertIn('token = 3', result.output)
 
-    def test_ping(self):
+    @parameterized.expand([
+        ("--config-file", ),
+        ("-f", ),
+        ("-c", ),
+    ])
+    def test_ping(self, config_file_option):
         config_file = 'dwave.conf'
         profile = 'profile'
+        client_type = 'qpu'
         params = dict(num_reads=10)
 
         with mock.patch('dwave.cloud.cli.Client') as m:
@@ -190,15 +197,17 @@ class TestCli(unittest.TestCase):
             with runner.isolated_filesystem():
                 touch(config_file)
                 result = runner.invoke(cli, ['ping',
-                                             '--config-file', config_file,
+                                             config_file_option, config_file,
                                              '--profile', profile,
+                                             '--client', client_type,
                                              '--sampling-params', json.dumps(params),
                                              '--request-timeout', '.5',
                                              '--polling-timeout', '30'])
 
             # proper arguments passed to Client.from_config?
             m.from_config.assert_called_with(
-                config_file=config_file, profile=profile, solver=None,
+                config_file=config_file, profile=profile,
+                client=client_type, solver=None,
                 request_timeout=0.5, polling_timeout=30)
 
             # get solver called?
@@ -249,9 +258,15 @@ class TestCli(unittest.TestCase):
         self.assertIn('error', res)
         self.assertEqual(result.exit_code, 9)
 
-    def test_sample(self):
+    @parameterized.expand([
+        ("--config-file", ),
+        ("-f", ),
+        ("-c", ),
+    ])
+    def test_sample(self, config_file_option):
         config_file = 'dwave.conf'
         profile = 'profile'
+        client = 'qpu'
         solver = '{"qpu": true}'
         biases = '[0]'
         couplings = '{(0, 4): 1}'
@@ -263,15 +278,17 @@ class TestCli(unittest.TestCase):
             with runner.isolated_filesystem():
                 touch(config_file)
                 result = runner.invoke(cli, ['sample',
-                                             '--config-file', config_file,
+                                             config_file_option, config_file,
                                              '--profile', profile,
+                                             '--client', client,
                                              '--solver', solver,
                                              '-h', biases,
                                              '-j', couplings,
                                              '-n', num_reads])
 
             # proper arguments passed to Client.from_config?
-            m.from_config.assert_called_with(config_file=config_file, profile=profile, solver=solver)
+            m.from_config.assert_called_with(
+                config_file=config_file, profile=profile, client=client, solver=solver)
 
             # get solver called?
             c = m.from_config.return_value
@@ -283,9 +300,15 @@ class TestCli(unittest.TestCase):
 
         self.assertEqual(result.exit_code, 0)
 
-    def test_solvers(self):
+    @parameterized.expand([
+        ("--config-file", ),
+        ("-f", ),
+        ("-c", ),
+    ])
+    def test_solvers(self, config_file_option):
         config_file = 'dwave.conf'
         profile = 'profile'
+        client_type = 'base'
         solver = '{"qpu": true}'
         solvers = [solver_object('A'), solver_object('B')]
 
@@ -298,13 +321,14 @@ class TestCli(unittest.TestCase):
             with runner.isolated_filesystem():
                 touch(config_file)
                 result = runner.invoke(cli, ['solvers',
-                                             '--config-file', config_file,
+                                             config_file_option, config_file,
                                              '--profile', profile,
+                                             '--client', client_type,
                                              '--solver', solver])
 
             # proper arguments passed to Client.from_config?
             m.from_config.assert_called_with(
-                config_file=config_file, profile=profile, solver=solver)
+                config_file=config_file, profile=profile, client=client_type, solver=solver)
 
             # get solvers called?
             client.get_solvers.assert_called_with()
@@ -315,9 +339,15 @@ class TestCli(unittest.TestCase):
         self.assertIn('Solver: A', result.output)
         self.assertIn('Solver: B', result.output)
 
-    def test_upload(self):
+    @parameterized.expand([
+        ("--config-file", ),
+        ("-f", ),
+        ("-c", ),
+    ])
+    def test_upload(self, config_file_option):
         config_file = 'dwave.conf'
         profile = 'profile'
+        client_type = 'base'
         format = 'dimodbqm'
         problem_id = 'prob:lem:id'
         filename = 'filename'
@@ -329,14 +359,16 @@ class TestCli(unittest.TestCase):
                 touch(config_file)
                 touch(filename)
                 result = runner.invoke(cli, ['upload',
-                                             '--config-file', config_file,
+                                             config_file_option, config_file,
                                              '--profile', profile,
+                                             '--client', client_type,
                                              '--format', format,
                                              '--problem-id', problem_id,
                                              filename])
 
                 # proper arguments passed to Client.from_config?
-                m.from_config.assert_called_with(config_file=config_file, profile=profile)
+                m.from_config.assert_called_with(
+                    config_file=config_file, profile=profile, client=client_type)
 
                 # upload method called on client?
                 c = m.from_config.return_value