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