1
0
mirror of https://github.com/HorlogeSkynet/archey4 synced 2025-05-08 20:00:12 +02:00

[CPU] Reworks entry to correctly deal with dual sockets CPUs

> Now compatible against Python < 3.6
> Caution : new proposal so still API-breaking
This commit is contained in:
Samuel FORESTIER 2020-10-24 11:13:53 +02:00
parent 25b9c47e69
commit 308b41ad7f
2 changed files with 242 additions and 110 deletions
archey
entries
test/entries

@ -12,15 +12,27 @@ class CPU(Entry):
Parse `/proc/cpuinfo` file to retrieve model names.
If no information could be retrieved, call `lscpu`.
`value` attribute is populated as a `dict`.
It means that for Python < 3.6, "physical" CPU order **MAY** be lost.
`value` attribute is populated as a `list` of `dict`.
Each `dict` **SHOULD** contain only one entry (CPU model name as key and cores count as value).
"""
_MODEL_NAME_REGEXP = re.compile(
r'^model name\s*:\s*(.*)$',
flags=re.IGNORECASE | re.MULTILINE
)
_CPUS_COUNT_REGEXP = re.compile(
r'^CPU\(s\)\s*:\s*(\d+)$',
_PHYSICAL_ID_REGEXP = re.compile(
r'^physical id\s*:\s*(\d+)$',
flags=re.IGNORECASE | re.MULTILINE
)
_THREADS_PER_CORE_REGEXP = re.compile(
r'^Thread\(s\) per core\s*:\s*(\d+)$',
flags=re.IGNORECASE | re.MULTILINE
)
_CORES_PER_SOCKET_REGEXP = re.compile(
r'^Core\(s\) per socket\s*:\s*(\d+)$',
flags=re.IGNORECASE | re.MULTILINE
)
_SOCKETS_REGEXP = re.compile(
r'^Socket\(s\)\s*:\s*(\d+)$',
flags=re.IGNORECASE | re.MULTILINE
)
@ -40,22 +52,29 @@ class CPU(Entry):
"""Read `/proc/cpuinfo` and search for CPU model names occurrences"""
try:
with open('/proc/cpuinfo') as f_cpu_info:
cpu_models = cls._MODEL_NAME_REGEXP.findall(f_cpu_info.read())
cpu_info = f_cpu_info.read()
except (PermissionError, FileNotFoundError):
return {}
return []
model_names = cls._MODEL_NAME_REGEXP.findall(cpu_info)
physical_ids = cls._PHYSICAL_ID_REGEXP.findall(cpu_info)
cpus_list = []
# Manually de-duplicates CPUs count.
cpu_info = {}
for cpu_model in cpu_models:
for model_name, physical_id in zip(model_names, physical_ids):
# Sometimes CPU model names contain extra ugly white-spaces.
cpu_model = re.sub(r'\s+', ' ', cpu_model)
model_name = re.sub(r'\s+', ' ', model_name)
if cpu_model not in cpu_info:
cpu_info[cpu_model] = 1
else:
cpu_info[cpu_model] += 1
try:
cpus_list[int(physical_id)][model_name] += 1
except KeyError:
# Different CPUs with same physical ids ? Shouldn't happen.
cpus_list[int(physical_id)][model_name] = 1
except IndexError:
cpus_list.append({model_name: 1})
return cpu_info
return cpus_list
@classmethod
def _parse_lscpu_output(cls):
@ -65,38 +84,43 @@ class CPU(Entry):
env={'LANG': 'C'}, universal_newlines=True
)
cpu_models = cls._MODEL_NAME_REGEXP.findall(cpu_info)
cpu_counts = cls._CPUS_COUNT_REGEXP.findall(cpu_info)
nb_threads = cls._THREADS_PER_CORE_REGEXP.findall(cpu_info)
nb_cores = cls._CORES_PER_SOCKET_REGEXP.findall(cpu_info)
nb_sockets = cls._SOCKETS_REGEXP.findall(cpu_info)
model_names = cls._MODEL_NAME_REGEXP.findall(cpu_info)
return {
# Sometimes CPU model names contain extra ugly white-spaces.
re.sub(r'\s+', ' ', cpu_model): int(cpu_count)
for cpu_model, cpu_count in zip(cpu_models, cpu_counts)
}
cpus_list = []
for threads, cores, sockets, model_name in zip(
nb_threads, nb_cores, nb_sockets, model_names
):
for _ in range(int(sockets)):
# Sometimes CPU model names contain extra ugly white-spaces.
cpus_list.append(
{re.sub(r'\s+', ' ', model_name): int(threads) * int(cores)}
)
return cpus_list
def output(self, output):
"""Writes CPUs to `output` based on preferences"""
def _pre_format(cpu_model, cpu_count):
"""Simple closure to format our CPU final entry content"""
if cpu_count > 1 and self.options.get('show_count', True):
return '{} x {}'.format(cpu_count, cpu_model)
return cpu_model
# No CPU could be detected.
if not self.value:
output.append(self.name, self._default_strings.get('not_detected'))
# One-line output is enabled : Join the results !
elif self.options.get('one_line', True):
output.append(
self.name,
', '.join([
_pre_format(cpu_model, cpu_count)
for cpu_model, cpu_count in self.value.items()
])
)
# One-line output has been disabled, add one entry per item.
return
entries = []
for cpus in self.value:
for model_name, cpu_count in cpus.items():
if cpu_count > 1 and self.options.get('show_count', True):
entries.append('{} x {}'.format(cpu_count, model_name))
else:
entries.append(model_name)
if self.options.get('one_line', True):
# One-line output is enabled : Join the results !
output.append(self.name, ', '.join(entries))
else:
for cpu_model, cpu_count in self.value.items():
output.append(self.name, _pre_format(cpu_model, cpu_count))
# One-line output has been disabled, add one entry per item.
for entry in entries:
output.append(self.name, entry)

@ -1,15 +1,15 @@
"""Test module for Archey's CPU detection module"""
import sys
import unittest
from unittest.mock import MagicMock, call, mock_open, patch
from archey.entries.cpu import CPU
from archey.test import CustomAssertions
from archey.test.entries import HelperMethods
from archey.constants import DEFAULT_CONFIG
class TestCPUEntry(unittest.TestCase):
class TestCPUEntry(unittest.TestCase, CustomAssertions):
"""
Here, we mock the `open` call on `/proc/cpuinfo` with fake content.
In some cases, `lscpu` output is being mocked too.
@ -23,14 +23,15 @@ vendor_id\t: CPU-VENDOR-NAME
cpu family\t: X
model\t\t: YY
model name\t: CPU-MODEL-NAME
physical id\t: 0
"""),
create=True
)
def test_model_name_match_cpuinfo(self):
def test_parse_proc_cpuinfo_one_entry(self):
"""Test `/proc/cpuinfo` parsing"""
self.assertDictEqual(
CPU().value,
{'CPU-MODEL-NAME': 1}
self.assertListEqual(
CPU._parse_proc_cpuinfo(), # pylint: disable=protected-access
[{'CPU-MODEL-NAME': 1}]
)
@patch(
@ -42,29 +43,32 @@ vendor_id\t: CPU-VENDOR-NAME
cpu family\t: X
model\t\t: YY
model name\t: CPU-MODEL-NAME
physical id\t: 0
processor\t: 0
processor\t: 1
vendor_id\t: CPU-VENDOR-NAME
cpu family\t: X
model\t\t: YY
model name\t: ANOTHER-CPU-MODEL
physical id\t: 1
processor\t: 0
processor\t: 2
vendor_id\t: CPU-VENDOR-NAME
cpu family\t: X
model\t\t: YY
model name\t: ANOTHER-CPU-MODEL
physical id\t: 1
"""),
create=True
)
def test_multiple_cpus_from_proc_cpuinfo(self):
def test_parse_proc_cpuinfo_multiple_entries(self):
"""Test `/proc/cpuinfo` parsing"""
self.assertDictEqual(
CPU().value,
{
'CPU-MODEL-NAME': 1,
'ANOTHER-CPU-MODEL': 2
}
self.assertListEqual(
CPU._parse_proc_cpuinfo(), # pylint: disable=protected-access
[
{'CPU-MODEL-NAME': 1},
{'ANOTHER-CPU-MODEL': 2}
]
)
@patch(
@ -75,37 +79,40 @@ processor\t: 0
vendor_id\t: CPU-VENDOR-NAME
cpu family\t: X
model\t\t: YY
model name\t: CPU-MODEL-NAME
physical id\t: 0
processor\t: 1
vendor_id\t: CPU-VENDOR-NAME
cpu family\t: X
model\t\t: YY
model name\t: CPU-MODEL-NAME
physical id\t: 1
processor\t: 2
vendor_id\t: CPU-VENDOR-NAME
cpu family\t: X
model\t\t: YY
model name\t: CPU-MODEL-NAME
physical id\t: 0
processor\t: 3
vendor_id\t: CPU-VENDOR-NAME
cpu family\t: X
model\t\t: YY
model name\t: CPU-MODEL-NAME
physical id\t: 1
"""),
create=True
)
@patch(
'archey.entries.cpu.check_output',
return_value="""\
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Thread(s) per core: X
Core(s) per socket: Y
Socket(s): 1
NUMA node(s): 1
Vendor ID: CPU-VENDOR-NAME
CPU family: Z
Model: \xde\xad\xbe\xef
Model name: CPU-MODEL-NAME-WITHOUT-PROC-CPUINFO
""")
def test_model_name_match_lscpu(self, _):
"""
Test model name parsing from `lscpu` output.
See issue #29 (ARM architectures).
`/proc/cpuinfo` will not contain `model name` info.
`lscpu` output will be used instead.
"""
self.assertDictEqual(
CPU().value,
{'CPU-MODEL-NAME-WITHOUT-PROC-CPUINFO': 4}
def test_parse_proc_cpuinfo_one_cpu_dual_socket(self):
"""Test `/proc/cpuinfo` parsing for same CPU model across two sockets"""
self.assertListEqual(
CPU._parse_proc_cpuinfo(), # pylint: disable=protected-access
[
{'CPU-MODEL-NAME': 2},
{'CPU-MODEL-NAME': 2}
]
)
@patch(
@ -116,15 +123,48 @@ processor\t: 0
vendor_id\t: CPU-VENDOR-NAME
cpu family\t: X
model\t\t: YY
model name\t: CPU MODEL\t NAME
model name\t: CPU-MODEL-NAME
physical id\t: 0
processor\t: 1
vendor_id\t: CPU-VENDOR-NAME
cpu family\t: X
model\t\t: YY
model name\t: ANOTHER-CPU-MODEL
physical id\t: 0
processor\t: 2
vendor_id\t: CPU-VENDOR-NAME
cpu family\t: X
model\t\t: YY
model name\t: ANOTHER\tCPU MODEL WITH STRANGE S P A C E S
physical id\t: 1
processor\t: 3
vendor_id\t: CPU-VENDOR-NAME
cpu family\t: X
model\t\t: YY
model name\t: ANOTHER\tCPU MODEL WITH STRANGE S P A C E S
physical id\t: 1
"""),
create=True
)
def test_spaces_squeezing(self):
"""Test name sanitizing, needed on some platforms"""
self.assertDictEqual(
CPU().value,
{'CPU MODEL NAME': 1}
def test_parse_proc_cpuinfo_multiple_inconsistent_entries(self):
"""
Test `/proc/cpuinfo` parsing with multiple CPUs sharing same physical ids (unlikely).
Also check our model name normalizations applied on white characters.
"""
self.assertListEqual(
CPU._parse_proc_cpuinfo(), # pylint: disable=protected-access
[
{
'CPU-MODEL-NAME': 1,
'ANOTHER-CPU-MODEL': 1
},
{
'ANOTHER CPU MODEL WITH STRANGE S P A C E S': 2
}
]
)
@patch(
@ -132,29 +172,103 @@ model name\t: CPU MODEL\t NAME
side_effect=PermissionError(),
create=True
)
def test_parse_proc_cpuinfo_unreadable_file(self, _):
"""Check behavior when `/proc/cpuinfo` could not be read from disk"""
self.assertListEmpty(CPU._parse_proc_cpuinfo()) # pylint: disable=protected-access
@patch(
'archey.entries.cpu.check_output',
return_value="""\
side_effect=[
"""\
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Thread(s) per core: X
Core(s) per socket: Y
Thread(s) per core: 1
Core(s) per socket: 4
Socket(s): 1
NUMA node(s): 1
Vendor ID: CPU-VENDOR-NAME
CPU family: Z
Model: \xde\xad\xbe\xef
Model name: CPU-MODEL-NAME-WITHOUT-PROC-CPUINFO
""")
def test_proc_cpuinfo_unreadable(self, _, __):
"""Check Archey does not crash when `/proc/cpuinfo` is not readable"""
self.assertDictEqual(
CPU().value,
{'CPU-MODEL-NAME-WITHOUT-PROC-CPUINFO': 4}
)
Model name: CPU-MODEL-NAME
""",
"""\
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 2
On-line CPU(s) list: 0-1
Thread(s) per core: 1
Core(s) per socket: 2
Socket(s): 1
NUMA node(s): 1
Vendor ID: CPU-VENDOR-NAME
CPU family: Z
Model: \xde\xad\xbe\xef
Model name: CPU-MODEL-NAME
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Thread(s) per core: 2
Core(s) per socket: 2
Socket(s): 1
NUMA node(s): 1
Vendor ID: CPU-VENDOR-NAME
CPU family: Z
Model: \xde\xad\xbe\xef
Model name: ANOTHER-CPU-MODEL
""",
"""\
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 16
On-line CPU(s) list: 0-15
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 2
NUMA node(s): 2
Vendor ID: CPU-VENDOR-NAME
CPU family: Z
Model: \xde\xad\xbe\xef
Model name: CPU-MODEL-NAME
"""])
def test_parse_lscpu_output(self, _):
"""
Test model name parsing from `lscpu` output.
See issue #29 (ARM architectures).
`/proc/cpuinfo` will not contain `model name` info.
`lscpu` output will be used instead.
"""
with self.subTest('Simple unique CPU.'):
self.assertListEqual(
CPU._parse_lscpu_output(), # pylint: disable=protected-access
[{'CPU-MODEL-NAME': 4}]
)
with self.subTest('Two CPUs, 1 socket.'):
self.assertListEqual(
CPU._parse_lscpu_output(), # pylint: disable=protected-access
[
{'CPU-MODEL-NAME': 2},
{'ANOTHER-CPU-MODEL': 4}
]
)
with self.subTest('1 CPU, 2 sockets.'):
self.assertListEqual(
CPU._parse_lscpu_output(), # pylint: disable=protected-access
[
{'CPU-MODEL-NAME': 8},
{'CPU-MODEL-NAME': 8}
]
)
@HelperMethods.patch_clean_configuration
def test_various_output_configuration(self):
@ -162,15 +276,12 @@ Model name: CPU-MODEL-NAME-WITHOUT-PROC-CPUINFO
cpu_instance_mock = HelperMethods.entry_mock(CPU)
output_mock = MagicMock()
cpu_instance_mock.value = {
'CPU-MODEL-NAME': 1,
'ANOTHER-CPU-MODEL': 2
}
cpu_instance_mock.value = [
{'CPU-MODEL-NAME': 1},
{'ANOTHER-CPU-MODEL': 2}
]
with self.subTest('Single-line combined output.'):
if sys.version_info < (3, 6):
self.skipTest("Cannot test behavior on Python < 3.6, skipping.")
CPU.output(cpu_instance_mock, output_mock)
output_mock.append.assert_called_once_with(
'CPU',
@ -180,9 +291,6 @@ Model name: CPU-MODEL-NAME-WITHOUT-PROC-CPUINFO
output_mock.reset_mock()
with self.subTest('Single-line combined output (no count).'):
if sys.version_info < (3, 6):
self.skipTest("Cannot test behavior on Python < 3.6, skipping.")
cpu_instance_mock.options['show_count'] = False
CPU.output(cpu_instance_mock, output_mock)