1 #!/usr/bin/env python3
2
3 """Download test fonts used by the FreeType regression test programs. These
4 will be copied to $FREETYPE/tests/data/ by default."""
5
6 import argparse
7 import collections
8 import hashlib
9 import io
10 import os
11 import requests
12 import sys
13 import zipfile
14
15 from 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
93 def 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
101 def 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
112 def 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
127 def 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
150 def 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
182 def _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
200 def 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
265 def 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
299 if __name__ == "__main__":
300 sys.exit(main())
301
302 # EOF