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