Source code for tests.test_platform_info

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Test os-release parsing and platform information gathering."""

from __future__ import annotations

import io
import os
import platform
import shutil
import subprocess

import pytest

from extra_platforms import (
    MACOS,
    UBUNTU,
    WINDOWS,
    detection as detection_module,
    invalidate_caches,
)
from extra_platforms.platform_info import (
    MACOS_CODENAMES,
    _hostnamectl_os_release,
    _parse_cpe_name,
    _parse_hostnamectl_content,
    _parse_os_release,
    _parse_os_release_content,
    get_macos_codename,
    invalidate_os_release_cache,
    linux_info,
    macos_info,
    os_release_id,
    windows_info,
)

HOSTNAMECTL_CLOUDLINUX = """\
   Static hostname: web.illinois.edu
         Icon name: computer-vm
           Chassis: vm
           Boot ID: 5b5b8c522ccd4974808f192aea001491
    Virtualization: kvm
  Operating System: CloudLinux 7.6 (Vladimir Lyakhov)
       CPE OS Name: cpe:/o:cloudlinux:cloudlinux:7.6:GA:server
            Kernel: Linux 3.10.0-962.3.2.lve1.5.24.9.el7.x86_64
      Architecture: x86-64
"""
"""Status output of the CloudLinux VM reported in
[`python-distro/distro#240`](https://github.com/python-distro/distro/issues/240),
where no os-release file is readable.
"""

CLOUDLINUX_OS_RELEASE = {
    "cpe_name": "cpe:/o:cloudlinux:cloudlinux:7.6:GA:server",
    "id": "cloudlinux",
    "version_id": "7.6",
    "pretty_name": "CloudLinux 7.6 (Vladimir Lyakhov)",
    # Codename extracted from the pretty name parenthetical.
    "version_codename": "Vladimir Lyakhov",
}
"""The os-release fields rebuilt from `HOSTNAMECTL_CLOUDLINUX`."""


[docs] @pytest.fixture def fresh_os_release_caches(): """Clear derived os-release caches around a test. The test patches ``_parse_os_release``. Only the caches of ``os_release_id()`` and ``linux_info()`` are cleared: ``_parse_os_release()`` itself is replaced by ``monkeypatch``, whose teardown runs after this fixture's, so the patched stand-in (a plain lambda) has no cache to clear. """ os_release_id.cache_clear() linux_info.cache_clear() yield os_release_id.cache_clear() linux_info.cache_clear()
[docs] @pytest.fixture def fresh_hostnamectl_caches(): """Clear the caches a `hostnamectl`-driving test fills. ``_parse_os_release()`` memoizes whatever the fallback answered, so it is cleared next to ``_hostnamectl_os_release()``. Both are cleared on entry and exit, which ``fresh_os_release_caches`` cannot do for ``_parse_os_release()``: no test using this fixture replaces either function, so the real ``cache_clear()`` stays reachable on both sides. """ _hostnamectl_os_release.cache_clear() _parse_os_release.cache_clear() yield _hostnamectl_os_release.cache_clear() _parse_os_release.cache_clear()
[docs] @pytest.mark.parametrize( ("content", "expected"), ( pytest.param("", {}, id="empty_input"), pytest.param( "# This is a comment\n\nID=fedora\n", {"id": "fedora"}, id="comments_and_blank_lines", ), pytest.param( 'PRETTY_NAME="Ubuntu 22.04.3 LTS"\n' 'NAME="Ubuntu"\n' 'VERSION_ID="22.04"\n' 'VERSION="22.04.3 LTS (Jammy Jellyfish)"\n' "VERSION_CODENAME=jammy\n" "ID=ubuntu\n" "ID_LIKE=debian\n", { "pretty_name": "Ubuntu 22.04.3 LTS", "name": "Ubuntu", "version_id": "22.04", "version": "22.04.3 LTS (Jammy Jellyfish)", "version_codename": "jammy", "id": "ubuntu", "id_like": "debian", }, id="typical_ubuntu", ), pytest.param( 'NAME="Fedora Linux"\n' 'VERSION="39 (Workstation Edition)"\n' "ID=fedora\n" "VERSION_ID=39\n" 'PRETTY_NAME="Fedora Linux 39 (Workstation Edition)"\n', { "name": "Fedora Linux", "version": "39 (Workstation Edition)", "id": "fedora", "version_id": "39", "pretty_name": "Fedora Linux 39 (Workstation Edition)", # Codename extracted from the VERSION parenthetical. "version_codename": "Workstation Edition", }, id="typical_fedora", ), pytest.param( 'ID="centos"\nNAME="CentOS Stream"\n', {"id": "centos", "name": "CentOS Stream"}, id="quoted_values", ), pytest.param( 'VERSION="10, Buster"\nID=debian\n', { "version": "10, Buster", "id": "debian", # Codename extracted from the VERSION comma separator. "version_codename": "Buster", }, id="codename_from_version_comma", ), pytest.param( 'VERSION="22.04.3 LTS (Jammy Jellyfish)"\n' "VERSION_CODENAME=jammy\n" "ID=ubuntu\n", { "version": "22.04.3 LTS (Jammy Jellyfish)", # VERSION_CODENAME takes precedence over VERSION extraction. "version_codename": "jammy", "id": "ubuntu", }, id="version_codename_precedence", ), pytest.param( "ID=linuxmint\nUBUNTU_CODENAME=focal\n", { "id": "linuxmint", "ubuntu_codename": "focal", # UBUNTU_CODENAME is a fallback for VERSION_CODENAME. "version_codename": "focal", }, id="ubuntu_codename_fallback", ), pytest.param( "ID=test\nPRETTY_NAME=Test\n", {"id": "test", "pretty_name": "Test"}, id="keys_lowercased", ), pytest.param( "a line with no separator\nID=arch\n", {"id": "arch"}, id="line_without_equal_sign", ), pytest.param( 'VERSION="39"\nID=fedora\n', {"version": "39", "id": "fedora"}, id="version_without_codename", ), pytest.param( 'NAME="Fedora Linux\nID=fedora\n', {"name": "Fedora Linux", "id": "fedora"}, id="unclosed_quote", ), ), ) def test_parse_os_release_content(content, expected): """Parsing os-release content produces the exact expected mapping.""" assert _parse_os_release_content(io.StringIO(content)) == expected
[docs] @pytest.mark.parametrize( ("raw_id", "expected"), ( ("ol", "ol"), ("opensuse-leap", "opensuse-leap"), ("opensuse-slowroll", "opensuse-slowroll"), ("opensuse-tumbleweed", "opensuse-tumbleweed"), ("Ubuntu", "ubuntu"), ("fedora", "fedora"), ), ) def test_os_release_id_sanitization( raw_id, expected, monkeypatch, fresh_os_release_caches ): """IDs are lowercased but otherwise preserved, sub-variants included.""" monkeypatch.setattr( "extra_platforms.platform_info._parse_os_release", lambda: {"id": raw_id}, ) assert os_release_id() == expected
[docs] def test_os_release_id_empty(monkeypatch, fresh_os_release_caches): """Return empty string when no ID field is present.""" monkeypatch.setattr( "extra_platforms.platform_info._parse_os_release", dict, ) assert os_release_id() == ""
[docs] def test_linux_info_structure(monkeypatch, fresh_os_release_caches): """Return dict has expected keys and version_parts structure.""" monkeypatch.setattr( "extra_platforms.platform_info._parse_os_release", lambda: {"id": "ubuntu", "version_id": "22.04.3", "id_like": "debian"}, ) info = linux_info() assert info["id"] == "ubuntu" assert info["version"] == "22.04.3" assert info["version_parts"] == { "major": "22", "minor": "04", "build_number": "3", } assert info["like"] == "debian"
[docs] def test_linux_info_empty_version(monkeypatch, fresh_os_release_caches): """Version parts are None when version_id is absent.""" monkeypatch.setattr( "extra_platforms.platform_info._parse_os_release", lambda: {"id": "arch"}, ) info = linux_info() assert info["version"] is None assert info["version_parts"] == { "major": None, "minor": None, "build_number": None, } assert info["like"] is None assert info["codename"] is None
[docs] @pytest.mark.parametrize( ("cpe_name", "distro_id"), ( ("cpe:/o:almalinux:almalinux:10::baseos", "almalinux"), ("cpe:/o:alt:server:10.1", "altlinux"), ("cpe:/o:amazon:linux:2016.03:ga", "amzn"), ("cpe:2.3:o:amazon:amazon_linux:2023", "amzn"), ("cpe:/o:centos:centos:7", "centos"), ("cpe:/o:cloudlinux:cloudlinux:7.3:GA:server", "cloudlinux"), ("cpe:/o:fedoraproject:fedora:19", "fedora"), ("cpe:/o:ibm:kvmibm:1.1.1", "kvmibm"), ("cpe:/o:opensuse:leap:15.2", "opensuse-leap"), ("cpe:/o:opensuse:opensuse:42.1", "opensuse"), ("cpe:/o:oracle:linux:7:5:server", "ol"), ("cpe:/o:redhat:enterprise_linux:9::baseos", "rhel"), ("cpe:/o:rocky:rocky:8.4:GA", "rocky"), ("cpe:/o:scientificlinux:scientificlinux:7.2:GA", "rhel"), ("cpe:/o:slackware:slackware_linux:14.1", "slackware"), ("cpe:/o:suse:sles:12:sp1", "sles"), ), ) def test_parse_cpe_name_matches_os_release_id(cpe_name, distro_id): """A CPE name derives the `ID` the same system declares in its os-release. Both fields of each case are read from one real os-release file, as collected in [python-distro's test resources](https://github.com/python-distro/distro/tree/master/tests/resources/distros). That covers every ``vendor:product`` pair those fixtures carry, so a new entry in ``CPE_ID_OVERRIDES`` that contradicts a released distribution fails here. """ assert _parse_cpe_name(cpe_name)["id"] == distro_id
[docs] @pytest.mark.parametrize( ("cpe_name", "expected"), ( pytest.param( "cpe:/o:cloudlinux:cloudlinux:7.6:GA:server", { "cpe_name": "cpe:/o:cloudlinux:cloudlinux:7.6:GA:server", "id": "cloudlinux", "version_id": "7.6", }, id="uri_binding_drops_trailing_components", ), pytest.param( "cpe:2.3:o:amazon:amazon_linux:2023", { "cpe_name": "cpe:2.3:o:amazon:amazon_linux:2023", "id": "amzn", "version_id": "2023", }, id="formatted_string_binding", ), pytest.param( "cpe:/o:vendor:product", {"cpe_name": "cpe:/o:vendor:product", "id": "product"}, id="no_version", ), pytest.param( "cpe:/o:vendor:product:*", {"cpe_name": "cpe:/o:vendor:product:*", "id": "product"}, id="any_version_placeholder", ), pytest.param( "cpe:/o:vendor:-:1.0", {"cpe_name": "cpe:/o:vendor:-:1.0", "version_id": "1.0"}, id="not_applicable_product_placeholder", ), pytest.param("cpe:/a:vendor:product:1.0", {}, id="application_not_os"), pytest.param("cpe:/h:vendor:product:1.0", {}, id="hardware_not_os"), pytest.param("", {}, id="empty_input"), pytest.param("CloudLinux 7.6", {}, id="not_a_cpe_name"), ), ) def test_parse_cpe_name(cpe_name, expected): """Parsing a CPE name produces the exact expected mapping.""" assert _parse_cpe_name(cpe_name) == expected
[docs] @pytest.mark.parametrize( ("content", "expected"), ( pytest.param("", {}, id="empty_input"), pytest.param(HOSTNAMECTL_CLOUDLINUX, CLOUDLINUX_OS_RELEASE, id="cloudlinux_vm"), pytest.param( " Operating System: Ubuntu 22.04.3 LTS\n" " Kernel: Linux 6.5.0-14-generic\n", {"pretty_name": "Ubuntu 22.04.3 LTS"}, id="no_cpe_line", ), pytest.param( " CPE OS Name: cpe:/o:fedoraproject:fedora:39\n", { "cpe_name": "cpe:/o:fedoraproject:fedora:39", "id": "fedora", "version_id": "39", }, id="no_pretty_name_line", ), pytest.param( " Static hostname: fridge\n" " Chassis: container\n" " Kernel: Linux 6.5.0-14-generic\n", {}, id="host_lines_only", ), pytest.param( "Failed to query system properties: Connection refused\n", {}, id="bus_error_message", ), pytest.param( "a line with no separator\n Operating System: Arch Linux\n", {"pretty_name": "Arch Linux"}, id="line_without_colon", ), ), ) def test_parse_hostnamectl_content(content, expected): """Parsing hostnamectl output produces the exact expected mapping.""" assert _parse_hostnamectl_content(content.splitlines()) == expected
def _fake_run(stdout): """Build a `subprocess.run` stand-in returning ``stdout``. It also checks the call it stands in for: `hostnamectl status` in the C locale, which keeps the labels parseable, raising on failure, and with a timeout against an unresponsive bus. """ def run(args, **kwargs): assert tuple(args) == ("hostnamectl", "status") assert kwargs["env"]["LC_ALL"] == "C" assert kwargs["check"] is True assert kwargs["timeout"] return subprocess.CompletedProcess(args, 0, stdout=stdout, stderr="") return run def _forbidden_run(*args, **kwargs): """A `subprocess.run` stand-in failing the test that reaches it.""" raise AssertionError("hostnamectl must not be spawned")
[docs] def test_hostnamectl_os_release_skips_missing_binary( monkeypatch, fresh_hostnamectl_caches ): """No subprocess is spawned on a system shipping no `hostnamectl`.""" monkeypatch.setattr(shutil, "which", lambda name: None) monkeypatch.setattr(subprocess, "run", _forbidden_run) assert _hostnamectl_os_release() == {}
[docs] @pytest.mark.parametrize( "error", ( pytest.param(FileNotFoundError("hostnamectl"), id="binary_missing"), pytest.param(PermissionError("hostnamectl"), id="not_executable"), pytest.param( subprocess.CalledProcessError(1, "hostnamectl"), id="non_zero_exit" ), pytest.param( subprocess.TimeoutExpired("hostnamectl", 2), id="unresponsive_bus" ), ), ) def test_hostnamectl_os_release_degrades(error, monkeypatch, fresh_hostnamectl_caches): """Every failure to reach the bus degrades to an empty result.""" def failing_run(*args, **kwargs): raise error monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/hostnamectl") monkeypatch.setattr(subprocess, "run", failing_run) assert _hostnamectl_os_release() == {}
[docs] def test_parse_os_release_falls_back_to_hostnamectl( monkeypatch, fresh_hostnamectl_caches, fresh_os_release_caches ): """A system hiding both os-release files is identified through the bus.""" monkeypatch.setattr(os.path, "isfile", lambda path: False) monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/hostnamectl") monkeypatch.setattr(subprocess, "run", _fake_run(HOSTNAMECTL_CLOUDLINUX)) assert _parse_os_release() == CLOUDLINUX_OS_RELEASE assert os_release_id() == "cloudlinux" assert linux_info() == { "id": "cloudlinux", "version": "7.6", "version_parts": {"major": "7", "minor": "6", "build_number": None}, "like": None, "codename": "Vladimir Lyakhov", }
[docs] def test_parse_os_release_prefers_files_over_hostnamectl( monkeypatch, fresh_hostnamectl_caches, fresh_os_release_caches ): """A readable os-release file is never second-guessed through the bus.""" monkeypatch.setattr(os.path, "isfile", lambda path: path == "/etc/os-release") monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/hostnamectl") monkeypatch.setattr(subprocess, "run", _forbidden_run) monkeypatch.setattr( "extra_platforms.platform_info.open", lambda *args, **kwargs: io.StringIO("ID=ubuntu\n"), raising=False, ) assert _parse_os_release() == {"id": "ubuntu"}
[docs] @pytest.mark.parametrize( ("unreadable", "expected"), ( pytest.param( {"/etc/os-release"}, {"id": "fedora"}, id="falls_back_to_second_file" ), pytest.param( {"/etc/os-release", "/usr/lib/os-release"}, {}, id="both_files_unreadable" ), ), ) def test_parse_os_release_skips_unreadable_file( unreadable, expected, monkeypatch, fresh_hostnamectl_caches ): """An os-release file that exists but cannot be read is skipped, not raised.""" def guarded_open(path, *args, **kwargs): if path in unreadable: raise PermissionError(13, "Permission denied", path) return io.StringIO("ID=fedora\n") monkeypatch.setattr(os.path, "isfile", lambda path: True) monkeypatch.setattr(shutil, "which", lambda name: None) monkeypatch.setattr( "extra_platforms.platform_info.open", guarded_open, raising=False ) assert _parse_os_release() == expected
[docs] def test_parse_os_release_replaces_invalid_bytes(monkeypatch, fresh_hostnamectl_caches): """A byte that is not UTF-8 spoils its own value, not the whole file.""" content = b'ID=fedora\nNAME="F\xffdora"\n' monkeypatch.setattr(os.path, "isfile", lambda path: path == "/etc/os-release") # Decode the bytes with the arguments the code under test passes to open(). monkeypatch.setattr( "extra_platforms.platform_info.open", lambda path, **kwargs: io.TextIOWrapper(io.BytesIO(content), **kwargs), raising=False, ) assert _parse_os_release() == { "id": "fedora", "name": "F\N{REPLACEMENT CHARACTER}dora", }
[docs] def test_invalidate_os_release_cache_clears_hostnamectl( monkeypatch, fresh_hostnamectl_caches ): """The hostnamectl result is re-read after a cache invalidation.""" monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/hostnamectl") monkeypatch.setattr(subprocess, "run", _fake_run(HOSTNAMECTL_CLOUDLINUX)) assert _hostnamectl_os_release()["id"] == "cloudlinux" monkeypatch.setattr( subprocess, "run", _fake_run(" Operating System: Fedora Linux 39\n"), ) # The cached CloudLinux answer is still served before invalidation. assert _hostnamectl_os_release()["id"] == "cloudlinux" invalidate_os_release_cache() assert _hostnamectl_os_release() == {"pretty_name": "Fedora Linux 39"}
[docs] @pytest.fixture def macos_sonoma(monkeypatch): """Report the macOS version the way a macOS 14.2.1 host does.""" monkeypatch.setattr(platform, "mac_ver", lambda: ("14.2.1", ("", "", ""), "arm64"))
[docs] def test_macos_info(macos_sonoma): """The version splits into its parts, and its major part names the release.""" assert macos_info() == { "version": "14.2.1", "version_parts": {"major": "14", "minor": "2", "build_number": "1"}, "codename": "Sonoma", }
[docs] @pytest.mark.parametrize( ("major", "minor", "codename"), [ pytest.param(major, minor, codename, id=codename) for (major, minor), codename in MACOS_CODENAMES.items() ], ) def test_get_macos_codename(major, minor, codename): """Every version the table lists resolves to its own codename.""" assert get_macos_codename(major, minor) == codename
[docs] def test_get_macos_codename_unknown_version(): """A version no codename covers raises, like the `16` of some Tahoe betas.""" with pytest.raises(ValueError, match=r"No macOS codename match version \('16'"): get_macos_codename("16", "0")
[docs] def test_macos_codenames_key_each_major_one_way(): """A major version keys its codenames either alone or with its minor versions. Both ways at once would make a version match two codenames. """ by_major_alone = {major for major, minor in MACOS_CODENAMES if minor is None} with_minor = {major for major, minor in MACOS_CODENAMES if minor is not None} assert not by_major_alone & with_minor
[docs] @pytest.fixture def windows_11(monkeypatch): """Report the Windows version the way a Windows 11 24H2 guest does.""" monkeypatch.setattr( platform, "win32_ver", lambda: ("11", "10.0.26100", "SP0", "Multiprocessor Free"), ) monkeypatch.setattr(platform, "win32_edition", lambda: "Professional")
[docs] def test_windows_info(windows_11): """The version is the NT one, build number included; the release names it.""" assert windows_info() == { "release": "11", "version": "10.0.26100", "version_parts": {"major": "10", "minor": "0", "build_number": "26100"}, "codename": "11 Professional", }
[docs] def test_windows_platform_info_release(monkeypatch, windows_11, fresh_caches): """``Platform.info()`` carries the Windows release beside the NT version.""" monkeypatch.setitem( detection_module._detection_registry, "is_windows", lambda: True ) invalidate_caches() info = WINDOWS.info() assert info["release"] == "11" assert info["version"] == "10.0.26100" assert info["codename"] == "11 Professional"
[docs] @pytest.mark.parametrize( ("plat", "version"), ((MACOS, "14.2.1"), (UBUNTU, "22.04")), ids=lambda value: value.id if hasattr(value, "id") else value, ) def test_release_is_windows_only( plat, version, monkeypatch, macos_sonoma, fresh_caches ): """No other platform fills ``release``. On macOS, CPython's own ``platform.release()`` names the Darwin kernel, another meaning. Each platform is forced current, since ``info()`` fills nothing for a platform that is not. """ monkeypatch.setitem( detection_module._detection_registry, plat.detection_func_id, lambda: True ) monkeypatch.setattr( "extra_platforms.trait.linux_info", lambda: {"id": "ubuntu", "version": "22.04"}, ) invalidate_caches() info = plat.info() # The version proves the fetchers of the platform ran. assert info["version"] == version assert info["release"] is None