feat: InstallQueue

This commit is contained in:
relikd
2025-08-31 12:22:19 +02:00
parent cdc6d6fa88
commit 8e4bba178d

290
brew.py
View File

@@ -177,10 +177,9 @@ def cli_fetch(args: ArgParams) -> None:
Log.info(' tag:', tag)
Log.info(' digest:', digest)
path = Brew.download(Brew.Dependency(args.package, tag or digest, digest),
askOverwrite=True)
pth = Brew.download(args.package, tag or digest, digest, askOverwrite=True)
Log.info('==> ', end='')
Log.main(path)
Log.main(pth)
# https://docs.brew.sh/Manpage#list-ls-options-installed_formulainstalled_cask-
@@ -282,66 +281,19 @@ def cli_missing(args: ArgParams) -> None:
# https://docs.brew.sh/Manpage#install-options-formulacask-
def cli_install(args: ArgParams) -> None:
''' Install a package with all dependencies. '''
if os.path.isfile(args.package) and args.package.endswith('.tar.gz'):
if args.dry_run:
Log.info('==> Would install from tar file ...')
else:
Log.info('==> Installing from tar file ...')
Cellar.install(args.package,
skipLink=args.skip_link, linkExe=args.binaries)
local = Cellar.info(args.package)
if local.installed and not args.force:
Log.info(args.package, 'already installed, checking for newer version')
Brew.checkUpdate(args.package)
return
elif '/' in args.package:
Log.error('package may not contain path-separator')
return
if args.ignore_dependencies:
Log.info('==> Ignoring dependencies ...')
deps = Brew.gatherDependencies(args.package, recursive=False)
else:
Log.info('==> Gather dependencies ...')
deps = Brew.gatherDependencies(args.package, recursive=True)
Log.info(Utils.prettyList([x.package for x in deps]))
infos = [Cellar.info(x.package) for x in deps]
# if all are installed, we dont care about which version exactly.
# users should run upgrade in that case
if not args.force and all(x.installed for x in infos):
Log.error(args.package, 'is already installed.')
Brew.checkUpdates(deps)
return
# if at least one digest couldn't be determined, we must fail whole queue
failed_arch = [x.package for x in deps if not x.digest]
if failed_arch:
Log.error('missing platform "{}" in: {}'.format(
Arch.BREW, ', '.join(failed_arch)))
return
# skip if a specific version already exists
needs_download = [dep for dep, info in zip(deps, infos)
if args.force or dep.version not in info.verAll]
Log.info()
Log.info('==> Download ...')
Log.beginCounter(len(needs_download))
needs_install = [Brew.download(dep, dryRun=args.dry_run)
for dep in needs_download]
Log.info()
Log.info('==> Install ...')
Log.beginCounter(len(needs_install))
Log.beginErrorSummary()
for tar in reversed(needs_install):
if args.dry_run:
Log.main('would install', os.path.relpath(tar, Cellar.ROOT),
count=True)
else:
Cellar.install(tar, skipLink=args.skip_link, linkExe=args.binaries)
Log.endCounter()
Log.dumpErrorSummary()
queue = InstallQueue(dryRun=args.dry_run, force=args.force)
queue.init(args.package, recursive=not args.no_dependencies)
if not args.no_dependencies:
queue.printQueue()
queue.validateQueue()
queue.download()
queue.install(skipLink=args.skip_link, linkExe=args.binaries)
# https://docs.brew.sh/Manpage#uninstall-remove-rm-options-installed_formulainstalled_cask-
@@ -652,7 +604,7 @@ def parseArgs() -> ArgParams:
Show what would be installed, but do not actually install anything''')
cmd.arg('-arch', help='''Manually set platform architecture
(e.g. 'arm64_sequoia' (brew), 'arm64|darwin|macOS 15' (ghcr))''')
cmd.arg_bool('--ignore-dependencies', help='Do not install dependencies')
cmd.arg_bool('--no-dependencies', help='Do not install dependencies')
cmd.arg_bool('--skip-link', help='Install but skip linking to opt')
cmd.arg('--binaries', action=BooleanOptionalAction, help='''
Enable/disable linking of helper executables (default: enabled).
@@ -1128,28 +1080,28 @@ class Brew:
return rv
@staticmethod
def checkUpdates(deps: list[Dependency]) -> None:
shownAny = False
for dep in deps:
info = Cellar.info(dep.package)
if dep.version not in info.verAll:
shownAny = True
Log.info(' * upgrade available {} {} (installed: {})'.format(
dep.package, dep.version, ', '.join(info.verAll)))
if not shownAny:
Log.info('all packages are up to date.')
def checkUpdate(pkg: str, *, force: bool = False) -> None:
''' Print whether package is up-to-date or needs upgrade '''
local = Cellar.info(pkg)
if local.installed:
onlineVersion = Brew.info(pkg, force=force).version
if onlineVersion in local.verAll:
Log.info('package is up to date.')
else:
Log.info(' * upgrade available {} (installed: {})'.format(
onlineVersion, ', '.join(local.verAll)))
@staticmethod
def download(
dep: Dependency, *, askOverwrite: bool = False, dryRun: bool = False
pkg: str, version: str, digest: str,
*, askOverwrite: bool = False, dryRun: bool = False
) -> str:
assert dep.digest, 'digest is required for download'
fname = Cellar.downloadPath(dep.package, dep.version)
assert digest, 'digest is required for download'
fname = Cellar.downloadPath(pkg, version)
# reuse already downloaded tar
if os.path.isfile(fname):
if File.sha256(fname) == dep.digest:
Log.main('skip already downloaded', dep.package, dep.version,
count=True)
if File.sha256(fname) == digest:
Log.main('skip already downloaded', pkg, version, count=True)
return fname
elif askOverwrite:
Log.warn(f'file "{fname}" already exists')
@@ -1160,11 +1112,11 @@ class Brew:
Log.warn('sha256 mismatch. Ignore local file and re-download.')
if dryRun:
Log.main('would download', dep.package, dep.version, count=True)
Log.main('would download', pkg, version, count=True)
else:
Log.main('download', dep.package, dep.version, count=True)
auth = Brew._ghcrAuth(dep.package)
os.rename(ApiGhcr.blob(auth, dep.package, dep.digest), fname)
Log.main('download', pkg, version, count=True)
auth = Brew._ghcrAuth(pkg)
os.rename(ApiGhcr.blob(auth, pkg, digest), fname)
return fname
@@ -1256,47 +1208,40 @@ class Cellar:
# Install management
@staticmethod
def install(
tarPath: str, *, skipLink: bool = False, linkExe: bool = False
) -> bool:
def installTar(tarPath: str, *, dryRun: bool = False) \
-> 'tuple[str, str]|None':
''' Extract tar file into `@/cellar/...` '''
shortPath = os.path.relpath(tarPath, Cellar.ROOT)
if shortPath.startswith('..'): # if path outside of cellar
shortPath = os.path.basename(tarPath)
if not os.path.isfile(tarPath):
if dryRun:
Log.main('would install', shortPath, count=True)
return None
pkg, version = None, None
with openTarfile(tarPath, 'r') as tar:
subset = []
for x in tar:
if tarFilter(x, Cellar.CELLAR):
subset.append(x)
if x.isdir() and x.path.endswith('/.brew'):
if not pkg and x.isdir() and x.path.endswith('/.brew'):
pkg, version, *_ = x.path.split('/')
else:
Log.error('prohibited tar entry "{}" in ({})'.format(
x.path, os.path.basename(tarPath)), summary=True)
Log.error(f'prohibited tar entry "{x.path}" in', shortPath,
summary=True)
if pkg is None or version is None:
Log.error('".brew" dir missing. Failed to extract {}'.format(
os.path.basename(tarPath)), summary=True, count=True)
return False
else:
Log.main(f'install {pkg} {version}', count=True)
Log.error('".brew" dir missing. Failed to extract', shortPath,
summary=True, count=True)
return None
Log.main('would install' if dryRun else 'install', shortPath,
f'({pkg} {version})', count=True)
if not dryRun:
tar.extractall(Cellar.CELLAR, subset)
with open(Cellar.rubyPath(pkg, version, 'digest'), 'w') as fp:
fp.write(File.sha256(tarPath))
# relink dylibs
Fixer.run(pkg, version)
if skipLink:
return True
if Cellar.isKegOnly(pkg, version):
Log.warn(f'keg-only, must link manually ({pkg}, {version})',
summary=True)
else:
withBin = Env.LINK_BINARIES if linkExe is None else linkExe
Cellar.unlinkPackage(pkg)
Cellar.linkPackage(pkg, version, noExe=not withBin)
return True
return pkg, version
@staticmethod
def getDependencyTree() -> DependencyTree:
@@ -1418,6 +1363,129 @@ class Cellar:
return RubyParser(Cellar.rubyPath(pkg, version)).parseKegOnly()
# -----------------------------------
# InstallQueue
# -----------------------------------
class InstallQueue:
class Item(NamedTuple):
package: str
version: str
digest: str
def __init__(self, *, dryRun: bool, force: bool) -> None:
self.dryRun = dryRun
self.force = force
self._missingDigest = [] # type: list[str] # pkg
self.downloadQueue = [] # type: list[InstallQueue.Item]
self.installQueue = [] # type: list[str] # tar file path
def init(self, pkgOrFile: str, *, recursive: bool) -> None:
''' Auto-detect input type and install from tar-file or brew online '''
if os.path.isfile(pkgOrFile) and pkgOrFile.endswith('.tar.gz'):
Log.info('==> Install from tar file ...')
self.installQueue.append(pkgOrFile)
elif '/' in pkgOrFile:
Log.error('package may not contain path-separator')
elif recursive:
Log.info('==> Gather dependencies ...')
self.addRecursive(pkgOrFile)
else:
Log.info('==> Ignoring dependencies ...')
bundle = Brew.info(pkgOrFile)
self.add(pkgOrFile, bundle.version, bundle.digest)
def addRecursive(self, pkg: str) -> None:
''' Recursive online search for dependencies '''
queue = [pkg]
done = set(pkg)
while queue:
pkg = queue.pop(0)
bundle = Brew.info(pkg)
self.add(pkg, bundle.version, bundle.digest)
subdeps = bundle.dependencies or []
queue.extend(x for x in subdeps if x not in done)
done.update(subdeps)
def add(self, pkg: str, version: str, digest: 'str|None') -> None:
''' Check if specific version exists and add to download queue '''
info = Cellar.info(pkg)
# skip if a specific version already exists
if not self.force and version in info.verAll:
# TODO: print already installed?
return
if not digest:
self._missingDigest.append(pkg)
else:
self.downloadQueue.append(InstallQueue.Item(pkg, version, digest))
def printQueue(self) -> None:
''' Print download Queue (if any) '''
if not self.downloadQueue:
return
Log.info(Utils.prettyList([x.package for x in self.downloadQueue]))
def validateQueue(self) -> None:
''' Check if any digest is missing. If so, fail with exit code 1 '''
# if any digest couldn't be determined, we must fail whole queue
if self._missingDigest:
Log.error('missing platform "{}" in: {}'.format(
Arch.BREW, ', '.join(self._missingDigest)))
exit(1)
def download(self) -> None:
''' Download all dependencies in normal order (depth-first) '''
if not self.downloadQueue:
return
Log.info()
Log.info('==> Download ...')
Log.beginCounter(len(self.downloadQueue))
for x in self.downloadQueue:
self.installQueue.append(Brew.download(
x.package, x.version, x.digest, dryRun=self.dryRun))
Log.endCounter()
def install(self, *, skipLink: bool, linkExe: bool) -> None:
''' Install all dependencies in reverse order (main package last) '''
Log.info()
Log.info('==> Install ...')
if not self.installQueue:
Log.info('nothing to install')
return
total = len(self.installQueue)
Log.beginCounter(total)
Log.beginErrorSummary()
# reverse to install main package last (allow re-install until success)
for i, tar in enumerate(reversed(self.installQueue), 1):
bundle = Cellar.installTar(tar, dryRun=self.dryRun)
if bundle:
self.postInstall(
bundle[0], bundle[1], File.sha256(tar),
skipLink=skipLink, linkExe=linkExe, isPrimary=i == total)
Log.endCounter()
Log.dumpErrorSummary()
def postInstall(
self, pkg: str, version: str, digest: str, *,
skipLink: bool, linkExe: bool, isPrimary: bool,
) -> None:
# copy digest of tar file into install dir
with open(Cellar.rubyPath(pkg, version, 'digest'), 'w') as fp:
fp.write(digest)
# relink dylibs
Fixer.run(pkg, version)
if not skipLink:
if Cellar.isKegOnly(pkg, version):
Log.warn(f'keg-only, must link manually ({pkg}, {version})',
summary=True)
else:
withBin = Env.LINK_BINARIES if linkExe is None else linkExe
Cellar.unlinkPackage(pkg)
Cellar.linkPackage(pkg, version, noExe=not withBin)
# -----------------------------------
# Fixer
# -----------------------------------