diff --git a/plinth/errors.py b/plinth/errors.py index c6dd4cf36..cfd6746d4 100644 --- a/plinth/errors.py +++ b/plinth/errors.py @@ -25,3 +25,11 @@ class DomainNotRegisteredError(PlinthError): FreedomBox doesn't have a registered domain """ pass + + +class MissingPackageError(PlinthError): + """Package is not available to be installed at this time.""" + + def __init__(self, name): + self.name = name + super().__init__(self.name) diff --git a/plinth/package.py b/plinth/package.py index c7f99d211..b4767524c 100644 --- a/plinth/package.py +++ b/plinth/package.py @@ -17,12 +17,78 @@ from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy from plinth import actions, app -from plinth.errors import ActionError +from plinth.errors import ActionError, MissingPackageError from plinth.utils import format_lazy logger = logging.getLogger(__name__) +class PackageExpression: + + def possible(self) -> list[str]: + """Return the list of possible packages before resolving.""" + raise NotImplementedError + + def actual(self) -> str: + """Return the resolved list of packages to install. + + TODO: Also return version and suite to install from. + """ + raise NotImplementedError + + +class Package(PackageExpression): + + def __init__( + self, + name, + optional: bool = False, + version: Optional[str] = None, # ">=1.0,<2.0" + distribution: Optional[str] = None, # Debian, Ubuntu + suite: Optional[str] = None, # stable, testing + codename: Optional[str] = None, # bullseye-backports + architecture: Optional[str] = None): # arm64 + self.name = name + self.optional = optional + self.version = version + self.distribution = distribution + self.suite = suite + self.codename = codename + self.architecture = architecture + + def __or__(self, other): + return PackageOr(self, other) + + def possible(self) -> list[str]: + return [self.name] + + def actual(self) -> str: + cache = apt.Cache() + if self.name in cache: + # TODO: Also return version and suite to install from + return self.name + + raise MissingPackageError(self.name) + + +class PackageOr(PackageExpression): + """Specify that one of the two packages will be installed.""" + + def __init__(self, package1: PackageExpression, + package2: PackageExpression): + self.package1 = package1 + self.package2 = package2 + + def possible(self) -> list[str]: + return self.package1.possible() + self.package2.possible() + + def actual(self) -> str: + try: + return self.package1.actual() + except MissingPackageError: + return self.package2.actual() + + class Packages(app.FollowerComponent): """Component to manage the packages of an app. diff --git a/plinth/tests/test_package.py b/plinth/tests/test_package.py index c67d4c25f..4b35a8517 100644 --- a/plinth/tests/test_package.py +++ b/plinth/tests/test_package.py @@ -3,17 +3,53 @@ Test module for package module. """ +import unittest from unittest.mock import Mock, call, patch import pytest from plinth.app import App -from plinth.errors import ActionError -from plinth.package import Packages, packages_installed, remove +from plinth.errors import ActionError, MissingPackageError +from plinth.package import Package, Packages, packages_installed, remove setup_helper = Mock() +class TestPackageExpressions(unittest.TestCase): + + def test_package(self): + """Test resolving package names.""" + package = Package('python3') + assert package.possible() == ['python3'] + assert package.actual() == 'python3' + + package = Package('unknown-package') + assert package.possible() == ['unknown-package'] + self.assertRaises(MissingPackageError, package.actual) + + def test_package_or_expression(self): + """Test resolving package OR expressions.""" + expression = Package('python3') | Package('unknown-package') + assert expression.possible() == ['python3', 'unknown-package'] + assert expression.actual() == 'python3' + + expression = Package('unknown-package') | Package('python3') + assert expression.possible() == ['unknown-package', 'python3'] + assert expression.actual() == 'python3' + + # When both packages are available, prefer the first. + expression = Package('bash') | Package('dash') + assert expression.possible() == ['bash', 'dash'] + assert expression.actual() == 'bash' + + expression = Package('unknown-package') | Package( + 'another-unknown-package') + assert expression.possible() == [ + 'unknown-package', 'another-unknown-package' + ] + self.assertRaises(MissingPackageError, expression.actual) + + def test_packages_init(): """Test initialization of packages component.""" component = Packages('test-component', ['foo', 'bar'])