certora-prover-cli 0.1.0__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.
@@ -0,0 +1,628 @@
1
+ # SPDX-FileCopyrightText: 2026 Certora Ltd.
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-only
4
+
5
+ """
6
+ Low-level data fetching utilities for the Prover API.
7
+ """
8
+ import functools
9
+ from typing import Any, Callable, Dict, List, Optional, TypeVar, cast
10
+
11
+ import requests
12
+
13
+ from ..auth import LoginEnv, resolve_login_env
14
+ from ..exceptions import AuthenticationError, JobNotFoundError, ProverAPIError
15
+ from .base_data_fetcher import BaseDataFetcher
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ # Per-environment data-api hosts. Kept in sync with the Certora login envs
21
+ # `resolve_login_env()` understands: AISS_ENV / GITHUB_ENVIRONMENT.
22
+ _API_BASE_URL_BY_ENV: Dict[LoginEnv, str] = {
23
+ "prod": "https://data-api.certora.com",
24
+ "stg": "https://data-api-stg.certora.com",
25
+ "dev": "https://data-api-dev.certora.com",
26
+ }
27
+
28
+
29
+ def default_api_base_url() -> str:
30
+ """Return the data-api base URL for the current Certora login env.
31
+
32
+ Resolution mirrors :func:`prover_output_utility.auth.resolve_login_env` —
33
+ ``AISS_ENV`` takes precedence; otherwise ``GITHUB_ENVIRONMENT`` maps to
34
+ stg/dev; otherwise prod. Callers that need a specific host should pass
35
+ ``api_base_url=`` to the constructor explicitly.
36
+
37
+ Public so callers (e.g. job-listing tooling) can resolve the env-aware
38
+ data-api host without re-deriving the env→host mapping or constructing a
39
+ (auth-triggering) ``ProverOutputAPI``.
40
+ """
41
+ return _API_BASE_URL_BY_ENV[resolve_login_env()]
42
+
43
+
44
+ # Per-environment Prover web-UI hosts (what `--server` resolves to in
45
+ # certoraUtils.SupportedServers). Distinct from the data-api hosts above:
46
+ # prod is prover.certora.com, not data-api.certora.com.
47
+ _PROVER_BASE_URL_BY_ENV: Dict[LoginEnv, str] = {
48
+ "prod": "https://prover.certora.com",
49
+ "stg": "https://vaas-stg.certora.com",
50
+ "dev": "https://vaas-dev.certora.com",
51
+ }
52
+
53
+
54
+ def _default_prover_base_url() -> str:
55
+ """Return the Prover web-UI base URL for the current Certora login env.
56
+
57
+ Same env resolution as :func:`default_api_base_url`, but for the
58
+ user-facing Prover host used in shareable links (e.g. group-summary URLs).
59
+ """
60
+ return _PROVER_BASE_URL_BY_ENV[resolve_login_env()]
61
+
62
+
63
+ def handle_token_expiration(func: Callable[..., T]) -> Callable[..., T]:
64
+ """Decorator to handle token expiration and retry with re-authentication."""
65
+
66
+ @functools.wraps(func)
67
+ def wrapper(self, *args, **kwargs) -> T:
68
+ try:
69
+ return func(self, *args, **kwargs)
70
+ except AuthenticationError as e:
71
+ # Check if this is a token expiration error (401 response)
72
+ if "401" in str(e) or "Authentication failed" in str(e):
73
+ # Only retry once
74
+ if not getattr(self, "_retry_auth", False):
75
+ self._retry_auth = True
76
+ try:
77
+ # Get the API instance and force re-login
78
+ if hasattr(self, "_api_instance"):
79
+ self._api_instance._setup_session(force_relogin=True)
80
+ # Update our session reference
81
+ self.session = self._api_instance.session
82
+ # Retry the original request
83
+ result = func(self, *args, **kwargs)
84
+ self._retry_auth = False
85
+ return result
86
+ finally:
87
+ self._retry_auth = False
88
+ raise
89
+
90
+ return wrapper
91
+
92
+
93
+ class DataFetcher(BaseDataFetcher):
94
+ """Handles low-level data fetching from Certora API endpoints."""
95
+
96
+ # API URLs by environment. Kept as class attributes for back-compat with
97
+ # callers that referenced them directly; the canonical lookup is
98
+ # ``_API_BASE_URL_BY_ENV`` at module level.
99
+ PRODUCTION_API_BASE_URL = _API_BASE_URL_BY_ENV["prod"]
100
+ STAGING_API_BASE_URL = _API_BASE_URL_BY_ENV["stg"]
101
+ DEV_API_BASE_URL = _API_BASE_URL_BY_ENV["dev"]
102
+
103
+ def __init__(
104
+ self,
105
+ session: requests.Session,
106
+ api_instance=None,
107
+ api_base_url: Optional[str] = None,
108
+ ):
109
+ """
110
+ Initialize the data fetcher.
111
+
112
+ Args:
113
+ session: Configured requests session with authentication
114
+ api_instance: Reference to the ProverOutputAPI instance for re-auth
115
+ api_base_url: API base URL. When None (the default), resolve from
116
+ the Certora login env (``AISS_ENV`` / ``GITHUB_ENVIRONMENT``,
117
+ via :func:`resolve_login_env`) — dev → data-api-dev,
118
+ stg → data-api-stg, prod → data-api. Pass an explicit
119
+ URL to override.
120
+ """
121
+ self.session = session
122
+ # Resolve inside the body so callers picking up `AISS_ENV` at runtime get
123
+ # the right host; the previous `= PRODUCTION_API_BASE_URL` default was
124
+ # bound at class-definition time and could not be retargeted.
125
+ self.api_base_url = api_base_url if api_base_url is not None else default_api_base_url()
126
+ self._api_instance = api_instance
127
+ self._retry_auth = False
128
+
129
+ @classmethod
130
+ def get_api_base_url_for_prover_url(cls, prover_url: str) -> str:
131
+ """
132
+ Get the appropriate API base URL for a prover output URL.
133
+
134
+ Dev jobs (vaas-dev.certora.com) use data-api-dev.certora.com,
135
+ staging jobs (vaas-stg.certora.com) use data-api-stg.certora.com,
136
+ production jobs use data-api.certora.com.
137
+
138
+ Args:
139
+ prover_url: Full prover URL (e.g., "https://vaas-stg.certora.com/output/...")
140
+
141
+ Returns:
142
+ Appropriate API base URL for API calls
143
+ """
144
+ if "vaas-dev.certora.com" in prover_url:
145
+ return cls.DEV_API_BASE_URL
146
+ if "vaas-stg.certora.com" in prover_url:
147
+ return cls.STAGING_API_BASE_URL
148
+ return cls.PRODUCTION_API_BASE_URL
149
+
150
+ def _execute_request(
151
+ self,
152
+ method: str,
153
+ endpoint: str,
154
+ params: Optional[Dict[str, Any]] = None,
155
+ json_data: Optional[Dict[str, Any]] = None
156
+ ) -> requests.Response:
157
+ """
158
+ Execute HTTP request with method dispatch.
159
+
160
+ Args:
161
+ method: HTTP method (GET, POST, PUT, DELETE)
162
+ endpoint: Full URL endpoint
163
+ params: Query parameters (for GET/DELETE)
164
+ json_data: JSON body data (for POST/PUT)
165
+
166
+ Returns:
167
+ requests.Response object
168
+
169
+ Raises:
170
+ ProverAPIError: If HTTP method is unsupported
171
+ """
172
+ if method.upper() == "GET":
173
+ return self.session.get(endpoint, params=params)
174
+ elif method.upper() == "POST":
175
+ return self.session.post(endpoint, json=json_data)
176
+ elif method.upper() == "PUT":
177
+ return self.session.put(endpoint, json=json_data)
178
+ elif method.upper() == "DELETE":
179
+ return self.session.delete(endpoint, params=params)
180
+ else:
181
+ raise ProverAPIError(f"Unsupported HTTP method: {method}")
182
+
183
+ @handle_token_expiration
184
+ def fetch_raw_output(self, job_identifier: str) -> Dict[str, Any]:
185
+ """
186
+ Fetch raw output data from the prover API.
187
+
188
+ Args:
189
+ job_identifier: Job ID to fetch
190
+
191
+ Returns:
192
+ Raw API response data
193
+
194
+ Raises:
195
+ AuthenticationError: If authentication fails
196
+ JobNotFoundError: If job is not found
197
+ ProverAPIError: If API call fails
198
+ """
199
+ endpoint = f"{self.api_base_url}/v1/domain/jobs/{job_identifier}"
200
+
201
+ try:
202
+ response = self.session.get(endpoint)
203
+
204
+ if response.status_code == 200:
205
+ return cast(Dict[str, Any], response.json())
206
+ elif response.status_code == 401:
207
+ raise AuthenticationError("Authentication failed - check CERTORAKEY")
208
+ elif response.status_code == 404:
209
+ raise JobNotFoundError(f"Job {job_identifier} not found")
210
+ else:
211
+ response.raise_for_status()
212
+ return cast(Dict[str, Any], response.json())
213
+
214
+ except requests.exceptions.RequestException as e:
215
+ raise ProverAPIError(f"Failed to fetch job data for {job_identifier}: {e}")
216
+
217
+ @handle_token_expiration
218
+ def fetch_tree_view_data(
219
+ self, job_identifier: str, path: Optional[str] = None
220
+ ) -> Dict[str, Any]:
221
+ """
222
+ Fetch tree-view data for a job.
223
+
224
+ Args:
225
+ job_identifier: Job ID to fetch
226
+ path: Optional path within the tree-view
227
+
228
+ Returns:
229
+ Tree-view data
230
+
231
+ Raises:
232
+ AuthenticationError: If authentication fails
233
+ JobNotFoundError: If job is not found
234
+ ProverAPIError: If API call fails
235
+ """
236
+ endpoint = f"{self.api_base_url}/v1/domain/jobs/{job_identifier}/tree-view"
237
+
238
+ params = {}
239
+ if path:
240
+ params["path"] = path
241
+
242
+ try:
243
+ response = self.session.get(endpoint, params=params)
244
+
245
+ if response.status_code == 200:
246
+ return cast(Dict[str, Any], response.json())
247
+ elif response.status_code == 401:
248
+ raise AuthenticationError("Authentication failed - check CERTORAKEY")
249
+ elif response.status_code == 404:
250
+ raise JobNotFoundError(f"Tree-view data not found for job {job_identifier}")
251
+ else:
252
+ response.raise_for_status()
253
+ return cast(Dict[str, Any], response.json())
254
+
255
+ except requests.exceptions.RequestException as e:
256
+ raise ProverAPIError(f"Failed to fetch tree-view data for {job_identifier}: {e}")
257
+
258
+ @handle_token_expiration
259
+ def fetch_statsdata(self, job_identifier: str) -> Dict[str, Any]:
260
+ """
261
+ Fetch statsdata.json for a job.
262
+
263
+ Args:
264
+ job_identifier: Job ID to fetch
265
+
266
+ Returns:
267
+ Stats data from statsdata.json
268
+
269
+ Raises:
270
+ AuthenticationError: If authentication fails
271
+ JobNotFoundError: If job is not found
272
+ ProverAPIError: If API call fails
273
+ """
274
+ endpoint = f"{self.api_base_url}/v1/domain/jobs/{job_identifier}/f/statsdata.json"
275
+
276
+ try:
277
+ response = self.session.get(endpoint)
278
+
279
+ if response.status_code == 200:
280
+ return cast(Dict[str, Any], response.json())
281
+ elif response.status_code == 401:
282
+ raise AuthenticationError("Authentication failed - check CERTORAKEY")
283
+ elif response.status_code == 404:
284
+ raise JobNotFoundError(f"Stats data not found for job {job_identifier}")
285
+ else:
286
+ response.raise_for_status()
287
+ return cast(Dict[str, Any], response.json())
288
+
289
+ except requests.exceptions.RequestException as e:
290
+ raise ProverAPIError(f"Failed to fetch stats data for {job_identifier}: {e}")
291
+
292
+ def fetch_outputs(self, job_identifier: str) -> bytes:
293
+ """
294
+ Fetch outputs archive (tar.gz) for a job.
295
+
296
+ Args:
297
+ job_identifier: Job ID to fetch
298
+
299
+ Returns:
300
+ Raw tar.gz content as bytes
301
+
302
+ Raises:
303
+ AuthenticationError: If authentication fails
304
+ JobNotFoundError: If job is not found
305
+ ProverAPIError: If API call fails
306
+ """
307
+ endpoint = f"{self.api_base_url}/v1/domain/jobs/{job_identifier}/f/outputs"
308
+
309
+ try:
310
+ response = self.session.get(endpoint)
311
+
312
+ if response.status_code == 200:
313
+ return response.content
314
+ elif response.status_code == 401:
315
+ raise AuthenticationError("Authentication failed - check CERTORAKEY")
316
+ elif response.status_code == 404:
317
+ raise JobNotFoundError(f"Outputs not found for job {job_identifier}")
318
+ else:
319
+ response.raise_for_status()
320
+ return response.content
321
+
322
+ except requests.exceptions.RequestException as e:
323
+ raise ProverAPIError(f"Failed to fetch outputs for {job_identifier}: {e}")
324
+
325
+ @handle_token_expiration
326
+ def list_recent_jobs(
327
+ self,
328
+ created_after: str,
329
+ limit: int = 100,
330
+ all_users: bool = True,
331
+ ) -> List[Dict[str, Any]]:
332
+ """List recent jobs from the data API.
333
+
334
+ See BaseDataFetcher.list_recent_jobs for parameter docs.
335
+ """
336
+ endpoint = f"{self.api_base_url}/v1/domain/jobs"
337
+
338
+ try:
339
+ response = self._execute_request("GET", endpoint, params={
340
+ "currentPage": 1,
341
+ # API caps page size at 100 per response, so don't request more
342
+ "pageSize": min(limit, 100),
343
+ "sortOrder": "desc",
344
+ "allUsers": "true" if all_users else "false",
345
+ "createdAfter": created_after,
346
+ "deleted": "false",
347
+ })
348
+
349
+ if response.status_code == 200:
350
+ return cast(List[Dict[str, Any]], response.json().get("items", []))
351
+ else:
352
+ raise ProverAPIError(
353
+ f"Failed to list recent jobs (status {response.status_code}): "
354
+ f"{response.text[:200]}"
355
+ )
356
+
357
+ except requests.exceptions.RequestException as e:
358
+ raise ProverAPIError(f"Failed to list recent jobs: {e}")
359
+
360
+ @handle_token_expiration
361
+ def fetch_group_jobs(
362
+ self, group_id: str, created_after: str, created_before: str
363
+ ) -> List[Dict[str, Any]]:
364
+ """Fetch all jobs in a group from the data API."""
365
+ endpoint = f"{self.api_base_url}/v1/domain/jobs"
366
+
367
+ try:
368
+ response = self._execute_request("GET", endpoint, params={
369
+ "groupIds": group_id,
370
+ "pageSize": 200,
371
+ "createdAfter": created_after,
372
+ "createdBefore": created_before,
373
+ "allUsers": "true",
374
+ })
375
+
376
+ if response.status_code == 200:
377
+ return cast(List[Dict[str, Any]], response.json().get("items", []))
378
+ else:
379
+ raise ProverAPIError(
380
+ f"Failed to fetch group jobs (status {response.status_code}): "
381
+ f"{response.text[:200]}"
382
+ )
383
+
384
+ except requests.exceptions.RequestException as e:
385
+ raise ProverAPIError(f"Failed to fetch group jobs: {e}")
386
+
387
+ @handle_token_expiration
388
+ def cancel_jobs(self, job_ids: List[str]) -> Dict[str, Any]:
389
+ """
390
+ Cancel multiple jobs by their IDs.
391
+
392
+ Args:
393
+ job_ids: List of job IDs to cancel
394
+
395
+ Returns:
396
+ API response data
397
+
398
+ Raises:
399
+ ProverAPIError: If API call fails
400
+ """
401
+ endpoint = f"{self.api_base_url}/v1/domain/jobs/cancel"
402
+ payload = {"ids": job_ids}
403
+
404
+ try:
405
+ response = self.session.post(endpoint, json=payload)
406
+
407
+ if response.status_code == 200:
408
+ return cast(Dict[str, Any], response.json())
409
+ elif response.status_code == 401:
410
+ raise AuthenticationError("Authentication failed - check CERTORAKEY")
411
+ elif response.status_code == 404:
412
+ raise JobNotFoundError("Cancel endpoint not found")
413
+ else:
414
+ response.raise_for_status()
415
+ return cast(Dict[str, Any], response.json())
416
+
417
+ except requests.exceptions.RequestException as e:
418
+ raise ProverAPIError(f"Failed to cancel jobs {job_ids}: {e}")
419
+
420
+ @handle_token_expiration
421
+ def fetch_console_logs(self, job_identifier: str) -> str:
422
+ """
423
+ Fetch console logs for a job.
424
+
425
+ Args:
426
+ job_identifier: Job ID to fetch logs for
427
+
428
+ Returns:
429
+ Console logs as a string
430
+
431
+ Raises:
432
+ AuthenticationError: If authentication fails
433
+ JobNotFoundError: If job is not found
434
+ ProverAPIError: If API call fails
435
+ """
436
+ endpoint = f"{self.api_base_url}/v1/domain/jobs/{job_identifier}/logs"
437
+
438
+ try:
439
+ response = self.session.get(endpoint)
440
+
441
+ if response.status_code == 200:
442
+ return response.text
443
+ elif response.status_code == 401:
444
+ raise AuthenticationError("Authentication failed - check CERTORAKEY")
445
+ elif response.status_code == 404:
446
+ raise JobNotFoundError(f"Console logs not found for job {job_identifier}")
447
+ else:
448
+ response.raise_for_status()
449
+ return response.text
450
+
451
+ except requests.exceptions.RequestException as e:
452
+ raise ProverAPIError(f"Failed to fetch console logs for {job_identifier}: {e}")
453
+
454
+ @handle_token_expiration
455
+ def who_am_i(self) -> Dict[str, Any]:
456
+ """
457
+ Get information about the currently authenticated user.
458
+
459
+ Returns:
460
+ User information including email and user ID
461
+
462
+ Raises:
463
+ AuthenticationError: If authentication fails
464
+ ProverAPIError: If API call fails
465
+ """
466
+ endpoint = f"{self.api_base_url}/who-am-i"
467
+
468
+ try:
469
+ response = self.session.get(endpoint)
470
+
471
+ if response.status_code == 200:
472
+ return cast(Dict[str, Any], response.json())
473
+ elif response.status_code == 401:
474
+ raise AuthenticationError("Authentication failed - check credentials")
475
+ else:
476
+ response.raise_for_status()
477
+ return cast(Dict[str, Any], response.json())
478
+
479
+ except requests.exceptions.RequestException as e:
480
+ raise ProverAPIError(f"Failed to get user information: {e}")
481
+
482
+ @handle_token_expiration
483
+ def fetch_custom_endpoint(
484
+ self, endpoint: str, params: Optional[Dict[str, Any]] = None, method: str = "GET"
485
+ ) -> Dict[str, Any]:
486
+ """
487
+ Fetch data from an arbitrary user-provided endpoint.
488
+
489
+ Args:
490
+ endpoint: The full URL endpoint to fetch from
491
+ params: Optional query parameters or request body
492
+ method: HTTP method (GET, POST, etc.)
493
+
494
+ Returns:
495
+ API response data
496
+
497
+ Raises:
498
+ AuthenticationError: If authentication fails
499
+ ProverAPIError: If API call fails
500
+ """
501
+ try:
502
+ # Use helper method for HTTP method dispatch
503
+ if method.upper() in ("GET", "DELETE"):
504
+ response = self._execute_request(method, endpoint, params=params)
505
+ elif method.upper() in ("POST", "PUT"):
506
+ response = self._execute_request(method, endpoint, json_data=params)
507
+ else:
508
+ raise ProverAPIError(f"Unsupported HTTP method: {method}")
509
+
510
+ if response.status_code == 200:
511
+ return cast(Dict[str, Any], response.json())
512
+ elif response.status_code == 401:
513
+ raise AuthenticationError("Authentication failed - check credentials")
514
+ else:
515
+ response.raise_for_status()
516
+ return cast(Dict[str, Any], response.json())
517
+
518
+ except requests.exceptions.RequestException as e:
519
+ raise ProverAPIError(f"Failed to fetch from custom endpoint {endpoint}: {e}")
520
+
521
+ @handle_token_expiration
522
+ def fetch_source_files_list(self, job_identifier: str) -> List[Dict[str, Any]]:
523
+ """
524
+ Fetch the source file tree for a job.
525
+
526
+ Calls the source-files endpoint with path="" which redirects to S3
527
+ and returns the full file tree as JSON.
528
+
529
+ Args:
530
+ job_identifier: Job ID to fetch
531
+
532
+ Returns:
533
+ List of source file tree nodes
534
+
535
+ Raises:
536
+ AuthenticationError: If authentication fails
537
+ JobNotFoundError: If job is not found
538
+ ProverAPIError: If API call fails
539
+ """
540
+ endpoint = f"{self.api_base_url}/v1/domain/jobs/{job_identifier}/source-files"
541
+
542
+ try:
543
+ response = self.session.get(endpoint, params={"path": ""})
544
+
545
+ if response.status_code == 200:
546
+ return cast(List[Dict[str, Any]], response.json())
547
+ elif response.status_code == 401:
548
+ raise AuthenticationError("Authentication failed - check CERTORAKEY")
549
+ elif response.status_code == 404:
550
+ raise JobNotFoundError(f"Source files not found for job {job_identifier}")
551
+ else:
552
+ response.raise_for_status()
553
+ return cast(List[Dict[str, Any]], response.json())
554
+
555
+ except requests.exceptions.RequestException as e:
556
+ raise ProverAPIError(f"Failed to fetch source files for {job_identifier}: {e}")
557
+
558
+ @handle_token_expiration
559
+ def fetch_source_file_content(self, job_identifier: str, path: str) -> str:
560
+ """
561
+ Fetch the content of a specific source file for a job.
562
+
563
+ Calls the source-files endpoint with the given path which redirects
564
+ to S3 and returns the raw file content.
565
+
566
+ Args:
567
+ job_identifier: Job ID to fetch
568
+ path: Path to the source file (e.g. ".certora_sources/src/Contract.sol")
569
+
570
+ Returns:
571
+ Raw file content as a string
572
+
573
+ Raises:
574
+ AuthenticationError: If authentication fails
575
+ JobNotFoundError: If job is not found
576
+ ProverAPIError: If API call fails
577
+ """
578
+ endpoint = f"{self.api_base_url}/v1/domain/jobs/{job_identifier}/source-files"
579
+
580
+ try:
581
+ response = self.session.get(endpoint, params={"path": path})
582
+
583
+ if response.status_code == 200:
584
+ return response.text
585
+ elif response.status_code == 401:
586
+ raise AuthenticationError("Authentication failed - check CERTORAKEY")
587
+ elif response.status_code == 404:
588
+ raise JobNotFoundError(f"Source file not found for job {job_identifier}: {path}")
589
+ else:
590
+ response.raise_for_status()
591
+ return response.text
592
+
593
+ except requests.exceptions.RequestException as e:
594
+ raise ProverAPIError(f"Failed to fetch source file for {job_identifier}: {e}")
595
+
596
+ @handle_token_expiration
597
+ def fetch_alert_report(self, job_identifier: str) -> List[Dict[str, Any]]:
598
+ """
599
+ Fetch alert report (alertReport.json) for a job.
600
+
601
+ Args:
602
+ job_identifier: Job ID to fetch
603
+
604
+ Returns:
605
+ Alert report data as a list of alert objects
606
+
607
+ Raises:
608
+ AuthenticationError: If authentication fails
609
+ JobNotFoundError: If job is not found
610
+ ProverAPIError: If API call fails
611
+ """
612
+ endpoint = f"{self.api_base_url}/v1/domain/jobs/{job_identifier}/f/alertReport.json"
613
+
614
+ try:
615
+ response = self.session.get(endpoint)
616
+
617
+ if response.status_code == 200:
618
+ return cast(List[Dict[str, Any]], response.json())
619
+ elif response.status_code == 401:
620
+ raise AuthenticationError("Authentication failed - check CERTORAKEY")
621
+ elif response.status_code == 404:
622
+ raise JobNotFoundError(f"Alert report not found for job {job_identifier}")
623
+ else:
624
+ response.raise_for_status()
625
+ return cast(List[Dict[str, Any]], response.json())
626
+
627
+ except requests.exceptions.RequestException as e:
628
+ raise ProverAPIError(f"Failed to fetch alert report for {job_identifier}: {e}")