vollständige umstellung auf gitea repo

This commit is contained in:
Simon Daniel Streich committed 2026-10-21 10:03:34 +02:00
1 parent 3a807da54c
commit 079e69963d
9 files changed
+426 -39

No files matched your search

+225
View File
@@ -0,0 +1,225 @@
"""Update timestamp and release manifests using only Python 3.10+.
Without --write, calculate changes without saving them. Never commit or push.
PowerShell statements in manifests are not executed.
"""
import argparse
from datetime import timezone
from email.utils import parsedate_to_datetime
import hashlib
import json
from pathlib import Path
import re
import sys
import tempfile
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urldefrag, urlsplit
from urllib.request import Request, urlopen
BUCKET = Path(__file__).resolve().parents[1] / 'bucket'
TIMESTAMP_REGEX = r'(?<version>\d{8}T\d{6}Z)'
def open_url(url, method='GET', headers=None):
parsed = urlsplit(url)
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password:
raise ValueError('Nur HTTPS-URLs ohne eingebettete Zugangsdaten sind erlaubt.')
return urlopen(Request(url, method=method, headers={
'User-Agent': 'Medelexis-Scoop-Updater', 'Cache-Control': 'no-cache',
'Accept-Encoding': 'identity', **(headers or {})}), timeout=180)
def metadata(headers):
return {key: headers.get(key, '') for key in ('Last-Modified', 'ETag')}
def read_head(url):
with open_url(url, 'HEAD') as response:
return metadata(response.headers)
def release_version(checkver):
if checkver.get('jsonpath') != '$.tag_name' or not checkver.get('regex'):
raise ValueError('Unterstützt werden Last-Modified oder jsonpath $.tag_name mit regex.')
with open_url(checkver['url']) as response:
tag = json.load(response)['tag_name']
pattern = checkver['regex'].replace('(?<version>', '(?P<version>')
match = re.search(pattern, tag)
if not match:
raise ValueError('Release-Tag passt nicht zum Versionsmuster.')
version = match.groupdict().get('version') or match.group(1)
if not re.fullmatch(r'[0-9A-Za-z][0-9A-Za-z._+-]*', version):
raise ValueError('Ungültige Release-Version.')
return version
def download_file(url, observed, target):
conditions = {}
if observed['ETag'] and not observed['ETag'].startswith('W/'):
conditions['If-Match'] = observed['ETag']
elif observed['Last-Modified']:
conditions['If-Unmodified-Since'] = observed['Last-Modified']
size = 0
prefix = b''
with open_url(url, headers=conditions) as response:
if metadata(response.headers) != observed:
raise ValueError('Datei hat sich zwischen HEAD und GET geändert; erneut versuchen.')
if response.headers.get('Content-Type', '').split(';')[0].lower() in (
'text/html', 'application/json'):
raise ValueError('Download liefert HTML/JSON statt einer Programmdatei.')
expected = response.headers.get('Content-Length')
total = int(expected) if expected is not None else None
last_report = time.monotonic()
if total is not None:
print(f' Download gestartet: {total / 1048576:.1f} MiB.', flush=True)
else:
print(' Download gestartet; Gesamtgrösse unbekannt.', flush=True)
with target.open('wb') as output:
while chunk := response.read(1024 * 1024):
if not size:
prefix = chunk[:4]
output.write(chunk)
size += len(chunk)
now = time.monotonic()
if now - last_report >= 5:
progress = f'{size / 1048576:.1f} MiB'
if total:
progress += f' / {total / 1048576:.1f} MiB ({size / total:.0%})'
print(f' Heruntergeladen: {progress}', flush=True)
last_report = now
if not size or (expected is not None and size != int(expected)):
raise ValueError('Leerer oder unvollständiger Download.')
suffix = Path(urlsplit(url).path).suffix.lower()
if suffix == '.exe' and not prefix.startswith(b'MZ'):
raise ValueError('Download hat keine Windows-EXE-Signatur.')
if suffix == '.zip' and not prefix.startswith(b'PK'):
raise ValueError('Download hat keine ZIP-Signatur.')
print(f' Download abgeschlossen: {size / 1048576:.1f} MiB.', flush=True)
return size
def hash_file(path):
total = path.stat().st_size
print(f' SHA-256-Berechnung gestartet: {total / 1048576:.1f} MiB.', flush=True)
digest = hashlib.sha256()
size = 0
last_report = time.monotonic()
with path.open('rb') as source:
while chunk := source.read(1024 * 1024):
digest.update(chunk)
size += len(chunk)
now = time.monotonic()
if now - last_report >= 5:
print(f' Gehasht: {size / 1048576:.1f} / {total / 1048576:.1f} MiB '
f'({size / total:.0%})', flush=True)
last_report = now
print(' SHA-256-Berechnung abgeschlossen.', flush=True)
return digest.hexdigest()
def download_hash(url, observed):
with tempfile.TemporaryDirectory(prefix='scoop-update-') as directory:
target = Path(directory) / 'download.bin'
size = download_file(url, observed, target)
return hash_file(target), size
def save_manifest(path, manifest):
original = path.read_bytes()
newline = '\r\n' if b'\r\n' in original else '\n'
content = json.dumps(manifest, ensure_ascii=False, indent=4) + '\n'
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=path.parent, suffix='.tmp', delete=False) as output:
temporary = Path(output.name)
output.write(content.replace('\n', newline).encode('utf-8'))
temporary.replace(path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def update_manifest(path, write=False):
manifest = json.loads(path.read_text(encoding='utf-8-sig'))
checkver, autoupdate = manifest.get('checkver'), manifest.get('autoupdate')
if not checkver or not autoupdate:
print(f'{path.stem}: kein automatisches Update konfiguriert; übersprungen.')
return False
if not isinstance(checkver, dict) or not isinstance(autoupdate.get('url'), str):
raise ValueError('Diese checkver/autoupdate-Konfiguration wird noch nicht unterstützt.')
template = autoupdate['url']
observed = None
if checkver.get('regex') == TIMESTAMP_REGEX:
source = urldefrag(manifest['url'])[0]
print(f'{path.stem}: HEAD-Anfrage an {source} ...', flush=True)
observed = read_head(source)
if not observed['Last-Modified']:
raise ValueError('Last-Modified-Header fehlt.')
date = parsedate_to_datetime(observed['Last-Modified'])
if date.tzinfo is None:
raise ValueError('Last-Modified enthält keine Zeitzone.')
version = date.astimezone(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
if urldefrag(template.replace('$version', version))[0] != source:
raise ValueError('Zeitstempel-Updates benötigen eine feste Download-URL.')
elif 'script' not in checkver:
print(f'{path.stem}: Release-Version abfragen: {checkver.get("url", "")} ...', flush=True)
version = release_version(checkver)
else:
raise ValueError('Dieses checkver-Skript wird nicht ausgeführt oder interpretiert.')
if version == manifest['version']:
print(f'{path.stem}: {version} unverändert; kein Download.')
return False
url = template.replace('$version', version)
if '$' in url:
raise ValueError('Unterstützt wird nur der Platzhalter $version.')
source = urldefrag(url)[0]
if observed is None:
print(f'{path.stem}: HEAD-Anfrage an {source} ...', flush=True)
observed = read_head(source)
print(f'{path.stem}: neue Version {version}; GET-Download ...', flush=True)
print(' Für den SHA-256-Hash muss die gesamte Datei heruntergeladen werden. '
'Danach wird der Hash berechnet. Bei grossen Dateien kann dies mehrere Minuten dauern, '
'auch im Probelauf.', flush=True)
digest, size = download_hash(source, observed)
print(f'{path.stem}: {manifest["version"]} -> {version}; {size} Bytes; SHA-256 {digest}')
manifest.update(version=version, url=url, hash=digest)
if write:
save_manifest(path, manifest)
print(f'{path.stem}: Manifest gespeichert.')
else:
print(f'{path.stem}: Probelauf; Manifest bleibt unverändert.')
return True
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--bucket', type=Path, default=BUCKET)
parser.add_argument('--app', action='append', help='Manifestname; mehrfach möglich, sonst alle.')
parser.add_argument('--write', action='store_true', help='Geprüfte Änderungen speichern.')
args = parser.parse_args(argv)
if not args.bucket.is_dir():
parser.error('Bucket-Verzeichnis existiert nicht.')
if args.app and any(not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', app) for app in args.app):
parser.error('--app erwartet einen Manifestnamen ohne Pfad oder .json-Endung.')
paths = ([args.bucket / f'{app}.json' for app in args.app] if args.app else
sorted(args.bucket.glob('*.json')))
errors = 0
for path in paths:
try:
update_manifest(path, args.write)
except HTTPError as error:
print(f'{path.stem}: HTTP {error.code}; Manifest unverändert.', file=sys.stderr)
errors += 1
except (ValueError, KeyError, TypeError, OSError, URLError) as error:
print(f'{path.stem}: {error}; Manifest unverändert.', file=sys.stderr)
errors += 1
return int(errors > 0)
if __name__ == '__main__':
sys.exit(main())