Repository: dwavesystems/dwave-cloud-client
Pull request #673 (2024-10-23 17:45:22)

Drop Python 3.8 support


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index eb3d823..47b3ca5 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -325,7 +325,7 @@ workflows:
           context: &ctx-build "ocean-build"
           matrix:
             parameters:
-              python-version: &python-versions ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
+              python-version: &python-versions ["3.9", "3.10", "3.11", "3.12", "3.13"]
               dimod-numpy-version: ["", "dimod~=0.9.0 numpy~=1.23.0", "dimod~=0.10.0", "dimod~=0.11.0", "dimod~=0.12.0"]
             exclude:
               # dimod < 0.10 not supported on py310+
diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py
index 5c1989f..5592963 100644
--- a/benchmarks/benchmarks.py
+++ b/benchmarks/benchmarks.py
@@ -19,7 +19,6 @@ import subprocess
 from copy import deepcopy
 from functools import partial
 from pathlib import Path
-from typing import List
 
 import dimod
 import requests
@@ -213,8 +212,8 @@ class SolverSelection:
             "avg_load": s['avg_load']
         } for s in solvers_data]
 
-        static_conf = TypeAdapter(List[SolverConfiguration]).validate_python(static_data)
-        dynamic_conf = TypeAdapter(List[SolverConfiguration]).validate_python(dynamic_data)
+        static_conf = TypeAdapter(list[SolverConfiguration]).validate_python(static_data)
+        dynamic_conf = TypeAdapter(list[SolverConfiguration]).validate_python(dynamic_data)
 
         class mock_session:
             def list_solvers(self, filter=None, **kwargs):
@@ -274,15 +273,15 @@ class JSONResponseDecode:
 
     def time_model_from_python_json(self):
         solvers = requests.get('http://mock').json()
-        TypeAdapter(List[SolverConfiguration]).validate_python(solvers)
+        TypeAdapter(list[SolverConfiguration]).validate_python(solvers)
 
     def time_model_from_json(self):
         content = requests.get('http://mock').content
-        TypeAdapter(List[SolverConfiguration]).validate_json(content)
+        TypeAdapter(list[SolverConfiguration]).validate_json(content)
 
     def time_model_from_python_orjson(self):
         solvers = orjson.loads(requests.get('http://mock').content)
-        TypeAdapter(List[SolverConfiguration]).validate_python(solvers)
+        TypeAdapter(list[SolverConfiguration]).validate_python(solvers)
 
     def time_manual_model_from_python_orjson(self):
         solvers = orjson.loads(requests.get('http://mock').content)
diff --git a/dwave/cloud/api/client.py b/dwave/cloud/api/client.py
index 55acf92..ed1a285 100644
--- a/dwave/cloud/api/client.py
+++ b/dwave/cloud/api/client.py
@@ -20,8 +20,8 @@ import logging
 import numbers
 import os
 import warnings
-from collections import deque, namedtuple
-from typing import Callable, Mapping, Optional, Tuple, TypedDict, Union, TYPE_CHECKING
+from collections import deque, namedtuple, abc
+from typing import Optional, TypedDict, Union, TYPE_CHECKING
 
 import orjson
 import requests
@@ -235,7 +235,7 @@ class VersionedAPISession(LoggingSession):
         return response
 
 
-def _create_default_cache_store(**kwargs) -> Mapping:
+def _create_default_cache_store(**kwargs) -> abc.Mapping:
     # TODO: de-dup vs `dwave.cloud.utils.decorators.cached`?
 
     # defer to break circular imports
@@ -289,7 +289,7 @@ class CachingSession(VersionedAPISession):
     class CacheConfig(TypedDict, total=False):
         enabled: bool
         maxage: float
-        store: Union[Mapping, Callable[[], Mapping]]
+        store: Union[abc.Mapping, abc.Callable[[], abc.Mapping]]
 
     # default cache config
     _default_cache_config = CacheConfig(enabled=True,
@@ -336,7 +336,7 @@ class CachingSession(VersionedAPISession):
     def _parse_cache_control(
             self,
             cache_control: Optional[str] = None,
-            ) -> Tuple[bool, Optional[int]]:
+            ) -> tuple[bool, Optional[int]]:
         # parse Cache-Control header field (if present) and derive a suitable max-age value
         # returns: (cache?, maxage)
 
@@ -580,7 +580,7 @@ class DWaveAPIClient:
         return cls.from_config_model(config)
 
     @classmethod
-    def from_config(cls, config: Optional[Union[ClientConfig, str]] = None, **kwargs):
+    def from_config(cls, config: Union[ClientConfig, str, None] = None, **kwargs):
         """Create class instance based on client config or config file.
 
         Args:
diff --git a/dwave/cloud/api/models.py b/dwave/cloud/api/models.py
index 229aad4..213b36c 100644
--- a/dwave/cloud/api/models.py
+++ b/dwave/cloud/api/models.py
@@ -13,8 +13,7 @@
 # limitations under the License.
 
 from datetime import datetime
-from typing import List, Union, Optional, Dict, Any
-from typing_extensions import Annotated     # backport for py37, py38
+from typing import Annotated, Any, Optional, Union
 
 from pydantic import BaseModel, RootModel, ConfigDict
 from pydantic.functional_validators import AfterValidator
@@ -147,20 +146,20 @@ class ProblemMetadata(BaseModel):
     submitted_by: str
     submitted_on: datetime
     solved_on: Optional[datetime] = None
-    messages: Optional[List[dict]] = None
+    messages: Optional[list[dict]] = None
 
 
 class ProblemInfo(BaseModel):
     id: str
     data: ProblemData
-    params: Dict[str, AnyIncludingNumpy]
+    params: dict[str, AnyIncludingNumpy]
     metadata: ProblemMetadata
     answer: ProblemAnswer
 
 
 class ProblemJob(BaseModel):
     data: ProblemData
-    params: Dict[str, AnyIncludingNumpy]
+    params: dict[str, AnyIncludingNumpy]
     solver: str
     type: constants.ProblemType
     label: Optional[str] = None
@@ -210,7 +209,7 @@ class _LeapActiveProjectResponse(BaseModel):
 
 # LeapAPI / account / projects response
 class _LeapProjectsWrapper(BaseModel):
-    projects: List[_LeapProjectWrapper]
+    projects: list[_LeapProjectWrapper]
 
 class _LeapProjectsResponse(BaseModel):
     data: _LeapProjectsWrapper
diff --git a/dwave/cloud/api/resources.py b/dwave/cloud/api/resources.py
index f863cb3..b0de2f0 100644
--- a/dwave/cloud/api/resources.py
+++ b/dwave/cloud/api/resources.py
@@ -16,7 +16,8 @@ from __future__ import annotations
 
 import io
 import orjson
-from typing import List, Union, Optional, Callable, get_type_hints, TYPE_CHECKING
+from collections import abc
+from typing import Union, Optional, get_type_hints, TYPE_CHECKING
 from functools import wraps
 
 from pydantic import TypeAdapter
@@ -44,7 +45,7 @@ class accepts:
         self.ctx = dict(media_type=media_type, accept_version=version)
         self.ctx.update(kwargs)
 
-    def __call__(self, fn: Callable):
+    def __call__(self, fn: abc.Callable):
         @wraps(fn)
         def wrapper(obj: 'ResourceBase', *args, **kwargs):
             try:
@@ -105,11 +106,11 @@ class Regions(ResourceBase):
     client_class = MetadataAPIClient
 
     @accepts(media_type='application/vnd.dwave.metadata.regions+json', version='~=1.0')
-    def list_regions(self, **kwargs) -> List[models.Region]:
+    def list_regions(self, **kwargs) -> list[models.Region]:
         path = ''
         response = self.session.get(path, **kwargs)
         regions = orjson.loads(response.content)
-        return TypeAdapter(List[models.Region]).validate_python(regions)
+        return TypeAdapter(list[models.Region]).validate_python(regions)
 
     @accepts(media_type='application/vnd.dwave.metadata.region+json', version='~=1.0')
     def get_region(self, code: str, **kwargs) -> models.Region:
@@ -125,7 +126,7 @@ class Solvers(ResourceBase):
     client_class = SolverAPIClient
 
     @accepts(media_type='application/vnd.dwave.sapi.solver-definition-list+json', version='~=2.0')
-    def list_solvers(self, filter: Optional[str] = None, **kwargs) -> List[models.SolverConfiguration]:
+    def list_solvers(self, filter: Optional[str] = None, **kwargs) -> list[models.SolverConfiguration]:
         path = 'remote/'
         params = {'filter': filter} if filter is not None else None
         response = self.session.get(path, params=params, **kwargs)
@@ -133,7 +134,7 @@ class Solvers(ResourceBase):
         # tl;dr: model_validate() on orjson-decoded content is much faster then
         #        model_validate_json() and faster than model_validate on json-decoded
         solvers = orjson.loads(response.content)
-        return TypeAdapter(List[models.SolverConfiguration]).validate_python(solvers)
+        return TypeAdapter(list[models.SolverConfiguration]).validate_python(solvers)
 
     @accepts(media_type='application/vnd.dwave.sapi.solver-definition+json', version='~=2.0')
     def get_solver(self, solver_id: str, filter: Optional[str] = None, **kwargs) -> models.SolverConfiguration:
@@ -156,7 +157,7 @@ class Problems(ResourceBase):
                       max_results: int = None,
                       status: Union[str, constants.ProblemStatus] = None,
                       solver: str = None,
-                      **params) -> List[models.ProblemStatus]:
+                      **params) -> list[models.ProblemStatus]:
         """Retrieve a filtered list of submitted problems."""
 
         # build query
@@ -176,7 +177,7 @@ class Problems(ResourceBase):
         path = ''
         response = self.session.get(path, params=params)
         statuses = orjson.loads(response.content)
-        return TypeAdapter(List[models.ProblemStatus]).validate_python(statuses)
+        return TypeAdapter(list[models.ProblemStatus]).validate_python(statuses)
 
     @accepts(media_type='application/vnd.dwave.sapi.problem+json', version='>=2.1,<3')
     def get_problem(self,
@@ -209,9 +210,9 @@ class Problems(ResourceBase):
     # XXX: @pydantic.validate_arguments
     @accepts(media_type='application/vnd.dwave.sapi.problems+json', version='>=2.1,<3')
     def get_problem_statuses(self,
-                             problem_ids: List[str],
+                             problem_ids: list[str],
                              timeout: Optional[int] = None,
-                             ) -> List[models.ProblemStatus]:
+                             ) -> list[models.ProblemStatus]:
         """Retrieve short problem statuses for a list of problems."""
         if not isinstance(problem_ids, list):
             raise TypeError('a list of problem ids expected')
@@ -224,7 +225,7 @@ class Problems(ResourceBase):
             params.setdefault('timeout', timeout)
         response = self.session.get(path, params=params)
         statuses = orjson.loads(response.content)
-        return TypeAdapter(List[models.ProblemStatus]).validate_python(statuses)
+        return TypeAdapter(list[models.ProblemStatus]).validate_python(statuses)
 
     @accepts(media_type='application/vnd.dwave.sapi.problem-data+json', version='>=2.1,<3')
     def get_problem_info(self, problem_id: str) -> models.ProblemInfo:
@@ -260,7 +261,7 @@ class Problems(ResourceBase):
         return output
 
     @accepts(media_type='application/vnd.dwave.sapi.problem-message+json', version='>=2.1,<3')
-    def get_problem_messages(self, problem_id: str) -> List[dict]:
+    def get_problem_messages(self, problem_id: str) -> list[dict]:
         """Retrieve list of problem messages."""
         path = '{}/messages'.format(problem_id)
         response = self.session.get(path)
@@ -272,7 +273,7 @@ class Problems(ResourceBase):
                        params: dict,
                        solver: str,
                        type: constants.ProblemType,
-                       label: str = None) -> \
+                       label: Optional[str] = None) -> \
             Union[models.ProblemStatusMaybeWithAnswer, models.ProblemSubmitError]:
         """Blocking problem submit with timeout, returning final status and
         answer, if problem is solved within the (undisclosed) time limit.
@@ -289,8 +290,8 @@ class Problems(ResourceBase):
         return TypeAdapter(rtype).validate_python(orjson.loads(response.content))
 
     @accepts(media_type='application/vnd.dwave.sapi.problems+json', version='>=2.1,<3')
-    def submit_problems(self, problems: List[models.ProblemJob]) -> \
-            List[Union[models.ProblemInitialStatus, models.ProblemSubmitError]]:
+    def submit_problems(self, problems: list[models.ProblemJob]) -> \
+            list[Union[models.ProblemInitialStatus, models.ProblemSubmitError]]:
         """Asynchronous multi-problem submit, returning initial statuses."""
         path = ''
         # note: TypeAdapter().dump_json() is 2ms vs 50us by orjson.dumps(model.model_dump())
@@ -310,8 +311,8 @@ class Problems(ResourceBase):
         return TypeAdapter(rtype).validate_python(orjson.loads(response.content))
 
     @accepts(media_type='application/vnd.dwave.sapi.problems+json', version='>=2.1,<3')
-    def cancel_problems(self, problem_ids: List[str]) -> \
-            List[Union[models.ProblemStatus, models.ProblemCancelError]]:
+    def cancel_problems(self, problem_ids: list[str]) -> \
+            list[Union[models.ProblemStatus, models.ProblemCancelError]]:
         """Initiate problem cancel for a list of problems."""
         path = ''
         response = self.session.delete(path, json=problem_ids)
@@ -334,7 +335,7 @@ class LeapAccount(ResourceBase):
             orjson.loads(response.content))
         return parsed.data.project
 
-    def list_projects(self) -> List[models.LeapProject]:
+    def list_projects(self) -> list[models.LeapProject]:
         """Retrieve list of Leap projects accessible to the authenticated user."""
         path = 'projects/oauth/'
         response = self.session.get(path)
diff --git a/dwave/cloud/auth/creds.py b/dwave/cloud/auth/creds.py
index 53230e8..a476d30 100644
--- a/dwave/cloud/auth/creds.py
+++ b/dwave/cloud/auth/creds.py
@@ -12,9 +12,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from __future__ import annotations
+
 import logging
 from pathlib import Path
-from typing import List, Optional, Type, Union
+from typing import Optional, Type, Union
 
 from dwave.cloud.auth._creds import _Cache
 from dwave.cloud.config import get_configfile_paths, get_default_configfile_path
@@ -31,7 +33,7 @@ class AutoDetect:
 
 def _get_creds_paths(
         *, system: bool = True, user: bool = True, local: bool = True,
-        only_existing: bool = True) -> List[str]:
+        only_existing: bool = True) -> list[str]:
     return get_configfile_paths(system=system, user=user, local=local,
                                 only_existing=only_existing, filename=CREDS_FILENAME)
 
diff --git a/dwave/cloud/auth/flows.py b/dwave/cloud/auth/flows.py
index 9dbc8a6..ffd91ca 100644
--- a/dwave/cloud/auth/flows.py
+++ b/dwave/cloud/auth/flows.py
@@ -17,8 +17,9 @@ from __future__ import annotations
 import logging
 import time
 import webbrowser
+from collections import abc
 from operator import sub
-from typing import Any, Callable, Dict, Optional, Union, Sequence, Literal, TYPE_CHECKING
+from typing import Any, Optional, Union, Literal, TYPE_CHECKING
 from urllib.parse import urljoin
 
 import click
@@ -80,12 +81,12 @@ class AuthFlow:
 
     def __init__(self, *,
                  client_id: str,
-                 scopes: Sequence[str],
+                 scopes: abc.Sequence[str],
                  redirect_uri: str,
                  authorization_endpoint: str,
                  token_endpoint: str,
                  revocation_endpoint: Optional[str] = None,
-                 session_config: Optional[Dict[str, Any]] = None,
+                 session_config: Optional[abc.Mapping[str, Any]] = None,
                  leap_api_endpoint: Optional[str] = None,
                  creds: Optional[Credentials] = None
                  ):
@@ -130,7 +131,7 @@ class AuthFlow:
 
         logger.debug(f"{type(self).__name__} initialized with: {pretty_argvalues()}")
 
-    def update_session(self, config: Dict[str, Any]) -> None:
+    def update_session(self, config: abc.Mapping[str, Any]) -> None:
         """Update OAuth2Session/requests.Session with config values for:
         ``cert``, ``cookies``, ``headers``, ``proxies``, ``timeout``, ``verify``.
         """
@@ -378,7 +379,7 @@ class LeapAuthFlow(AuthFlow):
 
         return flow
 
-    def run_oob_flow(self, *, open_browser: Union[bool,Callable] = False):
+    def run_oob_flow(self, *, open_browser: Union[bool, abc.Callable] = False):
         """Run OAuth 2.0 code exchange (out-of-band flow.) 
         
         Runs the OAuth 2.0 Authorization Code exchange flow using the out-of-band code 
@@ -405,7 +406,7 @@ class LeapAuthFlow(AuthFlow):
         click.echo()
         return self.fetch_token(code=code)
 
-    def run_redirect_flow(self, *, open_browser: Union[bool,Callable] = False,
+    def run_redirect_flow(self, *, open_browser: Union[bool, abc.Callable] = False,
                           timeout: Optional[float] = None):
         """Run the OAuth 2.0 code exchange (using locally hosted redirect URI). 
         
diff --git a/dwave/cloud/auth/server.py b/dwave/cloud/auth/server.py
index 778d5d3..365c8d2 100644
--- a/dwave/cloud/auth/server.py
+++ b/dwave/cloud/auth/server.py
@@ -12,13 +12,16 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from __future__ import annotations
+
 import logging
 import random
 import sys
 import threading
 import traceback
+from collections import abc
 from socketserver import ThreadingMixIn
-from typing import Callable, Iterator, Optional, Union
+from typing import Optional, Union
 from urllib.parse import urljoin, urlsplit, parse_qsl, SplitResult
 from wsgiref.simple_server import make_server, WSGIRequestHandler, WSGIServer
 from wsgiref.util import request_uri
@@ -102,7 +105,7 @@ class ThreadingWSGIServer(ThreadingMixIn, SocketTimeoutTCPServerMixin,
 
 
 def iterports(start: int, end: int,
-              n_lin: int, n_rand: Optional[int] = None) -> Iterator[int]:
+              n_lin: int, n_rand: Optional[int] = None) -> abc.Iterator[int]:
     """Server port proposal generator. Starts with a linear search, then
     switches to a randomized search (random permutation of remaining ports).
     """
@@ -167,7 +170,7 @@ class BackgroundAppServer(threading.Thread):
 
             return self._server
 
-    def __init__(self, *, host: str, base_port: int, max_port: int, app: Callable,
+    def __init__(self, *, host: str, base_port: int, max_port: int, app: abc.Callable,
                  linear_tries: int = 1, randomized_tries: Optional[int] = None,
                  timeout: Optional[float] = 10):
         super().__init__(daemon=True)
@@ -285,7 +288,7 @@ class RequestCaptureApp:
         if hasattr(self, '_exc') and self._exc:
             raise self._exc
 
-    def __call__(self, environ: dict, start_response: Callable):
+    def __call__(self, environ: dict, start_response: abc.Callable):
         self.store_request(environ)
 
         start_response("200 OK", [('Content-Type', 'text/plain; charset=utf-8')])
@@ -298,14 +301,14 @@ class RequestCaptureAndRedirectApp(RequestCaptureApp):
     """
 
     def __init__(self, message: str,
-                 redirect_uri: Union[str, Callable],
+                 redirect_uri: Union[str, abc.Callable],
                  include_query: bool = True):
         super().__init__(message)
 
         self.redirect_uri = redirect_uri
         self.include_query = include_query
 
-    def __call__(self, environ: dict, start_response: Callable):
+    def __call__(self, environ: dict, start_response: abc.Callable):
         self.store_request(environ)
 
         if callable(self.redirect_uri):
diff --git a/dwave/cloud/cli.py b/dwave/cloud/cli.py
index f96aef7..21d32e1 100644
--- a/dwave/cloud/cli.py
+++ b/dwave/cloud/cli.py
@@ -17,12 +17,12 @@ import sys
 import ast
 import orjson
 import subprocess
-from collections.abc import Sequence
+from collections import abc
 from configparser import ConfigParser
 from datetime import datetime
 from functools import wraps, partial
 from timeit import default_timer as timer
-from typing import Callable, Dict, Optional, Tuple
+from typing import Optional
 
 import click
 import requests.exceptions
@@ -207,7 +207,7 @@ def create(*, config_file, profile, ask_full, auto_token, project):
 
 def _input_config_variables(config: ConfigParser,
                             profile: str,
-                            prompts: Dict[str, Dict[str, str]]) -> ConfigParser:
+                            prompts: dict[str, dict[str, str]]) -> ConfigParser:
     """Update config variables in place with user-provided values."""
 
     for var, prompt in prompts.items():
@@ -652,7 +652,7 @@ def sample(*, config_file, profile, endpoint, region, client_type, solver_def,
     else:
         try:
             linear = ast.literal_eval(biases) if biases else {}
-            if isinstance(linear, Sequence):
+            if isinstance(linear, abc.Sequence):
                 linear = dict(enumerate(linear))
         except Exception as e:
             raise CLIError(f"Invalid biases: {e}", code=99)
@@ -1133,7 +1133,7 @@ def leap_project_token(*, config_file, profile, project_hint, json_output, outpu
 
 def _get_sapi_token_for_leap_project(
         *, config_file: str, profile: str,
-        project_hint: Optional[str], output: Callable) -> Tuple[api.models.LeapProject, str]:
+        project_hint: Optional[str], output: abc.Callable) -> tuple[api.models.LeapProject, str]:
 
     config = validate_config_v1(load_config(config_file=config_file, profile=profile))
     flow = LeapAuthFlow.from_config_model(config)
diff --git a/dwave/cloud/client/base.py b/dwave/cloud/client/base.py
index 9a0efdf..b000c9a 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, List, Optional, Union
+from typing import Optional, Union
 
 import orjson
 import requests
@@ -652,7 +652,7 @@ class Client(object):
         """
         return True
 
-    def get_regions(self, refresh: bool = False) -> Dict[str, Dict[str, str]]:
+    def get_regions(self, refresh: bool = False) -> dict[str, dict[str, str]]:
         """Retrieve available API regions.
 
         Args:
@@ -696,7 +696,7 @@ class Client(object):
     def _fetch_solvers(self,
                        name: Optional[str] = None,
                        refresh_: Optional[bool] = False,
-                       ) -> List[Union[StructuredSolver, UnstructuredSolver]]:
+                       ) -> list[Union[StructuredSolver, UnstructuredSolver]]:
 
         static_fields = 'all,-status,-avg_load'
         dynamic_fields = 'none,+id,+status,+avg_load'
@@ -1865,7 +1865,7 @@ class Client(object):
     @staticmethod
     def _combined_checksum(checksums):
         # TODO: drop this requirement server-side
-        # checksums: Dict[int, str] => hex(md5(cat(digests))): str
+        # checksums: dict[int, str] => hex(md5(cat(digests))): str
         combined = ''.join(h for _, h in sorted(checksums.items()))
         digest = codecs.decode(combined, 'hex')
         return Client._checksum_hex(Client._digest(digest))
diff --git a/dwave/cloud/coders.py b/dwave/cloud/coders.py
index 9b7c7c2..61cc027 100644
--- a/dwave/cloud/coders.py
+++ b/dwave/cloud/coders.py
@@ -14,7 +14,8 @@
 
 import struct
 import base64
-from typing import Callable, Dict, List, Sequence, Tuple, Union
+from collections import abc
+from typing import Union
 
 try:
     # note: TypedDict is available in py38+, but NotRequired only in py311+
@@ -40,14 +41,14 @@ class EncodedQP(TypedDict):
     offset: NotRequired[float]
 
 class QuadraticProblem(TypedDict):
-    linear: Dict[int, float]
-    quadratic: Dict[Tuple[int, int], float]
+    linear: dict[int, float]
+    quadratic: dict[tuple[int, int], float]
     offset: NotRequired[float]
 
 
 def encode_problem_as_qp(solver: 'dwave.cloud.solver.StructuredSolver',
-                         linear: Union[List[float], Dict[int, float]],
-                         quadratic: Dict[Tuple[int, int], float],
+                         linear: Union[list[float], dict[int, float]],
+                         quadratic: dict[tuple[int, int], float],
                          offset: float = 0,
                          undirected_biases: bool = False
                          ) -> EncodedQP:
@@ -74,7 +75,7 @@ def encode_problem_as_qp(solver: 'dwave.cloud.solver.StructuredSolver',
         Encoded submission dictionary.
     """
     # convert legacy format (list) to dict for performance
-    if isinstance(linear, Sequence):
+    if isinstance(linear, abc.Sequence):
         linear = dict(enumerate(linear))
 
     active = active_qubits(linear, quadratic)
@@ -417,7 +418,7 @@ def decode_bq(msg):
     return result
 
 
-def decode_binary_ref(msg: dict, ref_resolver: Callable) -> dict:
+def decode_binary_ref(msg: dict, ref_resolver: abc.Callable) -> dict:
     """Decode binary-ref answer."""
 
     answer = msg['answer']
diff --git a/dwave/cloud/config/loaders.py b/dwave/cloud/config/loaders.py
index 6086410..ce50a50 100644
--- a/dwave/cloud/config/loaders.py
+++ b/dwave/cloud/config/loaders.py
@@ -12,11 +12,13 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from __future__ import annotations
+
 import os
 import ast
 import logging
 import configparser
-from typing import List, Optional, Union
+from typing import Optional, Union
 
 import homebase
 
@@ -117,7 +119,7 @@ def parse_boolean(s, default=None):
 def get_configfile_paths(
         *, system: bool = True, user: bool = True, local: bool = True,
         only_existing: bool = True, app_author: str = CONF_AUTHOR,
-        app_name: str = CONF_APP, filename: str = CONF_FILENAME) -> List[str]:
+        app_name: str = CONF_APP, filename: str = CONF_FILENAME) -> list[str]:
     """Return a list of local configuration file paths.
 
     Search paths for configuration files on the local system
diff --git a/dwave/cloud/config/models.py b/dwave/cloud/config/models.py
index d79240a..e00d1cf 100644
--- a/dwave/cloud/config/models.py
+++ b/dwave/cloud/config/models.py
@@ -12,14 +12,15 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from __future__ import annotations
+
 import ast
 import copy
 import enum
 import logging
 import orjson
 from collections import abc
-from typing import Optional, Union, Literal, Tuple, Dict, Any
-from typing_extensions import Annotated     # backport for py37, py38
+from typing import Optional, Union, Literal, Any, Annotated
 
 import urllib3
 from pydantic import BaseModel, BeforeValidator, NonNegativeInt
@@ -145,7 +146,7 @@ class ClientConfig(BaseModel, GetterMixin):
 
     # [sapi client specific] feature-based solver selection query
     client: Optional[str] = None
-    solver: Optional[Dict[str, Any]] = None
+    solver: Optional[abc.Mapping[str, Any]] = None
 
     # [sapi client specific] polling schedule defaults [sec]
     # note: discriminated unions are faster than unions, but we want to be able
@@ -155,8 +156,8 @@ class ClientConfig(BaseModel, GetterMixin):
     polling_timeout: Optional[float] = None
 
     # general http(s) connection params
-    cert: Optional[Union[str, Tuple[str, str]]] = None
-    headers: Optional[Dict[str, str]] = None
+    cert: Optional[Union[str, tuple[str, str]]] = None
+    headers: Optional[abc.Mapping[str, str]] = None
     proxy: Optional[str] = None
 
     # specific connection options
@@ -166,11 +167,11 @@ class ClientConfig(BaseModel, GetterMixin):
 
     # api request retry params
     request_retry: Optional[RequestRetryConfig] = RequestRetryConfig()
-    request_timeout: Annotated[Optional[Union[float, Tuple[float, float]]],
+    request_timeout: Annotated[Optional[Union[float, tuple[float, float]]],
                                BeforeValidator(_literal_eval)] = (60.0, 120.0)
 
 
-def validate_config_v1(raw_config: dict) -> ClientConfig:
+def validate_config_v1(raw_config: abc.Mapping) -> ClientConfig:
     """Validate raw config data (as obtained from the current INI-style config
     via :func:`~dwave.cloud.config.loaders.load_config`) and construct a
     :class:`ClientConfig` object.
@@ -179,7 +180,8 @@ def validate_config_v1(raw_config: dict) -> ClientConfig:
     and structure to fit the :class:`ClientConfig` model.
     """
 
-    config = raw_config.copy()
+    # shallow copy
+    config = dict(raw_config)
 
     # parse string headers
     headers = config.get('headers')
diff --git a/dwave/cloud/events.py b/dwave/cloud/events.py
index 6197ce8..44ee168 100644
--- a/dwave/cloud/events.py
+++ b/dwave/cloud/events.py
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
 
 
 # package-global event hooks registry
-# Dict[event_name: str, handler_functions: list]
+# dict[event_name: str, handler_functions: list]
 _client_event_hooks_registry = {
     'before_client_init': [],
     'after_client_init': [],
diff --git a/dwave/cloud/regions.py b/dwave/cloud/regions.py
index cccfa42..844f9b0 100644
--- a/dwave/cloud/regions.py
+++ b/dwave/cloud/regions.py
@@ -14,7 +14,7 @@
 
 import logging
 import re
-from typing import Dict, List, Optional, Union
+from typing import Optional, Union
 from urllib.parse import urlsplit
 
 from dwave.cloud import api
@@ -33,7 +33,7 @@ _DEFAULT_REGIONS_CACHE_CONFIG = dict(
 )
 
 
-def _fetch_available_regions(config: ClientConfig, **kwargs) -> Dict[str, str]:
+def _fetch_available_regions(config: ClientConfig, **kwargs) -> list[api.models.Region]:
     logger.debug("Fetching available regions from the Metadata API at %r",
                  config.metadata_api_endpoint)
 
@@ -49,7 +49,7 @@ def get_regions(config: Optional[Union[ClientConfig, str, dict]] = None,
                 *,
                 refresh: bool = False,
                 maxage: Optional[float] = None,
-                no_cache: bool = False) -> List[api.models.Region]:
+                no_cache: bool = False) -> list[api.models.Region]:
     """Retrieve available Solver API regions.
 
     Args:
diff --git a/dwave/cloud/solver.py b/dwave/cloud/solver.py
index 9890a59..393b7b0 100644
--- a/dwave/cloud/solver.py
+++ b/dwave/cloud/solver.py
@@ -33,10 +33,10 @@ import concurrent.futures
 import copy
 import logging
 import orjson
-import typing
-from collections.abc import Mapping
+from collections import abc
 from functools import partial
 from tempfile import SpooledTemporaryFile
+from typing import Any, Literal, Optional, Union, TYPE_CHECKING
 
 from dwave.cloud.exceptions import (
     InvalidAPIResponseError, SolverPropertyMissingError,
@@ -70,15 +70,15 @@ __all__ = [
 logger = logging.getLogger(__name__)
 
 
-if typing.TYPE_CHECKING:
+if TYPE_CHECKING:
     # do a bit of fiddling
     try:
-        _Type = typing.Literal['qubo', 'ising']
+        _Type = Literal['qubo', 'ising']
     except ImportError:  # Python < 3.8
         _Type = str
 
     try:
-        _Vartype = typing.Union[_Type, dimod.typing.VartypeLike]
+        _Vartype = Union[_Type, dimod.typing.VartypeLike]
     except AttributeError:  # dimod not installed or too old
         _Vartype = _Type
 
@@ -187,11 +187,11 @@ class BaseSolver(object):
             return decode_qp(msg)
 
     def _download_binary_ref(self, *, auth_method: str, url: str,
-                             output: typing.Optional[io.IOBase] = None) -> typing.Union[bytes, io.IOBase]:
+                             output: Optional[io.IOBase] = None) -> Union[bytes, io.IOBase]:
         return self.client._download_answer_binary_ref(
             auth_method=auth_method, url=url, output=output).result()
 
-    def decode_response(self, msg, answer_data: typing.Optional[io.IOBase] = None):
+    def decode_response(self, msg, answer_data: Optional[io.IOBase] = None):
         if msg['type'] not in self._handled_problem_types:
             raise ValueError('Unknown problem type received.')
 
@@ -790,7 +790,7 @@ class NLSolver(BaseUnstructuredSolver):
     _handled_encoding_formats = {"binary-ref"}
 
     def _encode_problem_for_upload(self,
-                                   model: typing.Union['dwave.optimization.Model', io.IOBase],
+                                   model: Union['dwave.optimization.Model', io.IOBase],
                                    **kwargs
                                    ) -> io.IOBase:
         encode_params = {k: v for k, v in kwargs.items()
@@ -810,7 +810,7 @@ class NLSolver(BaseUnstructuredSolver):
 
     def sample_bqm(self,
                    bqm: 'dimod.BQM',
-                   label: typing.Optional[str] = None,
+                   label: Optional[str] = None,
                    **params) -> Future:
         """Use just for testing."""
         try:
@@ -834,9 +834,9 @@ class NLSolver(BaseUnstructuredSolver):
         return self.sample_nlm(nlm, label=label, **params)
 
     def sample_nlm(self,
-                   model: typing.Union['dwave.optimization.Model', io.IOBase, str],
-                   label: typing.Optional[str] = None,
-                   upload_params: typing.Optional[dict] = None,
+                   model: Union['dwave.optimization.Model', io.IOBase, str],
+                   label: Optional[str] = None,
+                   upload_params: Optional[dict] = None,
                    **sample_params
                    ) -> Future:
         """Sample from the specified :term:`NL model`.
@@ -888,7 +888,7 @@ class NLSolver(BaseUnstructuredSolver):
         return future
 
     def upload_nlm(self,
-                   model: typing.Union['dwave.optimization.Model', io.IOBase, bytes],
+                   model: Union['dwave.optimization.Model', io.IOBase, bytes],
                    **upload_params,
                    ) -> concurrent.futures.Future:
         r"""Upload the specified :term:`NL model` to SAPI, returning a Problem ID
@@ -1262,10 +1262,10 @@ class StructuredSolver(BaseSolver):
 
     @staticmethod
     def reformat_parameters(vartype: '_Vartype',
-                            parameters: typing.MutableMapping[str, typing.Any],
-                            properties: typing.Mapping[str, typing.Any],
+                            parameters: abc.MutableMapping[str, Any],
+                            properties: abc.Mapping[str, Any],
                             inplace: bool = False,
-                            ) -> typing.MutableMapping[str, typing.Any]:
+                            ) -> abc.MutableMapping[str, Any]:
         """Reformat some solver parameters for SAPI.
 
         Currently the only reformatted parameter is ``initial_state``. This
@@ -1308,7 +1308,7 @@ class StructuredSolver(BaseSolver):
         # update the parameters
         if 'initial_state' in parameters:
             initial_state = parameters['initial_state']
-            if isinstance(initial_state, typing.Mapping):
+            if isinstance(initial_state, abc.Mapping):
 
                 initial_state_list = [3]*properties['num_qubits']
 
@@ -1358,7 +1358,7 @@ class StructuredSolver(BaseSolver):
             True
         """
         # handle legacy format
-        if not isinstance(linear, Mapping):
+        if not isinstance(linear, abc.Mapping):
              linear = {idx: val for idx, val in enumerate(linear) if val != 0}
 
         return self.nodes.issuperset(linear) and self.edges.issuperset(quadratic)
@@ -1366,13 +1366,13 @@ class StructuredSolver(BaseSolver):
     def estimate_qpu_access_time(self,
                                  num_qubits: int,
                                  num_reads: int = 1,
-                                 annealing_time: typing.Optional[float] = None,
-                                 anneal_schedule: typing.Optional[typing.List[typing.Tuple[float, float]]] = None,
-                                 initial_state:  typing.Optional[typing.List[typing.Tuple[float, float]]] = None,
+                                 annealing_time: Optional[float] = None,
+                                 anneal_schedule: Optional[list[tuple[float, float]]] = None,
+                                 initial_state:  Optional[list[tuple[float, float]]] = None,
                                  reverse_anneal: bool = False,
                                  reinitialize_state: bool = False,
-                                 programming_thermalization: typing.Optional[float] = None,
-                                 readout_thermalization: typing.Optional[float] = None,
+                                 programming_thermalization: Optional[float] = None,
+                                 readout_thermalization: Optional[float] = None,
                                  reduce_intersample_correlation: bool = False,
                                  **kwargs) -> float:
         """Estimates QPU access time for a submission to the selected solver.
diff --git a/dwave/cloud/upload.py b/dwave/cloud/upload.py
index 6426728..1d5adbd 100644
--- a/dwave/cloud/upload.py
+++ b/dwave/cloud/upload.py
@@ -21,7 +21,7 @@ import logging
 import threading
 
 from abc import abstractmethod
-from collections.abc import Sized
+from collections import abc
 from functools import partial
 
 __all__ = ['ChunkedData']
@@ -29,7 +29,7 @@ __all__ = ['ChunkedData']
 logger = logging.getLogger(__name__)
 
 
-class Gettable(Sized):
+class Gettable(abc.Sized):
     """Abstract base class for objects that implement standard and efficient
     item getters.
 
diff --git a/dwave/cloud/utils/cli.py b/dwave/cloud/utils/cli.py
index b7f644d..525620a 100644
--- a/dwave/cloud/utils/cli.py
+++ b/dwave/cloud/utils/cli.py
@@ -18,15 +18,16 @@
    These functions previously lived under ``dwave.cloud.utils``.
 """
 
+from collections import abc
 from functools import partial
-from typing import Any, Callable, Optional, Union, Sequence
+from typing import Any, Optional, Union
 
 __all__ = []
 
 
 def default_text_input(prompt: str, default: Optional[Any] = None, *,
                        optional: bool = True,
-                       choices: Optional[Sequence[Any]] = None) -> Union[str, None]:
+                       choices: Optional[abc.Sequence[Any]] = None) -> Union[str, None]:
     # CLI util; defer click import until actually needed (see #473)
     import click
 
@@ -70,7 +71,7 @@ class CLIError(Exception):
         self.code = code
 
 
-def deprecated_option(msg: Optional[str] = None, update: Optional[str] = None) -> Callable:
+def deprecated_option(msg: Optional[str] = None, update: Optional[str] = None) -> abc.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.
 
diff --git a/dwave/cloud/utils/decorators.py b/dwave/cloud/utils/decorators.py
index 0d80a82..350dccb 100644
--- a/dwave/cloud/utils/decorators.py
+++ b/dwave/cloud/utils/decorators.py
@@ -25,9 +25,10 @@ import numbers
 import time
 import warnings
 
+from collections import abc
 from functools import wraps
 from secrets import token_hex
-from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Union
+from typing import Any, Optional, Union
 
 from dwave.cloud.utils.time import epochnow
 
@@ -209,7 +210,7 @@ class cached:
         except:
             pass
 
-    def _argshash(self, args: List[Any], kwargs: Dict[Any, Any]):
+    def _argshash(self, args: abc.Sequence[Any], kwargs: abc.Mapping[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
@@ -223,7 +224,7 @@ class cached:
 
     def __init__(self, *,
                  maxage: Optional[float] = None,
-                 store: Union[Mapping, Callable[[], Mapping], None] = None,
+                 store: Union[abc.Mapping, abc.Callable[[], abc.Mapping], None] = None,
                  key: Optional[str] = None,
                  bucket: Optional[str] = None):
 
@@ -366,7 +367,7 @@ class deprecated:
         self.msg = msg
         self.stacklevel = stacklevel
 
-    def __call__(self, fn: Callable) -> Callable:
+    def __call__(self, fn: abc.Callable) -> abc.Callable:
         if not callable(fn):
             raise TypeError("decorated object must be callable")
 
@@ -416,13 +417,13 @@ class retried:
         # normalize `backoff` to callable
         if isinstance(backoff, numbers.Number):
             self.backoff = lambda retry: backoff
-        elif isinstance(backoff, Sequence):
+        elif isinstance(backoff, abc.Sequence):
             it = iter(backoff)
             self.backoff = lambda retry: next(it)
         else:
             self.backoff = backoff
 
-    def __call__(self, fn: Callable) -> Callable:
+    def __call__(self, fn: abc.Callable) -> abc.Callable:
         if not callable(fn):
             raise TypeError("decorated object must be callable")
 
diff --git a/dwave/cloud/utils/dist.py b/dwave/cloud/utils/dist.py
index 37edd92..09f1a09 100644
--- a/dwave/cloud/utils/dist.py
+++ b/dwave/cloud/utils/dist.py
@@ -18,9 +18,9 @@
    These functions previously lived under ``dwave.cloud.utils``.
 """
 
-from importlib.metadata import (
-    EntryPoint, Distribution, PackageNotFoundError)
-from typing import List, OrderedDict, Union
+from collections import OrderedDict
+from importlib.metadata import EntryPoint, Distribution, PackageNotFoundError
+from typing import Union
 
 from importlib_metadata import entry_points
 from packaging.requirements import Requirement
@@ -28,7 +28,7 @@ from packaging.requirements import Requirement
 __all__ = ['get_distribution', 'PackageNotFoundError', 'VersionNotFoundError']
 
 
-def get_contrib_config() -> List[EntryPoint]:
+def get_contrib_config() -> list[EntryPoint]:
     """Return all registered contrib (non-open-source) Ocean packages."""
 
     # Note: we use `entry_points` from `importlib_metadata>=5` to simplify access
diff --git a/dwave/cloud/utils/exception.py b/dwave/cloud/utils/exception.py
index 472df55..91f8e47 100644
--- a/dwave/cloud/utils/exception.py
+++ b/dwave/cloud/utils/exception.py
@@ -18,12 +18,13 @@
    These functions previously lived under ``dwave.cloud.utils``.
 """
 
-from typing import Any, Iterable, Tuple, Union
+from collections import abc
+from typing import Any, Union
 
 __all__ = ['hasinstance', 'exception_chain', 'is_caused_by']
 
 
-def hasinstance(objs: Iterable[Any], class_or_tuple: Union[Any, Tuple[Any]]):
+def hasinstance(objs: abc.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``.
     """
@@ -71,7 +72,7 @@ def exception_chain(exception: Exception):
 
 
 def is_caused_by(exception: Exception,
-                 exception_types: Union[Exception, Tuple[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
diff --git a/dwave/cloud/utils/http.py b/dwave/cloud/utils/http.py
index 14f2ea6..3d08396 100644
--- a/dwave/cloud/utils/http.py
+++ b/dwave/cloud/utils/http.py
@@ -20,7 +20,7 @@
 
 import platform
 import sys
-import typing
+from typing import Optional
 from urllib.parse import urljoin
 
 import requests
@@ -89,8 +89,8 @@ class BaseUrlSession(requests.Session):
         return urljoin(self.base_url, url)
 
 
-def user_agent(name: typing.Optional[str] = None,
-               version: typing.Optional[str] = None,
+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".
@@ -149,7 +149,7 @@ def default_user_agent() -> str:
         name=__packagename__, version=__version__, include_platform_tags=False)
 
 
-def platform_tags() -> typing.List[str]:
+def platform_tags() -> list[str]:
     """Return a list of platform tags generated from registered entry points."""
 
     # import only when needed, as it's pretty slow! (~10ms)
diff --git a/dwave/cloud/utils/logging.py b/dwave/cloud/utils/logging.py
index c94448a..d8e1b2b 100644
--- a/dwave/cloud/utils/logging.py
+++ b/dwave/cloud/utils/logging.py
@@ -24,20 +24,20 @@ import os
 import re
 import sys
 import types
-import typing
+from typing import Optional, Union
 
 __all__ = []
 
 
 class ISOFormatter(logging.Formatter):
     # target timezone, e.g. `datetime.timezone.utc`, or `None` for naive timestamp
-    as_tz: typing.Optional[datetime.timezone] = None
+    as_tz: Optional[datetime.timezone] = None
 
-    def __init__(self, *args, as_tz: typing.Optional[datetime.timezone] = None, **kwargs):
+    def __init__(self, *args, as_tz: 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:
+    def formatTime(self, record: logging.LogRecord, datefmt: Optional[str] = None) -> str:
         return datetime.datetime.fromtimestamp(record.created, tz=self.as_tz).isoformat()
 
 
@@ -85,7 +85,7 @@ class BinaryFormatter(logging.Formatter):
         return super().format(record).encode('utf8')
 
 
-def parse_loglevel(level_name: typing.Union[str, int],
+def parse_loglevel(level_name: Union[str, int],
                    default: int = logging.NOTSET) -> int:
     """Resolve numeric and symbolic log level names to numeric levels."""
 
@@ -118,7 +118,7 @@ def parse_loglevel(level_name: typing.Union[str, int],
     return level
 
 
-def set_loglevel(logger: logging.Logger, level: typing.Union[str, int]) -> None:
+def set_loglevel(logger: logging.Logger, level: Union[str, int]) -> None:
     level = parse_loglevel(level)
     logger.setLevel(level)
     logger.info("Log level for %r namespace set to %r", logger.name, level)
@@ -151,14 +151,14 @@ class BinaryStreamHandler(logging.StreamHandler):
         super().__init__(stream=stream)
 
 
-def configure_logging(logger: typing.Optional[logging.Logger] = None,
+def configure_logging(logger: Optional[logging.Logger] = None,
                       *,
-                      level: typing.Union[str, int] = logging.WARNING,
+                      level: Union[str, int] = logging.WARNING,
                       filter_secrets: bool = True,
-                      output_stream: typing.Optional[io.BytesIO] = None,
+                      output_stream: Optional[io.BytesIO] = None,
                       in_utc: bool = False,
                       structured_output: bool = False,
-                      handler_level: typing.Optional[int] = None,
+                      handler_level: Optional[int] = None,
                       additive: bool = False,
                       ) -> logging.Logger:
     """Configure cloud-client's `dwave.cloud` base logger.
@@ -244,7 +244,7 @@ def pretty_argvalues():
 #
 # note: performance gained over `inspect.stack()` mostly by not parsing code
 # files, but also by limiting traversal depth.
-def fast_stack(max_depth: typing.Optional[int] = None) -> typing.List[inspect.FrameInfo]:
+def fast_stack(max_depth: Optional[int] = None) -> list[inspect.FrameInfo]:
     """Fast alternative to `inspect.stack()`
 
     Use optional `max_depth` to limit search depth
@@ -269,7 +269,7 @@ def fast_stack(max_depth: typing.Optional[int] = None) -> typing.List[inspect.Fr
     In [6]: %timeit stack_depth(100, lambda: fast_stack(10))
     14.1 µs ± 33.4 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
     """
-    def frame_infos(frame: typing.Optional[types.FrameType]):
+    def frame_infos(frame: Optional[types.FrameType]):
         while frame := frame and frame.f_back:
             yield inspect.FrameInfo(
                 frame=frame,
diff --git a/dwave/cloud/utils/qubo.py b/dwave/cloud/utils/qubo.py
index e2d65e2..1845f9d 100644
--- a/dwave/cloud/utils/qubo.py
+++ b/dwave/cloud/utils/qubo.py
@@ -15,13 +15,14 @@
 """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
+from collections.abc import Iterator, Mapping, Sequence
+from typing import Any, Optional, Union
 
 __all__ = []
 
 
 def uniform_iterator(sequence: Union[Mapping, Sequence]
-                     ) -> Iterator[Tuple[int, Any]]:
+                     ) -> Iterator[tuple[int, Any]]:
     """Uniform (key, value) iteration on a `dict`,
     or (idx, value) on a `list`."""
 
@@ -76,7 +77,7 @@ def evaluate_ising(linear: Union[Mapping, Sequence],
 
 def active_qubits(linear: Union[Mapping, Sequence],
                   quadratic: Mapping
-                  ) -> Set:
+                  ) -> set:
     """Calculate a set of all active qubits. Qubit is "active" if it has
     bias or coupling attached.
 
@@ -101,9 +102,9 @@ def active_qubits(linear: Union[Mapping, Sequence],
 
 
 def generate_random_ising_problem(solver: 'dwave.cloud.solver.Solver',
-                                  h_range: Optional[List[float]] = None,
-                                  j_range: Optional[List[float]] = None
-                                  ) -> Tuple[dict]:
+                                  h_range: Optional[Sequence[float]] = None,
+                                  j_range: Optional[Sequence[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`.
@@ -123,12 +124,12 @@ def generate_random_ising_problem(solver: 'dwave.cloud.solver.Solver',
 def generate_const_ising_problem(solver: 'dwave.cloud.solver.Solver',
                                  h: float = 1,
                                  j: float = -1
-                                 ) -> Tuple[dict]:
+                                 ) -> 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]:
+def reformat_qubo_as_ising(qubo: Mapping[tuple[int, int], float]) -> tuple[dict]:
     """Split QUBO coefficients into linear and quadratic terms (the Ising form).
 
     Args:
diff --git a/releasenotes/notes/drop-py38-e3352c6fdd35e3bf.yaml b/releasenotes/notes/drop-py38-e3352c6fdd35e3bf.yaml
new file mode 100644
index 0000000..aaddc5c
--- /dev/null
+++ b/releasenotes/notes/drop-py38-e3352c6fdd35e3bf.yaml
@@ -0,0 +1,4 @@
+---
+upgrade:
+  - |
+    Upgrade your python to 3.9+. We no longer support python 3.8 and below.
diff --git a/setup.py b/setup.py
index 946cbf4..e1a876d 100644
--- a/setup.py
+++ b/setup.py
@@ -10,7 +10,7 @@ package_info = {}
 with open(package_info_path, encoding='utf-8') as f:
     exec(f.read(), package_info)
 
-python_requires = '>=3.8'
+python_requires = '>=3.9'
 
 # Package requirements, minimal pinning
 install_requires = ['requests[socks]>=2.25,<3', 'urllib3>=1.26,<3',
@@ -52,7 +52,6 @@ classifiers = [
     'License :: OSI Approved :: Apache Software License',
     'Operating System :: OS Independent',
     'Programming Language :: Python :: 3',
-    'Programming Language :: Python :: 3.8',
     'Programming Language :: Python :: 3.9',
     'Programming Language :: Python :: 3.10',
     'Programming Language :: Python :: 3.11',


Test patch:
diff --git a/dwave/cloud/testing/mocks.py b/dwave/cloud/testing/mocks.py
index 81f6574..1d8db9d 100644
--- a/dwave/cloud/testing/mocks.py
+++ b/dwave/cloud/testing/mocks.py
@@ -15,7 +15,7 @@
 import uuid
 import itertools
 import random
-from typing import List, Optional
+from typing import Optional
 
 __all__ = [
     'solver_configuration_data',
@@ -63,8 +63,8 @@ def structured_solver_data(id: str = None,
                            description: str = None,
                            properties: dict = None,
                            # specific properties for convenience, override `properties` dict
-                           qubits: List[int] = None,
-                           couplers: List[List[int]] = None,
+                           qubits: list[int] = None,
+                           couplers: list[list[int]] = None,
                            **kwargs) -> dict:
     """Return data dict describing a structured solver. By default, solver has
     only one qubit.
@@ -318,7 +318,7 @@ def unstructured_solver_data(id: str = None,
                              description: str = None,
                              properties: dict = None,
                              # specific properties for convenience, override `properties` dict
-                             supported_problem_types: List[str] = None,
+                             supported_problem_types: list[str] = None,
                              **kwargs) -> dict:
     """Return data dict describing an unstructured solver."""
 
diff --git a/tests/api/mocks.py b/tests/api/mocks.py
index f1ee2a4..5ada89b 100644
--- a/tests/api/mocks.py
+++ b/tests/api/mocks.py
@@ -15,7 +15,7 @@
 import collections
 import io
 import uuid
-from typing import Any, Optional, Tuple, Union
+from typing import Any, Optional, Union
 from unittest import mock
 
 import orjson
@@ -77,7 +77,7 @@ class SapiMockResponses:
 
     def __init__(self,
                  solver: Union[StructuredSolver, UnstructuredSolver],
-                 problem: Union[Tuple[dict, dict], 'dimod.BQM'],
+                 problem: Union[tuple[dict, dict], 'dimod.BQM'],
                  answer: dict,
                  problem_type: str,
                  problem_id: str = None,
@@ -94,7 +94,7 @@ class SapiMockResponses:
 
     def problem_data(self,
                      solver: Union[StructuredSolver, UnstructuredSolver] = None,
-                     problem: Union[Tuple[dict, dict], 'dimod.BQM'] = None) -> dict:
+                     problem: Union[tuple[dict, dict], 'dimod.BQM'] = None) -> dict:
         raise NotImplementedError
 
     def problem_messages(self) -> dict:
@@ -252,7 +252,7 @@ class StructuredSapiMockResponses(SapiMockResponses):
 
     def problem_data(self,
                      solver: StructuredSolver = None,
-                     problem: Tuple[dict, dict] = None) -> dict:
+                     problem: tuple[dict, dict] = None) -> dict:
         if solver is None:
             solver = self.solver
         if problem is None:
diff --git a/tests/test_config.py b/tests/test_config.py
index 23a4f9e..e37208d 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -18,7 +18,8 @@ import unittest
 import configparser
 from unittest import mock
 from functools import partial
-from typing import Any, Dict, Callable
+from collections import abc
+from typing import Any
 
 from parameterized import parameterized
 
@@ -531,8 +532,8 @@ class TestConfigModel(unittest.TestCase):
     OMITTED = object()
 
     def _verify(self,
-                raw_config: Dict[str, Any],
-                get_field: Callable[[ClientConfig], Any],
+                raw_config: dict[str, Any],
+                get_field: abc.Callable[[ClientConfig], Any],
                 model_value: Any):
         """For a given ``raw_config``, test both model validate and dump."""
 
diff --git a/tests/test_mock_submission.py b/tests/test_mock_submission.py
index 9800155..88fb92d 100644
--- a/tests/test_mock_submission.py
+++ b/tests/test_mock_submission.py
@@ -19,7 +19,7 @@ import time
 import unittest
 import zlib
 from concurrent.futures import TimeoutError, ThreadPoolExecutor
-from typing import Any, Dict
+from typing import Any
 from unittest import mock
 from urllib.parse import urlencode
 
@@ -44,7 +44,7 @@ from dwave.cloud.utils.time import utcrel
 from tests.api.mocks import StructuredSapiMockResponses, choose_reply
 
 
-def fix_status_paths(replies: Dict[str, Any], **params):
+def fix_status_paths(replies: dict[str, Any], **params):
     """Append URL params to problem status paths."""
 
     def fix(path, **params):
diff --git a/tests/test_regions.py b/tests/test_regions.py
index 377ce50..788b5fe 100644
--- a/tests/test_regions.py
+++ b/tests/test_regions.py
@@ -14,7 +14,6 @@
 
 import unittest
 from unittest import mock
-from typing import List
 
 import requests_mock
 from parameterized import parameterized
@@ -103,7 +102,7 @@ class GetRegionsFunctionality(unittest.TestCase):
             Region(code='A', name='Region A', endpoint='http://a/'),
             Region(code='B', name='Region B', endpoint='http://b/'),
         ]
-        mock_json = TypeAdapter(List[Region]).dump_python(mock_regions)
+        mock_json = TypeAdapter(list[Region]).dump_python(mock_regions)
 
         m.get(f"{mock_endpoint}/regions/", json=mock_json,
               headers={'Content-Type': 'application/vnd.dwave.metadata.regions+json; version=1.0'})
diff --git a/tests/test_solver_utils.py b/tests/test_solver_utils.py
index 7feabfa..40adeb2 100644
--- a/tests/test_solver_utils.py
+++ b/tests/test_solver_utils.py
@@ -15,7 +15,6 @@
 import unittest
 
 import copy
-import networkx as nx
 from parameterized import parameterized, parameterized_class
 
 try:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index c079d7a..8e603f3 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -32,7 +32,7 @@ from datetime import datetime
 from dateutil.tz import UTC
 from functools import partial, wraps
 from itertools import count
-from typing import Tuple, Union
+from typing import Union
 from unittest import mock
 
 import numpy
@@ -983,7 +983,7 @@ class TestFilteredSecretsFormatter(unittest.TestCase):
 
     # dev note: define as unbound local function, to be used in class def context only
     # in py310+ we can declare it as @staticmethod and call it as regular function
-    def _snipped(inp: Union[str, uuid.UUID]) -> Tuple[str, str]:
+    def _snipped(inp: Union[str, uuid.UUID]) -> tuple[str, str]:
         """Naively assuming `inp` is a secret/token, scrub the middle part."""
         return (lambda x: (x, f"{x[:3]}...{x[-3:]}"))(str(inp))