mirror of
https://github.com/nicolargo/glances.git
synced 2026-08-02 16:27:01 -04:00
Merge branch 'develop' into develop-v5
This commit is contained in:
@@ -27,6 +27,18 @@ class GlancesStdoutCsv:
|
||||
# Display the header only on the first line
|
||||
self.header = True
|
||||
|
||||
# Remember, per plugin, the ordered list of list-item keys (e.g. network
|
||||
# interface names) captured when the header was built. Data rows are then
|
||||
# aligned to this fixed schema: absent items are filled with N/A and items
|
||||
# that appear after export start are omitted (they have no header column).
|
||||
self.list_keys = {}
|
||||
|
||||
# Ordered field names per list item for each plugin, captured at header time.
|
||||
# Data rows emit values by these names (N/A when a field is missing this
|
||||
# cycle, e.g. rate fields absent on an interface's first sample), so every
|
||||
# interface block always matches the header width.
|
||||
self.header_field_names = {}
|
||||
|
||||
# Build the list of plugin and/or plugin.attribute to display
|
||||
self.plugins_list = self.build_list()
|
||||
|
||||
@@ -58,10 +70,18 @@ class GlancesStdoutCsv:
|
||||
for k in stat:
|
||||
line += f'{plugin}.{str(k)}{self.separator}'
|
||||
elif isinstance(stat, list):
|
||||
keys_order = []
|
||||
for i in stat:
|
||||
if isinstance(i, dict) and 'key' in i:
|
||||
keys_order.append(str(i[i['key']]))
|
||||
for k in i:
|
||||
line += '{}.{}.{}{}'.format(plugin, str(i[i['key']]), str(k), self.separator)
|
||||
# Lock the interface schema: ordered identities + ordered field names
|
||||
self.list_keys[plugin] = keys_order
|
||||
for i in stat:
|
||||
if isinstance(i, dict) and 'key' in i:
|
||||
self.header_field_names[plugin] = list(i.keys())
|
||||
break
|
||||
else:
|
||||
line += f'{plugin}{self.separator}'
|
||||
|
||||
@@ -78,10 +98,22 @@ class GlancesStdoutCsv:
|
||||
for v in stat.values():
|
||||
line += f'{str(v)}{self.separator}'
|
||||
elif isinstance(stat, list):
|
||||
# Index current items by their identity value
|
||||
current = {}
|
||||
for i in stat:
|
||||
if isinstance(i, dict) and 'key' in i:
|
||||
for v in i.values():
|
||||
line += f'{str(v)}{self.separator}'
|
||||
ident = str(i[i['key']])
|
||||
current[ident] = i
|
||||
# Emit one block per identity locked in at header time, always using
|
||||
# the header's field names so the block width is constant. Missing
|
||||
# fields (absent interface, or rate fields not yet computed on an
|
||||
# interface's first sample) become N/A. Identities that appeared only
|
||||
# after the header was built are omitted (they have no column).
|
||||
field_names = self.header_field_names.get(plugin, [])
|
||||
for ident in self.list_keys.get(plugin, []):
|
||||
item = current.get(ident, {})
|
||||
for field in field_names:
|
||||
line += f'{str(item.get(field, self.na))}{self.separator}'
|
||||
else:
|
||||
line += f'{str(stat)}{self.separator}'
|
||||
|
||||
|
||||
145
tests/test_stdout_csv.py
Executable file
145
tests/test_stdout_csv.py
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# This file is part of Glances.
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2025 Nicolas Hennion <nicolas@nicolargo.com>
|
||||
#
|
||||
# SPDX-License-Identifier: LGPL-3.0-only
|
||||
#
|
||||
|
||||
"""Unit tests for the stdout CSV output (--stdout-csv).
|
||||
|
||||
Regression tests for issue #3606: when a list-type plugin (e.g. network) gains or
|
||||
loses items - or an item reappears with a reduced field set (rate fields are not
|
||||
computed on an interface's first sample) - the data rows must stay aligned with the
|
||||
header built on the first refresh.
|
||||
"""
|
||||
|
||||
from glances.outputs.glances_stdout_csv import GlancesStdoutCsv
|
||||
|
||||
|
||||
def _make_csv():
|
||||
"""Return a GlancesStdoutCsv instance without going through __init__."""
|
||||
csv = GlancesStdoutCsv.__new__(GlancesStdoutCsv)
|
||||
csv.separator = ','
|
||||
csv.na = 'N/A'
|
||||
csv.header = True
|
||||
csv.list_keys = {}
|
||||
csv.header_field_names = {}
|
||||
return csv
|
||||
|
||||
|
||||
def _iface_full(name, sent, recv):
|
||||
"""An interface with the full field set (rate/gauge fields present)."""
|
||||
return {
|
||||
'interface_name': name,
|
||||
'key': 'interface_name',
|
||||
'bytes_sent': sent,
|
||||
'bytes_recv': recv,
|
||||
'speed': 1000,
|
||||
'alias': None,
|
||||
'bytes_all': sent + recv,
|
||||
'time_since_update': 2.0,
|
||||
'bytes_recv_gauge': recv * 10,
|
||||
'bytes_recv_rate_per_sec': float(recv),
|
||||
'bytes_sent_gauge': sent * 10,
|
||||
'bytes_sent_rate_per_sec': float(sent),
|
||||
'bytes_all_gauge': (sent + recv) * 10,
|
||||
'bytes_all_rate_per_sec': float(sent + recv),
|
||||
}
|
||||
|
||||
|
||||
def _iface_partial(name, sent, recv):
|
||||
"""An interface on its first sample: rate/gauge fields not computed yet."""
|
||||
return {
|
||||
'interface_name': name,
|
||||
'key': 'interface_name',
|
||||
'bytes_sent': sent,
|
||||
'bytes_recv': recv,
|
||||
'speed': 1000,
|
||||
'alias': None,
|
||||
'bytes_all': sent + recv,
|
||||
}
|
||||
|
||||
|
||||
def _ncols(line):
|
||||
stripped = line.rstrip(',')
|
||||
return len(stripped.split(',')) if stripped else 0
|
||||
|
||||
|
||||
def test_list_plugin_steady_state():
|
||||
"""Rows match the header when the interface set is unchanged."""
|
||||
csv = _make_csv()
|
||||
stat = [_iface_full('eth0', 1, 2), _iface_full('wlan0', 3, 4)]
|
||||
header = csv.build_header('network', None, stat)
|
||||
data = csv.build_data('network', None, stat)
|
||||
assert _ncols(data) == _ncols(header)
|
||||
|
||||
|
||||
def test_list_plugin_interface_removed():
|
||||
"""A removed interface is N/A-filled so the row stays aligned."""
|
||||
csv = _make_csv()
|
||||
start = [_iface_full('eth0', 1, 2), _iface_full('wlan0', 3, 4)]
|
||||
header = csv.build_header('network', None, start)
|
||||
data = csv.build_data('network', None, [_iface_full('eth0', 5, 6)])
|
||||
assert _ncols(data) == _ncols(header)
|
||||
assert 'N/A' in data
|
||||
|
||||
|
||||
def test_list_plugin_interface_reappears_with_partial_fields():
|
||||
"""An interface back on its first sample (missing rate fields) is N/A-padded per field."""
|
||||
csv = _make_csv()
|
||||
start = [_iface_full('eth0', 1, 2), _iface_full('wlan0', 3, 4)]
|
||||
header = csv.build_header('network', None, start)
|
||||
# wlan0 returns with only the non-rate fields present
|
||||
data = csv.build_data('network', None, [_iface_full('eth0', 5, 6), _iface_partial('wlan0', 7, 8)])
|
||||
assert _ncols(data) == _ncols(header)
|
||||
# the 7 missing wlan0 fields become N/A
|
||||
assert data.count('N/A') == 7
|
||||
|
||||
|
||||
def test_list_plugin_interface_added_is_omitted():
|
||||
"""An interface appearing after export start has no column and is omitted."""
|
||||
csv = _make_csv()
|
||||
start = [_iface_full('eth0', 1, 2), _iface_full('wlan0', 3, 4)]
|
||||
header = csv.build_header('network', None, start)
|
||||
data = csv.build_data(
|
||||
'network',
|
||||
None,
|
||||
[_iface_full('eth0', 7, 8), _iface_full('ppp0', 9, 9), _iface_full('wlan0', 10, 11)],
|
||||
)
|
||||
assert _ncols(data) == _ncols(header)
|
||||
assert 'ppp0' not in data
|
||||
|
||||
|
||||
def test_list_plugin_added_keeps_existing_aligned():
|
||||
"""When a new interface shifts the live order, existing ones stay under their columns."""
|
||||
csv = _make_csv()
|
||||
start = [_iface_full('eth0', 1, 2), _iface_full('wlan0', 3, 4)]
|
||||
csv.build_header('network', None, start)
|
||||
data = csv.build_data(
|
||||
'network',
|
||||
None,
|
||||
[_iface_full('eth0', 7, 8), _iface_full('ppp0', 9, 9), _iface_full('wlan0', 10, 11)],
|
||||
)
|
||||
cells = data.rstrip(',').split(',')
|
||||
# wlan0's current bytes_sent (10) must appear under wlan0's block, not eth0's
|
||||
assert '10' in cells
|
||||
|
||||
|
||||
def test_dict_plugin_unaffected():
|
||||
"""Non-list plugins (dict) are unchanged by the fix."""
|
||||
csv = _make_csv()
|
||||
cpu = {'user': 1.2, 'system': 0.8, 'idle': 98.0}
|
||||
header = csv.build_header('cpu', None, cpu)
|
||||
data = csv.build_data('cpu', None, cpu)
|
||||
assert _ncols(data) == _ncols(header) == 3
|
||||
|
||||
|
||||
def test_attribute_selector_unaffected():
|
||||
"""A plugin.attribute selector still yields a single aligned column."""
|
||||
csv = _make_csv()
|
||||
cpu = {'user': 1.2, 'system': 0.8, 'idle': 98.0}
|
||||
header = csv.build_header('cpu', 'user', cpu)
|
||||
data = csv.build_data('cpu', 'user', cpu)
|
||||
assert _ncols(data) == _ncols(header) == 1
|
||||
Reference in New Issue
Block a user