diff --git a/.github/workflows/test_platform_utils.yml b/.github/workflows/test_platform_utils.yml new file mode 100644 index 000000000..0159c9e6c --- /dev/null +++ b/.github/workflows/test_platform_utils.yml @@ -0,0 +1,196 @@ +name: Test platform_utils.py + +on: + workflow_dispatch: + push: + paths: + - 'src/toolManager/platform_utils.py' + - '.github/workflows/test_platform_utils.yml' + pull_request: + paths: + - 'src/toolManager/platform_utils.py' + - '.github/workflows/test_platform_utils.yml' + +jobs: + + # ── Ubuntu (apt) ───────────────────────────────────────────────────────────── + test-ubuntu: + name: Ubuntu (apt) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Smoke test + working-directory: src/toolManager + run: | + python - <<'EOF' + from platform_utils import ( + IS_WINDOWS, IS_LINUX, IS_MAC, + detect_package_manager, distro_label, subprocess_flags + ) + + print(f"IS_LINUX : {IS_LINUX}") + print(f"detect_pkg_manager : {detect_package_manager()}") + print(f"distro_label : {distro_label()}") + + assert IS_LINUX is True + assert IS_WINDOWS is False + assert IS_MAC is False + assert detect_package_manager() == "apt", f"Got: {detect_package_manager()}" + assert "Ubuntu" in distro_label(), f"Got: {distro_label()}" + assert subprocess_flags() == {} + + print("\n[PASS] All Ubuntu assertions passed.") + EOF + + # ── Fedora (dnf) ───────────────────────────────────────────────────────────── + test-fedora: + name: Fedora (dnf) + runs-on: ubuntu-latest + container: fedora:40 + steps: + - name: Install deps + run: dnf install -y git python3 polkit + + - uses: actions/checkout@v4 + + - name: Smoke test + working-directory: src/toolManager + run: | + python3 - <<'EOF' + from platform_utils import ( + IS_WINDOWS, IS_LINUX, IS_MAC, + detect_package_manager, distro_label, subprocess_flags + ) + + print(f"IS_LINUX : {IS_LINUX}") + print(f"detect_pkg_manager : {detect_package_manager()}") + print(f"distro_label : {distro_label()}") + + assert IS_LINUX is True + assert IS_WINDOWS is False + assert IS_MAC is False + assert detect_package_manager() == "dnf", f"Got: {detect_package_manager()}" + assert "Fedora" in distro_label(), f"Got: {distro_label()}" + assert subprocess_flags() == {} + + print("\n[PASS] All Fedora assertions passed.") + EOF + + # ── Arch Linux (pacman) ─────────────────────────────────────────────────────── + test-arch: + name: Arch Linux (pacman) + runs-on: ubuntu-latest + container: archlinux:latest + steps: + - name: Install deps + run: pacman -Syu --noconfirm git python polkit + + - uses: actions/checkout@v4 + + - name: Smoke test + working-directory: src/toolManager + run: | + python - <<'EOF' + from platform_utils import ( + IS_WINDOWS, IS_LINUX, IS_MAC, + detect_package_manager, distro_label, subprocess_flags + ) + + print(f"IS_LINUX : {IS_LINUX}") + print(f"detect_pkg_manager : {detect_package_manager()}") + print(f"distro_label : {distro_label()}") + + assert IS_LINUX is True + assert IS_WINDOWS is False + assert IS_MAC is False + assert detect_package_manager() == "pacman", f"Got: {detect_package_manager()}" + assert subprocess_flags() == {} + + print("\n[PASS] All Arch Linux assertions passed.") + EOF + + # ── macOS (Homebrew) ────────────────────────────────────────────────────────── + test-macos: + name: macOS (Homebrew) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Smoke test + working-directory: src/toolManager + run: | + python - <<'EOF' + from platform_utils import ( + IS_WINDOWS, IS_LINUX, IS_MAC, + detect_package_manager, distro_label, subprocess_flags + ) + + print(f"IS_MAC : {IS_MAC}") + print(f"IS_LINUX : {IS_LINUX}") + print(f"IS_WINDOWS : {IS_WINDOWS}") + print(f"detect_pkg_manager : {detect_package_manager()}") + print(f"distro_label : {distro_label()}") + + assert IS_MAC is True + assert IS_LINUX is False + assert IS_WINDOWS is False + # GitHub macOS runners have Homebrew pre-installed + assert detect_package_manager() == "brew", f"Got: {detect_package_manager()}" + assert "macOS" in distro_label(), f"Got: {distro_label()}" + assert subprocess_flags() == {} + + print("\n[PASS] All macOS assertions passed.") + EOF + + # ── Windows ─────────────────────────────────────────────────────────────────── + test-windows: + name: Windows + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Smoke test + working-directory: src/toolManager + shell: python + run: | + import sys + sys.path.append('.') + from platform_utils import ( + IS_WINDOWS, IS_LINUX, IS_MAC, get_mysys2_path, + detect_package_manager, distro_label, subprocess_flags + ) + + print(f"IS_WINDOWS : {IS_WINDOWS}") + print(f"IS_LINUX : {IS_LINUX}") + print(f"IS_MAC : {IS_MAC}") + print(f"MSYS2_PATH : {get_mysys2_path()}") + print(f"detect_pkg_manager : {detect_package_manager()}") + print(f"distro_label : {distro_label()}") + + assert IS_WINDOWS is True + assert IS_LINUX is False + assert IS_MAC is False + assert detect_package_manager() is None + assert "Windows" in distro_label(), f"Got: {distro_label()}" + assert distro_label() in ("Windows 10", "Windows 11"), f"Got: {distro_label()}" + flags = subprocess_flags() + assert "creationflags" in flags, "Expected creationflags key on Windows" + assert "startupinfo" in flags, "Expected startupinfo key on Windows" + + print("\n[PASS] All Windows assertions passed.") diff --git a/src/toolManager/platform_utils.py b/src/toolManager/platform_utils.py new file mode 100644 index 000000000..93c544f83 --- /dev/null +++ b/src/toolManager/platform_utils.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +platform_utils.py +───────────────── +Central OS and distro detection module for eSim Tool Manager. + +All platform-specific branching in the Tool Manager should import +constants and helpers from this module. Do NOT scatter sys.platform +checks across other files. + +Exports +------- +IS_WINDOWS bool +IS_LINUX bool +IS_MAC bool +get_msys32_path() Path (Windows only: default C:\\msys64) +subprocess_flags() dict (suppress console window on Windows) +detect_package_manager() str | None +distro_label() str + +Note: privilege elevation (pkexec) is handled in tool_manager_linux.py, +not here. This module is detection-only. +""" + +import sys +import platform +import shutil +from pathlib import Path + +__version__ = "1.0.0" +__author__ = "Eashan Hasija" + +__all__ = [ + "IS_WINDOWS", "IS_LINUX", "IS_MAC", + "get_mysys32_path", + "subprocess_flags", + "detect_package_manager", + "distro_label", +] + +# ── OS Flags ────────────────────────────────────────────────────────────────── +IS_WINDOWS: bool = sys.platform == "win32" +IS_LINUX: bool = sys.platform.startswith("linux") +IS_MAC: bool = sys.platform == "darwin" + +# ── Windows: MSYS2 default install path ─────────────────────────────────────── +# Previously hardcoded in tool_manager_windows.py L25 as: +# MSYS2_PATH = Path(r"C:\msys64") +# Centralised here as the single place to change if the path changes. +def get_mysys2_path() -> Path: + if not IS_WINDOWS: + return RuntimeError("get_MSYS2_path() is only valid for Windows") + import os + return Path(os.environ.get("MSYS2_PATH", r"C:\msys64")) + +# ── Subprocess flags ─────────────────────────────────────────────────────────── +if IS_WINDOWS: + import subprocess as _sp + + def subprocess_flags() -> dict: + """Return Popen kwargs that hide the console window on Windows.""" + si = _sp.STARTUPINFO() + si.dwFlags |= _sp.STARTF_USESHOWWINDOW + si.wShowWindow = _sp.SW_HIDE + return {"creationflags": _sp.CREATE_NO_WINDOW, "startupinfo": si} +else: + def subprocess_flags() -> dict: + """No-op on Linux/macOS — returns empty dict.""" + return {} + + +# ── Package manager detection (Linux + macOS) ──────────────────────────────── +# Linux candidates — checked in priority order +_LINUX_PM_CANDIDATES: list[tuple[str, str]] = [ + ("apt-get", "apt"), # Ubuntu, Debian, Mint, Pop!_OS + ("dnf", "dnf"), # Fedora, RHEL 8+, AlmaLinux, Rocky + ("yum", "yum"), # CentOS 7, RHEL 7 + ("pacman", "pacman"), # Arch, Manjaro, EndeavourOS + ("zypper", "zypper"), # openSUSE, SLES + ("apk", "apk"), # Alpine +] + +# macOS candidates — Homebrew is checked before MacPorts +# Homebrew installs to user space (no sudo needed) +# MacPorts installs system-wide (sudo needed) +_MAC_PM_CANDIDATES: list[tuple[str, str]] = [ + ("brew", "brew"), # Homebrew + ("port", "port"), # MacPorts + ("nix", "nix"), # Nix +] + + +def detect_package_manager() -> str | None: + """ + Detect the active system package manager by checking PATH. + + Supported platforms + ------------------- + Linux : 'apt', 'dnf', 'yum', 'pacman', 'zypper', 'apk' + macOS : 'brew' (Homebrew), 'port' (MacPorts), 'nix' + Windows : always returns None (uses Chocolatey directly) + + Returns None if no supported package manager is found. + + Example + ------- + >>> pm = detect_package_manager() + >>> print(pm) # 'apt' on Ubuntu, 'brew' on macOS, 'pacman' on Arch + """ + if IS_WINDOWS: + return None # Windows uses Chocolatey directly in tool_manager_windows.py + if IS_LINUX: + for cmd, name in _LINUX_PM_CANDIDATES: + if shutil.which(cmd): + return name + if IS_MAC: + for cmd, name in _MAC_PM_CANDIDATES: + if shutil.which(cmd): + return name + return None + +# ── Linux: Human-readable distro string for GUI ─────────────────────────────── +def distro_label() -> str: + """ + Return a human-readable distro name read from /etc/os-release. + + Examples + -------- + 'Ubuntu 24.04', 'Fedora 40', 'Arch Linux', 'openSUSE Leap 15.5' + Returns 'Linux' as fallback if the file cannot be parsed. + Returns 'Windows' when called on Windows. + """ + if IS_WINDOWS: + return f"Windows {platform.release()}" + if IS_MAC: + return f"macOS {platform.mac_ver()[0]}" + try: + info = platform.freedesktop_os_release() + except AttributeError: + try: + info = {} + with open("/etc/os-release") as f: + for line in f: + line = line.strip() + if "=" in line: + k, _, v = line.partition("=") + info[k] = v.strip('"') + except (OSError, ValueError): + return "Linux" + except OSError: + return "Linux" + name = info.get("NAME", "Linux") + version = info.get("VERSION_ID", "") + return f"{name} {version}".strip() \ No newline at end of file