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