modal 1.0.3.dev10__py3-none-any.whl → 1.2.3.dev7__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.

Potentially problematic release.


This version of modal might be problematic. Click here for more details.

Files changed (160) hide show
  1. modal/__init__.py +0 -2
  2. modal/__main__.py +3 -4
  3. modal/_billing.py +80 -0
  4. modal/_clustered_functions.py +7 -3
  5. modal/_clustered_functions.pyi +15 -3
  6. modal/_container_entrypoint.py +51 -69
  7. modal/_functions.py +508 -240
  8. modal/_grpc_client.py +171 -0
  9. modal/_load_context.py +105 -0
  10. modal/_object.py +81 -21
  11. modal/_output.py +58 -45
  12. modal/_partial_function.py +48 -73
  13. modal/_pty.py +7 -3
  14. modal/_resolver.py +26 -46
  15. modal/_runtime/asgi.py +4 -3
  16. modal/_runtime/container_io_manager.py +358 -220
  17. modal/_runtime/container_io_manager.pyi +296 -101
  18. modal/_runtime/execution_context.py +18 -2
  19. modal/_runtime/execution_context.pyi +64 -7
  20. modal/_runtime/gpu_memory_snapshot.py +262 -57
  21. modal/_runtime/user_code_imports.py +28 -58
  22. modal/_serialization.py +90 -6
  23. modal/_traceback.py +42 -1
  24. modal/_tunnel.pyi +380 -12
  25. modal/_utils/async_utils.py +84 -29
  26. modal/_utils/auth_token_manager.py +111 -0
  27. modal/_utils/blob_utils.py +181 -58
  28. modal/_utils/deprecation.py +19 -0
  29. modal/_utils/function_utils.py +91 -47
  30. modal/_utils/grpc_utils.py +89 -66
  31. modal/_utils/mount_utils.py +26 -1
  32. modal/_utils/name_utils.py +17 -3
  33. modal/_utils/task_command_router_client.py +536 -0
  34. modal/_utils/time_utils.py +34 -6
  35. modal/app.py +256 -88
  36. modal/app.pyi +909 -92
  37. modal/billing.py +5 -0
  38. modal/builder/2025.06.txt +18 -0
  39. modal/builder/PREVIEW.txt +18 -0
  40. modal/builder/base-images.json +58 -0
  41. modal/cli/_download.py +19 -3
  42. modal/cli/_traceback.py +3 -2
  43. modal/cli/app.py +4 -4
  44. modal/cli/cluster.py +15 -7
  45. modal/cli/config.py +5 -3
  46. modal/cli/container.py +7 -6
  47. modal/cli/dict.py +22 -16
  48. modal/cli/entry_point.py +12 -5
  49. modal/cli/environment.py +5 -4
  50. modal/cli/import_refs.py +3 -3
  51. modal/cli/launch.py +102 -5
  52. modal/cli/network_file_system.py +11 -12
  53. modal/cli/profile.py +3 -2
  54. modal/cli/programs/launch_instance_ssh.py +94 -0
  55. modal/cli/programs/run_jupyter.py +1 -1
  56. modal/cli/programs/run_marimo.py +95 -0
  57. modal/cli/programs/vscode.py +1 -1
  58. modal/cli/queues.py +57 -26
  59. modal/cli/run.py +91 -23
  60. modal/cli/secret.py +48 -22
  61. modal/cli/token.py +7 -8
  62. modal/cli/utils.py +4 -7
  63. modal/cli/volume.py +31 -25
  64. modal/client.py +15 -85
  65. modal/client.pyi +183 -62
  66. modal/cloud_bucket_mount.py +5 -3
  67. modal/cloud_bucket_mount.pyi +197 -5
  68. modal/cls.py +200 -126
  69. modal/cls.pyi +446 -68
  70. modal/config.py +29 -11
  71. modal/container_process.py +319 -19
  72. modal/container_process.pyi +190 -20
  73. modal/dict.py +290 -71
  74. modal/dict.pyi +835 -83
  75. modal/environments.py +15 -27
  76. modal/environments.pyi +46 -24
  77. modal/exception.py +14 -2
  78. modal/experimental/__init__.py +194 -40
  79. modal/experimental/flash.py +618 -0
  80. modal/experimental/flash.pyi +380 -0
  81. modal/experimental/ipython.py +11 -7
  82. modal/file_io.py +29 -36
  83. modal/file_io.pyi +251 -53
  84. modal/file_pattern_matcher.py +56 -16
  85. modal/functions.pyi +673 -92
  86. modal/gpu.py +1 -1
  87. modal/image.py +528 -176
  88. modal/image.pyi +1572 -145
  89. modal/io_streams.py +458 -128
  90. modal/io_streams.pyi +433 -52
  91. modal/mount.py +216 -151
  92. modal/mount.pyi +225 -78
  93. modal/network_file_system.py +45 -62
  94. modal/network_file_system.pyi +277 -56
  95. modal/object.pyi +93 -17
  96. modal/parallel_map.py +942 -129
  97. modal/parallel_map.pyi +294 -15
  98. modal/partial_function.py +0 -2
  99. modal/partial_function.pyi +234 -19
  100. modal/proxy.py +17 -8
  101. modal/proxy.pyi +36 -3
  102. modal/queue.py +270 -65
  103. modal/queue.pyi +817 -57
  104. modal/runner.py +115 -101
  105. modal/runner.pyi +205 -49
  106. modal/sandbox.py +512 -136
  107. modal/sandbox.pyi +845 -111
  108. modal/schedule.py +1 -1
  109. modal/secret.py +300 -70
  110. modal/secret.pyi +589 -34
  111. modal/serving.py +7 -11
  112. modal/serving.pyi +7 -8
  113. modal/snapshot.py +11 -8
  114. modal/snapshot.pyi +25 -4
  115. modal/token_flow.py +4 -4
  116. modal/token_flow.pyi +28 -8
  117. modal/volume.py +416 -158
  118. modal/volume.pyi +1117 -121
  119. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +10 -9
  120. modal-1.2.3.dev7.dist-info/RECORD +195 -0
  121. modal_docs/mdmd/mdmd.py +17 -4
  122. modal_proto/api.proto +534 -79
  123. modal_proto/api_grpc.py +337 -1
  124. modal_proto/api_pb2.py +1522 -968
  125. modal_proto/api_pb2.pyi +1619 -134
  126. modal_proto/api_pb2_grpc.py +699 -4
  127. modal_proto/api_pb2_grpc.pyi +226 -14
  128. modal_proto/modal_api_grpc.py +175 -154
  129. modal_proto/sandbox_router.proto +145 -0
  130. modal_proto/sandbox_router_grpc.py +105 -0
  131. modal_proto/sandbox_router_pb2.py +149 -0
  132. modal_proto/sandbox_router_pb2.pyi +333 -0
  133. modal_proto/sandbox_router_pb2_grpc.py +203 -0
  134. modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
  135. modal_proto/task_command_router.proto +144 -0
  136. modal_proto/task_command_router_grpc.py +105 -0
  137. modal_proto/task_command_router_pb2.py +149 -0
  138. modal_proto/task_command_router_pb2.pyi +333 -0
  139. modal_proto/task_command_router_pb2_grpc.py +203 -0
  140. modal_proto/task_command_router_pb2_grpc.pyi +75 -0
  141. modal_version/__init__.py +1 -1
  142. modal/requirements/PREVIEW.txt +0 -16
  143. modal/requirements/base-images.json +0 -26
  144. modal-1.0.3.dev10.dist-info/RECORD +0 -179
  145. modal_proto/modal_options_grpc.py +0 -3
  146. modal_proto/options.proto +0 -19
  147. modal_proto/options_grpc.py +0 -3
  148. modal_proto/options_pb2.py +0 -35
  149. modal_proto/options_pb2.pyi +0 -20
  150. modal_proto/options_pb2_grpc.py +0 -4
  151. modal_proto/options_pb2_grpc.pyi +0 -7
  152. /modal/{requirements → builder}/2023.12.312.txt +0 -0
  153. /modal/{requirements → builder}/2023.12.txt +0 -0
  154. /modal/{requirements → builder}/2024.04.txt +0 -0
  155. /modal/{requirements → builder}/2024.10.txt +0 -0
  156. /modal/{requirements → builder}/README.md +0 -0
  157. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
  158. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
  159. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
  160. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
@@ -4,6 +4,7 @@ import dataclasses
4
4
  import hashlib
5
5
  import os
6
6
  import platform
7
+ import random
7
8
  import time
8
9
  from collections.abc import AsyncIterator
9
10
  from contextlib import AbstractContextManager, contextmanager
@@ -26,7 +27,6 @@ from modal_proto.modal_api_grpc import ModalClientModal
26
27
 
27
28
  from ..exception import ExecutionError
28
29
  from .async_utils import TaskContext, retry
29
- from .grpc_utils import retry_transient_errors
30
30
  from .hash_utils import UploadHashes, get_upload_hashes
31
31
  from .http_utils import ClientSessionRegistry
32
32
  from .logger import logger
@@ -37,12 +37,15 @@ if TYPE_CHECKING:
37
37
  # Max size for function inputs and outputs.
38
38
  MAX_OBJECT_SIZE_BYTES = 2 * 1024 * 1024 # 2 MiB
39
39
 
40
+ # Max size for async function inputs and outputs.
41
+ MAX_ASYNC_OBJECT_SIZE_BYTES = 8 * 1024 # 8 KiB
42
+
40
43
  # If a file is LARGE_FILE_LIMIT bytes or larger, it's uploaded to blob store (s3) instead of going through grpc
41
44
  # It will also make sure to chunk the hash calculation to avoid reading the entire file into memory
42
45
  LARGE_FILE_LIMIT = 4 * 1024 * 1024 # 4 MiB
43
46
 
44
47
  # Max parallelism during map calls
45
- BLOB_MAX_PARALLELISM = 10
48
+ BLOB_MAX_PARALLELISM = 20
46
49
 
47
50
  # read ~16MiB chunks by default
48
51
  DEFAULT_SEGMENT_CHUNK_SIZE = 2**24
@@ -55,6 +58,8 @@ MULTIPART_UPLOAD_THRESHOLD = 1024**3
55
58
  # For block based storage like volumefs2: the size of a block
56
59
  BLOCK_SIZE: int = 8 * 1024 * 1024
57
60
 
61
+ HEALTHY_R2_UPLOAD_PERCENTAGE = 0.95
62
+
58
63
 
59
64
  @retry(n_attempts=5, base_delay=0.5, timeout=None)
60
65
  async def _upload_to_s3_url(
@@ -79,7 +84,7 @@ async def _upload_to_s3_url(
79
84
  ) as resp:
80
85
  # S3 signal to slow down request rate.
81
86
  if resp.status == 503:
82
- logger.warning("Received SlowDown signal from S3, sleeping for 1 second before retrying.")
87
+ logger.debug("Received SlowDown signal from S3, sleeping for 1 second before retrying.")
83
88
  await asyncio.sleep(1)
84
89
 
85
90
  if resp.status != 200:
@@ -182,9 +187,37 @@ def get_content_length(data: BinaryIO) -> int:
182
187
  return content_length - pos
183
188
 
184
189
 
190
+ async def _blob_upload_with_fallback(
191
+ items, blob_ids: list[str], callback, content_length: int
192
+ ) -> tuple[str, bool, int]:
193
+ r2_throughput_bytes_s = 0
194
+ r2_failed = False
195
+ for idx, (item, blob_id) in enumerate(zip(items, blob_ids)):
196
+ # We want to default to R2 95% of the time and S3 5% of the time.
197
+ # To ensure the failure path is continuously exercised.
198
+ if idx == 0 and len(items) > 1 and random.random() > HEALTHY_R2_UPLOAD_PERCENTAGE:
199
+ continue
200
+ try:
201
+ if blob_id.endswith(":r2"):
202
+ t0 = time.monotonic_ns()
203
+ await callback(item)
204
+ dt_ns = time.monotonic_ns() - t0
205
+ r2_throughput_bytes_s = (content_length * 1_000_000_000) // max(dt_ns, 1)
206
+ else:
207
+ await callback(item)
208
+ return blob_id, r2_failed, r2_throughput_bytes_s
209
+ except Exception as _:
210
+ if blob_id.endswith(":r2"):
211
+ r2_failed = True
212
+ # Ignore all errors except the last one, since we're out of fallback options.
213
+ if idx == len(items) - 1:
214
+ raise
215
+ raise ExecutionError("Failed to upload blob")
216
+
217
+
185
218
  async def _blob_upload(
186
219
  upload_hashes: UploadHashes, data: Union[bytes, BinaryIO], stub, progress_report_cb: Optional[Callable] = None
187
- ) -> str:
220
+ ) -> tuple[str, bool, int]:
188
221
  if isinstance(data, bytes):
189
222
  data = BytesIO(data)
190
223
 
@@ -195,19 +228,26 @@ async def _blob_upload(
195
228
  content_sha256_base64=upload_hashes.sha256_base64,
196
229
  content_length=content_length,
197
230
  )
198
- resp = await retry_transient_errors(stub.BlobCreate, req)
199
-
200
- blob_id = resp.blob_id
231
+ resp = await stub.BlobCreate(req)
232
+
233
+ if resp.WhichOneof("upload_types_oneof") == "multiparts":
234
+
235
+ async def upload_multipart_upload(part):
236
+ return await perform_multipart_upload(
237
+ data,
238
+ content_length=content_length,
239
+ max_part_size=part.part_length,
240
+ part_urls=part.upload_urls,
241
+ completion_url=part.completion_url,
242
+ upload_chunk_size=DEFAULT_SEGMENT_CHUNK_SIZE,
243
+ progress_report_cb=progress_report_cb,
244
+ )
201
245
 
202
- if resp.WhichOneof("upload_type_oneof") == "multipart":
203
- await perform_multipart_upload(
204
- data,
246
+ blob_id, r2_failed, r2_throughput_bytes_s = await _blob_upload_with_fallback(
247
+ resp.multiparts.items,
248
+ resp.blob_ids,
249
+ upload_multipart_upload,
205
250
  content_length=content_length,
206
- max_part_size=resp.multipart.part_length,
207
- part_urls=resp.multipart.upload_urls,
208
- completion_url=resp.multipart.completion_url,
209
- upload_chunk_size=DEFAULT_SEGMENT_CHUNK_SIZE,
210
- progress_report_cb=progress_report_cb,
211
251
  )
212
252
  else:
213
253
  from .bytes_io_segment_payload import BytesIOSegmentPayload
@@ -215,34 +255,54 @@ async def _blob_upload(
215
255
  payload = BytesIOSegmentPayload(
216
256
  data, segment_start=0, segment_length=content_length, progress_report_cb=progress_report_cb
217
257
  )
218
- await _upload_to_s3_url(
219
- resp.upload_url,
220
- payload,
221
- # for single part uploads, we use server side md5 checksums
222
- content_md5_b64=upload_hashes.md5_base64,
258
+
259
+ async def upload_to_s3_url(url):
260
+ return await _upload_to_s3_url(
261
+ url,
262
+ payload,
263
+ # for single part uploads, we use server side md5 checksums
264
+ content_md5_b64=upload_hashes.md5_base64,
265
+ )
266
+
267
+ blob_id, r2_failed, r2_throughput_bytes_s = await _blob_upload_with_fallback(
268
+ resp.upload_urls.items,
269
+ resp.blob_ids,
270
+ upload_to_s3_url,
271
+ content_length=content_length,
223
272
  )
224
273
 
225
274
  if progress_report_cb:
226
275
  progress_report_cb(complete=True)
227
276
 
228
- return blob_id
277
+ return blob_id, r2_failed, r2_throughput_bytes_s
229
278
 
230
279
 
231
- async def blob_upload(payload: bytes, stub: ModalClientModal) -> str:
280
+ async def blob_upload_with_r2_failure_info(payload: bytes, stub: ModalClientModal) -> tuple[str, bool, int]:
232
281
  size_mib = len(payload) / 1024 / 1024
233
282
  logger.debug(f"Uploading large blob of size {size_mib:.2f} MiB")
234
283
  t0 = time.time()
235
284
  if isinstance(payload, str):
236
- logger.warning("Blob uploading string, not bytes - auto-encoding as utf8")
285
+ logger.debug("Blob uploading string, not bytes - auto-encoding as utf8")
237
286
  payload = payload.encode("utf8")
238
287
  upload_hashes = get_upload_hashes(payload)
239
- blob_id = await _blob_upload(upload_hashes, payload, stub)
288
+ blob_id, r2_failed, r2_throughput_bytes_s = await _blob_upload(upload_hashes, payload, stub)
240
289
  dur_s = max(time.time() - t0, 0.001) # avoid division by zero
241
290
  throughput_mib_s = (size_mib) / dur_s
242
- logger.debug(f"Uploaded large blob of size {size_mib:.2f} MiB ({throughput_mib_s:.2f} MiB/s). {blob_id}")
291
+ logger.debug(
292
+ f"Uploaded large blob of size {size_mib:.2f} MiB ({throughput_mib_s:.2f} MiB/s, total {dur_s:.2f}s). {blob_id}"
293
+ )
294
+ return blob_id, r2_failed, r2_throughput_bytes_s
295
+
296
+
297
+ async def blob_upload(payload: bytes, stub: ModalClientModal) -> str:
298
+ blob_id, _, _ = await blob_upload_with_r2_failure_info(payload, stub)
243
299
  return blob_id
244
300
 
245
301
 
302
+ async def format_blob_data(data: bytes, api_stub: ModalClientModal) -> dict[str, Any]:
303
+ return {"data_blob_id": await blob_upload(data, api_stub)} if len(data) > MAX_OBJECT_SIZE_BYTES else {"data": data}
304
+
305
+
246
306
  async def blob_upload_file(
247
307
  file_obj: BinaryIO,
248
308
  stub: ModalClientModal,
@@ -251,7 +311,8 @@ async def blob_upload_file(
251
311
  md5_hex: Optional[str] = None,
252
312
  ) -> str:
253
313
  upload_hashes = get_upload_hashes(file_obj, sha256_hex=sha256_hex, md5_hex=md5_hex)
254
- return await _blob_upload(upload_hashes, file_obj, stub, progress_report_cb)
314
+ blob_id, _, _ = await _blob_upload(upload_hashes, file_obj, stub, progress_report_cb)
315
+ return blob_id
255
316
 
256
317
 
257
318
  @retry(n_attempts=5, base_delay=0.1, timeout=None)
@@ -259,7 +320,7 @@ async def _download_from_url(download_url: str) -> bytes:
259
320
  async with ClientSessionRegistry.get_session().get(download_url) as s3_resp:
260
321
  # S3 signal to slow down request rate.
261
322
  if s3_resp.status == 503:
262
- logger.warning("Received SlowDown signal from S3, sleeping for 1 second before retrying.")
323
+ logger.debug("Received SlowDown signal from S3, sleeping for 1 second before retrying.")
263
324
  await asyncio.sleep(1)
264
325
 
265
326
  if s3_resp.status != 200:
@@ -273,23 +334,25 @@ async def blob_download(blob_id: str, stub: ModalClientModal) -> bytes:
273
334
  logger.debug(f"Downloading large blob {blob_id}")
274
335
  t0 = time.time()
275
336
  req = api_pb2.BlobGetRequest(blob_id=blob_id)
276
- resp = await retry_transient_errors(stub.BlobGet, req)
337
+ resp = await stub.BlobGet(req)
277
338
  data = await _download_from_url(resp.download_url)
278
339
  size_mib = len(data) / 1024 / 1024
279
340
  dur_s = max(time.time() - t0, 0.001) # avoid division by zero
280
341
  throughput_mib_s = size_mib / dur_s
281
- logger.debug(f"Downloaded large blob {blob_id} of size {size_mib:.2f} MiB ({throughput_mib_s:.2f} MiB/s)")
342
+ logger.debug(
343
+ f"Downloaded large blob {blob_id} of size {size_mib:.2f} MiB ({throughput_mib_s:.2f} MiB/s, total {dur_s:.2f}s)"
344
+ )
282
345
  return data
283
346
 
284
347
 
285
348
  async def blob_iter(blob_id: str, stub: ModalClientModal) -> AsyncIterator[bytes]:
286
349
  req = api_pb2.BlobGetRequest(blob_id=blob_id)
287
- resp = await retry_transient_errors(stub.BlobGet, req)
350
+ resp = await stub.BlobGet(req)
288
351
  download_url = resp.download_url
289
352
  async with ClientSessionRegistry.get_session().get(download_url) as s3_resp:
290
353
  # S3 signal to slow down request rate.
291
354
  if s3_resp.status == 503:
292
- logger.warning("Received SlowDown signal from S3, sleeping for 1 second before retrying.")
355
+ logger.debug("Received SlowDown signal from S3, sleeping for 1 second before retrying.")
293
356
  await asyncio.sleep(1)
294
357
 
295
358
  if s3_resp.status != 200:
@@ -380,20 +443,31 @@ def get_file_upload_spec_from_fileobj(fp: BinaryIO, mount_filename: PurePosixPat
380
443
  mode,
381
444
  )
382
445
 
446
+
383
447
  _FileUploadSource2 = Callable[[], ContextManager[BinaryIO]]
384
448
 
449
+
450
+ @dataclasses.dataclass
451
+ class FileUploadBlock:
452
+ # The start (byte offset, inclusive) of the block within the file
453
+ start: int
454
+ # The end (byte offset, exclusive) of the block, after having removed any trailing zeroes
455
+ end: int
456
+ # Raw (unencoded 32 byte) SHA256 sum of the block, not including trailing zeroes
457
+ contents_sha256: bytes
458
+
459
+
385
460
  @dataclasses.dataclass
386
461
  class FileUploadSpec2:
387
462
  source: _FileUploadSource2
388
463
  source_description: Union[str, Path]
389
464
 
390
465
  path: str
391
- # Raw (unencoded 32 byte) SHA256 sum per 8MiB file block
392
- blocks_sha256: list[bytes]
466
+ # 8MiB file blocks
467
+ blocks: list[FileUploadBlock]
393
468
  mode: int # file permission bits (last 12 bits of st_mode)
394
469
  size: int
395
470
 
396
-
397
471
  @staticmethod
398
472
  async def from_path(
399
473
  filename: Path,
@@ -416,7 +490,6 @@ class FileUploadSpec2:
416
490
  hash_semaphore,
417
491
  )
418
492
 
419
-
420
493
  @staticmethod
421
494
  async def from_fileobj(
422
495
  source_fp: Union[BinaryIO, BytesIO],
@@ -426,6 +499,7 @@ class FileUploadSpec2:
426
499
  ) -> "FileUploadSpec2":
427
500
  try:
428
501
  fileno = source_fp.fileno()
502
+
429
503
  def source():
430
504
  new_fd = os.dup(fileno)
431
505
  fp = os.fdopen(new_fd, "rb")
@@ -436,6 +510,7 @@ class FileUploadSpec2:
436
510
  # `.fileno()` not available; assume BytesIO-like type
437
511
  source_fp = cast(BytesIO, source_fp)
438
512
  buffer = source_fp.getbuffer()
513
+
439
514
  def source():
440
515
  return BytesIO(buffer)
441
516
 
@@ -447,7 +522,6 @@ class FileUploadSpec2:
447
522
  hash_semaphore,
448
523
  )
449
524
 
450
-
451
525
  @staticmethod
452
526
  async def _create(
453
527
  source: _FileUploadSource2,
@@ -461,53 +535,102 @@ class FileUploadSpec2:
461
535
  source_fp.seek(0, os.SEEK_END)
462
536
  size = source_fp.tell()
463
537
 
464
- blocks_sha256 = await hash_blocks_sha256(source, size, hash_semaphore)
538
+ blocks = await _gather_blocks(source, size, hash_semaphore)
465
539
 
466
540
  return FileUploadSpec2(
467
541
  source=source,
468
542
  source_description=source_description,
469
543
  path=mount_filename.as_posix(),
470
- blocks_sha256=blocks_sha256,
544
+ blocks=blocks,
471
545
  mode=mode & 0o7777,
472
546
  size=size,
473
547
  )
474
548
 
475
549
 
476
- async def hash_blocks_sha256(
550
+ async def _gather_blocks(
477
551
  source: _FileUploadSource2,
478
552
  size: int,
479
553
  hash_semaphore: asyncio.Semaphore,
480
- ) -> list[bytes]:
554
+ ) -> list[FileUploadBlock]:
481
555
  def ceildiv(a: int, b: int) -> int:
482
556
  return -(a // -b)
483
557
 
484
558
  num_blocks = ceildiv(size, BLOCK_SIZE)
485
559
 
486
- def blocking_hash_block_sha256(block_idx: int) -> bytes:
487
- sha256_hash = hashlib.sha256()
488
- block_start = block_idx * BLOCK_SIZE
560
+ async def gather_block(block_idx: int) -> FileUploadBlock:
561
+ async with hash_semaphore:
562
+ return await asyncio.to_thread(_gather_block, source, block_idx)
489
563
 
490
- with source() as block_fp:
491
- block_fp.seek(block_start)
564
+ tasks = (gather_block(idx) for idx in range(num_blocks))
565
+ return await asyncio.gather(*tasks)
492
566
 
493
- num_bytes_read = 0
494
- while num_bytes_read < BLOCK_SIZE:
495
- chunk = block_fp.read(BLOCK_SIZE - num_bytes_read)
496
567
 
497
- if not chunk:
498
- break
568
+ def _gather_block(source: _FileUploadSource2, block_idx: int) -> FileUploadBlock:
569
+ start = block_idx * BLOCK_SIZE
570
+ end = _find_end_of_block(source, start, start + BLOCK_SIZE)
571
+ contents_sha256 = _hash_range_sha256(source, start, end)
572
+ return FileUploadBlock(start=start, end=end, contents_sha256=contents_sha256)
499
573
 
500
- num_bytes_read += len(chunk)
501
- sha256_hash.update(chunk)
502
574
 
503
- return sha256_hash.digest()
575
+ def _hash_range_sha256(source: _FileUploadSource2, start, end):
576
+ sha256_hash = hashlib.sha256()
577
+ range_size = end - start
504
578
 
505
- async def hash_block_sha256(block_idx: int) -> bytes:
506
- async with hash_semaphore:
507
- return await asyncio.to_thread(blocking_hash_block_sha256, block_idx)
579
+ with source() as fp:
580
+ fp.seek(start)
581
+
582
+ num_bytes_read = 0
583
+ while num_bytes_read < range_size:
584
+ chunk = fp.read(range_size - num_bytes_read)
585
+
586
+ if not chunk:
587
+ break
588
+
589
+ num_bytes_read += len(chunk)
590
+ sha256_hash.update(chunk)
591
+
592
+ return sha256_hash.digest()
593
+
594
+
595
+ def _find_end_of_block(source: _FileUploadSource2, start: int, end: int) -> Optional[int]:
596
+ """Finds the appropriate end of a block, which is the index of the byte just past the last non-zero byte in the
597
+ block.
598
+
599
+ >>> _find_end_of_block(lambda: BytesIO(b"abc123\0\0\0"), 0, 1024)
600
+ 6
601
+ >>> _find_end_of_block(lambda: BytesIO(b"abc123\0\0\0"), 3, 1024)
602
+ 6
603
+ >>> _find_end_of_block(lambda: BytesIO(b"abc123\0\0\0"), 0, 3)
604
+ 4
605
+ >>> _find_end_of_block(lambda: BytesIO(b"abc123\0\0\0a"), 0, 9)
606
+ 6
607
+ >>> _find_end_of_block(lambda: BytesIO(b"\0\0\0"), 0, 3)
608
+ 0
609
+ >>> _find_end_of_block(lambda: BytesIO(b"\0\0\0\0\0\0"), 3, 6)
610
+ 3
611
+ >>> _find_end_of_block(lambda: BytesIO(b""), 0, 1024)
612
+ 0
613
+ """
614
+ size = end - start
615
+ new_end = start
508
616
 
509
- tasks = (hash_block_sha256(idx) for idx in range(num_blocks))
510
- return await asyncio.gather(*tasks)
617
+ with source() as block_fp:
618
+ block_fp.seek(start)
619
+
620
+ num_bytes_read = 0
621
+ while num_bytes_read < size:
622
+ chunk = block_fp.read(size - num_bytes_read)
623
+
624
+ if not chunk:
625
+ break
626
+
627
+ stripped_chunk = chunk.rstrip(b"\0")
628
+ if stripped_chunk:
629
+ new_end = start + num_bytes_read + len(stripped_chunk)
630
+
631
+ num_bytes_read += len(chunk)
632
+
633
+ return new_end
511
634
 
512
635
 
513
636
  def use_md5(url: str) -> bool:
@@ -122,3 +122,22 @@ def warn_on_renamed_autoscaler_settings(func: Callable[P, R]) -> Callable[P, R]:
122
122
  return func(*args, **kwargs)
123
123
 
124
124
  return wrapper
125
+
126
+
127
+ def warn_if_passing_namespace(
128
+ namespace: Any,
129
+ resource_name: str,
130
+ ) -> None:
131
+ """Issue deprecation warning for namespace parameter if non-None value is passed.
132
+
133
+ Args:
134
+ namespace: The namespace parameter value (may be None or actual value)
135
+ resource_name: Name of the resource type for the warning message
136
+ """
137
+ if namespace is not None:
138
+ deprecation_warning(
139
+ (2025, 6, 30),
140
+ f"The `namespace` parameter for `{resource_name}` is deprecated and will be"
141
+ " removed in a future release. It is no longer needed, so can be removed"
142
+ " from your code.",
143
+ )