xref: /trunk/test/aoo-test/src/aootest/fetch.py (revision 2cb6d6f119bbe2f78742f1c5c8538cb0d4e72ede)
1################################################################
2#
3#  Licensed to the Apache Software Foundation (ASF) under one
4#  or more contributor license agreements.  See the NOTICE file
5#  distributed with this work for additional information
6#  regarding copyright ownership.  The ASF licenses this file
7#  to you under the Apache License, Version 2.0 (the
8#  "License"); you may not use this file except in compliance
9#  with the License.  You may obtain a copy of the License at
10#
11#    http://www.apache.org/licenses/LICENSE-2.0
12#
13#  Unless required by applicable law or agreed to in writing,
14#  software distributed under the License is distributed on an
15#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16#  KIND, either express or implied.  See the License for the
17#  specific language governing permissions and limitations
18#  under the License.
19#
20################################################################
21
22"""Downloads the items listed in data/fetch.json (OASIS schemas, jing, odfvalidator) into a per-user cache.
23
24Every file is checked against its pinned sha256 before it is moved into place, so a cached file is always a verified one.
25Nothing third-party is committed. Command line: python -m aootest.fetch [--offline] [--list] [name ...]
26"""
27import hashlib
28import json
29import os
30import sys
31import tempfile
32import urllib.request
33from pathlib import Path
34
35from aootest.paths import DATA
36
37
38def cache_dir():
39    """AOO_TEST_CACHE, else the usual per-user cache directory of the platform."""
40    env = os.environ.get("AOO_TEST_CACHE")
41    if env:
42        return Path(env)
43    if sys.platform == "win32":
44        return Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "aootest" / "Cache"
45    if sys.platform == "darwin":
46        return Path.home() / "Library" / "Caches" / "aootest"
47    return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "aootest"
48
49
50def load_items(path=None):
51    """The entries of fetch.json: dicts with name, group, url, sha256, path (relative to the cache), license."""
52    return json.loads(Path(path or DATA / "fetch.json").read_text(encoding="utf-8"))["items"]
53
54
55def sha256_of(path):
56    h = hashlib.sha256()
57    with open(path, "rb") as f:
58        for block in iter(lambda: f.read(1 << 20), b""):
59            h.update(block)
60    return h.hexdigest()
61
62
63def status(item, cache=None):
64    """'ok' (present and matching), 'bad' (present, wrong checksum) or 'missing'."""
65    p = Path(cache or cache_dir()) / item["path"]
66    if not p.is_file():
67        return "missing"
68    return "ok" if sha256_of(p) == item["sha256"] else "bad"
69
70
71def cached_path(name, cache=None, items=None):
72    """Path of a verified cached item, or None."""
73    for it in items or load_items():
74        if it["name"] == name:
75            return Path(cache or cache_dir()) / it["path"] if status(it, cache) == "ok" else None
76    raise KeyError(name)
77
78
79def group_dir(group, cache=None, items=None):
80    """The directory holding a group (all files in one directory) when every item of it is verified, else None."""
81    its = [it for it in (items or load_items()) if it["group"] == group]
82    if not its or any(status(it, cache) != "ok" for it in its):
83        return None
84    dirs = {(Path(cache or cache_dir()) / it["path"]).parent for it in its}
85    return dirs.pop() if len(dirs) == 1 else None
86
87
88def _download(item, cache, opener):
89    url = item["url"]
90    if not url.startswith("https://"):
91        raise ValueError("only https URLs are fetched: " + url)
92    dest = Path(cache) / item["path"]
93    dest.parent.mkdir(parents=True, exist_ok=True)
94    fd, tmp = tempfile.mkstemp(dir=dest.parent, prefix=dest.name + ".part")
95    try:
96        h = hashlib.sha256()
97        with os.fdopen(fd, "wb") as out, opener(urllib.request.Request(url, headers={"User-Agent": "aootest-fetch"})) as src:
98            for block in iter(lambda: src.read(1 << 20), b""):
99                h.update(block)
100                out.write(block)
101        if h.hexdigest() != item["sha256"]:
102            raise ValueError("checksum mismatch for %s: got %s" % (url, h.hexdigest()))
103        os.replace(tmp, dest)
104    except BaseException:
105        if os.path.exists(tmp):
106            os.unlink(tmp)
107        raise
108
109
110def fetch(names=None, offline=False, cache=None, opener=None, items=None):
111    """Makes the named items (default: all) present. Returns {name: 'present' | 'downloaded' | 'missing' | 'failed: reason'}.
112    With offline=True nothing is downloaded and absent items are reported 'missing'. A file with the wrong checksum is
113    replaced only by a download that verifies."""
114    cache = Path(cache or cache_dir())
115    opener = opener or (lambda req: urllib.request.urlopen(req, timeout=60))
116    result = {}
117    for it in items or load_items():
118        if names and it["name"] not in names:
119            continue
120        if status(it, cache) == "ok":
121            result[it["name"]] = "present"
122        elif offline:
123            result[it["name"]] = "missing"
124        else:
125            try:
126                _download(it, cache, opener)
127                result[it["name"]] = "downloaded"
128            except Exception as e:
129                result[it["name"]] = "failed: %s" % e
130    return result
131
132
133def main(argv=None):
134    argv = sys.argv[1:] if argv is None else list(argv)
135    offline, listing = "--offline" in argv, "--list" in argv
136    names = [a for a in argv if not a.startswith("--")]
137    unknown = set(names) - {it["name"] for it in load_items()}
138    if unknown:
139        print("unknown item: " + ", ".join(sorted(unknown)), file=sys.stderr)
140        return 2
141    if listing:
142        for it in load_items():
143            print("%-9s %-8s %s" % (status(it), it["group"], it["name"]))
144        return 0
145    res = fetch(names or None, offline=offline)
146    for n, s in res.items():
147        print("%-12s %s" % (s.split(":")[0], n) + (s[s.index(":"):] if s.startswith("failed") else ""))
148    print("cache: %s" % cache_dir())
149    return 0 if all(s in ("present", "downloaded") for s in res.values()) else 1
150
151
152if __name__ == "__main__":
153    sys.exit(main())
154