From 844895f3cbc0e5fac481cf9ebf0103a7bf50ced5 Mon Sep 17 00:00:00 2001 From: dipetm Date: Fri, 7 Aug 2026 14:35:35 +0300 Subject: [PATCH 1/6] perf: build process views on demand instead of up front --- glances/plugins/plugin/model.py | 70 ++++++++++++++++++++++ glances/plugins/processlist/__init__.py | 4 ++ tests/test_lazy_views.py | 80 +++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 tests/test_lazy_views.py diff --git a/glances/plugins/plugin/model.py b/glances/plugins/plugin/model.py index ce0a764d..526bb641 100644 --- a/glances/plugins/plugin/model.py +++ b/glances/plugins/plugin/model.py @@ -53,9 +53,69 @@ fields_unit_type = { } +class LazyViews(dict): + """Views for a list-of-dicts plugin, built per key on first access. + + processlist can hold tens of thousands of items while the UI shows a few dozen rows, and + in curses mode nothing reads the views at all, so building them up front is pure waste. + Anything that reads the whole object goes through get_views(), which materialises it + first, so consumers still see a plain and complete dict. + """ + + def __init__(self, plugin, raw, key_field): + super().__init__() + self._plugin = plugin + self._items = {item[key_field]: item for item in raw} + + def _build(self, key): + item = self._items[key] # a genuinely unknown key raises KeyError, as a dict would + return {field: self._plugin._build_view_for_field(key=key, field=field) for field in item} + + def __missing__(self, key): + built = self._build(key) + super().__setitem__(key, built) + return built + + # Membership is deliberately left as dict's own: it reports what has been built, not what + # could be. _build_view_for_field() asks whether a previous view exists before indexing + # into it, and answering "yes" for an entry that is only about to be created sends it + # straight back in here. + + def materialize(self): + """Build every remaining view and return self as a fully populated dict.""" + for key in self._items: + if not super().__contains__(key): + super().__setitem__(key, self._build(key)) + return self + + def __iter__(self): + self.materialize() + return super().__iter__() + + def __len__(self): + self.materialize() + return super().__len__() + + def keys(self): + self.materialize() + return super().keys() + + def values(self): + self.materialize() + return super().values() + + def items(self): + self.materialize() + return super().items() + + class GlancesPluginModel: """Main class for Glances plugin model.""" + # Build the per-item views on demand instead of up front. Only worth it for plugins whose + # item count is large and unrelated to how many rows the UI shows. + lazy_views = False + def __init__(self, args=None, config=None, items_history_list=None, stats_init_value={}, fields_description=None): """Init the plugin of plugins model class. @@ -669,6 +729,13 @@ class GlancesPluginModel: """ ret = {} + # hide_zero makes _build_view_for_field() read the previous self.views, which a lazy + # container cannot provide: it is itself self.views by then, so the lookup would + # recurse into the entry being built. Fall back to building everything up front. + if self.lazy_views and not self.hide_zero and isinstance(self.get_raw(), list) and self.get_key() is not None: + self.views = LazyViews(self, self.get_raw(), self.get_key()) + return self.views + if self.get_raw() is not None and isinstance(self.get_raw(), list) and self.get_key() is not None: # Stats are stored in a list of dict (ex: DISKIO, NETWORK, FS...) for i in self.get_raw(): @@ -706,6 +773,9 @@ class GlancesPluginModel: item_views = self.views else: item_views = self.views[item] + if isinstance(item_views, LazyViews): + # The caller gets the object itself, so hand out a fully built one. + item_views = item_views.materialize() if key is None: return item_views if key not in item_views: diff --git a/glances/plugins/processlist/__init__.py b/glances/plugins/processlist/__init__.py index 2e084490..1fd8dd56 100644 --- a/glances/plugins/processlist/__init__.py +++ b/glances/plugins/processlist/__init__.py @@ -122,6 +122,10 @@ class ProcesslistPlugin(GlancesPluginModel): stats is a list """ + # The list holds every process on the machine while the UI shows a few dozen rows, and + # the curses output never reads the views at all. Build them on demand. + lazy_views = True + # Default list of processes stats to be grabbed / displayed # Can be altered by glances_processes.disable_stats enable_stats = [ diff --git a/tests/test_lazy_views.py b/tests/test_lazy_views.py new file mode 100644 index 00000000..ae36ac71 --- /dev/null +++ b/tests/test_lazy_views.py @@ -0,0 +1,80 @@ +"""Tests for the lazily built process views.""" + +import json +from unittest import mock + +import pytest + +from glances.plugins.plugin.model import LazyViews +from glances.plugins.processlist import ProcesslistPlugin + + +def make_process(pid): + return { + 'pid': pid, + 'key': 'pid', + 'name': f'proc{pid}', + 'cmdline': [f'proc{pid}'], + 'username': 'someone', + 'status': 'S', + 'nice': 0, + 'num_threads': 1, + 'cpu_percent': 0.0, + 'memory_percent': 0.1, + 'memory_info': {'rss': 1024, 'vms': 2048}, + 'cpu_times': {'user': 1.0, 'system': 1.0}, + 'io_counters': [0, 0, 0, 0, 0], + 'time_since_update': 1.0, + } + + +@pytest.fixture +def plugin(): + p = ProcesslistPlugin(args=mock.Mock(time=2), config=None) + p.stats = [make_process(pid) for pid in range(10)] + return p + + +def built_count(views): + """How many entries exist without asking the lazy container to build more.""" + return dict.__len__(views) + + +def test_views_are_lazy_by_default(plugin): + plugin.update_views() + assert isinstance(plugin.views, LazyViews) + assert built_count(plugin.views) == 0 + + +def test_reading_one_key_builds_only_that_key(plugin): + plugin.update_views() + view = plugin.views[4] + assert view['cpu_percent']['decoration'] is not None + assert built_count(plugin.views) == 1 + + +def test_unknown_key_raises(plugin): + plugin.update_views() + with pytest.raises(KeyError): + plugin.views[999999] + + +def test_get_views_materializes_everything(plugin): + plugin.update_views() + views = plugin.get_views() + assert len(views) == 10 + assert built_count(views) == 10 + + +def test_json_contains_every_process(plugin): + plugin.update_views() + assert len(json.loads(plugin.get_json_views())) == 10 + + +def test_hide_zero_falls_back_to_eager_views(plugin): + """hide_zero reads the previous view of a key, which a lazy container cannot provide.""" + plugin.hide_zero = True + plugin.hide_zero_fields = ['cpu_percent'] + plugin.update_views() + assert not isinstance(plugin.views, LazyViews) + assert len(plugin.views) == 10 From 397ec8dec6cd8d3b61a4a2d4c71afe766af4ae90 Mon Sep 17 00:00:00 2001 From: dipetm Date: Fri, 7 Aug 2026 15:24:24 +0300 Subject: [PATCH 2/6] Defer the whole view build instead of doing it per key A dict subclass that built entries on access turned out to be more machinery than the problem needs: nothing outside the plugin reads .views directly, and every per-item caller belongs to a plugin that builds eagerly. Remembering the stats and building once, on the first read, gives the same result with a plain dict. --- glances/plugins/plugin/model.py | 98 +++++++++------------------------ tests/test_lazy_views.py | 34 +++++------- 2 files changed, 39 insertions(+), 93 deletions(-) diff --git a/glances/plugins/plugin/model.py b/glances/plugins/plugin/model.py index 526bb641..600481bf 100644 --- a/glances/plugins/plugin/model.py +++ b/glances/plugins/plugin/model.py @@ -53,62 +53,6 @@ fields_unit_type = { } -class LazyViews(dict): - """Views for a list-of-dicts plugin, built per key on first access. - - processlist can hold tens of thousands of items while the UI shows a few dozen rows, and - in curses mode nothing reads the views at all, so building them up front is pure waste. - Anything that reads the whole object goes through get_views(), which materialises it - first, so consumers still see a plain and complete dict. - """ - - def __init__(self, plugin, raw, key_field): - super().__init__() - self._plugin = plugin - self._items = {item[key_field]: item for item in raw} - - def _build(self, key): - item = self._items[key] # a genuinely unknown key raises KeyError, as a dict would - return {field: self._plugin._build_view_for_field(key=key, field=field) for field in item} - - def __missing__(self, key): - built = self._build(key) - super().__setitem__(key, built) - return built - - # Membership is deliberately left as dict's own: it reports what has been built, not what - # could be. _build_view_for_field() asks whether a previous view exists before indexing - # into it, and answering "yes" for an entry that is only about to be created sends it - # straight back in here. - - def materialize(self): - """Build every remaining view and return self as a fully populated dict.""" - for key in self._items: - if not super().__contains__(key): - super().__setitem__(key, self._build(key)) - return self - - def __iter__(self): - self.materialize() - return super().__iter__() - - def __len__(self): - self.materialize() - return super().__len__() - - def keys(self): - self.materialize() - return super().keys() - - def values(self): - self.materialize() - return super().values() - - def items(self): - self.materialize() - return super().items() - - class GlancesPluginModel: """Main class for Glances plugin model.""" @@ -177,6 +121,8 @@ class GlancesPluginModel: # Init the views self.views = {} + # Stats whose views have not been built yet (see update_views) + self._views_source = None # Hide stats if all the hide_zero_fields has never been != 0 # Default is False, always display stats @@ -727,30 +673,37 @@ class GlancesPluginModel: 'splittable': False, >>> Is the stat can be cut (like process lon name) 'hidden': False} >>> Is the stats should be hidden in the UI """ - ret = {} - - # hide_zero makes _build_view_for_field() read the previous self.views, which a lazy - # container cannot provide: it is itself self.views by then, so the lookup would - # recurse into the entry being built. Fall back to building everything up front. + # A lazy plugin holds far more items than the UI shows and nothing reads its views + # while drawing, so remember the stats and let get_views() do the work if it is ever + # asked for. hide_zero is excluded: it carries the hidden flag over from the previous + # views, which are gone by the time a deferred build runs. if self.lazy_views and not self.hide_zero and isinstance(self.get_raw(), list) and self.get_key() is not None: - self.views = LazyViews(self, self.get_raw(), self.get_key()) + self._views_source = self.get_raw() + self.views = {} return self.views - if self.get_raw() is not None and isinstance(self.get_raw(), list) and self.get_key() is not None: + self._views_source = None + self.views = self._build_views(self.get_raw()) + return self.views + + def _build_views(self, raw): + """Build the views for raw stats. Reads self.views, which still holds the previous + views at this point; hide_zero relies on that to keep a field hidden.""" + ret = {} + + if raw is not None and isinstance(raw, list) and self.get_key() is not None: # Stats are stored in a list of dict (ex: DISKIO, NETWORK, FS...) - for i in self.get_raw(): + for i in raw: key = i[self.get_key()] ret[key] = {} for field in listkeys(i): ret[key][field] = self._build_view_for_field(key=key, field=field) - elif isinstance(self.get_raw(), dict) and self.get_raw() is not None: + elif isinstance(raw, dict) and raw is not None: # Stats are stored in a dict (ex: CPU, LOAD...) - for field in listkeys(self.get_raw()): + for field in listkeys(raw): ret[field] = self._build_view_for_field(key=None, field=field) - self.views = ret - - return self.views + return ret def set_views(self, input_views): """Set the views to input_views.""" @@ -769,13 +722,14 @@ class GlancesPluginModel: Specify item if the stats are stored in a dict of dict (ex: NETWORK, FS...) """ + if self._views_source is not None: + # First read since the last refresh: this is where a lazy plugin pays. + self.views = self._build_views(self._views_source) + self._views_source = None if item is None: item_views = self.views else: item_views = self.views[item] - if isinstance(item_views, LazyViews): - # The caller gets the object itself, so hand out a fully built one. - item_views = item_views.materialize() if key is None: return item_views if key not in item_views: diff --git a/tests/test_lazy_views.py b/tests/test_lazy_views.py index ae36ac71..bbfcf01b 100644 --- a/tests/test_lazy_views.py +++ b/tests/test_lazy_views.py @@ -5,7 +5,6 @@ from unittest import mock import pytest -from glances.plugins.plugin.model import LazyViews from glances.plugins.processlist import ProcesslistPlugin @@ -35,35 +34,28 @@ def plugin(): return p -def built_count(views): - """How many entries exist without asking the lazy container to build more.""" - return dict.__len__(views) - - -def test_views_are_lazy_by_default(plugin): +def test_refresh_builds_nothing(plugin): plugin.update_views() - assert isinstance(plugin.views, LazyViews) - assert built_count(plugin.views) == 0 + assert plugin.views == {} -def test_reading_one_key_builds_only_that_key(plugin): +def test_first_read_builds_the_views(plugin): plugin.update_views() - view = plugin.views[4] - assert view['cpu_percent']['decoration'] is not None - assert built_count(plugin.views) == 1 + views = plugin.get_views() + assert views[4]['cpu_percent']['decoration'] is not None + assert len(views) == 10 def test_unknown_key_raises(plugin): plugin.update_views() with pytest.raises(KeyError): - plugin.views[999999] + plugin.get_views(item=999999) -def test_get_views_materializes_everything(plugin): +def test_second_read_reuses_the_built_views(plugin): plugin.update_views() - views = plugin.get_views() - assert len(views) == 10 - assert built_count(views) == 10 + first = plugin.get_views() + assert plugin.get_views() is first def test_json_contains_every_process(plugin): @@ -71,10 +63,10 @@ def test_json_contains_every_process(plugin): assert len(json.loads(plugin.get_json_views())) == 10 -def test_hide_zero_falls_back_to_eager_views(plugin): - """hide_zero reads the previous view of a key, which a lazy container cannot provide.""" +def test_hide_zero_builds_eagerly(plugin): + """hide_zero carries the hidden flag over from the previous views, which a deferred + build no longer has.""" plugin.hide_zero = True plugin.hide_zero_fields = ['cpu_percent'] plugin.update_views() - assert not isinstance(plugin.views, LazyViews) assert len(plugin.views) == 10 From e6f3145ef5559e2ffdf3e279082dbb5bbd4bc9e1 Mon Sep 17 00:00:00 2001 From: dipetm Date: Fri, 7 Aug 2026 15:29:24 +0300 Subject: [PATCH 3/6] Trim the comments and drop a test that guarded nothing One rationale in one place instead of three, and the class attribute no longer describes the per-key build that is gone. test_unknown_key_raises only exercised dict lookup and passed unchanged on develop. --- glances/plugins/plugin/model.py | 16 +++++++--------- glances/plugins/processlist/__init__.py | 4 ++-- tests/test_lazy_views.py | 6 ------ 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/glances/plugins/plugin/model.py b/glances/plugins/plugin/model.py index 600481bf..829371f7 100644 --- a/glances/plugins/plugin/model.py +++ b/glances/plugins/plugin/model.py @@ -56,8 +56,8 @@ fields_unit_type = { class GlancesPluginModel: """Main class for Glances plugin model.""" - # Build the per-item views on demand instead of up front. Only worth it for plugins whose - # item count is large and unrelated to how many rows the UI shows. + # Defer building the views until something reads them. Worth it for a plugin holding far + # more items than the UI draws, whose own rendering does not consult them. lazy_views = False def __init__(self, args=None, config=None, items_history_list=None, stats_init_value={}, fields_description=None): @@ -121,7 +121,7 @@ class GlancesPluginModel: # Init the views self.views = {} - # Stats whose views have not been built yet (see update_views) + # Set by update_views() when the build is deferred; consumed by get_views() self._views_source = None # Hide stats if all the hide_zero_fields has never been != 0 @@ -673,10 +673,8 @@ class GlancesPluginModel: 'splittable': False, >>> Is the stat can be cut (like process lon name) 'hidden': False} >>> Is the stats should be hidden in the UI """ - # A lazy plugin holds far more items than the UI shows and nothing reads its views - # while drawing, so remember the stats and let get_views() do the work if it is ever - # asked for. hide_zero is excluded: it carries the hidden flag over from the previous - # views, which are gone by the time a deferred build runs. + # hide_zero is excluded: it carries the hidden flag over from the previous views, + # which a deferred build no longer has. if self.lazy_views and not self.hide_zero and isinstance(self.get_raw(), list) and self.get_key() is not None: self._views_source = self.get_raw() self.views = {} @@ -687,8 +685,8 @@ class GlancesPluginModel: return self.views def _build_views(self, raw): - """Build the views for raw stats. Reads self.views, which still holds the previous - views at this point; hide_zero relies on that to keep a field hidden.""" + """self.views still holds the previous views here; hide_zero reads them to keep a + field hidden once it has been hidden.""" ret = {} if raw is not None and isinstance(raw, list) and self.get_key() is not None: diff --git a/glances/plugins/processlist/__init__.py b/glances/plugins/processlist/__init__.py index 1fd8dd56..57b98653 100644 --- a/glances/plugins/processlist/__init__.py +++ b/glances/plugins/processlist/__init__.py @@ -122,8 +122,8 @@ class ProcesslistPlugin(GlancesPluginModel): stats is a list """ - # The list holds every process on the machine while the UI shows a few dozen rows, and - # the curses output never reads the views at all. Build them on demand. + # Every process on the machine, against a few dozen rows on screen; msg_curse() builds its + # own decorations and never looks at the views. lazy_views = True # Default list of processes stats to be grabbed / displayed diff --git a/tests/test_lazy_views.py b/tests/test_lazy_views.py index bbfcf01b..f5f44fc1 100644 --- a/tests/test_lazy_views.py +++ b/tests/test_lazy_views.py @@ -46,12 +46,6 @@ def test_first_read_builds_the_views(plugin): assert len(views) == 10 -def test_unknown_key_raises(plugin): - plugin.update_views() - with pytest.raises(KeyError): - plugin.get_views(item=999999) - - def test_second_read_reuses_the_built_views(plugin): plugin.update_views() first = plugin.get_views() From 7cfbebb6e5d3b5df8659eb2ab89f4d733b3c96ad Mon Sep 17 00:00:00 2001 From: dipetm Date: Fri, 7 Aug 2026 15:52:49 +0300 Subject: [PATCH 4/6] Read the stats once, drop the early return, stop poisoning the shared singleton update_views() called get_raw() twice and returned an empty dict from a separate exit; one read and one exit instead, with the deferred case named in the docstring. The test fixture built its own ProcesslistPlugin, whose load() writes Mock attributes into the glances_processes singleton and broke seven tests in test_core.py whenever the two files ran together. It now takes the shared plugin and restores what it changes. --- glances/plugins/plugin/model.py | 15 ++++++++++----- tests/test_lazy_views.py | 18 ++++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/glances/plugins/plugin/model.py b/glances/plugins/plugin/model.py index 829371f7..8c95aeb0 100644 --- a/glances/plugins/plugin/model.py +++ b/glances/plugins/plugin/model.py @@ -672,16 +672,21 @@ class GlancesPluginModel: 'additional': False, >>> Is the stat provide additional information 'splittable': False, >>> Is the stat can be cut (like process lon name) 'hidden': False} >>> Is the stats should be hidden in the UI + + A plugin with lazy_views set leaves the views empty here and builds them in + get_views(), so read them through that rather than off the attribute. """ + raw = self.get_raw() + # hide_zero is excluded: it carries the hidden flag over from the previous views, # which a deferred build no longer has. - if self.lazy_views and not self.hide_zero and isinstance(self.get_raw(), list) and self.get_key() is not None: - self._views_source = self.get_raw() + if self.lazy_views and not self.hide_zero and isinstance(raw, list) and self.get_key() is not None: + self._views_source = raw self.views = {} - return self.views + else: + self._views_source = None + self.views = self._build_views(raw) - self._views_source = None - self.views = self._build_views(self.get_raw()) return self.views def _build_views(self, raw): diff --git a/tests/test_lazy_views.py b/tests/test_lazy_views.py index f5f44fc1..a7f7897e 100644 --- a/tests/test_lazy_views.py +++ b/tests/test_lazy_views.py @@ -1,12 +1,9 @@ """Tests for the lazily built process views.""" import json -from unittest import mock import pytest -from glances.plugins.processlist import ProcesslistPlugin - def make_process(pid): return { @@ -28,10 +25,19 @@ def make_process(pid): @pytest.fixture -def plugin(): - p = ProcesslistPlugin(args=mock.Mock(time=2), config=None) +def plugin(glances_stats): + """The shared processlist plugin, fed a fixed list and put back afterwards. + + Building a plugin here instead would write Mock attributes into the glances_processes + singleton and break every later test that reads it. + """ + p = glances_stats.get_plugin('processlist') + saved = (p.stats, p.views, getattr(p, '_views_source', None), p.hide_zero, p.hide_zero_fields) p.stats = [make_process(pid) for pid in range(10)] - return p + yield p + p.stats, p.views, p.hide_zero, p.hide_zero_fields = saved[0], saved[1], saved[3], saved[4] + if hasattr(p, '_views_source'): + p._views_source = saved[2] def test_refresh_builds_nothing(plugin): From 7444b3eb44c1afe075b5c9608190aafdc7204156 Mon Sep 17 00:00:00 2001 From: dipetm Date: Fri, 7 Aug 2026 15:56:32 +0300 Subject: [PATCH 5/6] Drop the shape checks from the lazy branch _build_views() already dispatches on list, dict and neither, so repeating the condition one line earlier only duplicated it. Verified against dict stats, None and an empty list. --- glances/plugins/plugin/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/glances/plugins/plugin/model.py b/glances/plugins/plugin/model.py index 8c95aeb0..97cc89c4 100644 --- a/glances/plugins/plugin/model.py +++ b/glances/plugins/plugin/model.py @@ -680,7 +680,7 @@ class GlancesPluginModel: # hide_zero is excluded: it carries the hidden flag over from the previous views, # which a deferred build no longer has. - if self.lazy_views and not self.hide_zero and isinstance(raw, list) and self.get_key() is not None: + if self.lazy_views and not self.hide_zero: self._views_source = raw self.views = {} else: From 67304364084db457053ad260fc0cf824873eab75 Mon Sep 17 00:00:00 2001 From: dipetm Date: Fri, 7 Aug 2026 16:06:39 +0300 Subject: [PATCH 6/6] Keep the deferral inside processlist instead of the base class The base class carried a lazy_views flag, a _views_source field, a _build_views split and an introspection check for subclasses that decorate their views - all of it shared by 37 plugins to serve one. Overriding update_views/get_views in processlist does the same with no change to model.py, and set_views/reset_views clear the pending flag so a reset stays reset. --- glances/plugins/plugin/model.py | 41 +++++-------------------- glances/plugins/processlist/__init__.py | 33 ++++++++++++++++++-- tests/test_lazy_views.py | 23 ++++++++------ 3 files changed, 50 insertions(+), 47 deletions(-) diff --git a/glances/plugins/plugin/model.py b/glances/plugins/plugin/model.py index 97cc89c4..ce0a764d 100644 --- a/glances/plugins/plugin/model.py +++ b/glances/plugins/plugin/model.py @@ -56,10 +56,6 @@ fields_unit_type = { class GlancesPluginModel: """Main class for Glances plugin model.""" - # Defer building the views until something reads them. Worth it for a plugin holding far - # more items than the UI draws, whose own rendering does not consult them. - lazy_views = False - def __init__(self, args=None, config=None, items_history_list=None, stats_init_value={}, fields_description=None): """Init the plugin of plugins model class. @@ -121,8 +117,6 @@ class GlancesPluginModel: # Init the views self.views = {} - # Set by update_views() when the build is deferred; consumed by get_views() - self._views_source = None # Hide stats if all the hide_zero_fields has never been != 0 # Default is False, always display stats @@ -672,41 +666,24 @@ class GlancesPluginModel: 'additional': False, >>> Is the stat provide additional information 'splittable': False, >>> Is the stat can be cut (like process lon name) 'hidden': False} >>> Is the stats should be hidden in the UI - - A plugin with lazy_views set leaves the views empty here and builds them in - get_views(), so read them through that rather than off the attribute. """ - raw = self.get_raw() - - # hide_zero is excluded: it carries the hidden flag over from the previous views, - # which a deferred build no longer has. - if self.lazy_views and not self.hide_zero: - self._views_source = raw - self.views = {} - else: - self._views_source = None - self.views = self._build_views(raw) - - return self.views - - def _build_views(self, raw): - """self.views still holds the previous views here; hide_zero reads them to keep a - field hidden once it has been hidden.""" ret = {} - if raw is not None and isinstance(raw, list) and self.get_key() is not None: + if self.get_raw() is not None and isinstance(self.get_raw(), list) and self.get_key() is not None: # Stats are stored in a list of dict (ex: DISKIO, NETWORK, FS...) - for i in raw: + for i in self.get_raw(): key = i[self.get_key()] ret[key] = {} for field in listkeys(i): ret[key][field] = self._build_view_for_field(key=key, field=field) - elif isinstance(raw, dict) and raw is not None: + elif isinstance(self.get_raw(), dict) and self.get_raw() is not None: # Stats are stored in a dict (ex: CPU, LOAD...) - for field in listkeys(raw): + for field in listkeys(self.get_raw()): ret[field] = self._build_view_for_field(key=None, field=field) - return ret + self.views = ret + + return self.views def set_views(self, input_views): """Set the views to input_views.""" @@ -725,10 +702,6 @@ class GlancesPluginModel: Specify item if the stats are stored in a dict of dict (ex: NETWORK, FS...) """ - if self._views_source is not None: - # First read since the last refresh: this is where a lazy plugin pays. - self.views = self._build_views(self._views_source) - self._views_source = None if item is None: item_views = self.views else: diff --git a/glances/plugins/processlist/__init__.py b/glances/plugins/processlist/__init__.py index 57b98653..5bd4597f 100644 --- a/glances/plugins/processlist/__init__.py +++ b/glances/plugins/processlist/__init__.py @@ -122,9 +122,8 @@ class ProcesslistPlugin(GlancesPluginModel): stats is a list """ - # Every process on the machine, against a few dozen rows on screen; msg_curse() builds its - # own decorations and never looks at the views. - lazy_views = True + # True between a refresh and the first read of the views, see update_views() + _views_pending = False # Default list of processes stats to be grabbed / displayed # Can be altered by glances_processes.disable_stats @@ -263,6 +262,34 @@ class ProcesslistPlugin(GlancesPluginModel): return self.stats + def update_views(self): + """Note that the views are out of date, without building them. + + The list holds every process on the machine, while the UI shows a few dozen rows and + msg_curse() builds its own decorations rather than reading the views. Only the API + asks for them, so building them on every refresh is work nobody collects. + """ + self._views_pending = True + self.views = {} + return self.views + + def get_views(self, item=None, key=None, option=None): + """Build the views if the last refresh left them pending, then answer as usual.""" + if self._views_pending: + self._views_pending = False + super().update_views() + return super().get_views(item=item, key=key, option=option) + + def set_views(self, input_views): + """Views handed to us replace whatever a pending build would have produced.""" + self._views_pending = False + super().set_views(input_views) + + def reset_views(self): + """A reset must stay reset; without this the next read would rebuild them.""" + self._views_pending = False + super().reset_views() + def get_api(self): """Return the sorted processes list for the API.""" return glances_processes.get_list(sorted=True) diff --git a/tests/test_lazy_views.py b/tests/test_lazy_views.py index a7f7897e..1815d929 100644 --- a/tests/test_lazy_views.py +++ b/tests/test_lazy_views.py @@ -32,12 +32,11 @@ def plugin(glances_stats): singleton and break every later test that reads it. """ p = glances_stats.get_plugin('processlist') - saved = (p.stats, p.views, getattr(p, '_views_source', None), p.hide_zero, p.hide_zero_fields) + saved = (p.stats, p.views, getattr(p, '_views_pending', False)) p.stats = [make_process(pid) for pid in range(10)] yield p - p.stats, p.views, p.hide_zero, p.hide_zero_fields = saved[0], saved[1], saved[3], saved[4] - if hasattr(p, '_views_source'): - p._views_source = saved[2] + p.stats, p.views, pending = saved + p._views_pending = pending def test_refresh_builds_nothing(plugin): @@ -63,10 +62,14 @@ def test_json_contains_every_process(plugin): assert len(json.loads(plugin.get_json_views())) == 10 -def test_hide_zero_builds_eagerly(plugin): - """hide_zero carries the hidden flag over from the previous views, which a deferred - build no longer has.""" - plugin.hide_zero = True - plugin.hide_zero_fields = ['cpu_percent'] +def test_reset_stays_reset(plugin): + """Without clearing the pending flag the next read would rebuild what was reset.""" plugin.update_views() - assert len(plugin.views) == 10 + plugin.reset_views() + assert plugin.get_views() == {} + + +def test_set_views_is_not_overwritten(plugin): + plugin.update_views() + plugin.set_views({'given': 'by the server'}) + assert plugin.get_views() == {'given': 'by the server'}