1#!/usr/bin/env python3 2 3"""Download test fonts used by the FreeType regression test programs. These 4will be copied to $FREETYPE/tests/data/ by default.""" 5 6import argparse 7import collections 8import hashlib 9import io 10import os 11import requests 12import sys 13import zipfile 14 15from typing import Callable, List, Optional, Tuple 16 17# The list of download items describing the font files to install. Each 18# download item is a dictionary with one of the following schemas: 19# 20# - File item: 21# 22# file_url 23# Type: URL string. 24# Required: Yes. 25# Description: URL to download the file from. 26# 27# install_name 28# Type: file name string 29# Required: No 30# Description: Installation name for the font file, only provided if 31# it must be different from the original URL's basename. 32# 33# hex_digest 34# Type: hexadecimal string 35# Required: No 36# Description: Digest of the input font file. 37# 38# - Zip items: 39# 40# These items correspond to one or more font files that are embedded in a 41# remote zip archive. Each entry has the following fields: 42# 43# zip_url 44# Type: URL string. 45# Required: Yes. 46# Description: URL to download the zip archive from. 47# 48# zip_files 49# Type: List of file entries (see below) 50# Required: Yes 51# Description: A list of entries describing a single font file to be 52# extracted from the archive 53# 54# Apart from that, some schemas are used for dictionaries used inside 55# download items: 56# 57# - File entries: 58# 59# These are dictionaries describing a single font file to extract from an 60# archive. 61# 62# filename 63# Type: file path string 64# Required: Yes 65# Description: Path of source file, relative to the archive's 66# top-level directory. 67# 68# install_name 69# Type: file name string 70# Required: No 71# Description: Installation name for the font file; only provided if 72# it must be different from the original filename value. 73# 74# hex_digest 75# Type: hexadecimal string 76# Required: No 77# Description: Digest of the input source file 78# 79_DOWNLOAD_ITEMS = [ 80 { 81 "zip_url": "https://github.com/python-pillow/Pillow/files/6622147/As.I.Lay.Dying.zip", 82 "zip_files": [ 83 { 84 "filename": "As I Lay Dying.ttf", 85 "install_name": "As.I.Lay.Dying.ttf", 86 "hex_digest": "ef146bbc2673b387", 87 }, 88 ], 89 }, 90] 91 92 93def digest_data(data: bytes): 94 """Compute the digest of a given input byte string, which are the first 95 8 bytes of its sha256 hash.""" 96 m = hashlib.sha256() 97 m.update(data) 98 return m.digest()[:8] 99 100 101def check_existing(path: str, hex_digest: str): 102 """Return True if |path| exists and matches |hex_digest|.""" 103 if not os.path.exists(path) or hex_digest is None: 104 return False 105 106 with open(path, "rb") as f: 107 existing_content = f.read() 108 109 return bytes.fromhex(hex_digest) == digest_data(existing_content) 110 111 112def install_file(content: bytes, dest_path: str): 113 """Write a byte string to a given destination file. 114 115 Args: 116 content: Input data, as a byte string 117 dest_path: Installation path 118 """ 119 parent_path = os.path.dirname(dest_path) 120 if not os.path.exists(parent_path): 121 os.makedirs(parent_path) 122 123 with open(dest_path, "wb") as f: 124 f.write(content) 125 126 127def download_file(url: str, expected_digest: Optional[bytes] = None): 128 """Download a file from a given URL. 129 130 Args: 131 url: Input URL 132 expected_digest: Optional digest of the file 133 as a byte string 134 Returns: 135 URL content as binary string. 136 """ 137 r = requests.get(url, allow_redirects=True) 138 content = r.content 139 if expected_digest is not None: 140 digest = digest_data(r.content) 141 if digest != expected_digest: 142 raise ValueError( 143 "%s has invalid digest %s (expected %s)" 144 % (url, digest.hex(), expected_digest.hex()) 145 ) 146 147 return content 148 149 150def extract_file_from_zip_archive( 151 archive: zipfile.ZipFile, 152 archive_name: str, 153 filepath: str, 154 expected_digest: Optional[bytes] = None, 155): 156 """Extract a file from a given zipfile.ZipFile archive. 157 158 Args: 159 archive: Input ZipFile objec. 160 archive_name: Archive name or URL, only used to generate a 161 human-readable error message. 162 163 filepath: Input filepath in archive. 164 expected_digest: Optional digest for the file. 165 Returns: 166 A new File instance corresponding to the extract file. 167 Raises: 168 ValueError if expected_digest is not None and does not match the 169 extracted file. 170 """ 171 file = archive.open(filepath) 172 if expected_digest is not None: 173 digest = digest_data(archive.open(filepath).read()) 174 if digest != expected_digest: 175 raise ValueError( 176 "%s in zip archive at %s has invalid digest %s (expected %s)" 177 % (filepath, archive_name, digest.hex(), expected_digest.hex()) 178 ) 179 return file.read() 180 181 182def _get_and_install_file( 183 install_path: str, 184 hex_digest: Optional[str], 185 force_download: bool, 186 get_content: Callable[[], bytes], 187) -> bool: 188 if not force_download and hex_digest is not None \ 189 and os.path.exists(install_path): 190 with open(install_path, "rb") as f: 191 content: bytes = f.read() 192 if bytes.fromhex(hex_digest) == digest_data(content): 193 return False 194 195 content = get_content() 196 install_file(content, install_path) 197 return True 198 199 200def download_and_install_item( 201 item: dict, install_dir: str, force_download: bool 202) -> List[Tuple[str, bool]]: 203 """Download and install one item. 204 205 Args: 206 item: Download item as a dictionary, see above for schema. 207 install_dir: Installation directory. 208 force_download: Set to True to force download and installation, even 209 if the font file is already installed with the right content. 210 211 Returns: 212 A list of (install_name, status) tuples, where 'install_name' is the 213 file's installation name under 'install_dir', and 'status' is a 214 boolean that is True to indicate that the file was downloaded and 215 installed, or False to indicate that the file is already installed 216 with the right content. 217 """ 218 if "file_url" in item: 219 file_url = item["file_url"] 220 install_name = item.get("install_name", os.path.basename(file_url)) 221 install_path = os.path.join(install_dir, install_name) 222 hex_digest = item.get("hex_digest") 223 224 def get_content(): 225 return download_file(file_url, hex_digest) 226 227 status = _get_and_install_file( 228 install_path, hex_digest, force_download, get_content 229 ) 230 return [(install_name, status)] 231 232 if "zip_url" in item: 233 # One or more files from a zip archive. 234 archive_url = item["zip_url"] 235 archive = zipfile.ZipFile(io.BytesIO(download_file(archive_url))) 236 237 result = [] 238 for f in item["zip_files"]: 239 filename = f["filename"] 240 install_name = f.get("install_name", filename) 241 hex_digest = f.get("hex_digest") 242 243 def get_content(): 244 return extract_file_from_zip_archive( 245 archive, 246 archive_url, 247 filename, 248 bytes.fromhex(hex_digest) if hex_digest else None, 249 ) 250 251 status = _get_and_install_file( 252 os.path.join(install_dir, install_name), 253 hex_digest, 254 force_download, 255 get_content, 256 ) 257 result.append((install_name, status)) 258 259 return result 260 261 else: 262 raise ValueError("Unknown download item schema: %s" % item) 263 264 265def main(): 266 parser = argparse.ArgumentParser(description=__doc__) 267 268 # Assume this script is under tests/scripts/ and tests/data/ 269 # is the default installation directory. 270 install_dir = os.path.normpath( 271 os.path.join(os.path.dirname(__file__), "..", "data") 272 ) 273 274 parser.add_argument( 275 "--force", 276 action="store_true", 277 default=False, 278 help="Force download and installation of font files", 279 ) 280 281 parser.add_argument( 282 "--install-dir", 283 default=install_dir, 284 help="Specify installation directory [%s]" % install_dir, 285 ) 286 287 args = parser.parse_args() 288 289 for item in _DOWNLOAD_ITEMS: 290 for install_name, status in download_and_install_item( 291 item, args.install_dir, args.force 292 ): 293 print("%s %s" % (install_name, 294 "INSTALLED" if status else "UP-TO-DATE")) 295 296 return 0 297 298 299if __name__ == "__main__": 300 sys.exit(main()) 301 302# EOF 303