Repository: dwavesystems/dwave-cloud-client
Pull request #628 (2024-06-13 20:45:20)

Refactor/split utils into submodules
Related to #437.

Enables import time optimization.


Patch:
diff --git a/dwave/cloud/__init__.py b/dwave/cloud/__init__.py
index b2bc8ef..6e95bcf 100644
--- a/dwave/cloud/__init__.py
+++ b/dwave/cloud/__init__.py
@@ -12,20 +12,14 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import io
-import os
-import re
 import sys
-import json
 import logging
-import datetime
 import importlib
-import typing
 
 from dwave.cloud.client import Client
 from dwave.cloud.solver import Solver
 from dwave.cloud.computation import Future
-from dwave.cloud.utils import parse_loglevel
+from dwave.cloud.utils.logging import add_loglevel, configure_logging_from_env
 
 __all__ = ['Client', 'Solver', 'Future']
 
@@ -34,134 +28,11 @@ __all__ = ['Client', 'Solver', 'Future']
 logger = logging.getLogger(__name__)
 logger.addHandler(logging.NullHandler())
 
-# add TRACE log level and Logger.trace() method
-logging.TRACE = 5
-logging.addLevelName(logging.TRACE, "TRACE")
-
-def _trace(logger, message, *args, **kwargs):
-    logger.log(logging.TRACE, message, *args, **kwargs)
-
-logging.Logger.trace = _trace
-
-
-class ISOFormatter(logging.Formatter):
-    # target timezone, e.g. `datetime.timezone.utc`, or `None` for naive timestamp
-    as_tz: typing.Optional[datetime.timezone] = None
-
-    def __init__(self, *args, as_tz: typing.Optional[datetime.timezone] = None, **kwargs):
-        self.as_tz = as_tz
-        super().__init__(*args, **kwargs)
-
-    def formatTime(self, record: logging.LogRecord, datefmt: typing.Optional[str] = None) -> str:
-        return datetime.datetime.fromtimestamp(record.created, tz=self.as_tz).isoformat()
-
-
-class FilteredSecretsFormatter(logging.Formatter):
-    """Logging formatter that filters out secrets (like Solver API tokens).
-
-    Note: we assume, for easier disambiguation, a secret/token is prefixed with
-    a short alphanumeric string, and comprises 40 or more hex digits.
-    """
-
-    # prefixed 160-bit+ hex tokens (sapi token format: `A{2,4}-X{40,}`)
-    _SAPI_TOKEN_PATTERN = re.compile(
-        r'\b([0-9A-Za-z]{2,4}-[0-9A-Fa-f]{3})([0-9A-Fa-f]{34,})([0-9A-Fa-f]{3})\b')
-    # 128-bit+ hex tokens (`X{32,}`)
-    _HEX_TOKEN_PATTERN = re.compile(
-        r'\b([0-9A-Fa-f]{3})([0-9A-Fa-f]{26,})([0-9A-Fa-f]{3})\b')
-    # 128-bit uuid tokens (`X{8}-X{4}-X{4}-X{4}-X{12}`)
-    _UUID_TOKEN_PATTERN = re.compile(
-        r'\b([0-9A-Fa-f]{3})([0-9A-Fa-f]{5}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{9})([0-9A-Fa-f]{3})\b')
-
-    def format(self, record: logging.LogRecord) -> str:
-        output = super().format(record)
-        output = re.sub(self._SAPI_TOKEN_PATTERN, r'\1...\3', output)
-        output = re.sub(self._HEX_TOKEN_PATTERN, r'\1...\3', output)
-        output = re.sub(self._UUID_TOKEN_PATTERN, r'\1...\3', output)
-        return output
-
-
-class JSONFormatter(ISOFormatter):
-    def format(self, record: logging.LogRecord) -> str:
-        super().format(record)
-        # filter out message template and potentially unserializable args
-        rec = record.__dict__.copy()
-        del rec['args']
-        del rec['msg']
-        return json.dumps(rec)
-
-
-def configure_logging(logger: typing.Optional[logging.Logger] = None,
-                      *,
-                      level: int = logging.WARNING,
-                      filter_secrets: bool = True,
-                      output_stream: typing.Optional[io.IOBase] = None,
-                      in_utc: bool = False,
-                      structured_output: bool = False,
-                      handler_level: typing.Optional[int] = None,
-                      additive: bool = False,
-                      ) -> logging.Logger:
-    """Configure cloud-client's `dwave.cloud` base logger.
-
-    Logging output from the cloud-client is suppressed by default. This utility
-    function can be used to quickly setup basic logging from the library.
-
-    .. note::
-       This function is currently intended for internal/private use only.
-
-    .. versionadded:: 0.12.0
-       Explicit optional logging configuration. Previously, logger was minimally
-       configured by default.
-    """
-
-    if logger is None:
-        logger = logging.getLogger(__name__)
-    if output_stream is None:
-        output_stream = sys.stderr
-    if handler_level is None:
-        handler_level = level
-
-    format = dict(
-        fmt='%(asctime)s %(name)s %(levelname)s %(threadName)s [%(funcName)s] %(message)s',
-        as_tz=datetime.timezone.utc if in_utc else None,
-    )
-
-    if structured_output:
-        formatter_base = JSONFormatter
-    else:
-        formatter_base = ISOFormatter
-
-    if filter_secrets:
-        class Formatter(FilteredSecretsFormatter, formatter_base):
-            pass
-    else:
-        Formatter = formatter_base
-
-    if not additive:
-        # make sure handlers are not accumulated
-        while len(logger.handlers):
-            logger.removeHandler(logger.handlers[-1])
-
-    formatter = Formatter(**format)
-    handler = logging.StreamHandler(stream=output_stream)
-    handler.setFormatter(formatter)
-    handler.setLevel(handler_level)
-
-    logger.addHandler(handler)
-    logger.setLevel(level)
-
-    return logger
-
+# make sure TRACE level is available
+add_loglevel('TRACE', 5)
 
 # configure logger if DWAVE_LOG_LEVEL present in environment
-def _apply_loglevel_from_env(logger):
-    if log_level := os.getenv('DWAVE_LOG_LEVEL', os.getenv('dwave_log_level')):
-        level = parse_loglevel(log_level)
-        log_format = os.getenv('DWAVE_LOG_FORMAT', os.getenv('dwave_log_format', ''))
-        structured = log_format.strip().lower() == 'json'
-        configure_logging(logger, level=level, structured_output=structured)
-
-_apply_loglevel_from_env(logger)
+configure_logging_from_env(logger)
 
 
 # alias dwave.cloud.client.{qpu,sw,hybrid} as dwave.cloud.*
diff --git a/dwave/cloud/api/client.py b/dwave/cloud/api/client.py
index ee3ca28..0eed092 100644
--- a/dwave/cloud/api/client.py
+++ b/dwave/cloud/api/client.py
@@ -24,10 +24,10 @@ from packaging.specifiers import SpecifierSet
 from werkzeug.http import parse_options_header, dump_options_header
 
 from dwave.cloud.api import constants, exceptions
-from dwave.cloud.config import load_config, validate_config_v1, update_config
+from dwave.cloud.config import load_config, validate_config_v1
 from dwave.cloud.config.models import ClientConfig
-from dwave.cloud.utils import (
-    PretimedHTTPAdapter, BaseUrlSession, default_user_agent, is_caused_by)
+from dwave.cloud.utils.exception import is_caused_by
+from dwave.cloud.utils.http import PretimedHTTPAdapter, BaseUrlSession, default_user_agent
 
 __all__ = ['DWaveAPIClient', 'SolverAPIClient', 'MetadataAPIClient', 'LeapAPIClient']
 
diff --git a/dwave/cloud/api/models.py b/dwave/cloud/api/models.py
index 73e1906..f3a38c6 100644
--- a/dwave/cloud/api/models.py
+++ b/dwave/cloud/api/models.py
@@ -20,7 +20,7 @@ from pydantic import BaseModel, RootModel, Field
 from pydantic.functional_validators import AfterValidator
 
 from dwave.cloud.api import constants
-from dwave.cloud.utils import coerce_numpy_to_python
+from dwave.cloud.utils.coders import coerce_numpy_to_python
 
 
 # coerce common numpy types to python types on validation (parsing)
diff --git a/dwave/cloud/api/resources.py b/dwave/cloud/api/resources.py
index af9bf0b..6ef9fca 100644
--- a/dwave/cloud/api/resources.py
+++ b/dwave/cloud/api/resources.py
@@ -23,7 +23,7 @@ from dwave.cloud.api.client import (
     DWaveAPIClient, SolverAPIClient, MetadataAPIClient, LeapAPIClient)
 from dwave.cloud.api import constants, models
 from dwave.cloud.config.models import ClientConfig
-from dwave.cloud.utils import NumpyEncoder
+from dwave.cloud.utils.coders import NumpyEncoder
 
 __all__ = ['Solvers', 'Problems', 'Regions']
 
diff --git a/dwave/cloud/auth/flows.py b/dwave/cloud/auth/flows.py
index f4dd92b..bc5cf44 100644
--- a/dwave/cloud/auth/flows.py
+++ b/dwave/cloud/auth/flows.py
@@ -33,7 +33,8 @@ from dwave.cloud.auth.creds import Credentials
 from dwave.cloud.auth.server import SingleRequestAppServer, RequestCaptureAndRedirectApp
 from dwave.cloud.config.models import ClientConfig
 from dwave.cloud.regions import resolve_endpoints
-from dwave.cloud.utils import pretty_argvalues, default_user_agent
+from dwave.cloud.utils.http import default_user_agent
+from dwave.cloud.utils.logging import pretty_argvalues
 
 __all__ = ['AuthFlow', 'LeapAuthFlow', 'OAuthError']
 
diff --git a/dwave/cloud/cli.py b/dwave/cloud/cli.py
index 93d60db..e4077d5 100644
--- a/dwave/cloud/cli.py
+++ b/dwave/cloud/cli.py
@@ -28,14 +28,16 @@ import click
 import requests.exceptions
 
 import dwave.cloud
-from dwave.cloud import Client, configure_logging
+from dwave.cloud import Client
 from dwave.cloud import api
 from dwave.cloud.solver import StructuredSolver, BaseUnstructuredSolver
-from dwave.cloud.utils import (
-    default_text_input, generate_random_ising_problem,
-    datetime_to_timestamp, utcnow, strtrunc, CLIError, set_loglevel,
-    get_contrib_packages, user_agent, epochnow,
-    get_distribution, PackageNotFoundError, VersionNotFoundError)
+from dwave.cloud.utils.cli import default_text_input, strtrunc, CLIError
+from dwave.cloud.utils.dist import (
+    get_contrib_packages, get_distribution, PackageNotFoundError, VersionNotFoundError)
+from dwave.cloud.utils.http import user_agent
+from dwave.cloud.utils.logging import configure_logging
+from dwave.cloud.utils.qubo import generate_random_ising_problem
+from dwave.cloud.utils.time import datetime_to_timestamp, utcnow, epochnow
 from dwave.cloud.coders import bqm_as_file
 from dwave.cloud.package_info import __title__, __version__
 from dwave.cloud.exceptions import (
@@ -55,13 +57,11 @@ click.option = partial(click.option, show_default=True)
 
 def enable_logging(ctx, param, value):
     if value and not ctx.resilient_parsing:
-        configure_logging()
-        set_loglevel(dwave.cloud.logger, param.name)
+        configure_logging(level=param.name)
 
 def enable_loglevel(ctx, param, value):
     if value and not ctx.resilient_parsing:
-        configure_logging()
-        set_loglevel(dwave.cloud.logger, value)
+        configure_logging(level=value)
 
 def show_platform(ctx, param, value):
     if value and not ctx.resilient_parsing:
diff --git a/dwave/cloud/client/base.py b/dwave/cloud/client/base.py
index dde9b4c..067d52d 100644
--- a/dwave/cloud/client/base.py
+++ b/dwave/cloud/client/base.py
@@ -54,7 +54,7 @@ from itertools import chain, zip_longest
 from functools import partial, wraps
 from collections import namedtuple
 from concurrent.futures import ThreadPoolExecutor
-from typing import Dict, Optional, Union
+from typing import Dict, Optional
 
 import requests
 import urllib3
@@ -72,9 +72,10 @@ from dwave.cloud.concurrency import PriorityThreadPoolExecutor
 from dwave.cloud.regions import get_regions, resolve_endpoints
 from dwave.cloud.upload import ChunkedData
 from dwave.cloud.events import dispatches_events
-from dwave.cloud.utils import (
-    PretimedHTTPAdapter, BaseUrlSession, default_user_agent,
-    datetime_to_timestamp, utcnow, cached, retried, is_caused_by)
+from dwave.cloud.utils.http import PretimedHTTPAdapter, BaseUrlSession, default_user_agent
+from dwave.cloud.utils.time import datetime_to_timestamp, utcnow
+from dwave.cloud.utils.decorators import cached, retried
+from dwave.cloud.utils.exception import is_caused_by
 
 __all__ = ['Client']
 
diff --git a/dwave/cloud/coders.py b/dwave/cloud/coders.py
index d4f0d96..bca06b0 100644
--- a/dwave/cloud/coders.py
+++ b/dwave/cloud/coders.py
@@ -16,7 +16,7 @@ import struct
 import base64
 from typing import Callable
 
-from dwave.cloud.utils import uniform_get, active_qubits
+from dwave.cloud.utils.qubo import uniform_get, active_qubits
 
 __all__ = [
     'encode_problem_as_qp', 'decode_qp', 'decode_qp_numpy',
diff --git a/dwave/cloud/computation.py b/dwave/cloud/computation.py
index 1ea66be..1b008d3 100644
--- a/dwave/cloud/computation.py
+++ b/dwave/cloud/computation.py
@@ -36,9 +36,9 @@ from concurrent.futures import TimeoutError
 from dateutil.parser import parse
 from operator import itemgetter
 
-from dwave.cloud.utils import (
-    utcnow, datetime_to_timestamp, aliasdict, deprecated)
 from dwave.cloud.exceptions import InvalidAPIResponseError
+from dwave.cloud.utils.decorators import aliasdict, deprecated
+from dwave.cloud.utils.time import utcnow, datetime_to_timestamp
 
 # Use numpy if available for fast decoding
 try:
@@ -578,7 +578,7 @@ class Future(object):
 
             Instead of adding copies of ``solutions`` and ``num_occurrences``
             keys (as ``samples`` and ``occurrences``), we alias them using
-            :class:`~dwave.cloud.utils.aliasdict`. Values are available under
+            :class:`~dwave.cloud.utils.decorators.aliasdict`. Values are available under
             alias keys, but the keys themselves are not stored or visible.
 
         Examples:
diff --git a/dwave/cloud/regions.py b/dwave/cloud/regions.py
index 1856712..5e49ae4 100644
--- a/dwave/cloud/regions.py
+++ b/dwave/cloud/regions.py
@@ -25,7 +25,7 @@ from dwave.cloud.api.constants import (
     DEFAULT_REGION, DEFAULT_SOLVER_API_ENDPOINT, DEFAULT_LEAP_API_ENDPOINT,
     DEFAULT_METADATA_API_ENDPOINT)
 from dwave.cloud.config.models import ClientConfig, validate_config_v1
-from dwave.cloud.utils import cached
+from dwave.cloud.utils.decorators import cached
 
 __all__ = ['get_regions']
 
diff --git a/dwave/cloud/solver.py b/dwave/cloud/solver.py
index 66e8ed7..2004fe4 100644
--- a/dwave/cloud/solver.py
+++ b/dwave/cloud/solver.py
@@ -44,14 +44,15 @@ from dwave.cloud.exceptions import (
 from dwave.cloud.coders import (
     encode_problem_as_qp, encode_problem_as_ref, decode_binary_ref,
     decode_qp_numpy, decode_qp, decode_bq, bqm_as_file)
-from dwave.cloud.utils import reformat_qubo_as_ising, NumpyEncoder
 from dwave.cloud.computation import Future
 from dwave.cloud.concurrency import Present
 from dwave.cloud.events import dispatches_events
+from dwave.cloud.utils.coders import NumpyEncoder
+from dwave.cloud.utils.qubo import reformat_qubo_as_ising
 
 # Use numpy if available for fast encoding/decoding
 try:
-    import numpy as np
+    import numpy
     _numpy = True
 except ImportError:
     _numpy = False
@@ -593,6 +594,30 @@ class DQMSolver(BaseUnstructuredSolver):
     _handled_problem_types = {"dqm"}
     _handled_encoding_formats = {"bq"}
 
+    @staticmethod
+    def _bqm_to_dqm(bqm):
+        """Represent a :class:`dimod.BQM` as a :class:`dimod.DQM`."""
+        try:
+            from dimod import DiscreteQuadraticModel
+        except ImportError: # pragma: no cover
+            raise RuntimeError(
+                "dimod package with support for DiscreteQuadraticModel required."
+                "Re-install the library with 'dqm' support.")
+
+        dqm = DiscreteQuadraticModel()
+
+        ising = bqm.spin
+
+        for v, bias in ising.linear.items():
+            dqm.add_variable(2, label=v)
+            dqm.set_linear(v, [-bias, bias])
+
+        for (u, v), bias in ising.quadratic.items():
+            biases = numpy.array([[bias, -bias], [-bias, bias]], dtype=numpy.float64)
+            dqm.set_quadratic(u, v, biases)
+
+        return dqm
+
     def _encode_problem_for_upload(self, dqm):
         try:
             data = dqm.to_file()
@@ -607,10 +632,9 @@ class DQMSolver(BaseUnstructuredSolver):
 
     def sample_bqm(self, bqm, label=None, **params):
         """Use for testing."""
-        from dwave.cloud.utils import bqm_to_dqm
 
         # to sample BQM problems, we need to convert them to DQM
-        dqm = bqm_to_dqm(bqm)
+        dqm = self.bqm_to_dqm(bqm)
 
         # TODO: convert sampleset back
         return self.sample_dqm(dqm, label=label, **params)
@@ -1502,9 +1526,9 @@ class StructuredSolver(BaseSolver):
         q = readout_time_model_parameters[:n//2]
         t = readout_time_model_parameters[n//2:]
         if readout_time_model == 'pwl_log_log':
-            readout_time = pow(10, np.interp(np.emath.log10(num_qubits), q, t))
+            readout_time = pow(10, numpy.interp(numpy.emath.log10(num_qubits), q, t))
         elif readout_time_model == 'pwl_linear':
-            readout_time = np.interp(num_qubits, q, t)
+            readout_time = numpy.interp(num_qubits, q, t)
         else:
             raise ValueError("``estimate_qpu_access_time`` does not support "
                              f"``readout_time_model`` value {readout_time_model} "
diff --git a/dwave/cloud/utils.py b/dwave/cloud/utils.py
deleted file mode 100644
index daaca4d..0000000
--- a/dwave/cloud/utils.py
+++ /dev/null
@@ -1,1069 +0,0 @@
-# Copyright 2017 D-Wave Systems Inc.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-import sys
-import json
-import math
-import time
-import random
-import logging
-import platform
-import itertools
-import warnings
-import inspect
-import numbers
-
-from collections import OrderedDict
-from urllib.parse import urljoin
-from datetime import datetime, timedelta
-from dateutil.tz import UTC
-from functools import partial, wraps
-from importlib.metadata import Distribution, PackageNotFoundError
-from secrets import token_hex
-from typing import Any, Optional, Union, List, Dict, Mapping, Sequence
-from unittest import mock
-
-import requests
-import diskcache
-from importlib_metadata import entry_points
-from packaging.requirements import Requirement
-
-from dwave.cloud.package_info import __packagename__, __version__
-
-# Use numpy if available for fast decoding
-try:
-    import numpy
-    _numpy = True
-except ImportError:  # pragma: no cover
-    _numpy = False
-
-__all__ = ['evaluate_ising', 'uniform_iterator', 'uniform_get',
-           'default_text_input', 'datetime_to_timestamp',
-           'datetime_to_timestamp', 'utcnow', 'epochnow', 'tictoc',
-           'hasinstance', 'exception_chain', 'is_caused_by',
-           'NumpyEncoder', 'coerce_numpy_to_python',
-           'get_distribution', 'PackageNotFoundError', 'VersionNotFoundError',
-           ]
-
-logger = logging.getLogger(__name__)
-
-
-def evaluate_ising(linear, quad, state, offset=0):
-    """Calculate the energy of a state given the Hamiltonian.
-
-    Args:
-        linear: Linear Hamiltonian terms.
-        quad: Quadratic Hamiltonian terms.
-        offset: Energy offset.
-        state: Vector of spins describing the system state.
-
-    Returns:
-        Energy of the state evaluated by the given energy function.
-    """
-
-    # If we were given a numpy array cast to list
-    if _numpy and isinstance(state, numpy.ndarray):
-        return evaluate_ising(linear, quad, state.tolist(), offset=offset)
-
-    # Accumulate the linear and quadratic values
-    energy = offset
-    for index, value in uniform_iterator(linear):
-        energy += state[index] * value
-    for (index_a, index_b), value in quad.items():
-        energy += value * state[index_a] * state[index_b]
-    return energy
-
-
-def active_qubits(linear, quadratic):
-    """Calculate a set of all active qubits. Qubit is "active" if it has
-    bias or coupling attached.
-
-    Args:
-        linear (dict[variable, bias]/list[variable, bias]):
-            Linear terms of the model.
-
-        quadratic (dict[(variable, variable), bias]):
-            Quadratic terms of the model.
-
-    Returns:
-        set:
-            Active qubits' indices.
-    """
-
-    active = {idx for idx,bias in uniform_iterator(linear)}
-    for edge, _ in quadratic.items():
-        active.update(edge)
-    return active
-
-
-def generate_random_ising_problem(solver, h_range=None, j_range=None):
-    """Generates an Ising problem formulation valid for a particular solver,
-    using all qubits and all couplings and linear/quadratic biases sampled
-    uniformly from `h_range`/`j_range`.
-    """
-
-    if h_range is None:
-        h_range = solver.properties.get('h_range', [-1, 1])
-    if j_range is None:
-        j_range = solver.properties.get('j_range', [-1, 1])
-
-    lin = {qubit: random.uniform(*h_range) for qubit in solver.nodes}
-    quad = {edge: random.uniform(*j_range) for edge in solver.undirected_edges}
-
-    return lin, quad
-
-
-def generate_const_ising_problem(solver, h=1, j=-1):
-    return generate_random_ising_problem(solver, h_range=[h, h], j_range=[j, j])
-
-
-def uniform_iterator(sequence):
-    """Uniform (key, value) iteration on a `dict`,
-    or (idx, value) on a `list`."""
-
-    if isinstance(sequence, Mapping):
-        return sequence.items()
-    else:
-        return enumerate(sequence)
-
-
-def uniform_get(sequence, index, default=None):
-    """Uniform `dict`/`list` item getter, where `index` is interpreted as a key
-    for maps and as numeric index for lists."""
-
-    if isinstance(sequence, Mapping):
-        return sequence.get(index, default)
-    else:
-        return sequence[index] if index < len(sequence) else default
-
-
-def reformat_qubo_as_ising(qubo):
-    """Split QUBO coefficients into linear and quadratic terms (the Ising form).
-
-    Args:
-        qubo (dict[(int, int), float]):
-            Coefficients of a quadratic unconstrained binary optimization
-            (QUBO) model.
-
-    Returns:
-        (dict[int, float], dict[(int, int), float])
-
-    """
-
-    lin = {u: bias for (u, v), bias in qubo.items() if u == v}
-    quad = {(u, v): bias for (u, v), bias in qubo.items() if u != v}
-
-    return lin, quad
-
-
-def strip_head(sequence, values):
-    """Strips elements of `values` from the beginning of `sequence`."""
-    values = set(values)
-    return list(itertools.dropwhile(lambda x: x in values, sequence))
-
-
-def strip_tail(sequence, values):
-    """Strip `values` from the end of `sequence`."""
-    return list(reversed(list(strip_head(reversed(sequence), values))))
-
-
-def default_text_input(prompt: str, default: Optional[Any] = None, *,
-                       optional: bool = True,
-                       choices: Optional[Sequence[Any]] = None) -> Union[str, None]:
-    # CLI util; defer click import until actually needed (see #473)
-    import click
-    _skip = 'skip'
-    kwargs = dict(text=prompt)
-    if default:
-        kwargs.update(default=default)
-    else:
-        # make click print [skip] next to prompt
-        if optional:
-            kwargs.update(default=_skip)
-    if choices:
-        _type = click.Choice(choices)
-        kwargs.update(type=_type)
-        # a special case to skip user input instead of forcing input
-        if optional:
-            def allow_skip(value):
-                if value == _skip:
-                    return value
-                return click.types.convert_type(_type)(value)
-
-            kwargs.update(value_proc=allow_skip)
-
-    value = click.prompt(**kwargs)
-    if optional and value == _skip:
-        value = None
-    return value
-
-
-def datetime_to_timestamp(dt):
-    """Convert timezone-aware `datetime` to POSIX timestamp and
-    return seconds since UNIX epoch.
-
-    Note: similar to `datetime.timestamp()` in Python 3.3+.
-    """
-
-    epoch = datetime.fromtimestamp(0, tz=UTC)
-    return (dt - epoch).total_seconds()
-
-
-def utcnow():
-    """Returns tz-aware now in UTC."""
-    return datetime.now(tz=UTC)
-
-
-def epochnow() -> float:
-    """Returns now as UNIX timestamp.
-
-    Invariant:
-        epochnow() ~= datetime_to_timestamp(utcnow())
-
-    """
-    return time.time()
-
-
-def utcrel(offset):
-    """Return a timezone-aware `datetime` relative to now (UTC), shifted by
-    `offset` seconds in to the future.
-
-    Example:
-        a_minute_from_now = utcrel(60)
-    """
-    return utcnow() + timedelta(seconds=offset)
-
-
-def strtrunc(s, maxlen=60):
-    s = str(s)
-    return s[:(maxlen-3)]+'...' if len(s) > maxlen else s
-
-
-def coerce_numpy_to_python(obj):
-    """Numpy object serializer with support for basic scalar types and ndarrays."""
-
-    if isinstance(obj, numpy.integer):
-        return int(obj)
-    elif isinstance(obj, numpy.floating):
-        return float(obj)
-    elif isinstance(obj, numpy.bool_):
-        return bool(obj)
-    elif isinstance(obj, numpy.ndarray):
-        return [coerce_numpy_to_python(v) for v in obj.tolist()]
-    elif isinstance(obj, (list, tuple)):    # be explicit to avoid recursing over string et al
-        return type(obj)(coerce_numpy_to_python(v) for v in obj)
-    elif isinstance(obj, dict):
-        return {coerce_numpy_to_python(k): coerce_numpy_to_python(v) for k, v in obj.items()}
-    return obj
-
-
-# copied from dwave-hybrid utils
-# (https://github.com/dwavesystems/dwave-hybrid/blob/b9025b5bb3d88dce98ec70e28cfdb25400a10e4a/hybrid/utils.py#L43-L61)
-# TODO: switch to `dwave.common` if and when we create it
-class NumpyEncoder(json.JSONEncoder):
-    """JSON encoder for numpy types.
-
-    Supported types:
-     - basic numeric types: booleans, integers, floats
-     - arrays: ndarray, recarray
-    """
-
-    def default(self, obj):
-        if isinstance(obj, numpy.integer):
-            return int(obj)
-        elif isinstance(obj, numpy.floating):
-            return float(obj)
-        elif isinstance(obj, numpy.bool_):
-            return bool(obj)
-        elif isinstance(obj, numpy.ndarray):
-            return obj.tolist()
-
-        return super().default(obj)
-
-
-class PretimedHTTPAdapter(requests.adapters.HTTPAdapter):
-    """Sets a default timeout for all adapter (think session) requests. It is
-    overridden with per-request timeout. But it can not be reset back to
-    infinite wait (``None``).
-
-    Usage:
-
-        s = requests.Session()
-        s.mount("http://", PretimedHTTPAdapter(timeout=5))
-        s.mount("https://", PretimedHTTPAdapter(timeout=5))
-
-        s.get('http://httpbin.org/delay/6')                 # -> timeouts after 5sec
-        s.get('http://httpbin.org/delay/6', timeout=10)     # -> completes after 6sec
-
-    The alternative is to set ``timeout`` on each request manually/explicitly,
-    subclass ``Session``, or monkeypatch ``Session.request()``.
-    """
-
-    def __init__(self, timeout=None, *args, **kwargs):
-        self.timeout = timeout
-        super().__init__(*args, **kwargs)
-
-    def send(self, *args, **kwargs):
-        # can't use setdefault because caller always sets timeout kwarg
-        kwargs['timeout'] = self.timeout
-        return super().send(*args, **kwargs)
-
-
-class TimeoutingHTTPAdapter(PretimedHTTPAdapter):
-    """Alias for :class:`~dwave.cloud.utils.PretimedHTTPAdapter`. Deprecated,
-    but retained for backward compatibility.
-    """
-
-
-# Note: BaseUrlSession is taken from https://github.com/requests/toolbelt under
-# an Apache 2 license. This simple extension didn't warrant a new dependency.
-# If we later decide to use additional features from `requests-toolbelt`,
-# remove it from here.
-
-class BaseUrlSession(requests.Session):
-    """A Session with a URL that all requests will use as a base."""
-
-    base_url = None
-
-    def __init__(self, base_url=None):
-        if base_url:
-            self.base_url = base_url
-        super().__init__()
-
-    def request(self, method, url, *args, **kwargs):
-        """Send the request after generating the complete URL."""
-        url = self.create_url(url)
-        return super().request(method, url, *args, **kwargs)
-
-    def create_url(self, url):
-        """Create the URL based off this partial path."""
-        return urljoin(self.base_url, url)
-
-
-def hasinstance(iterable, class_or_tuple):
-    """Extension of ``isinstance`` to iterables/sequences. Returns True iff the
-    sequence contains at least one object which is instance of ``class_or_tuple``.
-    """
-
-    return any(isinstance(e, class_or_tuple) for e in iterable)
-
-
-def exception_chain(exception):
-    """Traverse the chain of embedded exceptions, yielding one at the time.
-
-    Args:
-        exception (:class:`Exception`): Chained exception.
-
-    Yields:
-        :class:`Exception`: The next exception in the input exception's chain.
-
-    Examples:
-        def f():
-            try:
-                1/0
-            except ZeroDivisionError:
-                raise ValueError
-
-        try:
-            f()
-        except Exception as e:
-            assert(hasinstance(exception_chain(e), ZeroDivisionError))
-
-    See: PEP-3134.
-    """
-
-    while exception:
-        yield exception
-
-        # explicit exception chaining, i.e `raise .. from ..`
-        if exception.__cause__:
-            exception = exception.__cause__
-
-        # implicit exception chaining
-        elif exception.__context__:
-            exception = exception.__context__
-
-        else:
-            return
-
-
-def is_caused_by(exception, exception_types):
-    """Check if any of ``exception_types`` is causing the ``exception``.
-    Equivalently, check if any of ``exception_types`` is contained in the
-    exception chain rooted at ``exception``.
-
-    Args:
-        exception (:class:`Exception`):
-            Chained exception.
-
-        exception_types (:class:`Exception` or tuple of :class:`Exception`):
-            Exception type or a tuple of exception types to check for.
-
-    Returns:
-        bool:
-            True when ``exception`` is caused by any of the exceptions in
-            ``exception_types``.
-    """
-
-    return hasinstance(exception_chain(exception), exception_types)
-
-
-def user_agent(name: Optional[str] = None,
-               version: Optional[str] = None,
-               *,
-               include_platform_tags: bool = True) -> str:
-    """Return User-Agent ~ "name/version language/version interpreter/version os/version".
-
-    Args:
-        name:
-            Package name, primary UA component name.
-        version:
-            Package version, primary UA component version.
-        include_platform_tags:
-            Look for, query and include externally-contributed platform tags
-            (via ``dwave.common.platform.tags`` entrypoint).
-            See :func:`dwave.cloud.utils.get_platform_tags`.
-
-    Return:
-        User-Agent string composed of "key/value" pairs (joined with a space
-        character), for following components: package, language, interpreter,
-        machine, system and platform.
-    """
-
-    def _interpreter():
-        name = platform.python_implementation()
-        version = platform.python_version()
-        if name == 'PyPy':
-            version = '.'.join(map(str, sys.pypy_version_info[:3]))
-        full_version = [version]
-        is_64bits = sys.maxsize > 2**32
-        if is_64bits:
-            full_version.append('64bit')
-        return name, "-".join(full_version)
-
-    tags = []
-
-    if name and version:
-        tags.append((name, version))
-
-    tags.extend([
-        ("python", platform.python_version()),
-        _interpreter(),
-        ("machine", platform.machine() or 'unknown'),
-        ("system", platform.system() or 'unknown'),
-        ("platform", platform.platform() or 'unknown'),
-    ])
-
-    # add platform-specific tags
-    if include_platform_tags:
-        tags.extend(get_platform_tags())
-
-    return ' '.join("{}/{}".format(name, version) for name, version in tags)
-
-
-# defined as a function rather than constant because env might change during runtime
-def default_user_agent() -> str:
-    """Default user agent string to be used consistently across client(s)."""
-    return user_agent(
-        name=__packagename__, version=__version__, include_platform_tags=False)
-
-
-class CLIError(Exception):
-    """CLI command error that includes the error code in addition to the
-    standard error message."""
-
-    def __init__(self, message, code):
-        super().__init__(message)
-        self.code = code
-
-
-class cached:
-    """Caching decorator with max-age/expiry, forced refresh, and
-    per-arguments-combo keys.
-
-    Args:
-        maxage:
-            Default cache max-age. Overridden with cached function's ``maxage_``
-            argument.
-        store:
-            Data store.
-        key:
-            Name of cached function's argument to be used as a cache key.
-        bucket:
-            Cache bucket prefix. By default, ``@cached`` instances use isolated
-            buckets.
-
-    The decorated function accepts two additional keyword arguments:
-        refresh_ (bool):
-            Force cache miss.
-        maxage_ (float):
-            Value's maximum allowed age for a cache hit.
-
-    Examples:
-        Cache for 5 minutes::
-
-            @cached(maxage=300)
-            def get_solvers(**features):
-                return requests.get(...)
-
-        Populate the cache on the first hit for a specific arguments combination::
-
-            get_solvers(name='asd', count=5)
-
-        Cache hit (note a different ordering of arguments)::
-
-            get_solvers(count=5, name='asd')
-
-        Not in cache::
-
-            get_solvers(count=10, name='asd')
-
-        But cache is refreshed, even on a hit, if ``refresh_=True``::
-
-            get_solvers(count=5, name='asd', refresh_=True)
-
-        By default, cache indefinitely::
-
-            @cached()
-            def f(x):
-                return x**2
-
-        Specify per-call value max-age::
-
-            f(x, maxage_=10)
-
-        For stability reasons, for a cache hit, we require item age to be
-        strictly less than `maxage`.
-
-    """
-
-    _disabled = False
-
-    def disable(self):
-        """Disable/bypass cache on the decorated function."""
-
-        # set on instance
-        self._disabled = True
-
-    def enable(self):
-        """Enable cache on the decorated function."""
-
-        # revert to class attr
-        try:
-            del self._disabled
-        except:
-            pass
-
-    def _argshash(self, args: List[Any], kwargs: Dict[Any, Any]):
-        """Hash mutable arguments' containers with immutable keys and values."""
-        if self.key is None:
-            # the default: use all args and kwargs for cache key
-            tokens = (repr(args),
-                      repr(sorted((repr(k), repr(v)) for k, v in kwargs.items())))
-        else:
-            # use a single named argument (required!) as the cache key
-            tokens = (repr(kwargs[self.key]), )
-
-        return '-'.join((self.bucket, *tokens))
-
-    def __init__(self, *,
-                 maxage: Optional[float] = None,
-                 store: Optional[Mapping] = None,
-                 key: Optional[str] = None,
-                 bucket: Optional[str] = None):
-
-        self.default_maxage = maxage
-
-        if store is None:
-            store = {}
-        self.store = store
-
-        self.key = key
-        self.bucket = bucket
-
-    def __call__(self, fn):
-        @wraps(fn)
-        def wrapper(*args, **kwargs):
-            # pop additional params before calling the fn
-            refresh = kwargs.pop('refresh_', False)
-            maxage = kwargs.pop('maxage_', self.default_maxage)
-            if maxage is None:
-                maxage = math.inf
-
-            if self._disabled:
-                return fn(*args, **kwargs)
-
-            now = epochnow()
-            key = self._argshash(args, kwargs)
-            data = self.store.get(key)
-
-            callee = type(self).__name__
-            logger.trace("[%s] call(refresh=%r, maxage=%r, now=%r, store=%r, key=%r, data=%r)",
-                         callee, refresh, maxage, now, self.store, key, data)
-            found = False
-            if not refresh and data and (now - data['created'] < maxage):
-                val = data['val']
-                found = True
-            else:
-                val = fn(*args, **kwargs)
-                self.store[key] = dict(created=now, val=val)
-
-            logger.trace("[%s] call(...) = %r (cache %s)", callee, val,
-                         'hit' if found else 'miss')
-            return val
-
-        # expose @cached internals for testing, debugging and cache control via
-        # :meth:`.disable()`
-        wrapper.cached = self
-
-        # set bucket prefix
-        if self.bucket is None:
-            self.bucket = f"{fn.__name__}-{token_hex(8)}"
-
-        return wrapper
-
-    @classmethod
-    def ondisk(cls, **kwargs):
-        """@cached backed by an on-disk sqlite3-based cache."""
-        from dwave.cloud.config import get_cache_dir
-        directory = kwargs.pop('directory', get_cache_dir())
-        compression_level = kwargs.pop('compression_level', 6)
-        cache = diskcache.Cache(disk=diskcache.JSONDisk, directory=directory,
-                                disk_compress_level=compression_level)
-        return cls(store=cache, **kwargs)
-
-    class disabled:
-        """Context manager and decorator that disables the cache within the
-        context or the decorated function.
-
-        Decorator use example::
-            @cached()
-            def f(x):
-                return x**2
-
-            @cached.disabled()
-            def no_cache(x):
-                return f(x)
-
-            f(1)            # cache miss
-            f(1)            # cache hit
-            no_cache(1)     # identical to the undecorated f(x) call; cache untouched
-
-        Context manager use example::
-            @cached()
-            def f(x):
-                return x**2
-
-            with cached.disabled():
-                f(1)        # identical to the undecorated f(x) call; cache untouched
-
-            f(1)            # cache miss
-
-        """
-
-        def start(self):
-            self.patcher = mock.patch.object(cached, '_disabled', True)
-            self.patcher.start()
-
-        def stop(self):
-            self.patcher.stop()
-
-        def __enter__(self):
-            return self.start()
-
-        def __exit__(self, exc_type, exc_value, traceback):
-            self.stop()
-
-        def __call__(self, fn):
-            @wraps(fn)
-            def wrapper(*args, **kwargs):
-                self.start()
-                try:
-                    return fn(*args, **kwargs)
-                finally:
-                    self.stop()
-
-            return wrapper
-
-
-class retried(object):
-    """Decorator that retries running the wrapped function `retries` times,
-    logging exceptions along the way.
-
-    Args:
-        retries (int, default=1):
-            Decorated function is allowed to fail `retries` times.
-
-        backoff (number/List[number]/callable, default=0):
-            Delay (in seconds) before a retry.
-
-    Example:
-        Retry up to three times::
-
-            import random
-
-            def f(thresh):
-                r = random.random()
-                if r < thresh:
-                    raise ValueError
-                return r
-
-            retried_f = retried(3)(f)
-
-            retried_f(0.5)
-    """
-
-    def __init__(self, retries=1, backoff=0):
-        self.retries = retries
-
-        # normalize `backoff` to callable
-        if isinstance(backoff, numbers.Number):
-            self.backoff = lambda retry: backoff
-        elif isinstance(backoff, Sequence):
-            it = iter(backoff)
-            self.backoff = lambda retry: next(it)
-        else:
-            self.backoff = backoff
-
-    def __call__(self, fn):
-        if not callable(fn):
-            raise TypeError("decorated object must be callable")
-
-        @wraps(fn)
-        def wrapped(*args, **kwargs):
-            for retries_left in range(self.retries, -1, -1):
-                try:
-                    return fn(*args, **kwargs)
-
-                except Exception as exc:
-                    fn_name = getattr(fn, '__name__', 'unnamed')
-                    logger.debug(
-                        "Running %s(*%r, **%r) failed with %r. Retries left: %d",
-                        fn_name, args, kwargs, exc, retries_left)
-
-                    if retries_left == 0:
-                        raise exc
-
-                retry = self.retries - retries_left + 1
-                delay = self.backoff(retry)
-                logger.debug("Sleeping for %s seconds before retrying.", delay)
-                time.sleep(delay)
-
-        return wrapped
-
-
-class tictoc(object):
-    """Timer as a context manager."""
-
-    def __enter__(self):
-        self.tick = time.perf_counter()
-        return self
-
-    def __exit__(self, exc_type, exc_value, traceback):
-        self.dt = time.perf_counter() - self.tick
-
-
-class deprecated(object):
-    """Decorator that issues a deprecation message on each call of the
-    decorated function.
-    """
-
-    def __init__(self, msg=None, stacklevel=2):
-        self.msg = msg
-        self.stacklevel = stacklevel
-
-    def __call__(self, fn):
-        if not callable(fn):
-            raise TypeError("decorated object must be callable")
-
-        @wraps(fn)
-        def wrapped(*args, **kwargs):
-            msg = self.msg
-            if not msg:
-                fn_name = getattr(fn, '__name__', 'unnamed')
-                msg = "{}() has been deprecated".format(fn_name)
-            warnings.warn(msg, DeprecationWarning, stacklevel=self.stacklevel)
-
-            return fn(*args, **kwargs)
-
-        return wrapped
-
-
-def deprecated_option(msg=None, update=None):
-    """Generate a 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``).
-
-    Example::
-
-        @click.option('--config-file', '-f', default=None, is_eager=True)
-        @click.option(
-            '-c', default=None, expose_value=False,
-            help="[Deprecated in favor of '-f']",
-            callback=deprecated_option(DEPRECATION_MSG, update='config_file'))
-        ...
-        def ping(config_file, ...):
-            pass
-
-    """
-    # CLI util; defer click import until actually needed (see #473)
-    import click
-
-    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)
-
-
-def parse_loglevel(level_name, default=logging.NOTSET):
-    """Resolve numeric and symbolic log level names to numeric levels."""
-
-    try:
-        level_name = str(level_name or '').strip().lower()
-    except:
-        return default
-
-    # note: make sure `TRACE` level is added to `logging` before calling this
-    known_levels = {
-        'notset': logging.NOTSET,
-        'trace': logging.TRACE,
-        'debug': logging.DEBUG,
-        'info': logging.INFO,
-        'warning': logging.WARNING,
-        'warn': logging.WARNING,
-        'error': logging.ERROR,
-        'critical': logging.CRITICAL,
-        'fatal': logging.CRITICAL
-    }
-
-    try:
-        level = int(level_name)
-    except ValueError:
-        level = known_levels.get(level_name, default)
-
-    return level
-
-
-def set_loglevel(logger, level_name):
-    level = parse_loglevel(level_name)
-    logger.setLevel(level)
-    logger.info("Log level for %r namespace set to %r", logger.name, level)
-
-
-def pretty_argvalues():
-    """Pretty-formatted function call arguments, from the caller's frame."""
-    return inspect.formatargvalues(*inspect.getargvalues(inspect.currentframe().f_back))
-
-
-def get_contrib_config():
-    """Return all registered contrib (non-open-source) Ocean packages."""
-
-    # Note: we use `entry_points` from `importlib_metadata` to simplify access
-    # and use py312 semantics. See "compatibility note" in `importlib.metadata`
-    # docs for entry points.
-    contrib = [ep.load() for ep in entry_points(group='dwave_contrib')]
-    return contrib
-
-
-def get_contrib_packages():
-    """Combine all contrib packages in an ordered dict. Assumes package names
-    are unique.
-    """
-
-    contrib = get_contrib_config()
-
-    packages = OrderedDict()
-    for dist in contrib:
-        for pkg in dist:
-            packages[pkg['name']] = pkg
-
-    return packages
-
-
-def get_platform_tags():
-    """Return a list of platform tags generated from registered entry points."""
-
-    fs = [ep.load() for ep in entry_points(group='dwave.common.platform.tags')]
-    tags = list(filter(None, [f() for f in fs]))
-    return tags
-
-
-class VersionNotFoundError(Exception):
-    """Package version requirement not satisfied."""
-
-
-def get_distribution(requirement: Union[str, Requirement],
-                     prereleases: bool = True) -> Distribution:
-    """Returns :class:`~importlib.metadata.Distribution` for a matching
-    `requirement` specification.
-
-    Note: this function re-implements :func:`pkg_resources.get_distribution`
-    functionality for py38+ (including py312, where setuptools/pkg_resources
-    is not available by default).
-
-    Args:
-        requirement:
-            Package dependency requirement according to PEP-508, given as string,
-            or :class:`~packaging.requirements.Requirement`.
-        prereleases:
-            Boolean flag to control if installed prereleases are allowed.
-
-    Raises:
-        :class:`~importlib.metadata.PackageNotFoundError`:
-            Package by name not found.
-        :class:`~dwave.cloud.utils.VersionNotFoundError`:
-            Version of the package found (distribution) does not match the
-            requirement.
-    """
-
-    if not isinstance(requirement, Requirement):
-        requirement = Requirement(requirement)
-
-    dist = Distribution.from_name(requirement.name)
-
-    if not requirement.specifier.contains(dist.version, prereleases=prereleases):
-        raise VersionNotFoundError(
-            f"Package {dist.name!r} version {dist.version} "
-            f"does not match {requirement.specifier!s}")
-
-    return dist
-
-
-class aliasdict(dict):
-    """A dict subclass with support for item aliasing -- when you want to allow
-    explicit access to some keys, but not to store them in the dict.
-
-    :class:`aliasdict` can be used as a stand-in replacement for :class:`dict`.
-    If no aliases are added, behavior is identical to :class:`dict`.
-
-    Alias items added can be explicitly accessed, but they are not visible
-    otherwise via the dict interface. Aliases shadow original keys, and their
-    values can be computed on access only.
-
-    Aliases are added with :meth:`.alias`, and they are stored in the
-    :attr:`.aliases` class instance dictionary.
-
-    Example:
-        >>> from operator import itemgetter
-        >>> from dwave.cloud.utils import aliasdict
-
-        >>> d = aliasdict(a=1, b=2)
-        >>> d.alias(c=itemgetter('a'))
-        >>> d
-        {'a': 1, 'b': 2}
-        >>> 'c' in d
-        True
-        >>> d['c']
-        1
-
-    """
-    __slots__ = ('aliases', )
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # keep alias keys and reference values separate from the base dict
-        self.aliases = {}
-
-    def alias(self, *args, **kwargs):
-        """Update aliases dictionary with the key/value pairs from ``other``,
-        overwriting existing keys.
-
-        Args:
-            other (dict/Iterable[(key,value)]):
-                Either another dictionary object or an iterable of key/value
-                pairs (as tuples or other iterables of length two). If keyword
-                arguments are specified, the dictionary is then updated with
-                those key/value pairs ``d.alias(red=1, blue=2)``.
-
-        Note:
-            Alias key will become available via item getter, but it will not
-            be listed in the container.
-
-            Alias value can be a concrete value for the alias key, or it can be
-            a callable that is evaluated on the aliasdict instance, on each
-            access.
-
-        """
-        self.aliases.update(*args, **kwargs)
-
-    def _alias_value(self, key):
-        value = self.aliases[key]
-        if callable(value):
-            value = value(self)
-        return value
-
-    def __getitem__(self, key):
-        if key in self.aliases:
-            return self._alias_value(key)
-        return super().__getitem__(key)
-
-    def get(self, key, default=None):
-        try:
-            return self[key]
-        except KeyError:
-            return default
-
-    def __setitem__(self, key, value):
-        if key in self.aliases:
-            return self.aliases.__setitem__(key, value)
-        return super().__setitem__(key, value)
-
-    def __delitem__(self, key):
-        if key in self.aliases:
-            return self.aliases.__delitem__(key)
-        return super().__delitem__(key)
-
-    def __contains__(self, key):
-        if key in self.aliases:
-            return True
-        return super().__contains__(key)
-
-    def copy(self):
-        new = type(self)(self)
-        new.alias(self.aliases)
-        return new
-
-
-def bqm_to_dqm(bqm):
-    """Represent a :class:`dimod.BQM` as a :class:`dimod.DQM`."""
-    try:
-        from dimod import DiscreteQuadraticModel
-    except ImportError: # pragma: no cover
-        raise RuntimeError(
-            "dimod package with support for DiscreteQuadraticModel required."
-            "Re-install the library with 'dqm' support.")
-
-    dqm = DiscreteQuadraticModel()
-
-    ising = bqm.spin
-
-    for v, bias in ising.linear.items():
-        dqm.add_variable(2, label=v)
-        dqm.set_linear(v, [-bias, bias])
-
-    for (u, v), bias in ising.quadratic.items():
-        biases = numpy.array([[bias, -bias], [-bias, bias]], dtype=numpy.float64)
-        dqm.set_quadratic(u, v, biases)
-
-    return dqm
diff --git a/dwave/cloud/utils/__init__.py b/dwave/cloud/utils/__init__.py
new file mode 100644
index 0000000..2094327
--- /dev/null
+++ b/dwave/cloud/utils/__init__.py
@@ -0,0 +1,26 @@
+# Copyright 2024 D-Wave Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Utilities for private and Ocean-internal use."""
+
+# imports for backward-compat
+from dwave.cloud.utils.logging import set_loglevel, configure_logging
+
+from dwave.cloud.utils.qubo import (
+    uniform_get, reformat_qubo_as_ising, active_qubits,
+)
+
+from dwave.cloud.utils.time import utcnow
+
+from dwave.cloud.utils.decorators import cached, retried
diff --git a/dwave/cloud/utils/cli.py b/dwave/cloud/utils/cli.py
new file mode 100644
index 0000000..b7f644d
--- /dev/null
+++ b/dwave/cloud/utils/cli.py
@@ -0,0 +1,105 @@
+# Copyright 2024 D-Wave Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Command-line interface utilities for private and Ocean-internal use.
+
+.. versionchanged:: 0.12.0
+   These functions previously lived under ``dwave.cloud.utils``.
+"""
+
+from functools import partial
+from typing import Any, Callable, Optional, Union, Sequence
+
+__all__ = []
+
+
+def default_text_input(prompt: str, default: Optional[Any] = None, *,
+                       optional: bool = True,
+                       choices: Optional[Sequence[Any]] = None) -> Union[str, None]:
+    # CLI util; defer click import until actually needed (see #473)
+    import click
+
+    _skip = 'skip'
+    kwargs = dict(text=prompt)
+    if default:
+        kwargs.update(default=default)
+    else:
+        # make click print [skip] next to prompt
+        if optional:
+            kwargs.update(default=_skip)
+    if choices:
+        _type = click.Choice(choices)
+        kwargs.update(type=_type)
+        # a special case to skip user input instead of forcing input
+        if optional:
+            def allow_skip(value):
+                if value == _skip:
+                    return value
+                return click.types.convert_type(_type)(value)
+
+            kwargs.update(value_proc=allow_skip)
+
+    value = click.prompt(**kwargs)
+    if optional and value == _skip:
+        value = None
+    return value
+
+
+def strtrunc(s: Any, maxlen: int = 60) -> str:
+    s = str(s)
+    return s[:(maxlen-3)]+'...' if len(s) > maxlen else s
+
+
+class CLIError(Exception):
+    """CLI command error that includes the error code in addition to the
+    standard error message."""
+
+    def __init__(self, message, code):
+        super().__init__(message)
+        self.code = code
+
+
+def deprecated_option(msg: Optional[str] = None, update: Optional[str] = None) -> Callable:
+    """Generate a 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``).
+
+    Example::
+
+        @click.option('--config-file', '-f', default=None, is_eager=True)
+        @click.option(
+            '-c', default=None, expose_value=False,
+            help="[Deprecated in favor of '-f']",
+            callback=deprecated_option(DEPRECATION_MSG, update='config_file'))
+        ...
+        def ping(config_file, ...):
+            pass
+
+    """
+    # CLI util; defer click import until actually needed (see #473)
+    import click
+
+    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)
diff --git a/dwave/cloud/utils/coders.py b/dwave/cloud/utils/coders.py
new file mode 100644
index 0000000..545fd35
--- /dev/null
+++ b/dwave/cloud/utils/coders.py
@@ -0,0 +1,70 @@
+# Copyright 2024 D-Wave Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Encoders/decoders of 3rd-party formats for private and Ocean-internal use.
+
+.. versionchanged:: 0.12.0
+   These functions previously lived under ``dwave.cloud.utils``.
+"""
+
+import json
+
+try:
+    import numpy
+except ImportError: # pragma: no cover
+    pass    # numpy not required for all cloud-client functionality
+
+__all__ = ['NumpyEncoder', 'coerce_numpy_to_python']
+
+
+def coerce_numpy_to_python(obj):
+    """Numpy object serializer with support for basic scalar types and ndarrays."""
+
+    if isinstance(obj, numpy.integer):
+        return int(obj)
+    elif isinstance(obj, numpy.floating):
+        return float(obj)
+    elif isinstance(obj, numpy.bool_):
+        return bool(obj)
+    elif isinstance(obj, numpy.ndarray):
+        return [coerce_numpy_to_python(v) for v in obj.tolist()]
+    elif isinstance(obj, (list, tuple)):    # be explicit to avoid recursing over string et al
+        return type(obj)(coerce_numpy_to_python(v) for v in obj)
+    elif isinstance(obj, dict):
+        return {coerce_numpy_to_python(k): coerce_numpy_to_python(v) for k, v in obj.items()}
+    return obj
+
+
+# copied from dwave-hybrid utils
+# (https://github.com/dwavesystems/dwave-hybrid/blob/b9025b5bb3d88dce98ec70e28cfdb25400a10e4a/hybrid/utils.py#L43-L61)
+# TODO: switch to `dwave.common` if and when we create it
+class NumpyEncoder(json.JSONEncoder):
+    """JSON encoder for numpy types.
+
+    Supported types:
+     - basic numeric types: booleans, integers, floats
+     - arrays: ndarray, recarray
+    """
+
+    def default(self, obj):
+        if isinstance(obj, numpy.integer):
+            return int(obj)
+        elif isinstance(obj, numpy.floating):
+            return float(obj)
+        elif isinstance(obj, numpy.bool_):
+            return bool(obj)
+        elif isinstance(obj, numpy.ndarray):
+            return obj.tolist()
+
+        return super().default(obj)
diff --git a/dwave/cloud/utils/decorators.py b/dwave/cloud/utils/decorators.py
new file mode 100644
index 0000000..7181e1a
--- /dev/null
+++ b/dwave/cloud/utils/decorators.py
@@ -0,0 +1,438 @@
+# Copyright 2024 D-Wave Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Various useful decorators (grouped here for lack of a better namespace)
+for private and Ocean-internal use.
+
+.. versionchanged:: 0.12.0
+   These functions previously lived under ``dwave.cloud.utils``.
+"""
+
+import logging
+import math
+import numbers
+import time
+import warnings
+
+from functools import wraps
+from secrets import token_hex
+from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
+from unittest import mock
+
+from dwave.cloud.utils.time import epochnow
+
+__all__ = ['aliasdict', 'cached', 'deprecated', 'retried']
+
+logger = logging.getLogger(__name__)
+
+
+class aliasdict(dict):
+    """A dict subclass with support for item aliasing -- when you want to allow
+    explicit access to some keys, but not to store them in the dict.
+
+    :class:`aliasdict` can be used as a stand-in replacement for :class:`dict`.
+    If no aliases are added, behavior is identical to :class:`dict`.
+
+    Alias items added can be explicitly accessed, but they are not visible
+    otherwise via the dict interface. Aliases shadow original keys, and their
+    values can be computed on access only.
+
+    Aliases are added with :meth:`.alias`, and they are stored in the
+    :attr:`.aliases` class instance dictionary.
+
+    Example:
+        >>> from operator import itemgetter
+        >>> from dwave.cloud.utils.decorators import aliasdict
+
+        >>> d = aliasdict(a=1, b=2)
+        >>> d.alias(c=itemgetter('a'))
+        >>> d
+        {'a': 1, 'b': 2}
+        >>> 'c' in d
+        True
+        >>> d['c']
+        1
+
+    """
+    __slots__ = ('aliases', )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # keep alias keys and reference values separate from the base dict
+        self.aliases = {}
+
+    def alias(self, *args, **kwargs):
+        """Update aliases dictionary with the key/value pairs from ``other``,
+        overwriting existing keys.
+
+        Args:
+            other (dict/Iterable[(key,value)]):
+                Either another dictionary object or an iterable of key/value
+                pairs (as tuples or other iterables of length two). If keyword
+                arguments are specified, the dictionary is then updated with
+                those key/value pairs ``d.alias(red=1, blue=2)``.
+
+        Note:
+            Alias key will become available via item getter, but it will not
+            be listed in the container.
+
+            Alias value can be a concrete value for the alias key, or it can be
+            a callable that is evaluated on the aliasdict instance, on each
+            access.
+
+        """
+        self.aliases.update(*args, **kwargs)
+
+    def _alias_value(self, key):
+        value = self.aliases[key]
+        if callable(value):
+            value = value(self)
+        return value
+
+    def __getitem__(self, key):
+        if key in self.aliases:
+            return self._alias_value(key)
+        return super().__getitem__(key)
+
+    def get(self, key, default=None):
+        try:
+            return self[key]
+        except KeyError:
+            return default
+
+    def __setitem__(self, key, value):
+        if key in self.aliases:
+            return self.aliases.__setitem__(key, value)
+        return super().__setitem__(key, value)
+
+    def __delitem__(self, key):
+        if key in self.aliases:
+            return self.aliases.__delitem__(key)
+        return super().__delitem__(key)
+
+    def __contains__(self, key):
+        if key in self.aliases:
+            return True
+        return super().__contains__(key)
+
+    def copy(self):
+        new = type(self)(self)
+        new.alias(self.aliases)
+        return new
+
+
+class cached:
+    """Caching decorator with max-age/expiry, forced refresh, and
+    per-arguments-combo keys.
+
+    Args:
+        maxage:
+            Default cache max-age. Overridden with cached function's ``maxage_``
+            argument.
+        store:
+            Data store.
+        key:
+            Name of cached function's argument to be used as a cache key.
+        bucket:
+            Cache bucket prefix. By default, ``@cached`` instances use isolated
+            buckets.
+
+    The decorated function accepts two additional keyword arguments:
+        refresh_ (bool):
+            Force cache miss.
+        maxage_ (float):
+            Value's maximum allowed age for a cache hit.
+
+    Examples:
+        Cache for 5 minutes::
+
+            @cached(maxage=300)
+            def get_solvers(**features):
+                return requests.get(...)
+
+        Populate the cache on the first hit for a specific arguments combination::
+
+            get_solvers(name='asd', count=5)
+
+        Cache hit (note a different ordering of arguments)::
+
+            get_solvers(count=5, name='asd')
+
+        Not in cache::
+
+            get_solvers(count=10, name='asd')
+
+        But cache is refreshed, even on a hit, if ``refresh_=True``::
+
+            get_solvers(count=5, name='asd', refresh_=True)
+
+        By default, cache indefinitely::
+
+            @cached()
+            def f(x):
+                return x**2
+
+        Specify per-call value max-age::
+
+            f(x, maxage_=10)
+
+        For stability reasons, for a cache hit, we require item age to be
+        strictly less than `maxage`.
+
+    """
+
+    _disabled = False
+
+    def disable(self):
+        """Disable/bypass cache on the decorated function."""
+
+        # set on instance
+        self._disabled = True
+
+    def enable(self):
+        """Enable cache on the decorated function."""
+
+        # revert to class attr
+        try:
+            del self._disabled
+        except:
+            pass
+
+    def _argshash(self, args: List[Any], kwargs: Dict[Any, Any]):
+        """Hash mutable arguments' containers with immutable keys and values."""
+        if self.key is None:
+            # the default: use all args and kwargs for cache key
+            tokens = (repr(args),
+                      repr(sorted((repr(k), repr(v)) for k, v in kwargs.items())))
+        else:
+            # use a single named argument (required!) as the cache key
+            tokens = (repr(kwargs[self.key]), )
+
+        return '-'.join((self.bucket, *tokens))
+
+    def __init__(self, *,
+                 maxage: Optional[float] = None,
+                 store: Optional[Mapping] = None,
+                 key: Optional[str] = None,
+                 bucket: Optional[str] = None):
+
+        self.default_maxage = maxage
+
+        if store is None:
+            store = {}
+        self.store = store
+
+        self.key = key
+        self.bucket = bucket
+
+    def __call__(self, fn):
+        @wraps(fn)
+        def wrapper(*args, **kwargs):
+            # pop additional params before calling the fn
+            refresh = kwargs.pop('refresh_', False)
+            maxage = kwargs.pop('maxage_', self.default_maxage)
+            if maxage is None:
+                maxage = math.inf
+
+            if self._disabled:
+                return fn(*args, **kwargs)
+
+            now = epochnow()
+            key = self._argshash(args, kwargs)
+            data = self.store.get(key)
+
+            callee = type(self).__name__
+            logger.trace("[%s] call(refresh=%r, maxage=%r, now=%r, store=%r, key=%r, data=%r)",
+                         callee, refresh, maxage, now, self.store, key, data)
+            found = False
+            if not refresh and data and (now - data['created'] < maxage):
+                val = data['val']
+                found = True
+            else:
+                val = fn(*args, **kwargs)
+                self.store[key] = dict(created=now, val=val)
+
+            logger.trace("[%s] call(...) = %r (cache %s)", callee, val,
+                         'hit' if found else 'miss')
+            return val
+
+        # expose @cached internals for testing, debugging and cache control via
+        # :meth:`.disable()`
+        wrapper.cached = self
+
+        # set bucket prefix
+        if self.bucket is None:
+            self.bucket = f"{fn.__name__}-{token_hex(8)}"
+
+        return wrapper
+
+    @classmethod
+    def ondisk(cls, **kwargs):
+        """@cached backed by an on-disk sqlite3-based cache."""
+
+        # lazy import for performance
+        import diskcache
+        from dwave.cloud.config import get_cache_dir
+
+        directory = kwargs.pop('directory', get_cache_dir())
+        compression_level = kwargs.pop('compression_level', 6)
+        cache = diskcache.Cache(disk=diskcache.JSONDisk, directory=directory,
+                                disk_compress_level=compression_level)
+        return cls(store=cache, **kwargs)
+
+    class disabled:
+        """Context manager and decorator that disables the cache within the
+        context or the decorated function.
+
+        Decorator use example::
+            @cached()
+            def f(x):
+                return x**2
+
+            @cached.disabled()
+            def no_cache(x):
+                return f(x)
+
+            f(1)            # cache miss
+            f(1)            # cache hit
+            no_cache(1)     # identical to the undecorated f(x) call; cache untouched
+
+        Context manager use example::
+            @cached()
+            def f(x):
+                return x**2
+
+            with cached.disabled():
+                f(1)        # identical to the undecorated f(x) call; cache untouched
+
+            f(1)            # cache miss
+
+        """
+
+        def start(self):
+            self.patcher = mock.patch.object(cached, '_disabled', True)
+            self.patcher.start()
+
+        def stop(self):
+            self.patcher.stop()
+
+        def __enter__(self):
+            return self.start()
+
+        def __exit__(self, exc_type, exc_value, traceback):
+            self.stop()
+
+        def __call__(self, fn):
+            @wraps(fn)
+            def wrapper(*args, **kwargs):
+                self.start()
+                try:
+                    return fn(*args, **kwargs)
+                finally:
+                    self.stop()
+
+            return wrapper
+
+
+class deprecated:
+    """Decorator that issues a deprecation message on each call of the
+    decorated function.
+    """
+
+    def __init__(self, msg: Optional[str] = None, stacklevel: int = 2):
+        self.msg = msg
+        self.stacklevel = stacklevel
+
+    def __call__(self, fn: Callable) -> Callable:
+        if not callable(fn):
+            raise TypeError("decorated object must be callable")
+
+        @wraps(fn)
+        def wrapped(*args, **kwargs):
+            msg = self.msg
+            if not msg:
+                fn_name = getattr(fn, '__name__', 'unnamed')
+                msg = "{}() has been deprecated".format(fn_name)
+            warnings.warn(msg, DeprecationWarning, stacklevel=self.stacklevel)
+
+            return fn(*args, **kwargs)
+
+        return wrapped
+
+
+class retried:
+    """Decorator that retries running the wrapped function `retries` times,
+    logging exceptions along the way.
+
+    Args:
+        retries (int, default=1):
+            Decorated function is allowed to fail `retries` times.
+
+        backoff (number/List[number]/callable, default=0):
+            Delay (in seconds) before a retry.
+
+    Example:
+        Retry up to three times::
+
+            import random
+
+            def f(thresh):
+                r = random.random()
+                if r < thresh:
+                    raise ValueError
+                return r
+
+            retried_f = retried(3)(f)
+
+            retried_f(0.5)
+    """
+
+    def __init__(self, retries: int = 1, backoff: float = 0):
+        self.retries = retries
+
+        # normalize `backoff` to callable
+        if isinstance(backoff, numbers.Number):
+            self.backoff = lambda retry: backoff
+        elif isinstance(backoff, Sequence):
+            it = iter(backoff)
+            self.backoff = lambda retry: next(it)
+        else:
+            self.backoff = backoff
+
+    def __call__(self, fn: Callable) -> Callable:
+        if not callable(fn):
+            raise TypeError("decorated object must be callable")
+
+        @wraps(fn)
+        def wrapped(*args, **kwargs):
+            for retries_left in range(self.retries, -1, -1):
+                try:
+                    return fn(*args, **kwargs)
+
+                except Exception as exc:
+                    fn_name = getattr(fn, '__name__', 'unnamed')
+                    logger.debug(
+                        "Running %s(*%r, **%r) failed with %r. Retries left: %d",
+                        fn_name, args, kwargs, exc, retries_left)
+
+                    if retries_left == 0:
+                        raise exc
+
+                retry = self.retries - retries_left + 1
+                delay = self.backoff(retry)
+                logger.debug("Sleeping for %s seconds before retrying.", delay)
+                time.sleep(delay)
+
+        return wrapped
diff --git a/dwave/cloud/utils/dist.py b/dwave/cloud/utils/dist.py
new file mode 100644
index 0000000..5923018
--- /dev/null
+++ b/dwave/cloud/utils/dist.py
@@ -0,0 +1,93 @@
+# Copyright 2024 D-Wave Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Distribution utilities for private and Ocean-internal use.
+
+.. versionchanged:: 0.12.0
+   These functions previously lived under ``dwave.cloud.utils``.
+"""
+
+from importlib.metadata import (
+    entry_points, EntryPoint, Distribution, PackageNotFoundError)
+from typing import Dict, List, OrderedDict, Union
+
+from packaging.requirements import Requirement
+
+__all__ = ['get_distribution', 'PackageNotFoundError', 'VersionNotFoundError']
+
+
+def get_contrib_config() -> List[EntryPoint]:
+    """Return all registered contrib (non-open-source) Ocean packages."""
+
+    # Note: we use `entry_points` from `importlib_metadata` to simplify access
+    # and use py312 semantics. See "compatibility note" in `importlib.metadata`
+    # docs for entry points.
+    contrib = [ep.load() for ep in entry_points(group='dwave_contrib')]
+    return contrib
+
+
+def get_contrib_packages() -> OrderedDict[str, dict]:
+    """Combine all contrib packages in an ordered dict. Assumes package names
+    are unique.
+    """
+
+    contrib = get_contrib_config()
+
+    packages = OrderedDict()
+    for dist in contrib:
+        for pkg in dist:
+            packages[pkg['name']] = pkg
+
+    return packages
+
+
+class VersionNotFoundError(Exception):
+    """Package version requirement not satisfied."""
+
+
+def get_distribution(requirement: Union[str, Requirement],
+                     prereleases: bool = True) -> Distribution:
+    """Returns :class:`~importlib.metadata.Distribution` for a matching
+    `requirement` specification.
+
+    Note: this function re-implements :func:`pkg_resources.get_distribution`
+    functionality for py38+ (including py312, where setuptools/pkg_resources
+    is not available by default).
+
+    Args:
+        requirement:
+            Package dependency requirement according to PEP-508, given as string,
+            or :class:`~packaging.requirements.Requirement`.
+        prereleases:
+            Boolean flag to control if installed prereleases are allowed.
+
+    Raises:
+        :class:`~importlib.metadata.PackageNotFoundError`:
+            Package by name not found.
+        :class:`~dwave.cloud.utils.dist.VersionNotFoundError`:
+            Version of the package found (distribution) does not match the
+            requirement.
+    """
+
+    if not isinstance(requirement, Requirement):
+        requirement = Requirement(requirement)
+
+    dist = Distribution.from_name(requirement.name)
+
+    if not requirement.specifier.contains(dist.version, prereleases=prereleases):
+        raise VersionNotFoundError(
+            f"Package {dist.name!r} version {dist.version} "
+            f"does not match {requirement.specifier!s}")
+
+    return dist
diff --git a/dwave/cloud/utils/exception.py b/dwave/cloud/utils/exception.py
new file mode 100644
index 0000000..472df55
--- /dev/null
+++ b/dwave/cloud/utils/exception.py
@@ -0,0 +1,93 @@
+# Copyright 2024 D-Wave Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Exception handling utilities for private and Ocean-internal use.
+
+.. versionchanged:: 0.12.0
+   These functions previously lived under ``dwave.cloud.utils``.
+"""
+
+from typing import Any, Iterable, Tuple, Union
+
+__all__ = ['hasinstance', 'exception_chain', 'is_caused_by']
+
+
+def hasinstance(objs: Iterable[Any], class_or_tuple: Union[Any, Tuple[Any]]):
+    """Extension of ``isinstance`` to iterables/sequences. Returns True iff the
+    sequence contains at least one object which is instance of ``class_or_tuple``.
+    """
+
+    return any(isinstance(e, class_or_tuple) for e in objs)
+
+
+def exception_chain(exception: Exception):
+    """Traverse the chain of embedded exceptions, yielding one at the time.
+
+    Args:
+        exception (:class:`Exception`): Chained exception.
+
+    Yields:
+        :class:`Exception`: The next exception in the input exception's chain.
+
+    Examples:
+        def f():
+            try:
+                1/0
+            except ZeroDivisionError:
+                raise ValueError
+
+        try:
+            f()
+        except Exception as e:
+            assert(hasinstance(exception_chain(e), ZeroDivisionError))
+
+    See: PEP-3134.
+    """
+
+    while exception:
+        yield exception
+
+        # explicit exception chaining, i.e `raise .. from ..`
+        if exception.__cause__:
+            exception = exception.__cause__
+
+        # implicit exception chaining
+        elif exception.__context__:
+            exception = exception.__context__
+
+        else:
+            return
+
+
+def is_caused_by(exception: Exception,
+                 exception_types: Union[Exception, Tuple[Exception]]
+                 ) -> bool:
+    """Check if any of ``exception_types`` is causing the ``exception``.
+    Equivalently, check if any of ``exception_types`` is contained in the
+    exception chain rooted at ``exception``.
+
+    Args:
+        exception (:class:`Exception`):
+            Chained exception.
+
+        exception_types (:class:`Exception` or tuple of :class:`Exception`):
+            Exception type or a tuple of exception types to check for.
+
+    Returns:
+        bool:
+            True when ``exception`` is caused by any of the exceptions in
+            ``exception_types``.
+    """
+
+    return hasinstance(exception_chain(exception), exception_types)
diff --git a/dwave/cloud/utils/http.py b/dwave/cloud/utils/http.py
new file mode 100644
index 0000000..0966c39
--- /dev/null
+++ b/dwave/cloud/utils/http.py
@@ -0,0 +1,160 @@
+# Copyright 2024 D-Wave Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""HTTP/requests utilities for private and Ocean-internal use.
+
+.. versionchanged:: 0.12.0
+   These functions previously lived under ``dwave.cloud.utils``.
+"""
+
+import platform
+import sys
+import typing
+from urllib.parse import urljoin
+
+import requests
+
+from dwave.cloud.package_info import __packagename__, __version__
+
+__all__ = []
+
+
+class PretimedHTTPAdapter(requests.adapters.HTTPAdapter):
+    """Sets a default timeout for all adapter (think session) requests. It is
+    overridden with per-request timeout. But it can not be reset back to
+    infinite wait (``None``).
+
+    Usage:
+
+        s = requests.Session()
+        s.mount("http://", PretimedHTTPAdapter(timeout=5))
+        s.mount("https://", PretimedHTTPAdapter(timeout=5))
+
+        s.get('http://httpbin.org/delay/6')                 # -> timeouts after 5sec
+        s.get('http://httpbin.org/delay/6', timeout=10)     # -> completes after 6sec
+
+    The alternative is to set ``timeout`` on each request manually/explicitly,
+    subclass ``Session``, or monkeypatch ``Session.request()``.
+    """
+
+    def __init__(self, timeout=None, *args, **kwargs):
+        self.timeout = timeout
+        super().__init__(*args, **kwargs)
+
+    def send(self, *args, **kwargs):
+        # can't use setdefault because caller always sets timeout kwarg
+        kwargs['timeout'] = self.timeout
+        return super().send(*args, **kwargs)
+
+
+class TimeoutingHTTPAdapter(PretimedHTTPAdapter):
+    """Alias for :class:`~dwave.cloud.utils.PretimedHTTPAdapter`. Deprecated,
+    but retained for backward compatibility.
+    """
+
+
+# Note: BaseUrlSession is taken from https://github.com/requests/toolbelt under
+# an Apache 2 license. This simple extension didn't warrant a new dependency.
+# If we later decide to use additional features from `requests-toolbelt`,
+# remove it from here.
+
+class BaseUrlSession(requests.Session):
+    """A Session with a URL that all requests will use as a base."""
+
+    base_url = None
+
+    def __init__(self, base_url=None):
+        if base_url:
+            self.base_url = base_url
+        super().__init__()
+
+    def request(self, method, url, *args, **kwargs):
+        """Send the request after generating the complete URL."""
+        url = self.create_url(url)
+        return super().request(method, url, *args, **kwargs)
+
+    def create_url(self, url):
+        """Create the URL based off this partial path."""
+        return urljoin(self.base_url, url)
+
+
+def user_agent(name: typing.Optional[str] = None,
+               version: typing.Optional[str] = None,
+               *,
+               include_platform_tags: bool = True) -> str:
+    """Return User-Agent ~ "name/version language/version interpreter/version os/version".
+
+    Args:
+        name:
+            Package name, primary UA component name.
+        version:
+            Package version, primary UA component version.
+        include_platform_tags:
+            Look for, query and include externally-contributed platform tags
+            (via ``dwave.common.platform.tags`` entrypoint).
+            See :func:`dwave.cloud.utils.get_platform_tags`.
+
+    Return:
+        User-Agent string composed of "key/value" pairs (joined with a space
+        character), for following components: package, language, interpreter,
+        machine, system and platform.
+    """
+
+    def _interpreter():
+        name = platform.python_implementation()
+        version = platform.python_version()
+        if name == 'PyPy':
+            version = '.'.join(map(str, sys.pypy_version_info[:3]))
+        full_version = [version]
+        is_64bits = sys.maxsize > 2**32
+        if is_64bits:
+            full_version.append('64bit')
+        return name, "-".join(full_version)
+
+    tags = []
+
+    if name and version:
+        tags.append((name, version))
+
+    tags.extend([
+        ("python", platform.python_version()),
+        _interpreter(),
+        ("machine", platform.machine() or 'unknown'),
+        ("system", platform.system() or 'unknown'),
+        ("platform", platform.platform() or 'unknown'),
+    ])
+
+    # add platform-specific tags
+    if include_platform_tags:
+        tags.extend(get_platform_tags())
+
+    return ' '.join("{}/{}".format(name, version) for name, version in tags)
+
+
+# defined as a function rather than constant because env might change during runtime
+def default_user_agent() -> str:
+    """Default user agent string to be used consistently across client(s)."""
+    return user_agent(
+        name=__packagename__, version=__version__, include_platform_tags=False)
+
+
+def get_platform_tags() -> typing.List[str]:
+    """Return a list of platform tags generated from registered entry points."""
+
+    # import only when needed, as it's pretty slow! (~10ms)
+    from importlib_metadata import entry_points
+
+    fs = [ep.load() for ep in entry_points(group='dwave.common.platform.tags')]
+    tags = list(filter(None, [f() for f in fs]))
+    return tags
diff --git a/dwave/cloud/utils/logging.py b/dwave/cloud/utils/logging.py
new file mode 100644
index 0000000..1fb5035
--- /dev/null
+++ b/dwave/cloud/utils/logging.py
@@ -0,0 +1,216 @@
+# Copyright 2024 D-Wave Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Logging utilities for private and Ocean-internal use."""
+
+import datetime
+import inspect
+import io
+import json
+import logging
+import os
+import re
+import sys
+import typing
+
+__all__ = []
+
+
+class ISOFormatter(logging.Formatter):
+    # target timezone, e.g. `datetime.timezone.utc`, or `None` for naive timestamp
+    as_tz: typing.Optional[datetime.timezone] = None
+
+    def __init__(self, *args, as_tz: typing.Optional[datetime.timezone] = None, **kwargs):
+        self.as_tz = as_tz
+        super().__init__(*args, **kwargs)
+
+    def formatTime(self, record: logging.LogRecord, datefmt: typing.Optional[str] = None) -> str:
+        return datetime.datetime.fromtimestamp(record.created, tz=self.as_tz).isoformat()
+
+
+class FilteredSecretsFormatter(logging.Formatter):
+    """Logging formatter that filters out secrets (like Solver API tokens).
+
+    Note: we assume, for easier disambiguation, a secret/token is prefixed with
+    a short alphanumeric string, and comprises 40 or more hex digits.
+    """
+
+    # prefixed 160-bit+ hex tokens (sapi token format: `A{2,4}-X{40,}`)
+    _SAPI_TOKEN_PATTERN = re.compile(
+        r'\b([0-9A-Za-z]{2,4}-[0-9A-Fa-f]{3})([0-9A-Fa-f]{34,})([0-9A-Fa-f]{3})\b')
+    # 128-bit+ hex tokens (`X{32,}`)
+    _HEX_TOKEN_PATTERN = re.compile(
+        r'\b([0-9A-Fa-f]{3})([0-9A-Fa-f]{26,})([0-9A-Fa-f]{3})\b')
+    # 128-bit uuid tokens (`X{8}-X{4}-X{4}-X{4}-X{12}`)
+    _UUID_TOKEN_PATTERN = re.compile(
+        r'\b([0-9A-Fa-f]{3})([0-9A-Fa-f]{5}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{9})([0-9A-Fa-f]{3})\b')
+
+    def format(self, record: logging.LogRecord) -> str:
+        output = super().format(record)
+        output = re.sub(self._SAPI_TOKEN_PATTERN, r'\1...\3', output)
+        output = re.sub(self._HEX_TOKEN_PATTERN, r'\1...\3', output)
+        output = re.sub(self._UUID_TOKEN_PATTERN, r'\1...\3', output)
+        return output
+
+
+class JSONFormatter(ISOFormatter):
+    def format(self, record: logging.LogRecord) -> str:
+        super().format(record)
+        # filter out message template and potentially unserializable args
+        rec = record.__dict__.copy()
+        del rec['args']
+        del rec['msg']
+        return json.dumps(rec)
+
+
+def parse_loglevel(level_name: typing.Union[str, int],
+                   default: int = logging.NOTSET) -> int:
+    """Resolve numeric and symbolic log level names to numeric levels."""
+
+    try:
+        level_name = str(level_name or '').strip().lower()
+    except:
+        return default
+
+    # note: in py 3.11+ we can use `logging.getLevelNamesMapping()` instead
+    known_levels = {
+        'notset': logging.NOTSET,
+        'debug': logging.DEBUG,
+        'info': logging.INFO,
+        'warning': logging.WARNING,
+        'warn': logging.WARNING,
+        'error': logging.ERROR,
+        'critical': logging.CRITICAL,
+        'fatal': logging.CRITICAL
+    }
+
+    # support TRACE level, but make sure this function works without it
+    if hasattr(logging, 'TRACE'):
+        known_levels.update(trace=logging.TRACE)
+
+    try:
+        level = int(level_name)
+    except ValueError:
+        level = known_levels.get(level_name, default)
+
+    return level
+
+
+def set_loglevel(logger: logging.Logger, level: typing.Union[str, int]) -> None:
+    level = parse_loglevel(level)
+    logger.setLevel(level)
+    logger.info("Log level for %r namespace set to %r", logger.name, level)
+
+
+def add_loglevel(name: str, value: int) -> None:
+    """Create a new globally available logging loglevel and a logging method
+    on the base ``logging.Logger`` class.
+    """
+
+    name = name.upper()
+
+    setattr(logging, name, value)
+    logging.addLevelName(value, name)
+
+    def log_method(logger, message, *args, **kwargs):
+        logger.log(value, message, *args, **kwargs)
+
+    setattr(logging.Logger, name.lower(), log_method)
+
+
+def configure_logging(logger: typing.Optional[logging.Logger] = None,
+                      *,
+                      level: typing.Union[str, int] = logging.WARNING,
+                      filter_secrets: bool = True,
+                      output_stream: typing.Optional[io.IOBase] = None,
+                      in_utc: bool = False,
+                      structured_output: bool = False,
+                      handler_level: typing.Optional[int] = None,
+                      additive: bool = False,
+                      ) -> logging.Logger:
+    """Configure cloud-client's `dwave.cloud` base logger.
+
+    Logging output from the cloud-client is suppressed by default. This utility
+    function can be used to quickly setup basic logging from the library.
+
+    .. note::
+       This function is currently intended for internal/private use only.
+
+    .. versionadded:: 0.12.0
+       Explicit optional logging configuration. Previously, logger was minimally
+       configured by default.
+    """
+
+    level = parse_loglevel(level)
+
+    if logger is None:
+        logger = logging.getLogger('dwave.cloud')
+    if output_stream is None:
+        output_stream = sys.stderr
+    if handler_level is None:
+        handler_level = level
+
+    format = dict(
+        fmt='%(asctime)s %(name)s %(levelname)s %(threadName)s [%(funcName)s] %(message)s',
+        as_tz=datetime.timezone.utc if in_utc else None,
+    )
+
+    if structured_output:
+        formatter_base = JSONFormatter
+    else:
+        formatter_base = ISOFormatter
+
+    if filter_secrets:
+        class Formatter(FilteredSecretsFormatter, formatter_base):
+            pass
+    else:
+        Formatter = formatter_base
+
+    if not additive:
+        # make sure handlers are not accumulated
+        while len(logger.handlers):
+            logger.removeHandler(logger.handlers[-1])
+
+    formatter = Formatter(**format)
+    handler = logging.StreamHandler(stream=output_stream)
+    handler.setFormatter(formatter)
+    handler.setLevel(handler_level)
+
+    logger.addHandler(handler)
+    logger.setLevel(level)
+
+    return logger
+
+
+def configure_logging_from_env(logger: logging.Logger) -> bool:
+    """Use ``DWAVE_LOG_LEVEL`` (name or value) and ``DWAVE_LOG_FORMAT`` ("json"
+    for structured output) environment variables to configure logging.
+    """
+
+    log_level = os.getenv('DWAVE_LOG_LEVEL', os.getenv('dwave_log_level'))
+    log_format = os.getenv('DWAVE_LOG_FORMAT', os.getenv('dwave_log_format', ''))
+
+    if not log_level:
+        return False
+
+    configure_logging(logger, additive=False,
+                      level=parse_loglevel(log_level),
+                      structured_output=(log_format.strip().lower() == 'json'))
+
+    return True
+
+
+def pretty_argvalues():
+    """Pretty-formatted function call arguments, from the caller's frame."""
+    return inspect.formatargvalues(*inspect.getargvalues(inspect.currentframe().f_back))
diff --git a/dwave/cloud/utils/qubo.py b/dwave/cloud/utils/qubo.py
new file mode 100644
index 0000000..37ac123
--- /dev/null
+++ b/dwave/cloud/utils/qubo.py
@@ -0,0 +1,145 @@
+# Copyright 2024 D-Wave Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Old QUBO/Ising utilities for private and Ocean-internal use."""
+
+import random
+from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Set, Tuple, Union
+
+__all__ = []
+
+
+def uniform_iterator(sequence: Union[Mapping, Sequence]
+                     ) -> Iterator[Tuple[int, Any]]:
+    """Uniform (key, value) iteration on a `dict`,
+    or (idx, value) on a `list`."""
+
+    if isinstance(sequence, Mapping):
+        return sequence.items()
+    else:
+        return enumerate(sequence)
+
+
+def uniform_get(sequence: Union[Mapping, Sequence],
+                index: int,
+                default: Optional[Any] = None
+                ) -> Any:
+    """Uniform `dict`/`list` item getter, where `index` is interpreted as a key
+    for maps and as numeric index for lists."""
+
+    if isinstance(sequence, Mapping):
+        return sequence.get(index, default)
+    else:
+        return sequence[index] if index < len(sequence) else default
+
+
+def evaluate_ising(linear: Union[Mapping, Sequence],
+                   quad: Mapping,
+                   state: Sequence,
+                   offset: float = 0
+                   ) -> float:
+    """Calculate the energy of a state given the Hamiltonian.
+
+    Args:
+        linear: Linear Hamiltonian terms.
+        quad: Quadratic Hamiltonian terms.
+        offset: Energy offset.
+        state: Vector of spins describing the system state
+
+    Returns:
+        Energy of the state evaluated by the given energy function.
+    """
+
+    # note: we avoid numpy import by tolist() check
+    if hasattr(state, 'tolist') and callable(state.tolist):
+        return evaluate_ising(linear, quad, state.tolist(), offset=offset)
+
+    # Accumulate the linear and quadratic values
+    energy = offset
+    for index, value in uniform_iterator(linear):
+        energy += state[index] * value
+    for (index_a, index_b), value in quad.items():
+        energy += value * state[index_a] * state[index_b]
+    return energy
+
+
+def active_qubits(linear: Union[Mapping, Sequence],
+                  quadratic: Mapping
+                  ) -> Set:
+    """Calculate a set of all active qubits. Qubit is "active" if it has
+    bias or coupling attached.
+
+    Args:
+        linear (dict[variable, bias]/list[variable, bias]):
+            Linear terms of the model.
+
+        quadratic (dict[(variable, variable), bias]):
+            Quadratic terms of the model.
+
+    Returns:
+        set:
+            Active qubits' indices.
+    """
+
+    active = {idx for idx, bias in uniform_iterator(linear)}
+    for edge, _ in quadratic.items():
+        active.update(edge)
+    return active
+
+
+def generate_random_ising_problem(solver: 'dwave.cloud.solver.Solver',
+                                  h_range: Optional[List[float]] = None,
+                                  j_range: Optional[List[float]] = None
+                                  ) -> Tuple[dict]:
+    """Generates an Ising problem formulation valid for a particular solver,
+    using all qubits and all couplings and linear/quadratic biases sampled
+    uniformly from `h_range`/`j_range`.
+    """
+
+    if h_range is None:
+        h_range = solver.properties.get('h_range', [-1, 1])
+    if j_range is None:
+        j_range = solver.properties.get('j_range', [-1, 1])
+
+    lin = {qubit: random.uniform(*h_range) for qubit in solver.nodes}
+    quad = {edge: random.uniform(*j_range) for edge in solver.undirected_edges}
+
+    return lin, quad
+
+
+def generate_const_ising_problem(solver: 'dwave.cloud.solver.Solver',
+                                 h: float = 1,
+                                 j: float = -1
+                                 ) -> Tuple[dict]:
+
+    return generate_random_ising_problem(solver, h_range=[h, h], j_range=[j, j])
+
+
+def reformat_qubo_as_ising(qubo: Dict[Tuple[int, int], float]) -> Tuple[dict]:
+    """Split QUBO coefficients into linear and quadratic terms (the Ising form).
+
+    Args:
+        qubo (dict[(int, int), float]):
+            Coefficients of a quadratic unconstrained binary optimization
+            (QUBO) model.
+
+    Returns:
+        (dict[int, float], dict[(int, int), float])
+
+    """
+
+    lin = {u: bias for (u, v), bias in qubo.items() if u == v}
+    quad = {(u, v): bias for (u, v), bias in qubo.items() if u != v}
+
+    return lin, quad
diff --git a/dwave/cloud/utils/time.py b/dwave/cloud/utils/time.py
new file mode 100644
index 0000000..a8cb1f3
--- /dev/null
+++ b/dwave/cloud/utils/time.py
@@ -0,0 +1,72 @@
+# Copyright 2024 D-Wave Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Date/time utilities for private and Ocean-internal use.
+
+.. versionchanged:: 0.12.0
+   These functions previously lived under ``dwave.cloud.utils``.
+"""
+
+import time
+from datetime import datetime, timedelta
+from dateutil.tz import UTC
+
+__all__ = ['datetime_to_timestamp', 'utcnow', 'epochnow', 'utcrel', 'tictoc']
+
+
+def datetime_to_timestamp(dt: datetime) -> float:
+    """Convert timezone-aware `datetime` to POSIX timestamp and
+    return seconds since UNIX epoch.
+
+    Note: similar to `datetime.timestamp()` in Python 3.3+.
+    """
+
+    epoch = datetime.fromtimestamp(0, tz=UTC)
+    return (dt - epoch).total_seconds()
+
+
+def utcnow() -> datetime:
+    """Returns tz-aware now in UTC."""
+    return datetime.now(tz=UTC)
+
+
+def epochnow() -> float:
+    """Returns now as UNIX timestamp.
+
+    Invariant:
+        epochnow() ~= datetime_to_timestamp(utcnow())
+
+    """
+    return time.time()
+
+
+def utcrel(offset: int) -> datetime:
+    """Return a timezone-aware `datetime` relative to now (UTC), shifted by
+    `offset` seconds in to the future.
+
+    Example:
+        a_minute_from_now = utcrel(60)
+    """
+    return utcnow() + timedelta(seconds=offset)
+
+
+class tictoc:
+    """Timer as a context manager."""
+
+    def __enter__(self):
+        self.tick = time.perf_counter()
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        self.dt = time.perf_counter() - self.tick
diff --git a/releasenotes/notes/refactor-utils-560b18c32f12feee.yaml b/releasenotes/notes/refactor-utils-560b18c32f12feee.yaml
new file mode 100644
index 0000000..2f444b4
--- /dev/null
+++ b/releasenotes/notes/refactor-utils-560b18c32f12feee.yaml
@@ -0,0 +1,17 @@
+---
+upgrade:
+  - |
+    ``dwave.cloud.utils`` module has been split into the following submodules:
+    ``dwave.cloud.utils.cli``, ``dwave.cloud.utils.coders``,
+    ``dwave.cloud.utils.decorators``, ``dwave.cloud.utils.dist``,
+    ``dwave.cloud.utils.exception``, ``dwave.cloud.utils.http``,
+    ``dwave.cloud.utils.logging``, ``dwave.cloud.utils.qubo``, and
+    ``dwave.cloud.utils.time``.
+
+    First reason for doing this was code clean-up (including adding type annotations),
+    but a more important reason is that this will enable import time optimization
+    in the future. And that's why we break backwards compatibility by not importing
+    everything back into the top-level utils namespace.
+
+    The utils module is considered Ocean-internal as is, so this move shouldn't
+    affect user's code.


Test patch:
diff --git a/tests/api/mocks.py b/tests/api/mocks.py
index ffba531..39c27d2 100644
--- a/tests/api/mocks.py
+++ b/tests/api/mocks.py
@@ -17,7 +17,8 @@ import uuid
 from typing import Any, Optional, Tuple, Union
 
 from dwave.cloud.coders import encode_problem_as_qp, encode_problem_as_ref
-from dwave.cloud.utils import utcrel, generate_const_ising_problem
+from dwave.cloud.utils.qubo import generate_const_ising_problem
+from dwave.cloud.utils.time import utcrel
 from dwave.cloud.solver import StructuredSolver, UnstructuredSolver
 from dwave.cloud.testing.mocks import qpu_clique_solver_data, hybrid_bqm_solver_data
 
diff --git a/tests/test_cli.py b/tests/test_cli.py
index f9f6ff8..184f8dd 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -70,7 +70,7 @@ class TestConfigCreate(unittest.TestCase):
         runner = CliRunner(mix_stderr=False)
         with runner.isolated_filesystem():
             with mock.patch("dwave.cloud.config.loaders.get_configfile_paths", lambda: ['dwave.conf']):
-                with mock.patch("dwave.cloud.utils.input", side_effect=inputs):
+                with mock.patch("dwave.cloud.utils.cli.input", side_effect=inputs):
                     result = runner.invoke(cli, [
                         'config', 'create'
                     ] + extra_opts, input='\n'.join('' if v is None else v for v in inputs.values()))
diff --git a/tests/test_client.py b/tests/test_client.py
index cb872c2..1c4c791 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -21,9 +21,9 @@ test_mock_solver_loading.py duplicates some of these tests against a mock server
 import json
 import time
 import unittest
-from unittest import mock
-from contextlib import contextmanager
 from collections import defaultdict
+from contextlib import contextmanager
+from unittest import mock
 
 import requests
 from parameterized import parameterized
@@ -32,11 +32,11 @@ from plucky import merge
 from dwave.cloud.api import constants, models, Regions
 from dwave.cloud.client import Client
 from dwave.cloud.config.models import dump_config_v1
-from dwave.cloud.solver import StructuredSolver, UnstructuredSolver
-from dwave.cloud.utils import cached
 from dwave.cloud.exceptions import (
     SolverAuthenticationError, SolverError, SolverNotFoundError)
+from dwave.cloud.solver import StructuredSolver, UnstructuredSolver
 from dwave.cloud.testing import iterable_mock_open, isolated_environ
+from dwave.cloud.utils.decorators import cached
 import dwave.cloud
 
 from tests import config
diff --git a/tests/test_coders.py b/tests/test_coders.py
index 33f2fa0..7732c43 100644
--- a/tests/test_coders.py
+++ b/tests/test_coders.py
@@ -30,7 +30,7 @@ from dwave.cloud.coders import (
     encode_problem_as_qp, decode_qp, decode_qp_numpy,
     encode_problem_as_bq, decode_bq, encode_problem_as_ref)
 from dwave.cloud.solver import StructuredSolver, UnstructuredSolver
-from dwave.cloud.utils import generate_const_ising_problem
+from dwave.cloud.utils.qubo import generate_const_ising_problem
 
 # parse string version as tuple
 if dimod:
diff --git a/tests/test_mock_submission.py b/tests/test_mock_submission.py
index 2b84195..e7c15ad 100644
--- a/tests/test_mock_submission.py
+++ b/tests/test_mock_submission.py
@@ -33,12 +33,13 @@ except ImportError:
     dimod = None
 
 from dwave.cloud.client import Client
-from dwave.cloud.solver import Solver
 from dwave.cloud.computation import Future
-from dwave.cloud.utils import evaluate_ising, utcrel
 from dwave.cloud.exceptions import (
     SolverFailureError, CanceledFutureError, SolverError,
     InvalidAPIResponseError)
+from dwave.cloud.solver import Solver
+from dwave.cloud.utils.qubo import evaluate_ising
+from dwave.cloud.utils.time import utcrel
 
 from tests.api.mocks import StructuredSapiMockResponses
 
diff --git a/tests/test_solver.py b/tests/test_solver.py
index b80d6ce..40a9f48 100644
--- a/tests/test_solver.py
+++ b/tests/test_solver.py
@@ -19,8 +19,8 @@ Note:
     These are all aggregate tests, not really testing single units.
 """
 
-import unittest
 import random
+import unittest
 import warnings
 from datetime import datetime
 
@@ -31,11 +31,11 @@ try:
 except ImportError:
     dimod = None
 
-from dwave.cloud.utils import evaluate_ising, generate_random_ising_problem
+import dwave.cloud.computation
 from dwave.cloud.client import Client
 from dwave.cloud.exceptions import (
     CanceledFutureError, SolverFailureError, InvalidProblemError)
-import dwave.cloud.computation
+from dwave.cloud.utils.qubo import evaluate_ising, generate_random_ising_problem
 
 
 from tests import config
diff --git a/tests/test_upload.py b/tests/test_upload.py
index 6a2f2fb..0a3978c 100644
--- a/tests/test_upload.py
+++ b/tests/test_upload.py
@@ -12,24 +12,24 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import collections
 import io
+import json
 import os
+import tempfile
 import time
-import json
 import unittest
-import tempfile
-import collections
-from unittest import mock
 from concurrent.futures import ThreadPoolExecutor, wait
+from unittest import mock
 
-from requests.exceptions import HTTPError
 from parameterized import parameterized
+from requests.exceptions import HTTPError
 
-from dwave.cloud.utils import tictoc
 from dwave.cloud.client import Client
 from dwave.cloud.exceptions import SAPIError, ProblemUploadError
 from dwave.cloud.upload import (
     Gettable, GettableFile, GettableMemory, FileView, ChunkedData)
+from dwave.cloud.utils.time import tictoc
 
 from tests import config
 
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f96014d..0263e26 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -31,14 +31,18 @@ from typing import Tuple, Union
 import numpy
 from parameterized import parameterized
 
-from dwave.cloud import FilteredSecretsFormatter, configure_logging
-from dwave.cloud.utils import (
-    uniform_iterator, uniform_get, strip_head, strip_tail,
-    active_qubits, generate_random_ising_problem,
-    NumpyEncoder, coerce_numpy_to_python,
-    default_text_input, utcnow, cached, retried, deprecated, aliasdict,
-    parse_loglevel, user_agent, default_user_agent, hasinstance, exception_chain,
-    is_caused_by, get_distribution, PackageNotFoundError, VersionNotFoundError)
+from dwave.cloud.utils.coders import NumpyEncoder, coerce_numpy_to_python
+from dwave.cloud.utils.cli import default_text_input
+from dwave.cloud.utils.decorators import aliasdict, cached, deprecated, retried
+from dwave.cloud.utils.dist import get_distribution, PackageNotFoundError, VersionNotFoundError
+from dwave.cloud.utils.exception import hasinstance, exception_chain, is_caused_by
+from dwave.cloud.utils.http import user_agent, default_user_agent
+from dwave.cloud.utils.logging import (
+    FilteredSecretsFormatter, configure_logging, parse_loglevel)
+from dwave.cloud.utils.qubo import (
+    uniform_iterator, uniform_get,
+    active_qubits, generate_random_ising_problem)
+from dwave.cloud.utils.time import utcnow
 
 logger = logging.getLogger(__name__)
 
@@ -60,18 +64,6 @@ class TestSimpleUtils(unittest.TestCase):
         self.assertEqual(uniform_get(l, 2), None)
         self.assertEqual(uniform_get(l, 2, default=0), 0)
 
-    def test_strip_head(self):
-        self.assertEqual(strip_head([0, 0, 1, 2], [0]), [1, 2])
-        self.assertEqual(strip_head([1], [0]), [1])
-        self.assertEqual(strip_head([1], []), [1])
-        self.assertEqual(strip_head([0, 0, 1, 2], [0, 1, 2]), [])
-
-    def test_strip_tail(self):
-        self.assertEqual(strip_tail([1, 2, 0, 0], [0]), [1, 2])
-        self.assertEqual(strip_tail([1], [0]), [1])
-        self.assertEqual(strip_tail([1], []), [1])
-        self.assertEqual(strip_tail([0, 0, 1, 2], [0, 1, 2]), [])
-
     def test_active_qubits_dict(self):
         self.assertEqual(active_qubits({}, {}), set())
         self.assertEqual(active_qubits({0: 0}, {}), {0})
@@ -322,19 +314,19 @@ class TestCachedCommon(unittest.TestCase):
             return next(counter)
 
         # populate
-        with mock.patch('dwave.cloud.utils.epochnow', lambda: 0):
+        with mock.patch('dwave.cloud.utils.decorators.epochnow', lambda: 0):
             self.assertEqual(f(), 0)
             self.assertEqual(f(1), 1)
             self.assertEqual(f(a=1, b=2), 2)
 
         # cache miss, expired
-        with mock.patch('dwave.cloud.utils.epochnow', lambda: 15):
+        with mock.patch('dwave.cloud.utils.decorators.epochnow', lambda: 15):
             self.assertEqual(f(), 3)
             self.assertEqual(f(1), 4)
             self.assertEqual(f(a=1, b=2), 5)
 
         # cache hit, still hot
-        with mock.patch('dwave.cloud.utils.epochnow', lambda: 5):
+        with mock.patch('dwave.cloud.utils.decorators.epochnow', lambda: 5):
             self.assertEqual(f(), 3)
             self.assertEqual(f(1), 4)
             self.assertEqual(f(a=1, b=2), 5)
@@ -347,17 +339,17 @@ class TestCachedCommon(unittest.TestCase):
             return next(counter)
 
         # populate
-        with mock.patch('dwave.cloud.utils.epochnow', lambda: 0):
+        with mock.patch('dwave.cloud.utils.decorators.epochnow', lambda: 0):
             self.assertEqual(f(), 0)
 
         # expired for default maxage
-        with mock.patch('dwave.cloud.utils.epochnow', lambda: 15):
+        with mock.patch('dwave.cloud.utils.decorators.epochnow', lambda: 15):
             self.assertEqual(f(maxage_=20), 0)
             self.assertEqual(f(maxage_=15.01), 0)
             self.assertEqual(f(maxage_=15), 1)
 
         # cache hot for default maxage
-        with mock.patch('dwave.cloud.utils.epochnow', lambda: 25):
+        with mock.patch('dwave.cloud.utils.decorators.epochnow', lambda: 25):
             self.assertEqual(f(maxage_=11), 1)
             self.assertEqual(f(), 2)
             self.assertEqual(f(maxage_=1), 2)