lib.pm.*.install_packages(): Add functions
Currently, jw-pkg install is sometimes left to the high-level package manager, which is allowed to install dependencies along, and that's sometimes unwanted, but there's no backend in the low-level package manager module lib.pm to allow specifying that. This commit adds it: lib.pm.*.install_packages(). The functions install a list of local package files using the low-level package managers rpm / dpkg, bypassing the high-level package managers yum / apt / zypper. pm.rpm runs rpm -U and pm.dpkg runs dpkg -i. Every file is classified against the installed state of its package, queried by name: a file for a package that is not installed is installed fresh, a file with another version installed is upgraded, and a file whose version is already installed is reinstalled when force is set and skipped otherwise. On rpm, plain -U refuses a package that is already installed, and --reinstall aborts the transaction when a file of the incoming package differs from the installed one, so the upgrades run before the reinstalls and the same-version files need force. With only_update, a file for a package that is not installed raises before anything is installed. The dpkg route is gated on an apt simulation, because dpkg -i leaves a half-configured package when a dependency is missing: the simulation runs first and the install is refused when it would install or remove anything but the given files' own packages. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v1.4.0 Signed-off-by: Jan Lindemann <jan@janware.com>
This commit is contained in:
parent
afbdc2e4b0
commit
36978c55e2
4 changed files with 659 additions and 2 deletions
|
|
@ -94,6 +94,111 @@ async def list_files(
|
||||||
file_list_str = await run_dpkg(['-L', *names], sudo = False, ec = ec)
|
file_list_str = await run_dpkg(['-L', *names], sudo = False, ec = ec)
|
||||||
return file_list_str.splitlines()
|
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(
|
def _parse_verify_output(
|
||||||
pkg: str,
|
pkg: str,
|
||||||
output: str,
|
output: str,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,69 @@ async def list_files(
|
||||||
)
|
)
|
||||||
return stdout.splitlines()
|
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(
|
def _parse_verify_output(
|
||||||
pkg: str,
|
pkg: str,
|
||||||
output: str,
|
output: str,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import asyncio
|
||||||
from typing import override
|
from typing import override
|
||||||
|
|
||||||
from jw.pkg.lib.ec.Local import Local
|
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
|
from jw.pkg.lib.Result import Result
|
||||||
|
|
||||||
class CannedLocal(Local):
|
class CannedLocal(Local):
|
||||||
|
|
@ -79,3 +79,261 @@ assert cl2.cmds == [
|
||||||
cl3 = CannedTwoStage('', '')
|
cl3 = CannedTwoStage('', '')
|
||||||
assert asyncio.run(list_files([], ec = cl3)) == []
|
assert asyncio.run(list_files([], ec = cl3)) == []
|
||||||
assert cl3.cmds == []
|
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]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from typing import override
|
||||||
|
|
||||||
from jw.pkg.lib.ec.Local import Local
|
from jw.pkg.lib.ec.Local import Local
|
||||||
from jw.pkg.lib.FlaggedFile import FlaggedFile
|
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
|
from jw.pkg.lib.Result import Result
|
||||||
|
|
||||||
# -- _parse_verify_output --
|
# -- _parse_verify_output --
|
||||||
|
|
@ -101,3 +101,234 @@ assert cl.cmds[-1] == ['/usr/bin/rpm', '-qal']
|
||||||
cl = CannedLocal('')
|
cl = CannedLocal('')
|
||||||
assert asyncio.run(list_files([], ec = cl)) == []
|
assert asyncio.run(list_files([], ec = cl)) == []
|
||||||
assert cl.cmds == []
|
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]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue