hcs-core 0.1.283__py3-none-any.whl → 0.1.316__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hcs_core/sglib/auth.py CHANGED
@@ -22,7 +22,7 @@ import time
22
22
  import jwt
23
23
  from authlib.integrations.httpx_client import OAuth2Client
24
24
 
25
- from hcs_core.ctxp import CtxpException, panic, profile
25
+ from hcs_core.ctxp import CtxpException, profile
26
26
  from hcs_core.ctxp.jsondot import dotdict, dotify
27
27
 
28
28
  from .csp import CspClient
@@ -30,122 +30,141 @@ from .csp import CspClient
30
30
  log = logging.getLogger(__name__)
31
31
 
32
32
 
33
- def _get_profile_auth_hash(effective_profile):
34
- csp = effective_profile.csp
35
- text = json.dumps(csp, default=vars)
36
- return profile.name() + "#" + hashlib.md5(text.encode("ascii"), usedforsecurity=False).hexdigest()
37
-
38
-
39
- def _is_auth_valid(auth_data, effective_profile):
40
- if not auth_data:
41
- return
42
- if not auth_data.token:
43
- return
44
- if auth_data.hash != _get_profile_auth_hash(effective_profile):
45
- return
33
+ def _is_auth_valid(auth_data):
46
34
  leeway = 60
47
- if time.time() + leeway >= auth_data.token.expires_at:
48
- return
49
- return True
50
-
51
-
52
- def _decode_http_basic_auth_token(basic_token: str):
53
- import base64
54
-
55
- try:
56
- decoded = base64.b64decode(basic_token).decode("utf-8")
57
- client_id, client_secret = decoded.split(":")
58
- return client_id, client_secret
59
- except Exception as e:
60
- raise CtxpException(f"Invalid basic http auth token: {e}")
35
+ return auth_data and time.time() + leeway < auth_data["expires_at"]
61
36
 
62
37
 
63
38
  _login_lock = threading.Lock()
64
39
 
65
40
 
66
- def login(force_refresh: bool = False, panic_on_failure: bool = True):
41
+ def login(force_refresh: bool = False, verbose: bool = False):
67
42
  """Ensure login state, using credentials from the current profile. Return oauth token."""
43
+ return _populate_token_with_cache(profile.current().csp, force_refresh, verbose)
68
44
 
69
- _login_lock.acquire()
70
- try:
71
- effective_profile = profile.current()
72
-
73
- auth_data = profile.auth.get()
74
- if force_refresh or not _is_auth_valid(auth_data, effective_profile):
75
- oauth_token = _get_new_oauth_token(auth_data.token, effective_profile)
76
- if oauth_token:
77
- use_oauth_token(oauth_token, effective_profile)
78
- elif panic_on_failure:
79
- panic(
80
- "Login failed. If this is configured API key or client credential, refresh the credential from CSP and update profile config. If this is browser based interactive login, login again."
81
- )
82
- else:
83
- return None
84
- else:
85
- oauth_token = auth_data.token
86
- return oauth_token
87
- finally:
88
- _login_lock.release()
89
-
90
-
91
- def _get_new_oauth_token(old_oauth_token, effective_profile):
92
- csp_config = effective_profile.csp
93
-
94
- csp_client = CspClient(url=csp_config.url)
95
-
96
- if csp_config.apiToken:
97
- oauth_token = csp_client.login_with_api_token(csp_config.apiToken)
98
- elif csp_config.clientId:
99
- oauth_token = csp_client.login_with_client_id_and_secret(
100
- csp_config.clientId, csp_config.clientSecret, csp_config.orgId
101
- )
102
- elif csp_config.basic:
103
- client_id, client_secret = _decode_http_basic_auth_token(csp_config.basic)
104
- oauth_token = csp_client.login_with_client_id_and_secret(client_id, client_secret, csp_config.orgId)
105
- else:
106
- # This should be a config from interactive login.
107
- # Use existing oauth_token to refresh.
108
- if not old_oauth_token:
109
- old_oauth_token = profile.auth.get().token
110
- if old_oauth_token:
111
- try:
112
- oauth_token = _refresh_oauth_token(old_oauth_token, csp_config.url)
113
- except Exception as e:
114
- oauth_token = None
115
- log.warning(e)
116
- else:
117
- oauth_token = None
118
-
119
- if oauth_token and not oauth_token.get("expires_at"):
120
- oauth_token["expires_at"] = int(time.time() + oauth_token["expires_in"])
121
-
122
- return oauth_token
123
-
124
-
125
- def _refresh_oauth_token(old_oauth_token: dict, csp_url: str):
45
+
46
+ def refresh_oauth_token(old_oauth_token: dict, csp_url: str):
126
47
  with OAuth2Client(token=old_oauth_token) as client:
127
48
  log.debug("Refresh auth token...")
128
- new_token = client.refresh_token(csp_url + "/csp/gateway/am/api/auth/token")
49
+ token_url = csp_url + "/csp/gateway/am/api/auth/token"
50
+ from .login_support import identify_client_id
51
+
52
+ csp_specific_req_not_oauth_standard = (identify_client_id(csp_url), "")
53
+ new_token = client.refresh_token(token_url, auth=csp_specific_req_not_oauth_standard)
129
54
  log.debug(f"New auth token: {new_token}")
130
55
  if not new_token:
131
56
  raise Exception("CSP auth refresh failed.")
132
57
  if "cspErrorCode" in new_token:
133
58
  raise Exception(f"CSP auth failed: {new_token.get('message')}")
59
+
134
60
  return new_token
135
61
 
136
62
 
137
- class MyOAuth2Client(OAuth2Client):
138
- def __init__(self):
63
+ def _has_credential(auth_config: dict):
64
+ return (
65
+ auth_config.get("apiToken")
66
+ or auth_config.get("api_token")
67
+ or auth_config.get("clientId")
68
+ or auth_config.get("client_id")
69
+ or auth_config.get("basic")
70
+ )
71
+
72
+
73
+ def get_auth_cache(auth_config: dict):
74
+ cache, hash, token = _get_auth_cache(auth_config)
75
+ return token
76
+
77
+
78
+ def _get_auth_cache(auth_config: dict):
79
+ text = json.dumps(auth_config, default=vars)
80
+ hash = hashlib.md5(text.encode("ascii"), usedforsecurity=False).hexdigest()
81
+ auth_cache = profile.auth.get()
82
+ token = auth_cache.get(hash, None)
83
+ if token and not _is_auth_valid(token):
84
+ token = None
85
+ return auth_cache, hash, token
86
+
87
+
88
+ def save_auth_cache(auth_config: dict, token: dict):
89
+ """Save the auth token to the profile auth cache."""
90
+ cache, hash, _ = _get_auth_cache(auth_config)
91
+ if not token:
92
+ if hash in cache:
93
+ del cache[hash]
94
+ return
95
+ if not token.get("expires_at"):
96
+ token["expires_at"] = int(time.time() + token["expires_in"])
97
+ cache[hash] = token
98
+ profile.auth.set(cache)
99
+
100
+
101
+ def _populate_token_with_cache(auth_config: dict, force_refresh: bool = False, verbose: bool = False):
102
+ if verbose:
103
+ print("_populate_token_with_cache")
104
+ with _login_lock:
105
+ cache, hash, token = _get_auth_cache(auth_config)
106
+ if token and not force_refresh:
107
+ if verbose:
108
+ print("Using cached auth token.")
109
+ return token
110
+
111
+ # invalid token. Refresh or recreate it.
112
+ if token:
113
+ # try using refresh token if possible
114
+ if auth_config.get("provider", "vmwarecsp") == "vmwarecsp":
115
+ if verbose:
116
+ print("Provider: vmwarecsp.")
117
+ try:
118
+ token = refresh_oauth_token(token, auth_config.url)
119
+ except Exception as e:
120
+ log.debug(f"Failed to refresh OAuth token: {e}")
121
+ token = None
122
+ else:
123
+ # hcs auth-service. Does not support refresh token.
124
+ if verbose:
125
+ print("Provider: hcs auth-service.")
126
+ token = None
127
+
128
+ if not token:
129
+ if _has_credential(auth_config):
130
+ if verbose:
131
+ print("Config:", auth_config)
132
+ token = CspClient.create(**auth_config).oauth_token()
133
+ if verbose:
134
+ print("Token:", json.dumps(token, indent=4))
135
+ else:
136
+ if auth_config.get("browser"):
137
+ from .login_support import login_via_browser
138
+
139
+ token = login_via_browser(auth_config.url, auth_config.orgId)
140
+ if not token:
141
+ raise CtxpException("Browser auth failed.")
142
+ else:
143
+ raise CtxpException("Browser auth was never attempted and no client credentials or API token provided.")
144
+
145
+ if not token.get("expires_at"):
146
+ token["expires_at"] = int(time.time() + token["expires_in"])
147
+
148
+ cache[hash] = token
149
+ profile.auth.set(cache)
150
+ return token
151
+
152
+
153
+ class CustomOAuth2Client(OAuth2Client):
154
+ def __init__(self, auth_config: dict):
139
155
  super().__init__()
156
+ self.auth_config = auth_config
140
157
 
141
158
  def ensure_token(self):
142
159
  # pylint: disable=access-member-before-definition
143
160
  if self.token is None or not super().ensure_active_token():
144
- self.token = login()
161
+ self.token = _populate_token_with_cache(self.auth_config)
145
162
 
146
163
 
147
- def oauth_client():
148
- return MyOAuth2Client()
164
+ def oauth_client(auth_config: dict = None):
165
+ if not auth_config:
166
+ auth_config = profile.current().csp
167
+ return CustomOAuth2Client(auth_config)
149
168
 
150
169
 
151
170
  def details(get_org_details: bool = False) -> dotdict:
@@ -174,9 +193,3 @@ def details_from_token(oauth_token, get_org_details: bool = False):
174
193
  def get_org_id_from_token(oauth_token: str) -> str:
175
194
  decoded = jwt.decode(oauth_token["access_token"], options={"verify_signature": False})
176
195
  return decoded["context_name"]
177
-
178
-
179
- def use_oauth_token(oauth_token, effective_profile=None):
180
- if effective_profile is None:
181
- effective_profile = profile.current()
182
- profile.auth.set({"token": oauth_token, "hash": _get_profile_auth_hash(effective_profile)})
@@ -18,7 +18,21 @@ import os
18
18
  import click
19
19
 
20
20
  from hcs_core.ctxp import CtxpException, recent
21
- from hcs_core.ctxp.cli_options import *
21
+ from hcs_core.ctxp.cli_options import apply_env as apply_env
22
+ from hcs_core.ctxp.cli_options import confirm as confirm
23
+ from hcs_core.ctxp.cli_options import env as env
24
+ from hcs_core.ctxp.cli_options import exclude_field as exclude_field
25
+ from hcs_core.ctxp.cli_options import field as field
26
+ from hcs_core.ctxp.cli_options import first as first
27
+ from hcs_core.ctxp.cli_options import force as force
28
+ from hcs_core.ctxp.cli_options import formatter as formatter
29
+ from hcs_core.ctxp.cli_options import ids as ids
30
+ from hcs_core.ctxp.cli_options import limit as limit
31
+ from hcs_core.ctxp.cli_options import output as output
32
+ from hcs_core.ctxp.cli_options import search as search
33
+ from hcs_core.ctxp.cli_options import sort as sort
34
+ from hcs_core.ctxp.cli_options import verbose as verbose
35
+ from hcs_core.ctxp.cli_options import wait as wait
22
36
 
23
37
  org_id = click.option(
24
38
  "--org",
@@ -14,95 +14,178 @@ limitations under the License.
14
14
  """
15
15
 
16
16
  import json
17
- import os
17
+ import logging
18
18
  import sys
19
19
  import threading
20
20
  import time
21
21
  from typing import Callable, Iterator
22
22
 
23
23
  from hcs_core.ctxp import CtxpException, panic, profile
24
- from hcs_core.sglib import hcs_client
25
24
  from hcs_core.sglib.ez_client import EzClient
26
25
  from hcs_core.util import duration, exit
27
26
  from hcs_core.util.query_util import PageRequest, with_query
28
27
 
28
+ log = logging.getLogger(__name__)
29
+
29
30
  _caches = {}
30
31
 
31
32
  _client_instance_lock = threading.RLock()
32
33
 
33
34
 
34
- def hdc_service_client(service_name: str) -> EzClient:
35
- _client_instance_lock.acquire()
36
- try:
37
- instance = _caches.get(service_name)
38
- if not instance:
35
+ def _get_service_override(service_name: str):
36
+ profile_data = profile.current()
37
+ override = profile_data.get("override", {})
38
+ service_override = {}
39
+ for k, v in override.items():
40
+ if service_name == k.lower():
41
+ service_override = v
39
42
 
40
- def _get_url(): # make it deferred so no need to initialize profile
41
- url = _get_hcs_url_considering_env_override(service_name)
42
- if not url.endswith("/"):
43
- url += "/"
44
- url += service_name
45
- return url
43
+ if not service_override:
44
+ if service_name == "org-service":
45
+ service_override = override.get("org", {})
46
+ elif service_name.find("-") >= 0:
47
+ camel_name = "".join(word.capitalize() for word in service_name.split("-"))
48
+ camel_name = camel_name[0].lower() + camel_name[1:]
49
+ service_override = override.get(camel_name, {})
50
+ return service_override
46
51
 
47
- instance = hcs_client(_get_url)
48
- _caches[service_name] = instance
49
- return instance
50
- finally:
51
- _client_instance_lock.release()
52
-
53
-
54
- def _get_hcs_url_considering_env_override(service_name: str):
55
- # service_name = service_name.replace("-", "_")
56
- # env_name = f"hcs_{service_name}_url"
57
- # url = os.environ.get(env_name)
58
- # if url:
59
- # print(f"Using env override for {env_name}: {url}")
60
- # return url
61
- # env_name = env_name.upper()
62
- # url = os.environ.get(env_name)
63
- # if url:
64
- # print(f"Using env override for {env_name}: {url}")
65
- # return url
66
-
67
- # check per-service override in profile
68
- service_override = profile.current().get("overrides", {}).get(service_name, {})
52
+
53
+ def _lazy_init(service_name: str, hdc: str = None, region: str = None): # make it deferred so no need to initialize profile
54
+ if region and hdc:
55
+ raise Exception("region and hdc cannot be specified at the same time.")
56
+
57
+ profile_data = profile.current()
58
+
59
+ service_override = _get_service_override(service_name)
69
60
  service_override_url = service_override.get("url")
70
61
  if service_override_url:
71
- print(f"Using per-service override for {service_name}: {service_override_url}")
72
- return service_override_url
73
- return profile.current().hcs.url
62
+ log.debug(f"Using per-service override for {service_name}: {service_override_url}")
63
+ url = service_override_url
64
+ else:
65
+ if hdc:
66
+ # prod only
67
+ hdc = hdc.upper()
68
+ if hdc == "US":
69
+ url = profile_data.hcs.url
70
+ elif hdc == "EU":
71
+ url = profile_data.hcs.url.replace("cloud-sg-us", "cloud-sg-eu")
72
+ elif hdc == "JP":
73
+ url = profile_data.hcs.url.replace("cloud-sg-us", "cloud-sg-jp")
74
+ else:
75
+ raise CtxpException(f"Invalid HDC name: {hdc}. Supported HDC names: US, EU, JP.")
76
+ elif region:
77
+ url = _get_region_url(region)
78
+ if not url:
79
+ panic("Missing profile property: hcs.regions")
80
+ else:
81
+ url = profile_data.hcs.url
82
+ url = url.rstrip("/") + "/" + service_name
83
+
84
+ # TODO
85
+ client_id = service_override.get("client-id", None)
86
+ if not client_id:
87
+ client_id = service_override.get("clientId", None)
88
+ client_secret = service_override.get("client-secret", None)
89
+ if not client_secret:
90
+ client_secret = service_override.get("clientSecret", None)
91
+ api_token = service_override.get("api-token", None)
92
+ if not api_token:
93
+ api_token = service_override.get("apiToken", None)
94
+ provider = service_override.get("provider", "vmwarecsp")
95
+ if provider == "vmwarecsp":
96
+ token_url = profile_data.csp.url
97
+ elif provider == "auth-service":
98
+ token_url = profile_data.auth["tokenUrl"]
99
+ else:
100
+ raise CtxpException(f"Unknown provider: {provider}. Supported providers: vmwarecsp, auth-service.")
101
+
102
+ if client_id:
103
+ if not client_secret:
104
+ panic(f"Client ID is specified but missing clientSecret for service {service_name} in profile override.")
105
+ else:
106
+ if client_secret:
107
+ panic(f"Client secret is specified but missing clientId for service {service_name} in profile override.")
108
+
109
+ if client_id:
110
+ auth_config = {
111
+ "url": token_url,
112
+ "client_id": client_id,
113
+ "client_secret": client_secret,
114
+ }
115
+ elif api_token:
116
+ auth_config = {
117
+ "url": token_url,
118
+ "api_token": api_token,
119
+ }
120
+ else:
121
+ auth_config = None
122
+
123
+ from hcs_core.sglib import auth
124
+
125
+ oauth_client = auth.oauth_client(auth_config=auth_config)
126
+ return url, oauth_client
74
127
 
75
128
 
76
- def _get_region_url(region_name: str):
129
+ def hdc_service_client(service_name: str, hdc: str = None) -> EzClient:
130
+ """A client for HDC service. Cached per service name, thread-safe, lazy initialization."""
131
+ if hdc:
132
+ hdc = hdc.upper()
133
+ if hdc not in ["US", "EU", "JP"]:
134
+ panic(f"Invalid HDC name: {hdc}. Supported HDC names: US, EU, JP.")
135
+ else:
136
+ hdc = "US"
137
+
138
+ k = f"{service_name}#{hdc}"
139
+ with _client_instance_lock:
140
+ instance = _caches.get(k)
141
+ if not instance:
142
+ instance = EzClient(lazy_init=lambda: _lazy_init(service_name=service_name, hdc=hdc))
143
+ _caches[k] = instance
144
+ return instance
145
+
146
+
147
+ def _get_region_url(region: str):
77
148
  regions = profile.current().hcs.regions
78
- if not region_name:
149
+ if not region:
79
150
  return regions[0].url
80
151
  for r in regions:
81
- if r.name.lower() == region_name.lower():
152
+ if r.name.lower() == region.lower():
82
153
  return r.url
83
154
  names = []
84
155
  for r in regions:
85
156
  names.append(r.name)
86
- panic(f"Region not found: {region_name}. Available regions: {names}")
157
+ panic(f"Region not found: {region}. Available regions: {names}")
87
158
 
88
159
 
89
- def regional_service_client(region_name: str, service_name: str):
160
+ def regional_service_client(service_name: str, region: str = None):
90
161
  # 'https://dev1b-westus2-cp103a.azcp.horizon.vmware.com/vmhub'
91
- url = _get_region_url(region_name)
92
- if not url:
93
- panic("Missing profile property: hcs.regions")
94
- if not url.endswith("/"):
95
- url += "/"
96
- url += service_name
97
- return hcs_client(url)
162
+ region_names = [r.name.lower() for r in profile.current().hcs.regions]
163
+ if region:
164
+ region = region.lower()
165
+ if region not in region_names:
166
+ panic(f"Invalid region name: {region}. Available regions: {', '.join(region_names)}")
167
+ else:
168
+ region = region_names[0]
98
169
 
170
+ k = f"{service_name}#{region}"
171
+ with _client_instance_lock:
172
+ instance = _caches.get(k)
173
+ if not instance:
174
+ instance = EzClient(lazy_init=lambda: _lazy_init(service_name=service_name, region=region))
175
+ _caches[k] = instance
176
+ return instance
99
177
 
100
- def _with_org_id(url: str, org_id: str):
101
- if org_id:
102
- if url.find("?") < 0:
103
- url += "?"
104
- url += "org_id=" + org_id
105
- return url
178
+
179
+ def is_regional_service(service_name: str):
180
+ regional_services = ["vmhub", "connection-service"]
181
+ return service_name in regional_services
182
+
183
+
184
+ def service_client(service_name: str, region: str = None, hdc: str = None):
185
+ if is_regional_service(service_name):
186
+ return regional_service_client(service_name, region)
187
+ else:
188
+ return hdc_service_client(service_name, hdc)
106
189
 
107
190
 
108
191
  class default_crud:
@@ -178,7 +261,10 @@ class default_crud:
178
261
 
179
262
  def wait_for_deleted(self, id: str, org_id: str, timeout: str, fn_is_error: Callable = None):
180
263
  name = self._resource_type_name + "/" + id
181
- fn_get = lambda: self.get(id, org_id)
264
+
265
+ def fn_get():
266
+ return self.get(id, org_id)
267
+
182
268
  return wait_for_res_deleted(name, fn_get, timeout=timeout, fn_is_error=fn_is_error)
183
269
 
184
270
  def update(self, id: str, org_id: str, data: dict, **kwargs):
@@ -202,10 +288,13 @@ def wait_for_res_deleted(
202
288
  resource_name: str,
203
289
  fn_get: Callable,
204
290
  timeout: str,
205
- polling_interval_seconds: int = 10,
291
+ polling_interval: int = 10,
206
292
  fn_is_error: Callable = None,
207
293
  ):
208
294
  timeout_seconds = _parse_timeout(timeout)
295
+ polling_interval_seconds = _parse_timeout(polling_interval)
296
+ if polling_interval_seconds < 3:
297
+ polling_interval_seconds = 3
209
298
  start = time.time()
210
299
  while True:
211
300
  t = fn_get()
@@ -224,9 +313,10 @@ def wait_for_res_deleted(
224
313
  sleep_seconds = remaining_seconds
225
314
  if sleep_seconds > polling_interval_seconds:
226
315
  sleep_seconds = polling_interval_seconds
227
- exit.sleep(sleep_seconds)
316
+ time.sleep(sleep_seconds)
228
317
 
229
318
 
319
+ # flake8: noqa=E731
230
320
  def wait_for_res_status(
231
321
  resource_name: str,
232
322
  fn_get: Callable,
@@ -250,21 +340,25 @@ def wait_for_res_status(
250
340
  field_name = get_status
251
341
  get_status = lambda t: t[field_name]
252
342
  if status_map:
253
- if isinstance(status_map["ready"], str):
254
- status_map["ready"] = [status_map["ready"]]
255
- if isinstance(status_map["transition"], str):
256
- status_map["transition"] = [status_map["transition"]]
257
- if isinstance(status_map["error"], str):
258
- status_map["error"] = [status_map["error"]]
259
343
  if is_ready:
260
344
  raise CtxpException("Can not specify is_ready when status_map is provided.")
261
345
  if is_error:
262
346
  raise CtxpException("Can not specify is_error when status_map is provided.")
263
347
  if is_transition:
264
348
  raise CtxpException("Can not specify is_transition when status_map is provided.")
265
- is_ready = lambda s: s in status_map["ready"]
266
- is_error = lambda s: s in status_map["error"]
267
- is_transition = lambda s: s in status_map["transition"]
349
+
350
+ ready_status = status_map["ready"]
351
+ error_status = status_map["error"]
352
+ transition_status = status_map["transition"]
353
+ if isinstance(ready_status, str):
354
+ ready_status = [ready_status]
355
+ if isinstance(error_status, str):
356
+ error_status = [error_status]
357
+ if isinstance(transition_status, str):
358
+ transition_status = [transition_status]
359
+ is_ready = lambda s: s in ready_status
360
+ is_error = lambda s: error_status is None or s in error_status
361
+ is_transition = lambda s: transition_status is None or s in transition_status
268
362
  else:
269
363
  if not is_ready:
270
364
  raise CtxpException("Either status_map or is_ready must be specified.")
@@ -272,6 +366,9 @@ def wait_for_res_status(
272
366
  raise CtxpException("Either status_map or is_error must be specified.")
273
367
  if not is_transition:
274
368
  raise CtxpException("Either status_map or is_transition must be specified.")
369
+ ready_status = None
370
+ error_status = None
371
+ transition_status = None
275
372
 
276
373
  while True:
277
374
  t = fn_get()
@@ -281,9 +378,9 @@ def wait_for_res_status(
281
378
  raise CtxpException(prefix + "Not found.")
282
379
  status = get_status(t)
283
380
  if is_error(status):
284
- msg = prefix + f"Status error. Actual={status}"
285
- if status_map:
286
- msg += f", expected={status_map['ready']}"
381
+ msg = prefix + f"Unexpected terminal state. Actual={status}"
382
+ if ready_status:
383
+ msg += f", expected={ready_status}"
287
384
  print("-- DUMP START --", file=sys.stderr)
288
385
  print(json.dumps(t, indent=4), file=sys.stderr)
289
386
  print("-- DUMP END --", file=sys.stderr)
@@ -291,14 +388,15 @@ def wait_for_res_status(
291
388
  if is_ready(status):
292
389
  return t
293
390
  if not is_transition(status):
294
- raise CtxpException(
295
- prefix + f"Unexpected status: {status}. If this is a transition, add it to status_map['transition']."
296
- )
391
+ raise CtxpException(prefix + f"Unexpected status: {status}. If this is a transition, add it to status_map['transition'].")
297
392
 
298
393
  now = time.time()
299
394
  remaining_seconds = timeout_seconds - (now - start)
300
395
  if remaining_seconds < 1:
301
- raise TimeoutError(prefix + "Timeout.")
396
+ msg = prefix + f"Timeout. Current: {status}."
397
+ if ready_status:
398
+ msg += f" Expected: {ready_status}."
399
+ raise TimeoutError(msg)
302
400
  sleep_seconds = remaining_seconds
303
401
  if sleep_seconds > polling_interval_seconds:
304
402
  sleep_seconds = polling_interval_seconds