1111- Automatic session lifecycle management
1212- Thread-safe session access
1313- Retry logic (3 attempts per chunk, 10s timeout)
14- - Progress tracking
14+ - Progress tracking with 2-decimal precision
15+ - Download speed reporting
1516- Resume support via Range headers
1617
1718Example:
2021 # Download to memory
2122 data = await DownloadManager.download_url("https://api.example.com/data.json")
2223
23- # Download to file with progress
24- async def progress(pct):
25- print(f"{pct}%")
24+ # Download to file with progress and speed
25+ async def on_progress(pct):
26+ print(f"{pct:.2f}%") # e.g., "45.67%"
27+
28+ async def on_speed(speed_bps):
29+ print(f"{speed_bps / 1024:.1f} KB/s")
2630
2731 success = await DownloadManager.download_url(
2832 "https://example.com/file.bin",
2933 outfile="/sdcard/file.bin",
30- progress_callback=progress
34+ progress_callback=on_progress,
35+ speed_callback=on_speed
3136 )
3237
3338 # Stream processing
@@ -46,6 +51,7 @@ async def process_chunk(chunk):
4651_DEFAULT_TOTAL_SIZE = 100 * 1024 # 100KB default if Content-Length missing
4752_MAX_RETRIES = 3 # Retry attempts per chunk
4853_CHUNK_TIMEOUT_SECONDS = 10 # Timeout per chunk read
54+ _SPEED_UPDATE_INTERVAL_MS = 1000 # Update speed every 1 second
4955
5056# Module-level state (singleton pattern)
5157_session = None
@@ -169,7 +175,8 @@ async def close_session():
169175
170176
171177async def download_url (url , outfile = None , total_size = None ,
172- progress_callback = None , chunk_callback = None , headers = None ):
178+ progress_callback = None , chunk_callback = None , headers = None ,
179+ speed_callback = None ):
173180 """Download a URL with flexible output modes.
174181
175182 This async download function can be used in 3 ways:
@@ -182,11 +189,14 @@ async def download_url(url, outfile=None, total_size=None,
182189 outfile (str, optional): Path to write file. If None, returns bytes.
183190 total_size (int, optional): Expected size in bytes for progress tracking.
184191 If None, uses Content-Length header or defaults to 100KB.
185- progress_callback (coroutine, optional): async def callback(percent: int)
186- Called with progress 0-100.
192+ progress_callback (coroutine, optional): async def callback(percent: float)
193+ Called with progress 0.00-100.00 (2 decimal places).
194+ Only called when progress changes by at least 0.01%.
187195 chunk_callback (coroutine, optional): async def callback(chunk: bytes)
188196 Called for each chunk. Cannot use with outfile.
189197 headers (dict, optional): HTTP headers (e.g., {'Range': 'bytes=1000-'})
198+ speed_callback (coroutine, optional): async def callback(bytes_per_second: float)
199+ Called periodically (every ~1 second) with download speed.
190200
191201 Returns:
192202 bytes: Downloaded content (if outfile and chunk_callback are None)
@@ -199,14 +209,18 @@ async def download_url(url, outfile=None, total_size=None,
199209 # Download to memory
200210 data = await DownloadManager.download_url("https://example.com/file.json")
201211
202- # Download to file with progress
212+ # Download to file with progress and speed
203213 async def on_progress(percent):
204- print(f"Progress: {percent}%")
214+ print(f"Progress: {percent:.2f}%")
215+
216+ async def on_speed(bps):
217+ print(f"Speed: {bps / 1024:.1f} KB/s")
205218
206219 success = await DownloadManager.download_url(
207220 "https://example.com/large.bin",
208221 outfile="/sdcard/large.bin",
209- progress_callback=on_progress
222+ progress_callback=on_progress,
223+ speed_callback=on_speed
210224 )
211225
212226 # Stream processing
@@ -282,6 +296,18 @@ async def on_chunk(chunk):
282296 chunks = []
283297 partial_size = 0
284298 chunk_size = _DEFAULT_CHUNK_SIZE
299+
300+ # Progress tracking with 2-decimal precision
301+ last_progress_pct = - 1.0 # Track last reported progress to avoid duplicates
302+
303+ # Speed tracking
304+ speed_bytes_since_last_update = 0
305+ speed_last_update_time = None
306+ try :
307+ import time
308+ speed_last_update_time = time .ticks_ms ()
309+ except ImportError :
310+ pass # time module not available
285311
286312 print (f"DownloadManager: { 'Writing to ' + outfile if outfile else 'Downloading' } { total_size } bytes in chunks of size { chunk_size } " )
287313
@@ -317,12 +343,31 @@ async def on_chunk(chunk):
317343 else :
318344 chunks .append (chunk_data )
319345
320- # Report progress
321- partial_size += len (chunk_data )
322- progress_pct = round ((partial_size * 100 ) / int (total_size ))
323- print (f"DownloadManager: Progress: { partial_size } / { total_size } bytes = { progress_pct } %" )
324- if progress_callback :
346+ # Track bytes for speed calculation
347+ chunk_len = len (chunk_data )
348+ partial_size += chunk_len
349+ speed_bytes_since_last_update += chunk_len
350+
351+ # Report progress with 2-decimal precision
352+ # Only call callback if progress changed by at least 0.01%
353+ progress_pct = round ((partial_size * 100 ) / int (total_size ), 2 )
354+ if progress_callback and progress_pct != last_progress_pct :
355+ print (f"DownloadManager: Progress: { partial_size } / { total_size } bytes = { progress_pct :.2f} %" )
325356 await progress_callback (progress_pct )
357+ last_progress_pct = progress_pct
358+
359+ # Report speed periodically
360+ if speed_callback and speed_last_update_time is not None :
361+ import time
362+ current_time = time .ticks_ms ()
363+ elapsed_ms = time .ticks_diff (current_time , speed_last_update_time )
364+ if elapsed_ms >= _SPEED_UPDATE_INTERVAL_MS :
365+ # Calculate bytes per second
366+ bytes_per_second = (speed_bytes_since_last_update * 1000 ) / elapsed_ms
367+ await speed_callback (bytes_per_second )
368+ # Reset for next interval
369+ speed_bytes_since_last_update = 0
370+ speed_last_update_time = current_time
326371 else :
327372 # Chunk is None, download complete
328373 print (f"DownloadManager: Finished downloading { url } " )
0 commit comments