Files
msys2-web/app/fetch.py
Christoph Reiter 6ba3c8bfca fetch: wait 5 seconds after we get triggered via the api
it seems sometimes we update things at github, but it takes some time
until our server sees the new version, so wait a bit.
2022-01-23 10:53:15 +01:00

400 lines
14 KiB
Python

# Copyright 2016-2020 Christoph Reiter
# SPDX-License-Identifier: MIT
import os
import sys
import io
import json
import asyncio
import traceback
import hashlib
import functools
from asyncio import Event
from urllib.parse import urlparse, quote_plus
from itertools import zip_longest
from typing import Any, Dict, Tuple, List, Set, Iterable
import httpx
from aiolimiter import AsyncLimiter
from .appstate import state, Source, CygwinVersions, ArchMapping, get_repositories, get_arch_names, SrcInfoPackage, Package, DepType
from .appconfig import CYGWIN_VERSION_CONFIG, REQUEST_TIMEOUT, VERSION_CONFIG, ARCH_MAPPING_CONFIG, \
SRCINFO_CONFIG, UPDATE_INTERVAL_MAX, BUILD_STATUS_CONFIG, UPDATE_INTERVAL_MIN
from .utils import version_is_newer_than, arch_version_to_msys
from . import appconfig
from .exttarfile import ExtTarFile
def get_update_urls() -> List[str]:
urls = []
for config in VERSION_CONFIG + SRCINFO_CONFIG + ARCH_MAPPING_CONFIG + CYGWIN_VERSION_CONFIG + BUILD_STATUS_CONFIG:
urls.append(config[0])
for repo in get_repositories():
urls.append(repo.files_url)
return sorted(urls)
async def get_content_cached(url: str, *args: Any, **kwargs: Any) -> bytes:
cache_dir = appconfig.CACHE_DIR
if cache_dir is None:
async with httpx.AsyncClient(follow_redirects=True) as client:
r = await client.get(url, *args, **kwargs)
return r.content
os.makedirs(cache_dir, exist_ok=True)
cache_fn = quote_plus(
(urlparse(url).hostname or "") +
"." + hashlib.sha256(url.encode()).hexdigest()[:16] +
".cache")
fn = os.path.join(cache_dir, cache_fn)
if not os.path.exists(fn):
async with httpx.AsyncClient(follow_redirects=True) as client:
r = await client.get(url, *args, **kwargs)
with open(fn, "wb") as h:
h.write(r.content)
with open(fn, "rb") as h:
data = h.read()
return data
def parse_cygwin_versions(base_url: str, data: bytes) -> CygwinVersions:
# This is kinda hacky: extract the source name from the src tarball and take
# last version line before it
version = None
source_package = None
versions: CygwinVersions = {}
base_url = base_url.rsplit("/", 2)[0]
for line in data.decode("utf-8").splitlines():
if line.startswith("version:"):
version = line.split(":", 1)[-1].strip().split("-", 1)[0].split("+", 1)[0]
elif line.startswith("source:"):
source = line.split(":", 1)[-1].strip()
fn = source.rsplit(None, 2)[0]
source_package = fn.rsplit("/")[-1].rsplit("-", 3)[0]
src_url = base_url + "/" + fn
assert version is not None
if source_package not in versions:
versions[source_package] = (version, "https://cygwin.com/packages/summary/%s-src.html" % source_package, src_url)
return versions
async def update_cygwin_versions() -> None:
print("update cygwin info")
url = CYGWIN_VERSION_CONFIG[0][0]
print("Loading %r" % url)
data = await get_content_cached(url, timeout=REQUEST_TIMEOUT)
cygwin_versions = parse_cygwin_versions(url, data)
state.cygwin_versions = cygwin_versions
async def update_build_status() -> None:
print("update build status")
url = BUILD_STATUS_CONFIG[0][0]
print("Loading %r" % url)
data = await get_content_cached(url, timeout=REQUEST_TIMEOUT)
state.build_status = json.loads(data)
def parse_desc(t: str) -> Dict[str, List[str]]:
d: Dict[str, List[str]] = {}
cat = None
values: List[str] = []
for l in t.splitlines():
l = l.strip()
if cat is None:
cat = l
elif not l:
d[cat] = values
cat = None
values = []
else:
values.append(l)
if cat is not None:
d[cat] = values
return d
async def parse_repo(repo: str, repo_variant: str, files_url: str, download_url: str) -> Dict[str, Source]:
sources: Dict[str, Source] = {}
print("Loading %r" % files_url)
def add_desc(d: Any, download_url: str) -> None:
source = Source.from_desc(d, repo)
if source.name not in sources:
sources[source.name] = source
else:
source = sources[source.name]
source.add_desc(d, download_url, repo, repo_variant)
data = await get_content_cached(files_url, timeout=REQUEST_TIMEOUT)
with io.BytesIO(data) as f:
with ExtTarFile.open(fileobj=f, mode="r") as tar:
packages: Dict[str, list] = {}
for info in tar:
package_name = info.name.split("/", 1)[0]
infofile = tar.extractfile(info)
if infofile is None:
continue
with infofile:
packages.setdefault(package_name, []).append(
(info.name, infofile.read()))
for package_name, infos in sorted(packages.items()):
t = ""
for name, data in sorted(infos):
if name.endswith("/desc"):
t += data.decode("utf-8")
elif name.endswith("/depends"):
t += data.decode("utf-8")
elif name.endswith("/files"):
t += data.decode("utf-8")
desc = parse_desc(t)
add_desc(desc, download_url)
return sources
async def update_arch_versions() -> None:
print("update versions")
arch_versions: Dict[str, Tuple[str, str, int]] = {}
awaitables = []
for (url, repo, variant) in VERSION_CONFIG:
download_url = url.rsplit("/", 1)[0]
awaitables.append(parse_repo(repo, variant, url, download_url))
for sources in (await asyncio.gather(*awaitables)):
for source in sources.values():
msys_ver = arch_version_to_msys(source.version)
for p in source.packages.values():
url = "https://www.archlinux.org/packages/%s/%s/%s/" % (
p.repo, p.arch, p.name)
if p.name in arch_versions:
old_ver = arch_versions[p.name][0]
if version_is_newer_than(msys_ver, old_ver):
arch_versions[p.name] = (msys_ver, url, p.builddate)
else:
arch_versions[p.name] = (msys_ver, url, p.builddate)
url = "https://www.archlinux.org/packages/%s/%s/%s/" % (
source.repos[0], source.arches[0], source.name)
if source.name in arch_versions:
old_ver = arch_versions[source.name][0]
if version_is_newer_than(msys_ver, old_ver):
arch_versions[source.name] = (msys_ver, url, source.date)
else:
arch_versions[source.name] = (msys_ver, url, source.date)
print("done")
print("update versions from AUR")
# a bit hacky, try to get the remaining versions from AUR
possible_names = set()
for s in state.sources.values():
for p in s.packages.values():
possible_names.update(get_arch_names(p.realname))
possible_names.update(get_arch_names(s.realname))
r = await get_content_cached("https://aur.archlinux.org/packages.gz",
timeout=REQUEST_TIMEOUT)
aur_packages = set()
for name in r.decode().splitlines():
if name.startswith("#"):
continue
if name in arch_versions:
continue
if name not in possible_names:
continue
aur_packages.add(name)
def chunks(iterable: Iterable[str], n: int) -> List[List[str]]:
return [list(filter(None, x)) for x in zip_longest(*([iter(iterable)] * n))]
# too long URLs lead to errors, so split things up
for chunk in chunks(sorted(aur_packages), 250):
aur_url = (
"https://aur.archlinux.org/rpc/?v=5&type=info&" +
"&".join(["arg[]=%s" % n for n in chunk]))
data = await get_content_cached(aur_url, timeout=REQUEST_TIMEOUT)
json_obj = json.loads(data.decode("utf-8"))
for result in json_obj["results"]:
name = result["Name"]
if name not in chunk or name in arch_versions:
continue
last_modified = result["LastModified"]
url = "https://aur.archlinux.org/packages/%s" % name
arch_versions[name] = (result["Version"], url, last_modified)
print("done")
state.arch_versions = arch_versions
async def check_needs_update(_cache_key: List[str] = [""]) -> bool:
"""Raises RequestException"""
if appconfig.CACHE_DIR:
return True
async def get_headers(client: httpx.AsyncClient, *args: Any, **kwargs: Any) -> httpx.Headers:
r = await client.head(*args, **kwargs)
r.raise_for_status()
return r.headers
combined = ""
async with httpx.AsyncClient(follow_redirects=True) as client:
awaitables = []
for url in get_update_urls():
awaitables.append(get_headers(client, url, timeout=REQUEST_TIMEOUT))
for headers in (await asyncio.gather(*awaitables)):
key = headers.get("last-modified", "")
key += headers.get("etag", "")
combined += key
if combined != _cache_key[0]:
_cache_key[0] = combined
return True
else:
return False
async def update_source() -> None:
"""Raises RequestException"""
print("update source")
final: Dict[str, Source] = {}
awaitables = []
for repo in get_repositories():
awaitables.append(parse_repo(repo.name, repo.variant, repo.files_url, repo.download_url))
for sources in await asyncio.gather(*awaitables):
for name, source in sources.items():
if name in final:
final[name].packages.update(source.packages)
else:
final[name] = source
fill_rdepends(final)
state.sources = final
async def update_sourceinfos() -> None:
print("update sourceinfos")
result: Dict[str, SrcInfoPackage] = {}
for cfg in SRCINFO_CONFIG:
url = cfg[0]
print("Loading %r" % url)
data = await get_content_cached(url, timeout=REQUEST_TIMEOUT)
json_obj = json.loads(data.decode("utf-8"))
for hash_, m in json_obj.items():
for repo, srcinfo in m["srcinfo"].items():
for pkg in SrcInfoPackage.for_srcinfo(srcinfo, repo, m["repo"], m["path"], m["date"]):
if pkg.pkgname in result:
print(f"WARN: duplicate: {pkg.pkgname} provided by "
f"{pkg.pkgbase} and {result[pkg.pkgname].pkgbase}")
result[pkg.pkgname] = pkg
state.sourceinfos = result
def fill_rdepends(sources: Dict[str, Source]) -> None:
deps: Dict[str, Dict[Package, Set[DepType]]] = {}
for s in sources.values():
for p in s.packages.values():
for n, r in p.depends.items():
deps.setdefault(n, dict()).setdefault(p, set()).add(DepType.NORMAL)
for n, r in p.makedepends.items():
deps.setdefault(n, dict()).setdefault(p, set()).add(DepType.MAKE)
for n, r in p.optdepends.items():
deps.setdefault(n, dict()).setdefault(p, set()).add(DepType.OPTIONAL)
for n, r in p.checkdepends.items():
deps.setdefault(n, dict()).setdefault(p, set()).add(DepType.CHECK)
for s in sources.values():
for p in s.packages.values():
rdeps = [deps.get(p.name, dict())]
for prov in p.provides:
rdeps.append(deps.get(prov, dict()))
merged: Dict[Package, Set[DepType]] = {}
for rd in rdeps:
for rp, rs in rd.items():
merged.setdefault(rp, set()).update(rs)
p.rdepends = merged
async def update_arch_mapping() -> None:
print("update arch mapping")
url = ARCH_MAPPING_CONFIG[0][0]
print("Loading %r" % url)
data = await get_content_cached(url, timeout=REQUEST_TIMEOUT)
state.arch_mapping = ArchMapping(json.loads(data))
_rate_limit = AsyncLimiter(1, UPDATE_INTERVAL_MIN)
@functools.lru_cache(maxsize=None)
def _get_update_event() -> Event:
return Event()
async def wait_for_update() -> None:
update_event = _get_update_event()
await update_event.wait()
update_event.clear()
def queue_update() -> None:
update_event = _get_update_event()
update_event.set()
async def trigger_loop() -> None:
while True:
print("Sleeping for %d" % UPDATE_INTERVAL_MAX)
await asyncio.sleep(UPDATE_INTERVAL_MAX)
queue_update()
async def update_loop() -> None:
asyncio.create_task(trigger_loop())
while True:
async with _rate_limit:
try:
print("check for update")
if await check_needs_update():
print("update needed")
rounds = []
rounds.append([
update_arch_mapping(),
update_cygwin_versions(),
update_source(),
update_sourceinfos(),
update_build_status(),
])
# update_arch_versions() depends on update_source()
rounds.append([
update_arch_versions()
])
for r in rounds:
await asyncio.gather(*r)
state.ready = True
else:
print("no update needed")
except Exception:
traceback.print_exc(file=sys.stdout)
print("Waiting for next update")
await wait_for_update()
# XXX: it seems some updates don't propagate right away, so wait a bit
await asyncio.sleep(5)