[docs]classDownloadError(Exception):"""Raised if the downloaded content size does not match the "Content-Length" from the response headers """
[docs]classStupidZeroContentLengthError(Exception):"""Raised when the reported "Content-Length" is zero """
[docs]classThisShouldBeA404ErrorButItsNot(ClientResponseError):"""Exception raised when Granicus redirects you to a warning page saying a file doesn't exist even though that is something built into the HTTP protocol yes this is a runon sentence in a docstring header line but I am beyond the point of caring Yes, another lovely edge case discovered - shocking. The response can be detected by a ``302 "Found"`` redirect with ``"/Confirmation.aspx"`` as the url path and ``M1=Gone`` in the query. """
[docs]@classmethoddefdetect(cls,response:ClientResponse)->bool:"""Check whether this exception should be raised from the given *response* """hist=response.historyiflen(hist)<2:returnFalseifhist[-2].status!=302:returnFalseurl=response.urlifurl.path.endswith('Confirmation.aspx')andurl.query.get('M1')=='Gone':returnTruereturnFalse
[docs]@classmethoddefdetect_and_raise(cls,response:ClientResponse)->None:"""Check and raise this exception if its pattern is found on *response* """ifnotcls.detect(response):returnraisecls(request_info=response.request_info,history=response.history,status=404,message='Not Found',headers=response.headers,)
[docs]classDownloadRequest(TypedDict):"""Keyword arguments to create :class:`FileDownload` instances """url:URL"""URL for the download"""filename:Path"""Local filename for the download"""chunk_size:NotRequired[int]"""Chunk size for streaming download segments"""timeout:NotRequired[ClientTimeout]"""Custom :class:`~aiohttp.ClientTimeout` specification"""
[docs]classDownloadResult(TypedDict):"""Result type used for :attr:`FileDownload.result` """url:URL"""The :attr:`~DownloadRequest.url` of the request"""filename:Path"""The :attr:`~DownloadRequest.filename` of the request"""meta:FileMeta"""Metadata from the response headers as a :class:`~.types.FileMeta` instance"""
[docs]classFileDownload:"""Downloads a single file Arguments: session (aiohttp.ClientSession): **kwargs (DownloadRequest): """session:ClientSession"""The current :class:`aiohttp.ClientSession`"""progress:float"""Current download progress (from ``0.0`` to ``1.0``)"""def__init__(self,session:ClientSession,**kwargs:Unpack[DownloadRequest])->None:self.session=sessionself.url=kwargs['url']self.filename=kwargs['filename']self.chunk_size=kwargs.get('chunk_size',65536)timeout=kwargs.get('timeout')iftimeoutisNone:timeout=ClientTimeout(total=300)self.timeout=timeoutself.progress:float=0self._meta:FileMeta|None=None@propertydefmeta(self)->FileMeta:"""Metadata from the response headers """ifself._metaisNone:raiseRuntimeError('FileMeta not available')returnself._meta@propertydefresult(self)->DownloadResult:"""The completed :class:`DownloadResult` """return{'url':self.url,'filename':self.filename,'meta':self.meta,}asyncdef__call__(self)->Self:asyncwithself.session.get(self.url,timeout=self.timeout)asresp:ThisShouldBeA404ErrorButItsNot.detect_and_raise(resp)ifresp.headers.get('Content-Length')in['0',0]:raiseStupidZeroContentLengthError(f'Content-Length 0 for {self.url}')meta=self._meta=FileMeta.from_headers(resp.headers)ifmeta.content_length==0:raiseStupidZeroContentLengthError(f'Content-Length 0 for {self.url}')total_bytes=meta.content_lengthbytes_recv=0asyncwithaiofile.async_open(self.filename,'wb')asfd:asyncforchunkinresp.content.iter_chunked(self.chunk_size):awaitfd.write(chunk)bytes_recv+=len(chunk)self.progress=total_bytes/bytes_recvst=self.filename.stat()ifst.st_size!=total_bytes:self.filename.unlink()raiseDownloadError(f'Filesize mismatch: {st.st_size=}, {total_bytes=}, {bytes_recv=}, {self.url=}, {self.filename=}')returnself
[docs]classDownloader:"""Manager for scheduling :class:`FileDownload` jobs """session:ClientSession"""The current :class:`aiohttp.ClientSession`"""scheduler:aiojobs.Scheduler|None"""The :class:`~aiojobs.Scheduler` to place download jobs on"""default_chunk_size:int"""Default to use for :attr:`FileDownload.chunk_size`"""default_timeout:ClientTimeout"""Default to use for :attr:`FileDownload.timeout`"""def__init__(self,session:ClientSession,scheduler:aiojobs.Scheduler|None=None)->None:self.session=sessionself.scheduler=schedulerself.default_chunk_size=65536self.default_timeout=ClientTimeout(total=300)def_build_download_obj(self,**kwargs:Unpack[DownloadRequest])->FileDownload:kwargs.setdefault('chunk_size',self.default_chunk_size)kwargs.setdefault('timeout',self.default_timeout)returnFileDownload(session=self.session,**kwargs)
[docs]asyncdefspawn(self,**kwargs:Unpack[DownloadRequest])->aiojobs.Job[FileDownload]:"""Create a :class:`FileDownload` and run it on the :attr:`scheduler` - If :attr:`~DownloadRequest.chunk_size` is not provided, the :attr:`default_chunk_size` will be used. - If :attr:`~DownloadRequest.timeout` is not provided, :attr:`default_timeout` will be used. Arguments: **kwargs (DownloadRequest): Keyword arguments to initialize the :class:`FileDownload` instance """ifself.schedulerisNone:raiseRuntimeError('scheduler not set')dl=self._build_download_obj(**kwargs)returnawaitself.scheduler.spawn(dl())
[docs]asyncdefdownload(self,**kwargs:Unpack[DownloadRequest])->FileDownload:"""Create a :class:`FileDownload` and begin downloading immediately (bypassing :attr:`scheduler`) - If :attr:`~DownloadRequest.chunk_size` is not provided, the :attr:`default_chunk_size` will be used. - If :attr:`~DownloadRequest.timeout` is not provided, :attr:`default_timeout` will be used. Arguments: **kwargs (DownloadRequest): Keyword arguments to initialize the :class:`FileDownload` instance """dl=self._build_download_obj(**kwargs)returnawaitdl()