diff --git a/src/python/jw/pkg/lib/pm/dpkg.py b/src/python/jw/pkg/lib/pm/dpkg.py index b2ad671e..9255976f 100644 --- a/src/python/jw/pkg/lib/pm/dpkg.py +++ b/src/python/jw/pkg/lib/pm/dpkg.py @@ -94,6 +94,111 @@ async def list_files( file_list_str = await run_dpkg(['-L', *names], sudo = False, ec = ec) return file_list_str.splitlines() +async def _file_name_version( + path: str, + ec: ExecContext | None = None, +) -> tuple[str, str]: + """Return the (name, version) pair of a local deb file.""" + out = await run_dpkg(['-I', path], sudo = False, throw = True, ec = ec) + name: str | None = None + version: str | None = None + for line in out.splitlines(): + line = line.strip() + if line.startswith('Package: '): + name = line[len('Package: '):].strip() + elif line.startswith('Version: '): + version = line[len('Version: '):].strip() + if name is None or version is None: + raise Exception(f'cannot read the package name and version of {path}') + return name, version + +async def _simulate_install( + files: list[str], + expected: set[str], + ec: ExecContext | None = None, +) -> None: + """Gate a direct dpkg install on an apt simulation. + + dpkg -i leaves a half-configured package when a dependency is + missing, instead of aborting the transaction cleanly, so the + install is simulated first and refused when the simulation + would install or remove anything but the expected packages. + """ + result = await run_sudo( + ['/usr/bin/apt-get', '-s', '--no-install-recommends', 'install', *files], + ec = ec, + cmd_input = InputMode.NonInteractive, + throw = False, + ) + out = result.stdout_str + if result.status != 0: + raise Exception(f'apt simulation of the direct install failed:\n{out}') + inst: list[str] = [] + remv: list[str] = [] + for line in out.splitlines(): + parts = line.split() + if parts and parts[0] == 'Inst': + inst.append(parts[1]) + elif parts and parts[0] == 'Remv': + remv.append(parts[1]) + pulled = [pkg for pkg in inst if pkg not in expected] + if pulled or remv: + raise Exception(f'apt simulation would install {pulled} or remove {remv}') + +async def install_packages( + paths: Iterable[str], + ec: ExecContext | None = None, + force: bool = False, + only_update: bool = False, +) -> None: + """Install local deb files. + + Every install is gated on an apt simulation, which refuses the + transaction when it would install or remove anything but the + given files' own packages; see _simulate_install(). + + Files are classified against the installed state of their + packages: files for packages that are not installed are + installed fresh, files with another version installed are + upgraded, and files whose version is already installed are + reinstalled with -i --force-reinstall when force is set and + skipped otherwise. The upgrades run before the reinstalls. + + With only_update, a file for a package that is not installed + raises before anything is installed. + """ + paths = list(paths) + if not paths: + return + update: list[str] = [] + reinstall: list[str] = [] + not_installed: list[str] = [] + names: dict[str, str] = {} + for path in paths: + name, version = await _file_name_version(path, ec = ec) + names[path] = name + pkgs = list(await query_packages([name], ec = ec, throw = False)) + if not pkgs: + (not_installed if only_update else update).append(path) + continue + if version in {p.version for p in pkgs if p.version}: + reinstall.append(path) + continue + update.append(path) + if not_installed: + raise Exception( + 'only_update: package not installed for ' + + '\n'.join(f' {p}' for p in not_installed) + ) + selected = [*update, *reinstall] if force else update + if not selected: + return + await _simulate_install(selected, {names[path] for path in selected}, ec = ec) + if update: + await run_dpkg(['-i', *update], sudo = True, ec = ec) + if force and reinstall: + await run_dpkg(['-i', '--force-reinstall', *reinstall], sudo = True, ec = ec) + def _parse_verify_output( pkg: str, output: str, diff --git a/src/python/jw/pkg/lib/pm/rpm.py b/src/python/jw/pkg/lib/pm/rpm.py index cb63e26a..29070878 100644 --- a/src/python/jw/pkg/lib/pm/rpm.py +++ b/src/python/jw/pkg/lib/pm/rpm.py @@ -80,6 +80,69 @@ async def list_files( ) return stdout.splitlines() +async def _file_name_version( + path: str, + ec: ExecContext | None = None, +) -> tuple[str, str]: + """Return the (name, version-release) pair of a local rpm file.""" + out = await run_rpm( + ['-qp', '--queryformat', '%{NAME}\t%{VERSION}-%{RELEASE}', path], + throw = True, + sudo = False, + mode = InputMode.NonInteractive, + ec = ec + ) + name, version = out.split('\t', 1) + return name, version + +async def install_packages( + paths: Iterable[str], + ec: ExecContext | None = None, + force: bool = False, + only_update: bool = False, +) -> None: + """Install local rpm files. + + Files are classified against the installed state of their + packages: files for packages that are not installed are + installed fresh, files with another version installed are + upgraded, and files whose version is already installed are + reinstalled with -U --reinstall when force is set and + skipped otherwise, because plain -U refuses a package that + is already installed. The upgrades run before the + reinstalls: --reinstall aborts the whole transaction when a + file of the incoming package differs from the installed + one, which is exactly what an upgrade changes. + + With only_update, a file for a package that is not installed + raises before anything is installed. + """ + paths = list(paths) + if not paths: + return + update: list[str] = [] + reinstall: list[str] = [] + not_installed: list[str] = [] + for path in paths: + name, version = await _file_name_version(path, ec = ec) + pkgs = list(await query_packages([name], ec = ec, throw = False)) + if not pkgs: + (not_installed if only_update else update).append(path) + continue + if version in {p.version for p in pkgs if p.version}: + reinstall.append(path) + continue + update.append(path) + if not_installed: + raise Exception( + 'only_update: package not installed for ' + + '\n'.join(f' {p}' for p in not_installed) + ) + if update: + await run_rpm(['-U', *update], sudo = True, ec = ec) + if force and reinstall: + await run_rpm(['-U', '--reinstall', *reinstall], sudo = True, ec = ec) + def _parse_verify_output( pkg: str, output: str, diff --git a/test/unit/python/jw/pkg/lib/pm/dpkg/test.py b/test/unit/python/jw/pkg/lib/pm/dpkg/test.py index 66f06df6..bb8a9dba 100644 --- a/test/unit/python/jw/pkg/lib/pm/dpkg/test.py +++ b/test/unit/python/jw/pkg/lib/pm/dpkg/test.py @@ -5,7 +5,7 @@ import asyncio from typing import override from jw.pkg.lib.ec.Local import Local -from jw.pkg.lib.pm.dpkg import list_files +from jw.pkg.lib.pm.dpkg import install_packages, list_files from jw.pkg.lib.Result import Result class CannedLocal(Local): @@ -79,3 +79,261 @@ assert cl2.cmds == [ cl3 = CannedTwoStage('', '') assert asyncio.run(list_files([], ec = cl3)) == [] assert cl3.cmds == [] + +# -- install_packages -- + +class InstallLocal(Local): + """A canned execution context that answers dpkg queries and + the apt simulation""" + + def __init__( + self, + files: dict[str, str], + installed: dict[str, str], + simulate: str, + simulate_status: int = 0, + ) -> None: + super().__init__('local') + self.files = files + self.installed = installed + self.simulate = simulate + self.simulate_status = simulate_status + self.cmds: list[list[str]] = [] + + @override + async def _run( + self, + cmd: list[str], + wd: str | None, + verbose: bool, + cmd_input: bytes | None, + mod_env: dict[str, str] | None, + interactive: bool, + log_prefix: str, + ) -> Result: + self.cmds.append(cmd) + if '-I' in cmd: + return Result(stdout = self.files.get(cmd[-1], '').encode(), status = 0) + if cmd[0].endswith('dpkg-query'): + name = cmd[-1] + return Result( + stdout = self.installed.get(name, '').encode(), + status = 0 if name in self.installed else 1, + ) + if '/usr/bin/apt-get' in cmd: + return Result( + stdout = self.simulate.encode(), status = self.simulate_status + ) + return Result(stdout = b'', status = 0) + +def dpkg_args(cmd: list[str]) -> list[str]: + """The dpkg command without the sudo prefix and its options""" + return cmd[cmd.index('/usr/bin/dpkg'):] + +# -- Without force or only_update, all files go through a single +# plain -i, gated on the apt simulation +il = InstallLocal( + files = {'/a/jw-base-run-1.0.0-42.deb': 'Package: jw-base\nVersion: 1.0.0-42'}, + installed = {'jw-base': 'jw-base|||||1.0.0-41'}, + simulate = 'Inst jw-base [1.0.0-42]\n', +) +asyncio.run(install_packages(['/a/jw-base-run-1.0.0-42.deb'], ec = il)) +sim = [cmd for cmd in il.cmds if '/usr/bin/apt-get' in cmd] +assert len(sim) == 1 +args = sim[0][sim[0].index('/usr/bin/apt-get'):] +assert '-s' in args +assert '--no-install-recommends' in args +assert args[-1] == '/a/jw-base-run-1.0.0-42.deb' +installs = [dpkg_args(cmd) for cmd in il.cmds if '-i' in cmd] +assert installs == [['/usr/bin/dpkg', '-i', '/a/jw-base-run-1.0.0-42.deb']] + +# -- Without force, a file whose version is already installed is +# skipped +il = InstallLocal( + files = {'/a/jw-base-run-1.0.0-42.deb': 'Package: jw-base\nVersion: 1.0.0-42'}, + installed = {'jw-base': 'jw-base|||||1.0.0-42'}, + simulate = '', +) +asyncio.run(install_packages(['/a/jw-base-run-1.0.0-42.deb'], ec = il)) +assert not [cmd for cmd in il.cmds if '-i' in cmd] +assert not [cmd for cmd in il.cmds if '/usr/bin/apt-get' in cmd] + +# -- An empty selection runs no commands +il = InstallLocal(files = {}, installed = {}, simulate = '') +assert asyncio.run(install_packages([], ec = il)) is None +assert il.cmds == [] + +# -- force reinstalls a file whose version is already installed +il = InstallLocal( + files = {'/a/jw-base-run-1.0.0-42.deb': 'Package: jw-base\nVersion: 1.0.0-42'}, + installed = {'jw-base': 'jw-base|||||1.0.0-42'}, + simulate = '', +) +asyncio.run(install_packages(['/a/jw-base-run-1.0.0-42.deb'], ec = il, force = True)) +assert dpkg_args(il.cmds[-1]) == [ + '/usr/bin/dpkg', + '-i', + '--force-reinstall', + '/a/jw-base-run-1.0.0-42.deb', +] + +# -- force upgrades a file whose package is installed in another +# version with plain -i +il = InstallLocal( + files = {'/a/jw-base-run-1.0.0-42.deb': 'Package: jw-base\nVersion: 1.0.0-42'}, + installed = {'jw-base': 'jw-base|||||1.0.0-41'}, + simulate = 'Inst jw-base [1.0.0-42]\n', +) +asyncio.run(install_packages(['/a/jw-base-run-1.0.0-42.deb'], ec = il, force = True)) +assert dpkg_args(il.cmds[-1]) == ['/usr/bin/dpkg', '-i', '/a/jw-base-run-1.0.0-42.deb'] + +# -- force installs a file whose package is not installed with +# plain -i +il = InstallLocal( + files = {'/a/jw-base-run-1.0.0-42.deb': 'Package: jw-base\nVersion: 1.0.0-42'}, + installed = {}, + simulate = 'Inst jw-base [1.0.0-42]\n', +) +asyncio.run(install_packages(['/a/jw-base-run-1.0.0-42.deb'], ec = il, force = True)) +assert dpkg_args(il.cmds[-1]) == ['/usr/bin/dpkg', '-i', '/a/jw-base-run-1.0.0-42.deb'] + +# -- A file that cannot be queried raises before the simulation +# or the install +il = InstallLocal( + files = {}, + installed = {}, + simulate = '', +) +try: + asyncio.run(install_packages(['/a/broken.deb'], ec = il, force = True)) +except Exception as e: + assert 'package name and version' in str(e) +else: + raise AssertionError('expected an exception') +assert not [cmd for cmd in il.cmds if '-i' in cmd] +assert not [cmd for cmd in il.cmds if '/usr/bin/apt-get' in cmd] + +# -- only_update raises on files whose package is not installed, +# before simulating or installing anything +il = InstallLocal( + files = { + '/a/jw-base-run-1.0.0-42.deb': 'Package: jw-base\nVersion: 1.0.0-42', + '/a/jw-foo-run-1.0.0-42.deb': 'Package: jw-foo\nVersion: 1.0.0-42', + }, + installed = {'jw-base': 'jw-base|||||1.0.0-42'}, + simulate = '', +) +try: + asyncio.run( + install_packages( + ['/a/jw-base-run-1.0.0-42.deb', '/a/jw-foo-run-1.0.0-42.deb'], + ec = il, + force = True, + only_update = True, + ) + ) +except Exception as e: + assert 'only_update' in str(e) + assert '/a/jw-foo-run-1.0.0-42.deb' in str(e) +else: + raise AssertionError('expected an exception') +assert not [cmd for cmd in il.cmds if '-i' in cmd] +assert not [cmd for cmd in il.cmds if '/usr/bin/apt-get' in cmd] + +# -- only_update installs an update of an installed package +il = InstallLocal( + files = {'/a/jw-base-run-1.0.0-42.deb': 'Package: jw-base\nVersion: 1.0.0-42'}, + installed = {'jw-base': 'jw-base|||||1.0.0-41'}, + simulate = 'Inst jw-base [1.0.0-42]\n', +) +asyncio.run( + install_packages( + ['/a/jw-base-run-1.0.0-42.deb'], ec = il, force = True, only_update = True + ) +) +assert dpkg_args(il.cmds[-1]) == ['/usr/bin/dpkg', '-i', '/a/jw-base-run-1.0.0-42.deb'] + +# -- force runs two installs for a mixed selection, the upgrades +# first +il = InstallLocal( + files = { + '/a/jw-base-run-1.0.0-42.deb': 'Package: jw-base\nVersion: 1.0.0-42', + '/a/jw-base-run-1.0.0-41.deb': 'Package: jw-base\nVersion: 1.0.0-41', + }, + installed = {'jw-base': 'jw-base|||||1.0.0-42'}, + simulate = 'Inst jw-base [1.0.0-41]\n', +) +asyncio.run( + install_packages( + ['/a/jw-base-run-1.0.0-42.deb', '/a/jw-base-run-1.0.0-41.deb'], + ec = il, + force = True, + ) +) +installs = [dpkg_args(cmd) for cmd in il.cmds if '-i' in cmd] +assert installs == [ + ['/usr/bin/dpkg', '-i', '/a/jw-base-run-1.0.0-41.deb'], + [ + '/usr/bin/dpkg', + '-i', + '--force-reinstall', + '/a/jw-base-run-1.0.0-42.deb', + ], +] + +# -- The simulation refuses the install when it would pull a +# dependency +il = InstallLocal( + files = {'/a/jw-base-run-1.0.0-42.deb': 'Package: jw-base\nVersion: 1.0.0-42'}, + installed = {}, + simulate = 'Inst jw-base [1.0.0-42]\nInst jw-foo [2.0]\n', +) +try: + asyncio.run( + install_packages(['/a/jw-base-run-1.0.0-42.deb'], ec = il, force = True) + ) +except Exception as e: + assert 'would install' in str(e) + assert 'jw-foo' in str(e) +else: + raise AssertionError('expected an exception') +assert not [cmd for cmd in il.cmds if '-i' in cmd] + +# -- The simulation refuses the install when it would remove a +# package +il = InstallLocal( + files = {'/a/jw-base-run-1.0.0-42.deb': 'Package: jw-base\nVersion: 1.0.0-42'}, + installed = {}, + simulate = 'Inst jw-base [1.0.0-42]\nRemv jw-old [1.0]\n', +) +try: + asyncio.run( + install_packages(['/a/jw-base-run-1.0.0-42.deb'], ec = il, force = True) + ) +except Exception as e: + assert 'remove' in str(e) + assert 'jw-old' in str(e) +else: + raise AssertionError('expected an exception') +assert not [cmd for cmd in il.cmds if '-i' in cmd] + +# -- A failed simulation, for instance unmet dependencies, aborts +# with its output +il = InstallLocal( + files = {'/a/jw-base-run-1.0.0-42.deb': 'Package: jw-base\nVersion: 1.0.0-42'}, + installed = {}, + simulate = ( + 'The following packages have unmet dependencies:\n' + ' jw-base : Depends: jw-foo but it is not going to be installed\n' + ), + simulate_status = 100, +) +try: + asyncio.run( + install_packages(['/a/jw-base-run-1.0.0-42.deb'], ec = il, force = True) + ) +except Exception as e: + assert 'unmet dependencies' in str(e) +else: + raise AssertionError('expected an exception') +assert not [cmd for cmd in il.cmds if '-i' in cmd] diff --git a/test/unit/python/jw/pkg/lib/pm/rpm/test.py b/test/unit/python/jw/pkg/lib/pm/rpm/test.py index e42ace0a..deed1875 100644 --- a/test/unit/python/jw/pkg/lib/pm/rpm/test.py +++ b/test/unit/python/jw/pkg/lib/pm/rpm/test.py @@ -6,7 +6,7 @@ from typing import override from jw.pkg.lib.ec.Local import Local from jw.pkg.lib.FlaggedFile import FlaggedFile -from jw.pkg.lib.pm.rpm import _parse_verify_output, list_files +from jw.pkg.lib.pm.rpm import _parse_verify_output, install_packages, list_files from jw.pkg.lib.Result import Result # -- _parse_verify_output -- @@ -101,3 +101,234 @@ assert cl.cmds[-1] == ['/usr/bin/rpm', '-qal'] cl = CannedLocal('') assert asyncio.run(list_files([], ec = cl)) == [] assert cl.cmds == [] + +# -- install_packages -- + +def rpm_args(cmd: list[str]) -> list[str]: + """The rpm command without the sudo prefix and its options""" + return cmd[cmd.index('/usr/bin/rpm'):] + +class DispatchLocal(Local): + """A canned execution context that answers rpm queries""" + + def __init__(self, files: dict[str, str], installed: dict[str, str]) -> None: + super().__init__('local') + self.files = files + self.installed = installed + self.cmds: list[list[str]] = [] + + @override + async def _run( + self, + cmd: list[str], + wd: str | None, + verbose: bool, + cmd_input: bytes | None, + mod_env: dict[str, str] | None, + interactive: bool, + log_prefix: str, + ) -> Result: + self.cmds.append(cmd) + if '-qp' in cmd: + return Result(stdout = self.files.get(cmd[-1], '').encode(), status = 0) + if '-q' in cmd: + name = cmd[-1] + return Result( + stdout = self.installed.get(name, '').encode(), + status = 0 if name in self.installed else 1, + ) + return Result(stdout = b'', status = 0) + +# -- Without force, a file whose version is already installed is +# skipped, because plain -U refuses it +dl = DispatchLocal( + files = {'/a/jw-base-run-1.0.0-42.x86_64.rpm': 'jw-base\t1.0.0-42'}, + installed = {'jw-base': 'jw-base|||||1.0.0-42\n'}, +) +asyncio.run(install_packages(['/a/jw-base-run-1.0.0-42.x86_64.rpm'], ec = dl)) +assert not [cmd for cmd in dl.cmds if '-U' in cmd] + +# -- Without force, fresh and update files go through a single +# plain -U +dl = DispatchLocal( + files = { + '/a/jw-base-run-1.0.0-42.x86_64.rpm': 'jw-base\t1.0.0-42', + '/b/jw-foo-run-1.0.0-42.x86_64.rpm': 'jw-foo\t1.0.0-42', + }, + installed = {'jw-base': 'jw-base|||||1.0.0-41\n'}, +) +asyncio.run( + install_packages( + ['/a/jw-base-run-1.0.0-42.x86_64.rpm', '/b/jw-foo-run-1.0.0-42.x86_64.rpm'], + ec = dl, + ) +) +installs = [cmd for cmd in dl.cmds if '-U' in cmd] +assert len(installs) == 1 +assert installs[0][0] == '/usr/bin/sudo' +assert rpm_args(installs[0]) == [ + '/usr/bin/rpm', + '-U', + '/a/jw-base-run-1.0.0-42.x86_64.rpm', + '/b/jw-foo-run-1.0.0-42.x86_64.rpm', +] + +# -- An empty selection installs nothing and runs no command +dl = DispatchLocal(files = {}, installed = {}) +asyncio.run(install_packages([], ec = dl)) +assert dl.cmds == [] + +# -- With force, a file whose version is already installed is reinstalled +dl = DispatchLocal( + files = {'/a/jw-base-run-1.0.0-42.x86_64.rpm': 'jw-base\t1.0.0-42'}, + installed = {'jw-base': 'jw-base|||||1.0.0-42\n'}, +) +asyncio.run( + install_packages(['/a/jw-base-run-1.0.0-42.x86_64.rpm'], ec = dl, force = True) +) +assert rpm_args(dl.cmds[-1]) == [ + '/usr/bin/rpm', + '-U', + '--reinstall', + '/a/jw-base-run-1.0.0-42.x86_64.rpm', +] + +# -- With force, a file with another version installed is a plain upgrade +dl = DispatchLocal( + files = {'/a/jw-base-run-1.0.0-42.x86_64.rpm': 'jw-base\t1.0.0-42'}, + installed = {'jw-base': 'jw-base|||||1.0.0-41\n'}, +) +asyncio.run( + install_packages(['/a/jw-base-run-1.0.0-42.x86_64.rpm'], ec = dl, force = True) +) +assert rpm_args(dl.cmds[-1]) == [ + '/usr/bin/rpm', + '-U', + '/a/jw-base-run-1.0.0-42.x86_64.rpm', +] + +# -- With force, a file for a package that is not installed is a plain +# install +dl = DispatchLocal( + files = {'/a/jw-base-run-1.0.0-42.x86_64.rpm': 'jw-base\t1.0.0-42'}, + installed = {}, +) +asyncio.run( + install_packages(['/a/jw-base-run-1.0.0-42.x86_64.rpm'], ec = dl, force = True) +) +assert rpm_args(dl.cmds[-1]) == [ + '/usr/bin/rpm', + '-U', + '/a/jw-base-run-1.0.0-42.x86_64.rpm', +] + +# -- An unqueryable file raises before anything is installed +dl = DispatchLocal(files = {}, installed = {}) +try: + asyncio.run( + install_packages(['/a/jw-base-run-1.0.0-42.x86_64.rpm'], ec = dl, force = True) + ) +except Exception: + pass +else: + raise AssertionError('expected an exception') +assert not [cmd for cmd in dl.cmds if '-U' in cmd] + +# -- With force, a mixed transaction runs two installs, the upgrades first +dl = DispatchLocal( + files = { + '/a/jw-base-run-1.0.0-42.x86_64.rpm': 'jw-base\t1.0.0-42', + '/a/jw-base-run-1.0.0-41.x86_64.rpm': 'jw-base\t1.0.0-41', + }, + installed = {'jw-base': 'jw-base|||||1.0.0-42\n'}, +) +asyncio.run( + install_packages( + ['/a/jw-base-run-1.0.0-42.x86_64.rpm', '/a/jw-base-run-1.0.0-41.x86_64.rpm'], + ec = dl, + force = True, + ) +) +installs = [rpm_args(cmd) for cmd in dl.cmds if '-U' in cmd] +assert installs == [ + ['/usr/bin/rpm', '-U', '/a/jw-base-run-1.0.0-41.x86_64.rpm'], + [ + '/usr/bin/rpm', + '-U', + '--reinstall', + '/a/jw-base-run-1.0.0-42.x86_64.rpm', + ], +] + +# -- With only_update, a file whose package is not installed raises +# before anything is installed +dl = DispatchLocal( + files = { + '/a/jw-base-run-1.0.0-42.x86_64.rpm': 'jw-base\t1.0.0-42', + '/a/jw-foo-run-1.0.0-42.x86_64.rpm': 'jw-foo\t1.0.0-42', + }, + installed = {'jw-base': 'jw-base|||||1.0.0-42\n'}, +) +try: + asyncio.run( + install_packages( + ['/a/jw-base-run-1.0.0-42.x86_64.rpm', '/a/jw-foo-run-1.0.0-42.x86_64.rpm'], + ec = dl, + force = True, + only_update = True, + ) + ) +except Exception as e: + assert 'only_update' in str(e) + assert '/a/jw-foo-run-1.0.0-42.x86_64.rpm' in str(e) +else: + raise AssertionError('expected an exception') +assert not [cmd for cmd in dl.cmds if '-U' in cmd] + +# -- With only_update, an update of an installed package is installed +dl = DispatchLocal( + files = {'/a/jw-base-run-1.0.0-43.x86_64.rpm': 'jw-base\t1.0.0-43'}, + installed = {'jw-base': 'jw-base|||||1.0.0-42\n'}, +) +asyncio.run( + install_packages( + ['/a/jw-base-run-1.0.0-43.x86_64.rpm'], + ec = dl, + force = True, + only_update = True, + ) +) +installs = [rpm_args(cmd) for cmd in dl.cmds if '-U' in cmd] +assert installs == [ + ['/usr/bin/rpm', '-U', '/a/jw-base-run-1.0.0-43.x86_64.rpm'], +] + +# -- With only_update and force off, a file whose version is +# already installed is skipped +dl = DispatchLocal( + files = {'/a/jw-base-run-1.0.0-42.x86_64.rpm': 'jw-base\t1.0.0-42'}, + installed = {'jw-base': 'jw-base|||||1.0.0-42\n'}, +) +asyncio.run( + install_packages( + ['/a/jw-base-run-1.0.0-42.x86_64.rpm'], ec = dl, only_update = True + ) +) +assert not [cmd for cmd in dl.cmds if '-U' in cmd] + +# -- With only_update, all files absent raises without installing +dl = DispatchLocal( + files = {'/a/jw-foo-run-1.0.0-42.x86_64.rpm': 'jw-foo\t1.0.0-42'}, + installed = {}, +) +try: + asyncio.run( + install_packages( + ['/a/jw-foo-run-1.0.0-42.x86_64.rpm'], ec = dl, only_update = True + ) + ) +except Exception as e: + assert 'only_update' in str(e) +else: + raise AssertionError('expected an exception') +assert not [cmd for cmd in dl.cmds if '-U' in cmd]