# 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]
@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