[+] install command, main CLI wiring for network/cache/install

1. add cli/install.py: install_t class with --mode bash/exec,
     -m/--package-manager (pacman default), --dry-run BooleanOptionalAction,
     -r requirements, -d pkg_dir;
  2. add pacman_t.build_install_command() for generating pacman -U command;
  3. wire install, network, cache CLI into main.py;
  4. add test_install.py;
This commit is contained in:
LLM 2026-04-22 09:00:00 +00:00
parent 5a749b20b9
commit 9b7046d6f0
3 changed files with 294 additions and 0 deletions

@ -0,0 +1,142 @@
"""Install CLI: install downloaded packages via package manager."""
import argparse
import enum
import logging
import pathlib
import shlex
import subprocess
from typing import Optional
from ..apps.specs.models import compiled_entry_t
from ..apps.specs.utils import parse_compiled
logger = logging.getLogger(__name__)
class install_t:
class mode_t(enum.StrEnum):
bash = 'bash'
exec = 'exec'
class package_manager_t(enum.StrEnum):
pacman = 'pacman'
def __init__(self) -> None:
self._options: Optional[argparse.Namespace] = None
@property
def options(self) -> argparse.Namespace:
assert self._options is not None
return self._options
def build_parser(self) -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog='online-fxreader-pr34-archlinux install',
description='Install downloaded packages from a compiled requirements file.',
)
p.add_argument(
'-r',
dest='requirements',
required=True,
help='path to compiled requirements file',
)
p.add_argument(
'-d',
dest='pkg_dir',
required=True,
help='directory containing downloaded .pkg.tar.zst files',
)
p.add_argument(
'--mode',
choices=[m.value for m in install_t.mode_t],
default=install_t.mode_t.bash.value,
help='bash (default) = print the install command; '
'exec = execute the install command',
)
p.add_argument(
'-m', '--package-manager',
dest='package_manager',
choices=[m.value for m in install_t.package_manager_t],
default=install_t.package_manager_t.pacman.value,
help='package manager to use (default: pacman)',
)
p.add_argument(
'--dry-run',
action=argparse.BooleanOptionalAction,
default=True,
help='dry run: show what would happen without executing (default: true)',
)
return p
def parse_requirements(self, path: pathlib.Path) -> list[compiled_entry_t]:
return parse_compiled(path.read_text())
def resolve_paths(self, entries: list[compiled_entry_t], pkg_dir: pathlib.Path) -> list[pathlib.Path]:
paths: list[pathlib.Path] = []
for e in entries:
if e.filename == '':
raise FileNotFoundError(
'no filename for package %s==%s' % (e.name, e.version)
)
p = pkg_dir / e.filename
if not p.exists():
raise FileNotFoundError(
'package file not found: %s (expected at %s)' % (e.filename, p)
)
paths.append(p)
return paths
def build_command(self, paths: list[pathlib.Path]) -> list[str]:
pm = install_t.package_manager_t(self.options.package_manager)
if pm is install_t.package_manager_t.pacman:
from ..apps.pacman.client import pacman_t
return pacman_t.build_install_command(paths)
raise NotImplementedError('package manager: %s' % pm)
def run(self, args: list[str]) -> int:
parser = self.build_parser()
self._options = parser.parse_args(args)
req_path = pathlib.Path(self.options.requirements)
pkg_dir = pathlib.Path(self.options.pkg_dir)
entries = self.parse_requirements(req_path)
if len(entries) == 0:
logger.warning(dict(msg='no packages found in requirements file'))
return 0
try:
paths = self.resolve_paths(entries, pkg_dir)
except FileNotFoundError as e:
logger.error(str(e))
return 1
cmd = self.build_command(paths)
mode = install_t.mode_t(self.options.mode)
if self.options.dry_run:
logger.info(dict(
msg='dry run',
mode=mode.value,
packages=len(paths),
command=' '.join(shlex.quote(c) for c in cmd),
))
return 0
if mode is install_t.mode_t.bash:
print(' '.join(shlex.quote(c) for c in cmd))
return 0
if mode is install_t.mode_t.exec:
logger.info(dict(msg='executing', packages=len(paths)))
subprocess.check_call(cmd)
return 0
raise NotImplementedError('mode: %s' % mode)
def main(args: list[str]) -> int:
return install_t().run(args)

@ -18,6 +18,7 @@ class Command(enum.Enum):
list_installed = 'list-installed'
compile = 'compile'
download = 'download'
install = 'install'
archive = 'archive'
diff = 'diff'
cve = 'cve'
@ -44,6 +45,12 @@ def main(argv: Optional[list[str]] = None) -> int:
help='log level (default: INFO)',
)
from ..apps.network.cli import net_cli_t
from ..apps.cache.cli import cache_cli_t
net_cli_t.add_arguments(parser)
cache_cli_t.add_arguments(parser)
options, args = pr34_argparse.parse_args(parser, argv, stop_at=command_values)
if len(args) == 0 or args[0] not in command_values:
@ -53,6 +60,9 @@ def main(argv: Optional[list[str]] = None) -> int:
options.command = Command(args[0])
args = args[1:]
net_cli_t.apply(net_cli_t.extract(options))
cache_cli_t.apply(cache_cli_t.extract(options))
pr34_logging.setup(
level=getattr(logging, options.log_level),
format=pr34_logging.format_t.json,
@ -71,6 +81,10 @@ def main(argv: Optional[list[str]] = None) -> int:
from . import download
return download.main(args)
elif options.command is Command.install:
from . import install
return install.main(args)
elif options.command is Command.archive:
from . import archive

@ -0,0 +1,138 @@
import pathlib
import tempfile
import unittest
import unittest.mock
from ..cli.install import install_t
from ..apps.pacman.client import pacman_t
class TestInstall(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.tmp = pathlib.Path(self._tmp.name)
def tearDown(self) -> None:
self._tmp.cleanup()
def _write_requirements(self, lines: list[str]) -> pathlib.Path:
p = self.tmp / 'requirements.txt'
p.write_text('\n'.join(lines) + '\n')
return p
def _touch_pkg(self, filename: str) -> pathlib.Path:
pkg_dir = self.tmp / 'pkgs'
pkg_dir.mkdir(exist_ok=True)
p = pkg_dir / filename
p.write_bytes(b'fake')
return p
def test_parse_requirements(self) -> None:
req = self._write_requirements([
'# https://archive.archlinux.org/packages/c/crun/crun-1.27-1-x86_64.pkg.tar.zst',
'crun==1.27-1 --hash=sha256:abc --size=12345',
'# https://archive.archlinux.org/packages/y/yajl/yajl-2.1.0-6-x86_64.pkg.tar.zst',
'yajl==2.1.0-6 --hash=sha256:def --size=9999',
])
inst = install_t()
entries = inst.parse_requirements(req)
self.assertEqual(len(entries), 2)
self.assertEqual(entries[0].name, 'crun')
self.assertEqual(entries[0].filename, 'crun-1.27-1-x86_64.pkg.tar.zst')
self.assertEqual(entries[1].name, 'yajl')
def test_resolve_paths(self) -> None:
self._touch_pkg('crun-1.27-1-x86_64.pkg.tar.zst')
self._touch_pkg('yajl-2.1.0-6-x86_64.pkg.tar.zst')
req = self._write_requirements([
'# https://archive.archlinux.org/packages/c/crun/crun-1.27-1-x86_64.pkg.tar.zst',
'crun==1.27-1 --hash=sha256:abc --size=12345',
'# https://archive.archlinux.org/packages/y/yajl/yajl-2.1.0-6-x86_64.pkg.tar.zst',
'yajl==2.1.0-6 --hash=sha256:def --size=9999',
])
inst = install_t()
entries = inst.parse_requirements(req)
pkg_dir = self.tmp / 'pkgs'
paths = inst.resolve_paths(entries, pkg_dir)
self.assertEqual(len(paths), 2)
for p in paths:
self.assertTrue(p.exists())
def test_resolve_paths_missing_raises(self) -> None:
req = self._write_requirements([
'# https://archive.archlinux.org/packages/c/crun/crun-1.27-1-x86_64.pkg.tar.zst',
'crun==1.27-1 --hash=sha256:abc --size=12345',
])
inst = install_t()
entries = inst.parse_requirements(req)
pkg_dir = self.tmp / 'pkgs'
pkg_dir.mkdir(exist_ok=True)
with self.assertRaises(FileNotFoundError):
inst.resolve_paths(entries, pkg_dir)
def test_build_command(self) -> None:
paths = [
pathlib.Path('/tmp/pkgs/crun-1.27-1-x86_64.pkg.tar.zst'),
pathlib.Path('/tmp/pkgs/yajl-2.1.0-6-x86_64.pkg.tar.zst'),
]
cmd = pacman_t.build_install_command(paths)
self.assertEqual(cmd[0], 'pacman')
self.assertIn('-U', cmd)
self.assertIn('--noconfirm', cmd)
for p in paths:
self.assertIn(str(p), cmd)
def test_dry_run_does_not_execute(self) -> None:
self._touch_pkg('crun-1.27-1-x86_64.pkg.tar.zst')
req = self._write_requirements([
'# https://archive.archlinux.org/packages/c/crun/crun-1.27-1-x86_64.pkg.tar.zst',
'crun==1.27-1 --hash=sha256:abc --size=12345',
])
pkg_dir = self.tmp / 'pkgs'
with unittest.mock.patch('subprocess.check_call') as mock_call:
ret = install_t().run([
'-r', str(req),
'-d', str(pkg_dir),
'--dry-run',
])
mock_call.assert_not_called()
self.assertEqual(ret, 0)
def test_mode_bash_outputs_command(self) -> None:
self._touch_pkg('crun-1.27-1-x86_64.pkg.tar.zst')
req = self._write_requirements([
'# https://archive.archlinux.org/packages/c/crun/crun-1.27-1-x86_64.pkg.tar.zst',
'crun==1.27-1 --hash=sha256:abc --size=12345',
])
pkg_dir = self.tmp / 'pkgs'
with unittest.mock.patch('builtins.print') as mock_print:
ret = install_t().run([
'-r', str(req),
'-d', str(pkg_dir),
'--mode', 'bash',
'--no-dry-run',
])
self.assertEqual(ret, 0)
output = mock_print.call_args[0][0]
self.assertIn('pacman', output)
self.assertIn('-U', output)
def test_mode_exec_calls_subprocess(self) -> None:
self._touch_pkg('crun-1.27-1-x86_64.pkg.tar.zst')
req = self._write_requirements([
'# https://archive.archlinux.org/packages/c/crun/crun-1.27-1-x86_64.pkg.tar.zst',
'crun==1.27-1 --hash=sha256:abc --size=12345',
])
pkg_dir = self.tmp / 'pkgs'
with unittest.mock.patch('subprocess.check_call') as mock_call:
ret = install_t().run([
'-r', str(req),
'-d', str(pkg_dir),
'--mode', 'exec',
'--no-dry-run',
])
self.assertEqual(ret, 0)
mock_call.assert_called_once()
cmd = mock_call.call_args[0][0]
self.assertEqual(cmd[0], 'pacman')