Files
msys2-web/app/fetch.py
Christoph Reiter 515198449c Add proper pypi integration
We now fetch the cached pypi API responses and extract the version
and the oldest date of any version artifact
2023-07-03 19:55:50 +02:00

523 lines
18 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
import gzip
import yaml
import datetime
from asyncio import Event
from urllib.parse import urlparse, quote_plus
from typing import Any, Dict, Tuple, List, Set, Optional
from email.utils import parsedate_to_datetime
import httpx
from aiolimiter import AsyncLimiter
import zstandard
from .appstate import state, Source, get_repositories, SrcInfoPackage, Package, DepType, Repository, BuildStatus, PkgMeta, \
ExtInfo, ExtId
from .appconfig import CYGWIN_METADATA_URL, REQUEST_TIMEOUT, AUR_METADATA_URL, ARCH_REPO_CONFIG, PKGMETA_URLS, \
SRCINFO_URLS, UPDATE_INTERVAL, BUILD_STATUS_URLS, UPDATE_MIN_RATE, UPDATE_MIN_INTERVAL, PYPI_URLS
from .utils import version_is_newer_than, arch_version_to_msys, extract_upstream_version
from . import appconfig
from .exttarfile import ExtTarFile
def get_mtime_for_response(response: httpx.Response) -> Optional[datetime.datetime]:
last_modified = response.headers.get("last-modified")
if last_modified is not None:
dt: datetime.datetime = parsedate_to_datetime(last_modified)
return dt.astimezone(datetime.timezone.utc)
return None
async def get_content_cached_mtime(url: str, *args: Any, **kwargs: Any) -> Tuple[bytes, Optional[datetime.datetime]]:
"""Returns the content of the URL response, and a datetime object for when the content was last modified"""
# cache the file locally, and store the "last-modified" date as the file mtime
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)
r.raise_for_status()
return (r.content, get_mtime_for_response(r))
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)
r.raise_for_status()
with open(fn, "wb") as h:
h.write(r.content)
mtime = get_mtime_for_response(r)
if mtime is not None:
os.utime(fn, (mtime.timestamp(), mtime.timestamp()))
with open(fn, "rb") as h:
data = h.read()
file_mtime = datetime.datetime.fromtimestamp(os.path.getmtime(fn), datetime.timezone.utc)
return (data, file_mtime)
async def get_content_cached(url: str, *args: Any, **kwargs: Any) -> bytes:
return (await get_content_cached_mtime(url, *args, **kwargs))[0]
def parse_cygwin_versions(base_url: str, data: bytes) -> Tuple[Dict[str, ExtInfo], Dict[str, ExtInfo]]:
# 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: Dict[str, ExtInfo] = {}
versions_mingw64: Dict[str, ExtInfo] = {}
base_url = base_url.rsplit("/", 2)[0]
in_main = True
for line in data.decode("utf-8").splitlines():
if line.startswith("@"):
in_main = True
if line.startswith("version:"):
version = line.split(":")[-1].strip().split("-", 1)[0].split("+", 1)[0]
elif in_main and line.startswith("source:"):
in_main = False
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
src_url_name = src_url.rsplit("/")[-1]
if source_package.startswith("mingw64-x86_64-"):
info_name = source_package.split("-", 2)[-1]
if info_name in versions_mingw64:
existing_version = versions_mingw64[info_name][0]
if not version_is_newer_than(version, existing_version):
continue
versions_mingw64[info_name] = ExtInfo(
info_name, version, 0,
"https://cygwin.com/packages/summary/%s-src.html" % source_package,
{src_url: src_url_name})
else:
info_name = source_package
if info_name in versions:
existing_version = versions[info_name][0]
if not version_is_newer_than(version, existing_version):
continue
versions[info_name] = ExtInfo(
info_name, version, 0,
"https://cygwin.com/packages/summary/%s-src.html" % source_package,
{src_url: src_url_name})
return versions, versions_mingw64
async def update_cygwin_versions() -> None:
url = CYGWIN_METADATA_URL
if not await check_needs_update([url]):
return
print("update cygwin info")
print("Loading %r" % url)
data = await get_content_cached(url, timeout=REQUEST_TIMEOUT)
data = zstandard.ZstdDecompressor().decompress(data)
cygwin_versions, cygwin_versions_mingw64 = parse_cygwin_versions(url, data)
state.set_ext_infos(ExtId("cygwin", "Cygwin", True), cygwin_versions)
state.set_ext_infos(ExtId("cygwin-mingw64", "Cygwin-mingw64", False), cygwin_versions_mingw64)
async def update_build_status() -> None:
urls = BUILD_STATUS_URLS
if not await check_needs_update(urls):
return
print("update build status")
responses = []
for url in urls:
print("Loading %r" % url)
data, mtime = await get_content_cached_mtime(url, timeout=REQUEST_TIMEOUT)
print("Done: %r, %r" % (url, str(mtime)))
responses.append((mtime, url, data))
# use the newest of all status summaries
newest = sorted(responses)[-1]
print("Selected: %r" % (newest[1],))
state.build_status = BuildStatus.parse_raw(newest[2])
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: Repository, include_files: bool = True) -> Dict[str, Source]:
sources: Dict[str, Source] = {}
def add_desc(d: Any) -> 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, repo)
repo_url = repo.files_url if include_files else repo.db_url
print("Loading %r" % repo_url)
data = await get_content_cached(repo_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)
return sources
async def update_arch_versions() -> None:
urls = [i[0] for i in ARCH_REPO_CONFIG]
if not await check_needs_update(urls):
return
print("update versions")
arch_versions: Dict[str, ExtInfo] = {}
awaitables = []
for (url, repo) in ARCH_REPO_CONFIG:
download_url = url.rsplit("/", 1)[0]
awaitables.append(
parse_repo(
Repository(repo, "", "", "", download_url, download_url, ""),
False
)
)
# priority: real packages > real provides > aur packages > aur provides
for sources in (await asyncio.gather(*awaitables)):
for source in sources.values():
version = extract_upstream_version(arch_version_to_msys(source.version))
for p in source.packages.values():
url = "https://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(version, old_ver):
arch_versions[p.name] = ExtInfo(p.name, version, p.builddate, url, {})
else:
arch_versions[p.name] = ExtInfo(p.name, version, p.builddate, url, {})
url = "https://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(version, old_ver):
arch_versions[source.name] = ExtInfo(source.name, version, source.date, url, {})
else:
arch_versions[source.name] = ExtInfo(source.name, version, source.date, url, {})
# use provides as fallback
for p in source.packages.values():
url = "https://archlinux.org/packages/%s/%s/%s/" % (
p.repo, p.arch, p.name)
for provides in sorted(p.provides.keys()):
if provides not in arch_versions:
arch_versions[provides] = ExtInfo(provides, version, p.builddate, url, {})
print("done")
state.set_ext_infos(ExtId("archlinux", "Arch Linux", False), arch_versions)
print("update versions from AUR")
aur_versions: Dict[str, ExtInfo] = {}
r = await get_content_cached(AUR_METADATA_URL,
timeout=REQUEST_TIMEOUT)
items = json.loads(r)
for item in items:
name = item["Name"]
if name in aur_versions:
continue
version = item["Version"]
msys_ver = extract_upstream_version(arch_version_to_msys(version))
last_modified = item["LastModified"]
url = "https://aur.archlinux.org/packages/%s" % name
aur_versions[name] = ExtInfo(name, msys_ver, last_modified, url, {})
for item in items:
name = item["Name"]
for provides in sorted(item.get("Provides", [])):
if provides in aur_versions:
continue
version = item["Version"]
msys_ver = extract_upstream_version(arch_version_to_msys(version))
last_modified = item["LastModified"]
url = "https://aur.archlinux.org/packages/%s" % name
aur_versions[provides] = ExtInfo(provides, msys_ver, last_modified, url, {})
print("done")
state.set_ext_infos(ExtId("aur", "AUR", True), aur_versions)
async def check_needs_update(urls: List[str], _cache: Dict[str, str] = {}) -> bool:
"""Raises RequestException"""
if appconfig.CACHE_DIR:
return True
async def get_headers(client: httpx.AsyncClient, url: str, *args: Any, **kwargs: Any) -> Tuple[str, httpx.Headers]:
r = await client.head(url, *args, **kwargs)
r.raise_for_status()
return (url, r.headers)
needs_update = False
async with httpx.AsyncClient(follow_redirects=True) as client:
awaitables = []
for url in urls:
awaitables.append(get_headers(client, url, timeout=REQUEST_TIMEOUT))
for url, headers in (await asyncio.gather(*awaitables)):
old = _cache.get(url)
new = headers.get("last-modified", "")
new += headers.get("etag", "")
if old != new:
needs_update = True
_cache[url] = new
return needs_update
async def update_source() -> None:
"""Raises RequestException"""
urls = [repo.files_url for repo in get_repositories()]
if not await check_needs_update(urls):
return
print("update source")
final: Dict[str, Source] = {}
awaitables = []
for repo in get_repositories():
awaitables.append(parse_repo(repo))
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)
fill_provided_by(final)
state.sources = final
async def update_sourceinfos() -> None:
urls = SRCINFO_URLS
if not await check_needs_update(urls):
return
print("update sourceinfos")
result: Dict[str, SrcInfoPackage] = {}
for url in urls:
print("Loading %r" % url)
data = await get_content_cached(url, timeout=REQUEST_TIMEOUT)
json_obj = json.loads(gzip.decompress(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
async def update_pkgmeta() -> None:
urls = PKGMETA_URLS
if not await check_needs_update(urls):
return
print("update pkgmeta")
merged = PkgMeta(packages={})
for url in urls:
print("Loading %r" % url)
data = await get_content_cached(url, timeout=REQUEST_TIMEOUT)
merged.packages.update(PkgMeta.parse_obj(yaml.safe_load(data)).packages)
state.pkgmeta = merged
await update_pypi_versions(merged)
async def update_pypi_versions(pkgmeta: PkgMeta) -> None:
urls = PYPI_URLS
if not await check_needs_update(urls):
return
projects = {}
for url in urls:
print("Loading %r" % url)
data = await get_content_cached(url, timeout=REQUEST_TIMEOUT)
json_obj = json.loads(gzip.decompress(data).decode("utf-8"))
projects.update(json_obj.get("projects", {}))
pypi_versions = {}
for entry in pkgmeta.packages.values():
if "pypi" not in entry.references:
continue
pypi_name = entry.references["pypi"]
assert isinstance(pypi_name, str)
if pypi_name in projects:
project = projects[pypi_name]
info = project["info"]
project_urls = project.get("urls", [])
oldest_timestamp = 0
for url_entry in project_urls:
dt = datetime.datetime.fromisoformat(
url_entry["upload_time_iso_8601"].replace("Z", "+00:00"))
timestamp = int(dt.timestamp())
if oldest_timestamp == 0 or timestamp < oldest_timestamp:
oldest_timestamp = timestamp
pypi_versions[pypi_name] = ExtInfo(
pypi_name, info["version"], oldest_timestamp, info["project_url"], {})
state.set_ext_infos(ExtId("pypi", "PyPI", True), pypi_versions)
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
def fill_provided_by(sources: Dict[str, Source]) -> None:
provided_by: Dict[str, Set[Package]] = {}
for s in sources.values():
for p in s.packages.values():
for provides in p.provides.keys():
provided_by.setdefault(provides, set()).add(p)
for s in sources.values():
for p in s.packages.values():
if p.name in provided_by:
p.provided_by = provided_by[p.name]
_rate_limit = AsyncLimiter(UPDATE_MIN_RATE, UPDATE_MIN_INTERVAL)
@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)
await asyncio.sleep(UPDATE_INTERVAL)
queue_update()
_background_tasks = set()
async def update_loop() -> None:
task = asyncio.create_task(trigger_loop())
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
while True:
async with _rate_limit:
print("check for updates")
try:
awaitables = [
update_pkgmeta(),
update_cygwin_versions(),
update_arch_versions(),
update_source(),
update_sourceinfos(),
update_build_status(),
]
await asyncio.gather(*awaitables)
state.ready = True
print("done")
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)