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:
Jan Lindemann 2026-10-27 14:51:56 +01:00
commit 36978c55e2
Signed by: Jan Lindemann
GPG key ID: 3750640C9E25DD61
2 changed files with 659 additions and 2 deletions

View file

@ -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,

View file

@ -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,