diff --git a/pmb/config/init.py b/pmb/config/init.py index b3a3d234..88fd56d5 100644 --- a/pmb/config/init.py +++ b/pmb/config/init.py @@ -497,11 +497,24 @@ def ask_for_device(context: Context) -> tuple[str, bool, str]: if not pmb.helpers.cli.confirm(default=True): continue else: - # Archived devices can be selected, but are not displayed - devices = sorted(pmb.helpers.devices.list_codenames(vendor, archived=False)) - # Remove "vendor-" prefixes from device list - codenames = [x.split("-", 1)[1] for x in devices] - logging.info(f"Available codenames ({len(codenames)}): " + ", ".join(codenames)) + device_list = "Devices are categorised as follows, from best to worst:\n" + styles = pmb.config.styles + for category in pmb.helpers.devices.DeviceCategory.shown(): + device_list += f"* {category.color()}{str(category).capitalize()}{styles['END']}: {category.explain()}.\n" + device_entries = pmb.helpers.devices.list_codenames(vendor) + # Sort devices alphabetically. + device_entries = sorted( + device_entries, key=pmb.helpers.devices.DeviceEntry.codename_without_vendor + ) + device_count = len(device_entries) + device_list += f"\nAvailable devices by codename ({device_count}): " + device_strings = [] + for device_entry in device_entries: + codenames.append(device_entry.codename_without_vendor()) + device_strings.append(str(device_entry)) + + device_list += ", ".join(device_strings) + logging.info(device_list) if current_vendor != vendor: current_codename = "" diff --git a/pmb/helpers/devices.py b/pmb/helpers/devices.py index 5575fdad..3f426dd7 100644 --- a/pmb/helpers/devices.py +++ b/pmb/helpers/devices.py @@ -1,8 +1,14 @@ # Copyright 2023 Oliver Smith # SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import annotations + import os +from dataclasses import dataclass +from enum import Enum from pathlib import Path +import pmb.config from pmb.core.pkgrepo import pkgrepo_glob_one, pkgrepo_iglob +from pmb.helpers import logging def find_path(codename: str, file: str = "") -> Path | None: @@ -19,20 +25,109 @@ def find_path(codename: str, file: str = "") -> Path | None: return g -def list_codenames(vendor: str | None = None, archived: bool = True) -> list[str]: - """Get all devices, for which aports are available. +# TODO: This could be simplified using StrEnum once we stop supporting Python 3.10. +class DeviceCategory(Enum): + """Enum for representing a specific device category.""" - :param vendor: vendor name to choose devices from, or None for all vendors - :param archived: include archived devices - :returns: ["first-device", "second-device", ...] + ARCHIVED = "archived" + DOWNSTREAM = "downstream" + TESTING = "testing" + COMMUNITY = "community" + MAIN = "main" + + @staticmethod + def shown() -> list[DeviceCategory]: + """Get a list of all device categories that typically are visible, in order of "best" to + "worst". + + :returns: List of all non-hidden device categories. + """ + + return [ + DeviceCategory.MAIN, + DeviceCategory.COMMUNITY, + DeviceCategory.TESTING, + DeviceCategory.DOWNSTREAM, + ] + + def explain(self) -> str: + """Provide an explanation of a given category. + + :returns: String explaining the given category. + """ + + match self: + case DeviceCategory.ARCHIVED: + return "ports that have a better alternative available" + case DeviceCategory.DOWNSTREAM: + return "ports that use a downstream kernel — very limited functionality. Not recommended" + case DeviceCategory.TESTING: + return 'anything from "just boots in some sense" to almost fully functioning ports' + + case DeviceCategory.COMMUNITY: + return "often mostly usable, but may lack important functionality" + case DeviceCategory.MAIN: + return "ports where mostly everything works" + case _: + raise AssertionError + + def color(self) -> str: + """Returns the color associated with the given device category. + + :returns: ANSI escape sequence for the color associated with the given device category.""" + styles = pmb.config.styles + + match self: + case DeviceCategory.ARCHIVED: + return styles["RED"] + case DeviceCategory.DOWNSTREAM: + return styles["YELLOW"] + case DeviceCategory.TESTING: + return styles["GREEN"] + case DeviceCategory.COMMUNITY: + return styles["BLUE"] + case DeviceCategory.MAIN: + return styles["MAGENTA"] + case _: + raise AssertionError + + def __str__(self) -> str: + return self.value + + +@dataclass(frozen=True) +class DeviceEntry: + codename: str + category: DeviceCategory + + def codename_without_vendor(self) -> str: + return self.codename.split("-", 1)[1] + + def __str__(self) -> str: + """Remove "vendor-" prefix from device codename and add category.""" + styles = pmb.config.styles + return f"{self.category.color()}{self.codename_without_vendor()}{styles['END']} ({self.category})" + + +def list_codenames(vendor: str) -> list[DeviceEntry]: + """Get all devices for which aports are available. + + :param vendor: Vendor name to choose devices from. + :returns: ["first-device", "second-device", ...]} """ - ret = [] - for path in pkgrepo_iglob("device/*/device-*"): - if not archived and "archived" in path.parts: + ret: list[DeviceEntry] = [] + for path in pkgrepo_iglob(f"device/*/device-{vendor}-*"): + codename = os.path.basename(path).split("-", 1)[1] + # Ensure we don't crash on unknown device categories. + try: + category = get_device_category_by_apkbuild_path(path / "APKBUILD") + except RuntimeError as exception: + logging.warning("WARNING: %s: %s", codename, exception) continue - device = os.path.basename(path).split("-", 1)[1] - if (vendor is None) or device.startswith(vendor + "-"): - ret.append(device) + # Get rid of ports inside of hidden device categories. + if category not in DeviceCategory.shown(): + continue + ret.append(DeviceEntry(codename, category)) return ret @@ -46,3 +141,26 @@ def list_vendors() -> set[str]: vendor = path.name.split("-", 2)[1] ret.add(vendor) return ret + + +def get_device_category_by_apkbuild_path(apkbuild_path: Path) -> DeviceCategory: + """Get the category of a device based on the path to its APKBUILD inside of pmaports. + + This will fail to determine the device category from out-of-tree APKBUILDs. + + :apkbuild_path: Path to an APKBUILD within pmaports for a particular device. + :returns: The device category of the provided device APKBUILD. + """ + + # Path is something like this: + # .../device/community/device-samsung-m0/APKBUILD + # ↑ ↑ parent 1 + # | parent 2 + category_str = apkbuild_path.parent.parent.name + + try: + device_category = DeviceCategory(category_str) + except ValueError as exception: + raise RuntimeError(f'Unknown device category "{category_str}"') from exception + + return device_category diff --git a/pmb/helpers/test_devices.py b/pmb/helpers/test_devices.py new file mode 100644 index 00000000..70601b89 --- /dev/null +++ b/pmb/helpers/test_devices.py @@ -0,0 +1,26 @@ +# Copyright 2025 Stefan Hansson +# SPDX-License-Identifier: GPL-3.0-or-later + +from .devices import DeviceCategory, get_device_category_by_apkbuild_path + +from pathlib import Path + +import pytest + + +def test_get_device_category_by_apkbuild_path() -> None: + valid_path_1 = Path("device") / "community" / "device-samsung-m0" / "APKBUILD" + valid_path_2 = Path("pmos_work") / "device" / "main" / "device-pine64-pinephone" / "APKBUILD" + + # Missing category segment of path. + invalid_path_1 = Path("APKBUILD") + # Nonexistent category ("pendeltåg"). + invalid_path_2 = Path("device") / "pendeltåg" / "device-samsung-m0" / "APKBUILD" + + assert get_device_category_by_apkbuild_path(valid_path_1) == DeviceCategory.COMMUNITY + assert get_device_category_by_apkbuild_path(valid_path_2) == DeviceCategory.MAIN + + with pytest.raises(RuntimeError): + get_device_category_by_apkbuild_path(invalid_path_1) + with pytest.raises(RuntimeError): + get_device_category_by_apkbuild_path(invalid_path_2)