Add 'case _: pass' to match statements that are intentionally non-exhaustive, satisfying the new exhaustive-match mypy rule. Also replaced 'case '_':' (a string literal) with 'case _: pass' in pkg_relations.py since it was an unreachable case (syntax is a VersionSyntax enum, not a str). Added 'case VersionSyntax.names_only:' to the match in pkg_relations.py to handle the missing enum value. Files modified: - util.py: Two match statements for askpass env vars - Distro.py: Three match statements for backend/os detection - pkg_relations.py: Match on VersionSyntax enum - CmdListRepos.py: Match on URL scheme Assisted-by: unsloth/Qwen3.6-35B-A3B-GGUF:IQ4_NL and pi.dev 0.81.1 Signed-off-by: Jan Lindemann <jan@janware.com>
472 lines
15 KiB
Python
472 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import abc
|
|
import importlib
|
|
import re
|
|
import sys
|
|
|
|
from functools import cached_property
|
|
from typing import TYPE_CHECKING
|
|
|
|
from .log import ERR, INFO, WARNING, log
|
|
from .base import InputMode
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Collection
|
|
from typing import Iterable
|
|
|
|
from .base import Result
|
|
from .ExecContext import ExecContext
|
|
from .Package import Package
|
|
from .PackageFilter import PackageFilter
|
|
|
|
class Distro(abc.ABC):
|
|
|
|
def __init__(
|
|
self,
|
|
ec: ExecContext,
|
|
id: str | None = None,
|
|
os_release_str: str | None = None,
|
|
default_pkg_filter: PackageFilter | None = None,
|
|
) -> None:
|
|
if id is None:
|
|
raise ValueError('Tried to instantiate Distro without id')
|
|
if ec is None:
|
|
raise ValueError(
|
|
f'Tried to instantiate Distro "{id}" without execution context'
|
|
)
|
|
self.__exec_context = ec
|
|
self.__id: str | None = None
|
|
self.__os_release_str: str | None = os_release_str
|
|
self.__default_pkg_filter = default_pkg_filter
|
|
|
|
# Names that can be used by code outside this class to retrieve
|
|
# distribution properties by
|
|
# getattr(instance, name.replace('-', '_'))
|
|
macro_names = [
|
|
'os',
|
|
'id',
|
|
'name',
|
|
'codename',
|
|
'gnu-triplet',
|
|
'os-cascade',
|
|
'os-release',
|
|
'pkg-ext',
|
|
]
|
|
|
|
# == Load
|
|
|
|
@classmethod
|
|
async def read_os_release_str(cls, ec: ExecContext) -> str:
|
|
release_file = '/etc/os-release'
|
|
try:
|
|
result = await ec.get(release_file, throw = True)
|
|
return result.stdout_str
|
|
except Exception as e:
|
|
log(
|
|
INFO,
|
|
f'Failed to read {release_file} ({str(e)}), falling back to uname'
|
|
)
|
|
result = await ec.run(
|
|
['uname', '-s'], throw = False, cmd_input = InputMode.NonInteractive
|
|
)
|
|
if result.status != 0:
|
|
log(
|
|
ERR,
|
|
(
|
|
'/etc/os-release and uname both failed, '
|
|
f'the latter with {result.summary}'
|
|
),
|
|
)
|
|
raise
|
|
uname = result.stdout_str.strip().lower()
|
|
ret = f'ID={uname}\nVERSION_CODENAME=unknown'
|
|
return ret
|
|
|
|
@classmethod
|
|
def parse_os_release_field(cls, key: str, os_release_str: str) -> str:
|
|
m = re.search(
|
|
r'^\s*' + key + r'\s*=\s*("?)([^"\n]+)\1\s*$', os_release_str, re.MULTILINE
|
|
)
|
|
if m is None:
|
|
raise Exception(f'Could not read "{key}=" from /etc/os-release')
|
|
return m.group(2)
|
|
|
|
@classmethod
|
|
def parse_os_release_field_id(cls, os_release_str: str) -> str:
|
|
ret = cls.parse_os_release_field('ID', os_release_str)
|
|
match ret:
|
|
case 'opensuse-tumbleweed':
|
|
return 'suse'
|
|
case _:
|
|
pass
|
|
return ret
|
|
|
|
@classmethod
|
|
async def instantiate(
|
|
cls,
|
|
ec: ExecContext,
|
|
id: str | None = None,
|
|
os_release_str: str | None = None,
|
|
**kwargs,
|
|
):
|
|
if id is None:
|
|
os_release_str = await cls.read_os_release_str(ec)
|
|
id = cls.parse_os_release_field_id(os_release_str)
|
|
backend_id = id.lower().replace('-', '_')
|
|
match backend_id:
|
|
case 'ubuntu' | 'raspbian' | 'kali':
|
|
backend_id = 'debian'
|
|
case 'centos':
|
|
backend_id = 'redhat'
|
|
case 'opensuse' | 'suse':
|
|
backend_id = 'suse'
|
|
case _:
|
|
pass
|
|
module_path = 'jw.pkg.lib.distros.' + backend_id + '.Distro'
|
|
try:
|
|
module = importlib.import_module(module_path)
|
|
except Exception as e:
|
|
log(ERR, f'Failed to import Distro module {module_path} ({str(e)})')
|
|
raise
|
|
cls = getattr(module, 'Distro')
|
|
ret = cls(ec, id = id, os_release_str = os_release_str, **kwargs)
|
|
return ret
|
|
|
|
def os_release_field(self, key: str) -> str:
|
|
return self.parse_os_release_field(key, self.os_release_str)
|
|
|
|
async def cache(self) -> None:
|
|
if self.__os_release_str is None:
|
|
self.__os_release_str = await self.read_os_release_str(self.__exec_context)
|
|
|
|
@cached_property
|
|
def os_cascade(self) -> list[str]:
|
|
|
|
def __append(entry: str):
|
|
if entry not in ret:
|
|
ret.append(entry)
|
|
|
|
ret = ['os']
|
|
match self.id:
|
|
case 'centos':
|
|
__append('linux')
|
|
__append('pkg-rpm')
|
|
__append('pm-yum')
|
|
__append('redhat')
|
|
__append('rhel')
|
|
case 'fedora' | 'rhel':
|
|
__append('linux')
|
|
__append('pkg-rpm')
|
|
__append('pm-yum')
|
|
__append('redhat')
|
|
case 'suse':
|
|
__append('linux')
|
|
__append('pkg-rpm')
|
|
__append('pm-zypper')
|
|
case 'kali' | 'raspbian':
|
|
__append('linux')
|
|
__append('pkg-debian')
|
|
__append('pm-apt')
|
|
__append('debian')
|
|
case 'ubuntu':
|
|
__append('linux')
|
|
__append('pkg-debian')
|
|
__append('pm-apt')
|
|
case 'archlinux':
|
|
__append('linux')
|
|
__append('pkg-pm')
|
|
__append('pm-pacman')
|
|
case _:
|
|
pass
|
|
|
|
os = self.os
|
|
name = re.sub(r'-.*', '', os)
|
|
series = os
|
|
rx = re.compile(r'\.[0-9]+$')
|
|
while True:
|
|
n = re.sub(rx, '', series)
|
|
if n == series:
|
|
break
|
|
ret.append(n)
|
|
series = n
|
|
__append(name)
|
|
__append(os)
|
|
__append(self.id)
|
|
# e.g. os, linux, suse, suse-tumbleweed
|
|
return ret
|
|
|
|
@cached_property
|
|
def cascade(self) -> str:
|
|
return ' '.join(self.os_cascade)
|
|
|
|
@property
|
|
def os_release_str(self) -> str:
|
|
if self.__os_release_str is None:
|
|
raise Exception(
|
|
'Tried to access OS release from an incompletely loaded Distro '
|
|
'instance. Call cache() before'
|
|
)
|
|
return self.__os_release_str
|
|
|
|
@cached_property
|
|
def name(self) -> str:
|
|
return self.os_release_field('NAME')
|
|
|
|
@cached_property
|
|
def id(self) -> str:
|
|
return self.parse_os_release_field_id(self.os_release_str)
|
|
|
|
@cached_property
|
|
def codename(self) -> str:
|
|
match self.id:
|
|
case 'suse':
|
|
return self.os_release_field('ID').split('-')[1]
|
|
case 'kali':
|
|
return self.os_release_field('VERSION_CODENAME').split('-')[1]
|
|
case _:
|
|
return self.os_release_field('VERSION_CODENAME')
|
|
raise NotImplementedError(
|
|
f"Can't determine code name from distribution ID {self.id}"
|
|
)
|
|
|
|
@cached_property
|
|
def os(self) -> str:
|
|
return self.id + '-' + self.codename
|
|
|
|
@cached_property
|
|
def pkg_ext(self) -> str:
|
|
for entry in self.os_cascade:
|
|
ret = entry.replace('pkg-', '')
|
|
if ret != entry:
|
|
return ret
|
|
raise RuntimeError(f'No package extension in found in {self.os_cascade}')
|
|
|
|
@cached_property
|
|
def gnu_triplet(self) -> str:
|
|
|
|
import shutil
|
|
import subprocess
|
|
import sysconfig
|
|
|
|
# Best: GNU host triplet Python was built for
|
|
for key in ('HOST_GNU_TYPE', 'BUILD_GNU_TYPE'): # BUILD_GNU_TYPE can exist too
|
|
ret = sysconfig.get_config_var(key)
|
|
if isinstance(ret, str) and ret:
|
|
return ret
|
|
|
|
# Common on Debian/Ubuntu: multiarch component (often looks like a triplet)
|
|
ret = sysconfig.get_config_var('MULTIARCH')
|
|
if isinstance(ret, str) and ret:
|
|
return ret
|
|
|
|
# Sometimes exposed (privately) by CPython
|
|
ret = getattr(sys.implementation, '_multiarch', None)
|
|
if isinstance(ret, str) and ret:
|
|
return ret
|
|
|
|
# Last resort: ask the system compiler
|
|
for cc in ('gcc', 'cc', 'clang'):
|
|
path = shutil.which(cc)
|
|
if not path:
|
|
continue
|
|
try:
|
|
ret = subprocess.check_output(
|
|
[path, '-dumpmachine'], text = True, stderr = subprocess.DEVNULL
|
|
).strip()
|
|
if ret:
|
|
return ret
|
|
except Exception:
|
|
pass
|
|
|
|
raise RuntimeError('Failed to get GNU triplet from running machine')
|
|
|
|
@classmethod
|
|
def macros(cls) -> list[str]:
|
|
return ['%%{' + name + '}' for name in cls.macro_names]
|
|
|
|
def expand_macros(self, fmt: str | Iterable[str]) -> str | list[str]:
|
|
ret: str | list[str]
|
|
if not isinstance(fmt, str):
|
|
ret = []
|
|
for entry in fmt:
|
|
rv = self.expand_macros(entry)
|
|
if isinstance(rv, str):
|
|
ret.append(rv)
|
|
continue
|
|
raise NotImplementedError(
|
|
f'Expanding macros in nested lists is not supported: {rv}'
|
|
)
|
|
return ret
|
|
ret = fmt
|
|
for macro in re.findall('%{([A-Za-z_-]+)}', fmt):
|
|
try:
|
|
name = macro.replace('-', '_')
|
|
val = getattr(self, name)
|
|
patt = r'%{' + macro + r'}'
|
|
if ret.find(patt) == -1:
|
|
continue
|
|
ret = ret.replace(patt, val)
|
|
except Exception as e:
|
|
log(ERR, f'Failed to expand macro "{macro}" inside "{fmt}": {str(e)}')
|
|
raise
|
|
return ret
|
|
|
|
# == Convenience methods
|
|
|
|
@property
|
|
def ctx(self) -> ExecContext:
|
|
return self.__exec_context
|
|
|
|
@property
|
|
def default_pkg_filter(self) -> PackageFilter | None:
|
|
return self.__default_pkg_filter
|
|
|
|
async def run(self, *args, **kwargs) -> Result:
|
|
return await self.__exec_context.run(*args, **kwargs)
|
|
|
|
async def sudo(self, *args, **kwargs) -> Result:
|
|
return await self.__exec_context.sudo(*args, **kwargs)
|
|
|
|
@property
|
|
def interactive(self) -> bool | None:
|
|
return self.__exec_context.interactive
|
|
|
|
# == Distribution abstraction methods
|
|
|
|
# -- ref
|
|
|
|
@abc.abstractmethod
|
|
async def _ref(self) -> None:
|
|
pass
|
|
|
|
async def ref(self) -> None:
|
|
return await self._ref()
|
|
|
|
# -- dup
|
|
|
|
@abc.abstractmethod
|
|
async def _dup(self, download_only: bool) -> None:
|
|
pass
|
|
|
|
async def dup(self, download_only: bool = False) -> None:
|
|
return await self._dup(download_only = download_only)
|
|
|
|
# -- reboot_required
|
|
|
|
@abc.abstractmethod
|
|
async def _reboot_required(self, verbose: bool) -> bool:
|
|
pass
|
|
|
|
async def reboot_required(self, verbose: bool | None = None) -> bool:
|
|
if verbose is None:
|
|
verbose = self.ctx.verbose_default
|
|
return await self._reboot_required(verbose = verbose)
|
|
|
|
# -- select
|
|
|
|
@abc.abstractmethod
|
|
async def _select_by_name(self, names: Collection[str]) -> Iterable[Package]:
|
|
pass
|
|
|
|
async def _select(self, names: Collection[str],
|
|
filter: PackageFilter) -> Iterable[Package]:
|
|
return [p for p in await self._select_by_name(names) if filter.match(p)]
|
|
|
|
async def select(
|
|
self,
|
|
names: Collection[str] = [],
|
|
filter: PackageFilter | None = None
|
|
) -> Iterable[Package]:
|
|
if not filter:
|
|
filter = self.__default_pkg_filter
|
|
if not filter:
|
|
return await self._select_by_name(names)
|
|
return await self._select(names, filter)
|
|
|
|
# -- install
|
|
|
|
# Pass names to the package manager
|
|
@abc.abstractmethod
|
|
async def _install(self, names: Iterable[str], only_update: bool) -> None:
|
|
pass
|
|
|
|
# Default implementation assumes package manager can handle local files.
|
|
# Not true for all distros. Override if Distro knows better.
|
|
async def _install_local_files(
|
|
self, paths: Iterable[str], only_update: bool
|
|
) -> None:
|
|
await self._install(paths, only_update = only_update)
|
|
|
|
# Download first and then install. Override if Distro knows better.
|
|
async def _install_urls(self, urls: Iterable[str], only_update: bool) -> None:
|
|
from .util import copy
|
|
|
|
tmp: str | None = None
|
|
try:
|
|
tmp = await self.__exec_context.mktemp(
|
|
'/tmp/jw-pkg-XXXXXX', directory = True
|
|
)
|
|
paths = await copy(
|
|
urls, self.__exec_context.uri.scheme_plus_authority + tmp
|
|
)
|
|
if isinstance(paths, Exception):
|
|
raise paths
|
|
if isinstance(paths, str):
|
|
paths = [paths]
|
|
await self._install_local_files(paths, only_update = only_update)
|
|
finally:
|
|
if tmp is not None:
|
|
await self.__exec_context.erase(tmp)
|
|
|
|
# Default implementation installs in two steps:
|
|
# - Download URLs into local directories and install
|
|
# - Pass names to package manager
|
|
# Override if Distro knows better.
|
|
async def _install_urls_and_names(
|
|
self, packages: Iterable[str], only_update: bool
|
|
) -> None:
|
|
urls: list[str] = []
|
|
names: list[str] = []
|
|
for package in packages:
|
|
if package[0] == '/':
|
|
urls.append('file://' + package)
|
|
continue
|
|
if package.find('://') != -1:
|
|
urls.append(package)
|
|
continue
|
|
names.append(package)
|
|
if urls:
|
|
await self._install_urls(urls, only_update = only_update)
|
|
if names:
|
|
await self._install(names, only_update = only_update)
|
|
|
|
async def install(self, names: Collection[str], only_update: bool = False) -> None:
|
|
if not names:
|
|
log(WARNING, 'No packages specified for installation')
|
|
return
|
|
await self._install_urls_and_names(names, only_update = only_update)
|
|
|
|
# -- delete
|
|
|
|
@abc.abstractmethod
|
|
async def _delete(self, names: Iterable[str]) -> None:
|
|
pass
|
|
|
|
async def delete(self, names: Collection[str]) -> None:
|
|
if not names:
|
|
log(WARNING, 'No packages specified for deletion')
|
|
return
|
|
return await self._delete(names)
|
|
|
|
# -- pkg_files
|
|
|
|
@abc.abstractmethod
|
|
async def _pkg_files(self, name: str) -> Iterable[str]:
|
|
pass
|
|
|
|
async def pkg_files(self, name: str) -> Iterable[str]:
|
|
if not name:
|
|
log(WARNING, 'No package specified for inspection')
|
|
return []
|
|
return await self._pkg_files(name)
|