Source code for extra_platforms.platform

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""Platforms, also known as Operating Systems.

Everything here can be aggressively cached and frozen, as it's only compute
platform-dependent values.
"""

from __future__ import annotations

import platform
from dataclasses import dataclass, field
from typing import Any

import distro

# TODO: eliminate boltons dependency
from boltons.iterutils import remap

from . import detection

_MACOS_CODENAMES = {
    ("10", "0"): "Cheetah",
    ("10", "1"): "Puma",
    ("10", "2"): "Jaguar",
    ("10", "3"): "Panther",
    ("10", "4"): "Tiger",
    ("10", "5"): "Leopard",
    ("10", "6"): "Snow Leopard",
    ("10", "7"): "Lion",
    ("10", "8"): "Mountain Lion",
    ("10", "9"): "Mavericks",
    ("10", "10"): "Yosemite",
    ("10", "11"): "El Capitan",
    ("10", "12"): "Sierra",
    ("10", "13"): "High Sierra",
    ("10", "14"): "Mojave",
    ("10", "15"): "Catalina",
    ("11", None): "Big Sur",
    ("12", None): "Monterey",
    ("13", None): "Ventura",
    ("14", None): "Sonoma",
    ("15", None): "Sequoia",
}
"""Maps macOS ``(major, minor)`` version parts to release code name.

See:
- https://en.wikipedia.org/wiki/Template:MacOS_versions
- https://docs.python.org/3/library/platform.html#platform.mac_ver
"""


def _get_macos_codename(major: str | None = None, minor: str | None = None) -> str:
    matches = set()
    for (major_key, minor_key), codename in _MACOS_CODENAMES.items():
        if minor_key is not None and minor_key != minor:
            continue
        if major_key == major:
            matches.add(codename)
    if not matches:
        raise ValueError(f"No macOS codename match version ({major!r}, {minor!r})")
    if len(matches) != 1:
        raise ValueError(
            f"Version {major}.{minor} match multiple codenames: {matches!r}"
        )
    return matches.pop()


def _recursive_update(
    a: dict[str, Any], b: dict[str, Any], strict: bool = False
) -> dict[str, Any]:
    """Like standard ``dict.update()``, but recursive so sub-dict gets updated.

    Ignore elements present in ``b`` but not in ``a``. Unless ``strict`` is set to
    `True`, in which case a `ValueError` exception will be raised.
    """
    for k, v in b.items():
        if isinstance(v, dict) and isinstance(a.get(k), dict):
            a[k] = _recursive_update(a[k], v, strict=strict)
        # Ignore elements unregistered in the template structure.
        elif k in a:
            a[k] = b[k]
        elif strict:
            msg = f"Parameter {k!r} found in second dict but not in first."
            raise ValueError(msg)
    return a


def _remove_blanks(
    tree: dict,
    remove_none: bool = True,
    remove_dicts: bool = True,
    remove_str: bool = True,
) -> dict:
    """Returns a copy of a dict without items whose values blanks.

    Are considered blanks:
    - `None` values
    - empty strings
    - empty `dict`

    The removal of each of these class can be skipped by setting ``remove_*``
    parameters.

    Dictionarries are inspected recursively and their own blank values are removed.
    """

    def visit(path, key, value) -> bool:
        """Ignore some class of blank values depending on configuration."""
        if remove_none and value is None:
            return False
        if remove_dicts and isinstance(value, dict) and not len(value):
            return False
        if remove_str and isinstance(value, str) and not len(value):
            return False
        return True

    return remap(tree, visit=visit)  # type: ignore[no-any-return]


[docs] @dataclass(frozen=True) class Platform: """A platform can identify multiple distributions or OSes with the same characteristics. It has a unique ID, a human-readable name, and boolean to flag current platform. """ id: str """Unique ID of the platform.""" name: str """User-friendly name of the platform.""" icon: str = field(repr=False, default="❓") """Icon of the platform.""" current: bool = field(init=False) """`True` if current environment runs on this platform.""" def __post_init__(self): """Validate and normalize platform fields: - Ensure the platform ID, name and icon are not empty. - Set the ``current`` field. - Populate the docstring. """ assert self.id, "Platform ID cannot be empty." assert self.name, "Platform name cannot be empty." assert self.icon, "Platform icon cannot be empty." object.__setattr__(self, "current", detection.__dict__[f"is_{self.id}"]()) object.__setattr__(self, "__doc__", f"Identify {self.name}.")
[docs] def info(self) -> dict[str, str | bool | None | dict[str, str | None]]: """Returns all platform attributes we can gather.""" info = { "id": self.id, "name": self.name, "icon": self.icon, "current": self.current, # Extra fields from distro.info(). "distro_id": None, "version": None, "version_parts": {"major": None, "minor": None, "build_number": None}, "like": None, "codename": None, } if self.current: # Get extra Linux distribution info from distro. distro_info = dict(distro.info()) # Rename distro ID to avoid conflict with our own ID. distro_info["distro_id"] = distro_info.pop("id") info = _recursive_update(info, _remove_blanks(distro_info), strict=True) # Add extra macOS infos. if self.id == "macos": info = _recursive_update(info, self._macos_infos(), strict=True) # Add extra Windows infos. elif self.id == "windows": info = _recursive_update(info, self._windows_infos(), strict=True) return info # type: ignore[return-value]
@staticmethod def _macos_infos() -> dict[str, Any]: """Fetch extra macOS infos. Returns the same dict structure as ``distro.info()``. """ release, _versioninfo, _machine = platform.mac_ver() parts = dict(zip(("major", "minor", "build_number"), release.split(".", 2))) major = parts.get("major") minor = parts.get("minor") build_number = parts.get("build_number") return { "version": release, "version_parts": { "major": major, "minor": minor, "build_number": build_number, }, "codename": _get_macos_codename(major, minor), } @staticmethod def _windows_infos() -> dict[str, Any]: """Fetch extra Windows infos. Returns the same dict structure as ``distro.info()``. .. todo: Get even more details for windows version? See inspirations from: https://github.com/saltstack/salt/blob/246d066/salt/grains/core.py#L1432-L1488 """ release, _version, _csd, _ptype = platform.win32_ver() parts = dict(zip(("major", "minor", "build_number"), release.split(".", 2))) major = parts.get("major") minor = parts.get("minor") build_number = parts.get("build_number") return { "version": release, "version_parts": { "major": major, "minor": minor, "build_number": build_number, }, "codename": " ".join((release, platform.win32_edition())), }