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(' tag:', tag)
Log.info(' digest:', digest) Log.info(' digest:', digest)
path = Brew.download(Brew.Dependency(args.package, tag or digest, digest), pth = Brew.download(args.package, tag or digest, digest, askOverwrite=True)
askOverwrite=True)
Log.info('==> ', end='') Log.info('==> ', end='')
Log.main(path) Log.main(pth)
# https://docs.brew.sh/Manpage#list-ls-options-installed_formulainstalled_cask- # 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- # https://docs.brew.sh/Manpage#install-options-formulacask-
def cli_install(args: ArgParams) -> None: def cli_install(args: ArgParams) -> None:
''' Install a package with all dependencies. ''' ''' Install a package with all dependencies. '''
if os.path.isfile(args.package) and args.package.endswith('.tar.gz'): local = Cellar.info(args.package)
if args.dry_run: if local.installed and not args.force:
Log.info('==> Would install from tar file ...') Log.info(args.package, 'already installed, checking for newer version')
else: Brew.checkUpdate(args.package)
Log.info('==> Installing from tar file ...')
Cellar.install(args.package,
skipLink=args.skip_link, linkExe=args.binaries)
return return
elif '/' in args.package: queue = InstallQueue(dryRun=args.dry_run, force=args.force)
Log.error('package may not contain path-separator') queue.init(args.package, recursive=not args.no_dependencies)
return if not args.no_dependencies:
queue.printQueue()
if args.ignore_dependencies: queue.validateQueue()
Log.info('==> Ignoring dependencies ...') queue.download()
deps = Brew.gatherDependencies(args.package, recursive=False) queue.install(skipLink=args.skip_link, linkExe=args.binaries)
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()
# https://docs.brew.sh/Manpage#uninstall-remove-rm-options-installed_formulainstalled_cask- # 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''') Show what would be installed, but do not actually install anything''')
cmd.arg('-arch', help='''Manually set platform architecture cmd.arg('-arch', help='''Manually set platform architecture
(e.g. 'arm64_sequoia' (brew), 'arm64|darwin|macOS 15' (ghcr))''') (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_bool('--skip-link', help='Install but skip linking to opt')
cmd.arg('--binaries', action=BooleanOptionalAction, help=''' cmd.arg('--binaries', action=BooleanOptionalAction, help='''
Enable/disable linking of helper executables (default: enabled). Enable/disable linking of helper executables (default: enabled).
@@ -1128,28 +1080,28 @@ class Brew:
return rv return rv
@staticmethod @staticmethod
def checkUpdates(deps: list[Dependency]) -> None: def checkUpdate(pkg: str, *, force: bool = False) -> None:
shownAny = False ''' Print whether package is up-to-date or needs upgrade '''
for dep in deps: local = Cellar.info(pkg)
info = Cellar.info(dep.package) if local.installed:
if dep.version not in info.verAll: onlineVersion = Brew.info(pkg, force=force).version
shownAny = True if onlineVersion in local.verAll:
Log.info(' * upgrade available {} {} (installed: {})'.format( Log.info('package is up to date.')
dep.package, dep.version, ', '.join(info.verAll))) else:
if not shownAny: Log.info(' * upgrade available {} (installed: {})'.format(
Log.info('all packages are up to date.') onlineVersion, ', '.join(local.verAll)))
@staticmethod @staticmethod
def download( def download(
dep: Dependency, *, askOverwrite: bool = False, dryRun: bool = False pkg: str, version: str, digest: str,
*, askOverwrite: bool = False, dryRun: bool = False
) -> str: ) -> str:
assert dep.digest, 'digest is required for download' assert digest, 'digest is required for download'
fname = Cellar.downloadPath(dep.package, dep.version) fname = Cellar.downloadPath(pkg, version)
# reuse already downloaded tar # reuse already downloaded tar
if os.path.isfile(fname): if os.path.isfile(fname):
if File.sha256(fname) == dep.digest: if File.sha256(fname) == digest:
Log.main('skip already downloaded', dep.package, dep.version, Log.main('skip already downloaded', pkg, version, count=True)
count=True)
return fname return fname
elif askOverwrite: elif askOverwrite:
Log.warn(f'file "{fname}" already exists') Log.warn(f'file "{fname}" already exists')
@@ -1160,11 +1112,11 @@ class Brew:
Log.warn('sha256 mismatch. Ignore local file and re-download.') Log.warn('sha256 mismatch. Ignore local file and re-download.')
if dryRun: if dryRun:
Log.main('would download', dep.package, dep.version, count=True) Log.main('would download', pkg, version, count=True)
else: else:
Log.main('download', dep.package, dep.version, count=True) Log.main('download', pkg, version, count=True)
auth = Brew._ghcrAuth(dep.package) auth = Brew._ghcrAuth(pkg)
os.rename(ApiGhcr.blob(auth, dep.package, dep.digest), fname) os.rename(ApiGhcr.blob(auth, pkg, digest), fname)
return fname return fname
@@ -1256,47 +1208,40 @@ class Cellar:
# Install management # Install management
@staticmethod @staticmethod
def install( def installTar(tarPath: str, *, dryRun: bool = False) \
tarPath: str, *, skipLink: bool = False, linkExe: bool = False -> 'tuple[str, str]|None':
) -> bool:
''' Extract tar file into `@/cellar/...` ''' ''' 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 pkg, version = None, None
with openTarfile(tarPath, 'r') as tar: with openTarfile(tarPath, 'r') as tar:
subset = [] subset = []
for x in tar: for x in tar:
if tarFilter(x, Cellar.CELLAR): if tarFilter(x, Cellar.CELLAR):
subset.append(x) 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('/') pkg, version, *_ = x.path.split('/')
else: else:
Log.error('prohibited tar entry "{}" in ({})'.format( Log.error(f'prohibited tar entry "{x.path}" in', shortPath,
x.path, os.path.basename(tarPath)), summary=True) summary=True)
if pkg is None or version is None: if pkg is None or version is None:
Log.error('".brew" dir missing. Failed to extract {}'.format( Log.error('".brew" dir missing. Failed to extract', shortPath,
os.path.basename(tarPath)), summary=True, count=True) summary=True, count=True)
return False return None
else:
Log.main(f'install {pkg} {version}', count=True) Log.main('would install' if dryRun else 'install', shortPath,
f'({pkg} {version})', count=True)
if not dryRun:
tar.extractall(Cellar.CELLAR, subset) tar.extractall(Cellar.CELLAR, subset)
return pkg, version
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
@staticmethod @staticmethod
def getDependencyTree() -> DependencyTree: def getDependencyTree() -> DependencyTree:
@@ -1418,6 +1363,129 @@ class Cellar:
return RubyParser(Cellar.rubyPath(pkg, version)).parseKegOnly() 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 # Fixer
# ----------------------------------- # -----------------------------------