Coverage for apio/managers/downloader.py: 95%
33 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 03:53 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 03:53 +0000
1# -*- coding: utf-8 -*-
2# -- This file is part of the Apio project
3# -- (C) 2016-2019 FPGAwars
4# -- Author Jesús Arroyo
5# -- License GPLv2
6# -- Derived from:
7# ---- Platformio project
8# ---- (C) 2014-2016 Ivan Kravets <me@ikravets.com>
9# ---- License Apache v2
10"""Implement a remote file downloader. Used to fetch packages from github
11packages release repositories.
12"""
14# TODO: capture all the exceptions and return them as method return status.
15# Motivation is simplifying the usage.
17from math import ceil
18from pathlib import Path
19import requests
20from rich.progress import track
21from apio.common.apio_console import console, fatal_error
23# -- Timeout for getting a response from the server when downloading
24# -- a file (in seconds). We had github tests failing with timeout=10
25TIMEOUT_SECS = 30
28class FileDownloader:
29 """Class for downloading files"""
31 CHUNK_SIZE = 1024
33 def __init__(self, url: str, dest_dir=None):
34 """Initialize a FileDownloader object
35 * INPUTs:
36 * url: File to download (full url)
37 (Ex. 'https://github.com/FPGAwars/apio-examples/
38 releases/download/0.0.35/apio-examples-0.0.35.zip')
39 * dest_dir: Destination folder (where to download the file)
40 """
42 # -- Initialize the request field first, so that __del__ works even
43 # -- if the construction fails below, e.g. on a connection timeout.
44 self._request = None
46 # -- Store the url
47 self._url = url
49 # -- Get the file from the url
50 # -- Ex: 'apio-examples-0.0.35.zip'
51 self.fname = url.split("/")[-1]
53 # -- Build the destination path
54 self.destination: Path = Path(self.fname)
55 if dest_dir:
57 # -- Add the path
58 self.destination = dest_dir / self.fname
60 # -- Request the file
61 self._request = requests.get(url, stream=True, timeout=TIMEOUT_SECS)
63 # -- Fail in case of download error.
64 if self._request.status_code != 200: 64 ↛ 65line 64 didn't jump to line 65 because the condition on line 64 was never true
65 fatal_error(
66 "Got an unexpected HTTP status code: "
67 f"{self._request.status_code}",
68 f"When downloading {url}",
69 )
71 def get_size(self) -> int:
72 """Return the size (in bytes) of the latest bytes block received"""
73 assert self._request is not None
74 return int(self._request.headers["content-length"])
76 def download(self):
77 """Download of the file while displaying a progress bar."""
79 # -- Download iterator
80 itercontent = self._request.iter_content(chunk_size=self.CHUNK_SIZE)
82 # -- Open destination file, for writing bytes
83 with open(self.destination, "wb") as file:
85 # -- Get the file length in Kbytes
86 num_chunks = int(ceil(self.get_size() / float(self.CHUNK_SIZE)))
88 # -- Download and write the chunks, while displaying the progress.
89 for _ in track(
90 range(num_chunks),
91 description="Downloading",
92 console=console(),
93 ):
95 file.write(next(itercontent))
97 # -- Check that the iterator reached its end. When the end is
98 # -- reached, next() returns the default value None.
99 assert next(itercontent, None) is None
101 # -- Download done!
102 self._request.close()
104 def __del__(self):
105 """Close any pending request"""
107 # -- Using getattr() since __del__ can run on a partially constructed
108 # -- object, and an exception here would surface as an 'unraisable
109 # -- exception' at an arbitrary point of the program. Comparing to
110 # -- None rather than testing truthiness since bool(Response) is
111 # -- Response.ok, which is False for non-2xx error responses.
112 request = getattr(self, "_request", None)
113 if request is not None:
114 request.close()