google-genai 1.7.0__py3-none-any.whl → 1.53.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.
Files changed (42) hide show
  1. google/genai/__init__.py +4 -2
  2. google/genai/_adapters.py +55 -0
  3. google/genai/_api_client.py +1301 -299
  4. google/genai/_api_module.py +1 -1
  5. google/genai/_automatic_function_calling_util.py +54 -33
  6. google/genai/_base_transformers.py +26 -0
  7. google/genai/_base_url.py +50 -0
  8. google/genai/_common.py +560 -59
  9. google/genai/_extra_utils.py +371 -38
  10. google/genai/_live_converters.py +1467 -0
  11. google/genai/_local_tokenizer_loader.py +214 -0
  12. google/genai/_mcp_utils.py +117 -0
  13. google/genai/_operations_converters.py +394 -0
  14. google/genai/_replay_api_client.py +204 -92
  15. google/genai/_test_api_client.py +1 -1
  16. google/genai/_tokens_converters.py +520 -0
  17. google/genai/_transformers.py +633 -233
  18. google/genai/batches.py +1733 -538
  19. google/genai/caches.py +678 -1012
  20. google/genai/chats.py +48 -38
  21. google/genai/client.py +142 -15
  22. google/genai/documents.py +532 -0
  23. google/genai/errors.py +141 -35
  24. google/genai/file_search_stores.py +1296 -0
  25. google/genai/files.py +312 -744
  26. google/genai/live.py +617 -367
  27. google/genai/live_music.py +197 -0
  28. google/genai/local_tokenizer.py +395 -0
  29. google/genai/models.py +3598 -3116
  30. google/genai/operations.py +201 -362
  31. google/genai/pagers.py +23 -7
  32. google/genai/py.typed +1 -0
  33. google/genai/tokens.py +362 -0
  34. google/genai/tunings.py +1274 -496
  35. google/genai/types.py +14535 -5454
  36. google/genai/version.py +2 -2
  37. {google_genai-1.7.0.dist-info → google_genai-1.53.0.dist-info}/METADATA +736 -234
  38. google_genai-1.53.0.dist-info/RECORD +41 -0
  39. {google_genai-1.7.0.dist-info → google_genai-1.53.0.dist-info}/WHEEL +1 -1
  40. google_genai-1.7.0.dist-info/RECORD +0 -27
  41. {google_genai-1.7.0.dist-info → google_genai-1.53.0.dist-info/licenses}/LICENSE +0 -0
  42. {google_genai-1.7.0.dist-info → google_genai-1.53.0.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Google LLC
1
+ # Copyright 2025 Google LLC
2
2
  #
3
3
  # Licensed under the Apache License, Version 2.0 (the "License");
4
4
  # you may not use this file except in compliance with the License.
@@ -17,28 +17,82 @@
17
17
 
18
18
  import base64
19
19
  import copy
20
- import datetime
20
+ import contextlib
21
+ import enum
21
22
  import inspect
22
23
  import io
23
24
  import json
24
25
  import os
25
26
  import re
26
- from typing import Any, Literal, Optional, Union
27
+ from typing import Any, Literal, Optional, Union, Iterator, AsyncIterator
27
28
 
28
29
  import google.auth
29
- from requests.exceptions import HTTPError
30
30
 
31
31
  from . import errors
32
32
  from ._api_client import BaseApiClient
33
- from ._api_client import HttpOptions
34
33
  from ._api_client import HttpRequest
35
34
  from ._api_client import HttpResponse
36
35
  from ._common import BaseModel
36
+ from .types import HttpOptions, HttpOptionsOrDict
37
+
38
+
39
+ def to_snake_case(name: str) -> str:
40
+ """Converts a string from camelCase or PascalCase to snake_case."""
41
+
42
+ if not isinstance(name, str):
43
+ name = str(name)
44
+ s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
45
+ return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
46
+
47
+
48
+ def _normalize_json_case(obj: Any) -> Any:
49
+ if isinstance(obj, dict):
50
+ return {
51
+ to_snake_case(k): _normalize_json_case(v)
52
+ for k, v in obj.items()
53
+ }
54
+ elif isinstance(obj, list):
55
+ return [_normalize_json_case(item) for item in obj]
56
+ elif isinstance(obj, enum.Enum):
57
+ return obj.value
58
+ elif isinstance(obj, str):
59
+ # Python >= 3.14 has a new division by zero error message.
60
+ if 'division by zero' in obj:
61
+ return obj.replace(
62
+ 'division by zero', 'integer division or modulo by zero'
63
+ )
64
+ return obj
65
+
66
+
67
+ def _equals_ignore_key_case(obj1: Any, obj2: Any) -> bool:
68
+ """Compares two Python objects for equality ignoring key casing.
69
+
70
+ Returns:
71
+ bool: True if the two objects are equal regardless of key casing
72
+ (camelCase vs. snake_case). For example, the following are considered equal:
73
+
74
+ {'my_key': 'my_value'}
75
+ {'myKey': 'my_value'}
76
+
77
+ This also considers enums and strings with the same value as equal.
78
+ For example, the following are considered equal:
79
+
80
+ {'type': <Type.STRING: 'STRING'>}}
81
+ {'type': 'STRING'}
82
+ """
83
+
84
+ normalized_obj_1 = _normalize_json_case(obj1)
85
+ normalized_obj_2 = _normalize_json_case(obj2)
86
+
87
+ if normalized_obj_1 == normalized_obj_2:
88
+ return True
89
+ else:
90
+ return False
37
91
 
38
92
 
39
93
  def _redact_version_numbers(version_string: str) -> str:
40
94
  """Redacts version numbers in the form x.y.z from a string."""
41
- return re.sub(r'\d+\.\d+\.\d+', '{VERSION_NUMBER}', version_string)
95
+ return re.sub(r'\d+\.\d+\.\d+[a-zA-Z0-9]*', '{VERSION_NUMBER}', version_string)
42
96
 
43
97
 
44
98
  def _redact_language_label(language_label: str) -> str:
@@ -46,7 +100,7 @@ def _redact_language_label(language_label: str) -> str:
46
100
  return re.sub(r'gl-python/', '{LANGUAGE_LABEL}/', language_label)
47
101
 
48
102
 
49
- def _redact_request_headers(headers):
103
+ def _redact_request_headers(headers: dict[str, str]) -> dict[str, str]:
50
104
  """Redacts headers that should not be recorded."""
51
105
  redacted_headers = {}
52
106
  for header_name, header_value in headers.items():
@@ -88,7 +142,7 @@ def _redact_request_url(url: str) -> str:
88
142
  result,
89
143
  )
90
144
  result = re.sub(
91
- r'https://generativelanguage.googleapis.com/[^/]+',
145
+ r'.*generativelanguage.*.googleapis.com/[^/]+',
92
146
  '{MLDEV_URL_PREFIX}',
93
147
  result,
94
148
  )
@@ -109,28 +163,36 @@ def _redact_project_location_path(path: str) -> str:
109
163
  return path
110
164
 
111
165
 
112
- def _redact_request_body(body: dict[str, object]):
166
+ def _redact_request_body(body: dict[str, object]) -> None:
113
167
  """Redacts fields in the request body in place."""
114
168
  for key, value in body.items():
115
169
  if isinstance(value, str):
116
170
  body[key] = _redact_project_location_path(value)
117
171
 
118
172
 
119
- def redact_http_request(http_request: HttpRequest):
173
+ def redact_http_request(http_request: HttpRequest) -> None:
120
174
  http_request.headers = _redact_request_headers(http_request.headers)
121
175
  http_request.url = _redact_request_url(http_request.url)
122
- _redact_request_body(http_request.data)
176
+ if not isinstance(http_request.data, bytes):
177
+ _redact_request_body(http_request.data)
123
178
 
124
179
 
125
- def _current_file_path_and_line():
180
+ def _current_file_path_and_line() -> str:
126
181
  """Prints the current file path and line number."""
127
- frame = inspect.currentframe().f_back.f_back
128
- filepath = inspect.getfile(frame)
129
- lineno = frame.f_lineno
130
- return f'File: {filepath}, Line: {lineno}'
182
+ current_frame = inspect.currentframe()
183
+ if (
184
+ current_frame is not None
185
+ and current_frame.f_back is not None
186
+ and current_frame.f_back.f_back is not None
187
+ ):
188
+ frame = current_frame.f_back.f_back
189
+ filepath = inspect.getfile(frame)
190
+ lineno = frame.f_lineno
191
+ return f'File: {filepath}, Line: {lineno}'
192
+ return ''
131
193
 
132
194
 
133
- def _debug_print(message: str):
195
+ def _debug_print(message: str) -> None:
134
196
  print(
135
197
  'DEBUG (test',
136
198
  os.environ.get('PYTEST_CURRENT_TEST'),
@@ -141,6 +203,28 @@ def _debug_print(message: str):
141
203
  )
142
204
 
143
205
 
206
+ def pop_undeterministic_headers(headers: dict[str, str]) -> None:
207
+ """Remove headers that are not deterministic."""
208
+ headers.pop('Date', None) # pytype: disable=attribute-error
209
+ headers.pop('Server-Timing', None) # pytype: disable=attribute-error
210
+
211
+
212
+ @contextlib.contextmanager
213
+ def _record_on_api_error(client: 'ReplayApiClient', http_request: HttpRequest) -> Iterator[None]:
214
+ try:
215
+ yield
216
+ except errors.APIError as e:
217
+ client._record_interaction(http_request, e)
218
+ raise e
219
+
220
+ @contextlib.asynccontextmanager
221
+ async def _async_record_on_api_error(client: 'ReplayApiClient', http_request: HttpRequest) -> AsyncIterator[None]:
222
+ try:
223
+ yield
224
+ except errors.APIError as e:
225
+ client._record_interaction(http_request, e)
226
+ raise e
227
+
144
228
  class ReplayRequest(BaseModel):
145
229
  """Represents a single request in a replay."""
146
230
 
@@ -160,10 +244,7 @@ class ReplayResponse(BaseModel):
160
244
  sdk_response_segments: list[dict[str, object]]
161
245
 
162
246
  def model_post_init(self, __context: Any) -> None:
163
- # Remove headers that are not deterministic so the replay files don't change
164
- # every time they are recorded.
165
- self.headers.pop('Date', None)
166
- self.headers.pop('Server-Timing', None)
247
+ pop_undeterministic_headers(self.headers)
167
248
 
168
249
 
169
250
  class ReplayInteraction(BaseModel):
@@ -194,6 +275,7 @@ class ReplayApiClient(BaseApiClient):
194
275
  project: Optional[str] = None,
195
276
  location: Optional[str] = None,
196
277
  http_options: Optional[HttpOptions] = None,
278
+ private: bool = False,
197
279
  ):
198
280
  super().__init__(
199
281
  vertexai=vertexai,
@@ -209,33 +291,34 @@ class ReplayApiClient(BaseApiClient):
209
291
  'GOOGLE_GENAI_REPLAYS_DIRECTORY', None
210
292
  )
211
293
  # Valid replay modes are replay-only or record-and-replay.
212
- self.replay_session = None
294
+ self.replay_session: Union[ReplayFile, None] = None
213
295
  self._mode = mode
214
296
  self._replay_id = replay_id
297
+ self._private = private
215
298
 
216
- def initialize_replay_session(self, replay_id: str):
299
+ def initialize_replay_session(self, replay_id: str) -> None:
217
300
  self._replay_id = replay_id
218
301
  self._initialize_replay_session()
219
302
 
220
- def _get_replay_file_path(self):
303
+ def _get_replay_file_path(self) -> str:
221
304
  return self._generate_file_path_from_replay_id(
222
305
  self.replays_directory, self._replay_id
223
306
  )
224
307
 
225
- def _should_call_api(self):
308
+ def _should_call_api(self) -> bool:
226
309
  return self._mode in ['record', 'api'] or (
227
310
  self._mode == 'auto'
228
311
  and not os.path.isfile(self._get_replay_file_path())
229
312
  )
230
313
 
231
- def _should_update_replay(self):
314
+ def _should_update_replay(self) -> bool:
232
315
  return self._should_call_api() and self._mode != 'api'
233
316
 
234
- def _initialize_replay_session_if_not_loaded(self):
317
+ def _initialize_replay_session_if_not_loaded(self) -> None:
235
318
  if not self.replay_session:
236
319
  self._initialize_replay_session()
237
320
 
238
- def _initialize_replay_session(self):
321
+ def _initialize_replay_session(self) -> None:
239
322
  _debug_print('Test is using replay id: ' + self._replay_id)
240
323
  self._replay_index = 0
241
324
  self._sdk_response_index = 0
@@ -256,7 +339,7 @@ class ReplayApiClient(BaseApiClient):
256
339
  replay_id=self._replay_id, interactions=[]
257
340
  )
258
341
 
259
- def _generate_file_path_from_replay_id(self, replay_directory, replay_id):
342
+ def _generate_file_path_from_replay_id(self, replay_directory: Optional[str], replay_id: str) -> str:
260
343
  session_parts = replay_id.split('/')
261
344
  if len(session_parts) < 3:
262
345
  raise ValueError(
@@ -270,7 +353,7 @@ class ReplayApiClient(BaseApiClient):
270
353
  path_parts.extend(session_parts)
271
354
  return os.path.join(*path_parts) + '.json'
272
355
 
273
- def close(self):
356
+ def close(self) -> None:
274
357
  if not self._should_update_replay() or not self.replay_session:
275
358
  return
276
359
  replay_file_path = self._get_replay_file_path()
@@ -283,7 +366,7 @@ class ReplayApiClient(BaseApiClient):
283
366
  self,
284
367
  http_request: HttpRequest,
285
368
  http_response: Union[HttpResponse, errors.APIError, bytes],
286
- ):
369
+ ) -> None:
287
370
  if not self._should_update_replay():
288
371
  return
289
372
  redact_http_request(http_request)
@@ -321,6 +404,8 @@ class ReplayApiClient(BaseApiClient):
321
404
  raise ValueError(
322
405
  'Unsupported http_response type: ' + str(type(http_response))
323
406
  )
407
+ if self.replay_session is None:
408
+ raise ValueError('No replay session found.')
324
409
  self.replay_session.interactions.append(
325
410
  ReplayInteraction(request=request, response=response)
326
411
  )
@@ -329,7 +414,9 @@ class ReplayApiClient(BaseApiClient):
329
414
  self,
330
415
  http_request: HttpRequest,
331
416
  interaction: ReplayInteraction,
332
- ):
417
+ ) -> None:
418
+ _debug_print(f'http_request.url: {http_request.url}')
419
+ _debug_print(f'interaction.request.url: {interaction.request.url}')
333
420
  assert http_request.url == interaction.request.url
334
421
  assert http_request.headers == interaction.request.headers, (
335
422
  'Request headers mismatch:\n'
@@ -342,26 +429,29 @@ class ReplayApiClient(BaseApiClient):
342
429
  request_data_copy = copy.deepcopy(http_request.data)
343
430
  # Both the request and recorded request must be redacted before comparing
344
431
  # so that the comparison is fair.
345
- _redact_request_body(request_data_copy)
432
+ if not isinstance(request_data_copy, bytes):
433
+ _redact_request_body(request_data_copy)
346
434
 
347
435
  actual_request_body = [request_data_copy]
348
436
  expected_request_body = interaction.request.body_segments
349
- assert actual_request_body == expected_request_body, (
437
+ assert _equals_ignore_key_case(actual_request_body, expected_request_body), (
350
438
  'Request body mismatch:\n'
351
439
  f'Actual: {actual_request_body}\n'
352
440
  f'Expected: {expected_request_body}'
353
441
  )
354
442
 
355
- def _build_response_from_replay(self, http_request: HttpRequest):
443
+ def _build_response_from_replay(self, http_request: HttpRequest) -> HttpResponse:
356
444
  redact_http_request(http_request)
357
445
 
446
+ if self.replay_session is None:
447
+ raise ValueError('No replay session found.')
358
448
  interaction = self.replay_session.interactions[self._replay_index]
359
449
  # Replay is on the right side of the assert so the diff makes more sense.
360
450
  self._match_request(http_request, interaction)
361
451
  self._replay_index += 1
362
452
  self._sdk_response_index = 0
363
453
  errors.APIError.raise_for_response(interaction.response)
364
- return HttpResponse(
454
+ http_response = HttpResponse(
365
455
  headers=interaction.response.headers,
366
456
  response_stream=[
367
457
  json.dumps(segment)
@@ -369,17 +459,29 @@ class ReplayApiClient(BaseApiClient):
369
459
  ],
370
460
  byte_stream=interaction.response.byte_segments,
371
461
  )
462
+ if http_response.response_stream == ['{}']:
463
+ http_response.response_stream = [""]
464
+ return http_response
372
465
 
373
- def _verify_response(self, response_model: BaseModel):
466
+ def _verify_response(self, response_model: BaseModel) -> None:
374
467
  if self._mode == 'api':
375
468
  return
469
+ if not self.replay_session:
470
+ raise ValueError('No replay session found.')
376
471
  # replay_index is advanced in _build_response_from_replay, so we need to -1.
377
472
  interaction = self.replay_session.interactions[self._replay_index - 1]
378
473
  if self._should_update_replay():
379
474
  if isinstance(response_model, list):
380
475
  response_model = response_model[0]
381
- if response_model and 'http_headers' in response_model.model_fields:
382
- response_model.http_headers.pop('Date', None)
476
+ sdk_response_response = getattr(response_model, 'sdk_http_response', None)
477
+ if response_model and (
478
+ sdk_response_response is not None
479
+ ):
480
+ headers = getattr(
481
+ sdk_response_response, 'headers', None
482
+ )
483
+ if headers:
484
+ pop_undeterministic_headers(headers)
383
485
  interaction.response.sdk_response_segments.append(
384
486
  response_model.model_dump(exclude_none=True)
385
487
  )
@@ -387,29 +489,46 @@ class ReplayApiClient(BaseApiClient):
387
489
 
388
490
  if isinstance(response_model, list):
389
491
  response_model = response_model[0]
390
- print('response_model: ', response_model.model_dump(exclude_none=True))
492
+ _debug_print(
493
+ f'response_model: {response_model.model_dump(exclude_none=True)}'
494
+ )
391
495
  actual = response_model.model_dump(exclude_none=True, mode='json')
392
496
  expected = interaction.response.sdk_response_segments[
393
497
  self._sdk_response_index
394
498
  ]
395
- assert (
396
- actual == expected
397
- ), f'SDK response mismatch:\nActual: {actual}\nExpected: {expected}'
499
+ # The sdk_http_response.body has format in the string, need to get rid of
500
+ # the format information before comparing.
501
+ if isinstance(expected, dict):
502
+ if 'sdk_http_response' in expected and isinstance(
503
+ expected['sdk_http_response'], dict
504
+ ):
505
+ if 'body' in expected['sdk_http_response']:
506
+ raw_body = expected['sdk_http_response']['body']
507
+ _debug_print(f'raw_body length: {len(raw_body)}')
508
+ _debug_print(f'raw_body: {raw_body}')
509
+ if isinstance(raw_body, str) and raw_body != '':
510
+ raw_body = json.loads(raw_body)
511
+ raw_body = json.dumps(raw_body)
512
+ expected['sdk_http_response']['body'] = raw_body
513
+ if not self._private:
514
+ assert (
515
+ actual == expected
516
+ ), f'SDK response mismatch:\nActual: {actual}\nExpected: {expected}'
517
+ else:
518
+ _debug_print(f'Expected SDK response mismatch:\nActual: {actual}\nExpected: {expected}')
398
519
  self._sdk_response_index += 1
399
520
 
400
521
  def _request(
401
522
  self,
402
523
  http_request: HttpRequest,
524
+ http_options: Optional[HttpOptionsOrDict] = None,
403
525
  stream: bool = False,
404
526
  ) -> HttpResponse:
405
527
  self._initialize_replay_session_if_not_loaded()
406
528
  if self._should_call_api():
407
529
  _debug_print('api mode request: %s' % http_request)
408
- try:
409
- result = super()._request(http_request, stream)
410
- except errors.APIError as e:
411
- self._record_interaction(http_request, e)
412
- raise e
530
+ with _record_on_api_error(self, http_request):
531
+ result = super()._request(http_request, http_options, stream)
413
532
  if stream:
414
533
  result_segments = []
415
534
  for segment in result.segments():
@@ -428,16 +547,16 @@ class ReplayApiClient(BaseApiClient):
428
547
  async def _async_request(
429
548
  self,
430
549
  http_request: HttpRequest,
550
+ http_options: Optional[HttpOptionsOrDict] = None,
431
551
  stream: bool = False,
432
552
  ) -> HttpResponse:
433
553
  self._initialize_replay_session_if_not_loaded()
434
554
  if self._should_call_api():
435
555
  _debug_print('api mode request: %s' % http_request)
436
- try:
437
- result = await super()._async_request(http_request, stream)
438
- except errors.APIError as e:
439
- self._record_interaction(http_request, e)
440
- raise e
556
+ async with _async_record_on_api_error(self, http_request):
557
+ result = await super()._async_request(
558
+ http_request, http_options, stream
559
+ )
441
560
  if stream:
442
561
  result_segments = []
443
562
  async for segment in result.async_segments():
@@ -453,7 +572,14 @@ class ReplayApiClient(BaseApiClient):
453
572
  else:
454
573
  return self._build_response_from_replay(http_request)
455
574
 
456
- def upload_file(self, file_path: Union[str, io.IOBase], upload_url: str, upload_size: int):
575
+ def upload_file(
576
+ self,
577
+ file_path: Union[str, io.IOBase],
578
+ upload_url: str,
579
+ upload_size: int,
580
+ *,
581
+ http_options: Optional[HttpOptionsOrDict] = None,
582
+ ) -> HttpResponse:
457
583
  if isinstance(file_path, io.IOBase):
458
584
  offset = file_path.tell()
459
585
  content = file_path.read()
@@ -470,25 +596,23 @@ class ReplayApiClient(BaseApiClient):
470
596
  )
471
597
  if self._should_call_api():
472
598
  result: Union[str, HttpResponse]
473
- try:
474
- result = super().upload_file(file_path, upload_url, upload_size)
475
- except HTTPError as e:
476
- result = HttpResponse(
477
- e.response.headers, [json.dumps({'reason': e.response.reason})]
599
+ with _record_on_api_error(self, request):
600
+ result = super().upload_file(
601
+ file_path, upload_url, upload_size, http_options=http_options
478
602
  )
479
- result.status_code = e.response.status_code
480
- raise e
481
- self._record_interaction(request, HttpResponse({}, [json.dumps(result)]))
603
+ self._record_interaction(request, result)
482
604
  return result
483
605
  else:
484
- return self._build_response_from_replay(request).json
606
+ return self._build_response_from_replay(request)
485
607
 
486
608
  async def async_upload_file(
487
609
  self,
488
610
  file_path: Union[str, io.IOBase],
489
611
  upload_url: str,
490
612
  upload_size: int,
491
- ) -> str:
613
+ *,
614
+ http_options: Optional[HttpOptionsOrDict] = None,
615
+ ) -> HttpResponse:
492
616
  if isinstance(file_path, io.IOBase):
493
617
  offset = file_path.tell()
494
618
  content = file_path.read()
@@ -504,55 +628,43 @@ class ReplayApiClient(BaseApiClient):
504
628
  method='POST', url='', data={'file_path': file_path}, headers={}
505
629
  )
506
630
  if self._should_call_api():
507
- result: Union[str, HttpResponse]
508
- try:
631
+ result: HttpResponse
632
+ async with _async_record_on_api_error(self, request):
509
633
  result = await super().async_upload_file(
510
- file_path, upload_url, upload_size
511
- )
512
- except HTTPError as e:
513
- result = HttpResponse(
514
- e.response.headers, [json.dumps({'reason': e.response.reason})]
634
+ file_path, upload_url, upload_size, http_options=http_options
515
635
  )
516
- result.status_code = e.response.status_code
517
- raise e
518
- self._record_interaction(request, HttpResponse({}, [json.dumps(result)]))
636
+ self._record_interaction(request, result)
519
637
  return result
520
638
  else:
521
- return self._build_response_from_replay(request).json
639
+ return self._build_response_from_replay(request)
522
640
 
523
- def download_file(self, path: str, http_options: HttpOptions):
641
+ def download_file(
642
+ self, path: str, *, http_options: Optional[HttpOptionsOrDict] = None
643
+ ) -> Union[HttpResponse, bytes, Any]:
524
644
  self._initialize_replay_session_if_not_loaded()
525
645
  request = self._build_request(
526
646
  'get', path=path, request_dict={}, http_options=http_options
527
647
  )
528
648
  if self._should_call_api():
529
- try:
530
- result = super().download_file(path, http_options)
531
- except HTTPError as e:
532
- result = HttpResponse(
533
- e.response.headers, [json.dumps({'reason': e.response.reason})]
534
- )
535
- result.status_code = e.response.status_code
536
- raise e
649
+ with _record_on_api_error(self, request):
650
+ result = super().download_file(path, http_options=http_options)
537
651
  self._record_interaction(request, result)
538
652
  return result
539
653
  else:
540
654
  return self._build_response_from_replay(request).byte_stream[0]
541
655
 
542
- async def async_download_file(self, path: str, http_options):
656
+ async def async_download_file(
657
+ self, path: str, *, http_options: Optional[HttpOptionsOrDict] = None
658
+ ) -> Any:
543
659
  self._initialize_replay_session_if_not_loaded()
544
660
  request = self._build_request(
545
661
  'get', path=path, request_dict={}, http_options=http_options
546
662
  )
547
663
  if self._should_call_api():
548
- try:
549
- result = await super().async_download_file(path, http_options)
550
- except HTTPError as e:
551
- result = HttpResponse(
552
- e.response.headers, [json.dumps({'reason': e.response.reason})]
664
+ async with _async_record_on_api_error(self, request):
665
+ result = await super().async_download_file(
666
+ path, http_options=http_options
553
667
  )
554
- result.status_code = e.response.status_code
555
- raise e
556
668
  self._record_interaction(request, result)
557
669
  return result
558
670
  else:
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Google LLC
1
+ # Copyright 2025 Google LLC
2
2
  #
3
3
  # Licensed under the Apache License, Version 2.0 (the "License");
4
4
  # you may not use this file except in compliance with the License.