From e1f10ed6b6715301f316fa01f7e780d89c7e37fe Mon Sep 17 00:00:00 2001 From: Tomas Zigo <50632337+tmszi@users.noreply.github.com> Date: Fri, 2 Jun 2023 21:39:56 +0200 Subject: [PATCH] wxGUI: fix show MASK statusbar button widget if mask is created (#2520) Add standalone watchdogs into single/multi window mode which handle changes in the current mapset and current mapset maps and show/hide MASK statusbar button widget according this state. --- .flake8 | 1 + gui/wxpython/core/watchdog.py | 202 ++++++++++++++++++++++++++++++ gui/wxpython/datacatalog/tree.py | 175 +++----------------------- gui/wxpython/lmgr/frame.py | 32 +++++ gui/wxpython/lmgr/statusbar.py | 11 +- gui/wxpython/main_window/frame.py | 31 +++++ 6 files changed, 293 insertions(+), 159 deletions(-) create mode 100644 gui/wxpython/core/watchdog.py diff --git a/.flake8 b/.flake8 index 7d2662b5bfa..f5b8e6b9e39 100644 --- a/.flake8 +++ b/.flake8 @@ -56,6 +56,7 @@ per-file-ignores = gui/wxpython/core/render.py: E722, F841, E501 gui/wxpython/core/ws.py: F841, E501 gui/wxpython/core/settings.py: E722 + gui/wxpython/core/watchdog.py: E402 gui/wxpython/datacatalog/tree.py: E731, E402, E501 gui/wxpython/dbmgr/base.py: E501, E722, F841 gui/wxpython/dbmgr/dialogs.py: E501, F841, E722 diff --git a/gui/wxpython/core/watchdog.py b/gui/wxpython/core/watchdog.py new file mode 100644 index 00000000000..ad20f3169d6 --- /dev/null +++ b/gui/wxpython/core/watchdog.py @@ -0,0 +1,202 @@ +""" +@package core.watchdog + +@brief Current mapset and maps watchdog + +Classes: + - watchdog::CurrentMapsetWatch + - watchdog::MapWatch + - watchdog::MapsetWatchdog + +(C) 2022 by the GRASS Development Team + +This program is free software under the GNU General Public License +(>=v2). Read the file COPYING that comes with GRASS for details. + +@author Anna Kratochvilova +@author Tomas Zigo +""" + +import os + +watchdog_used = True +try: + from watchdog.observers import Observer + from watchdog.events import ( + PatternMatchingEventHandler, + FileSystemEventHandler, + ) +except ImportError: + watchdog_used = False + PatternMatchingEventHandler = object + FileSystemEventHandler = object + +import wx +from wx.lib.newevent import NewEvent + +from grass.script import core as grass + +updateMapset, EVT_UPDATE_MAPSET = NewEvent() +currentMapsetChanged, EVT_CURRENT_MAPSET_CHANGED = NewEvent() + + +class CurrentMapsetWatch(FileSystemEventHandler): + """Monitors rc file to check if mapset has been changed. + In that case wx event is dispatched to event handler. + Needs to check timestamp, because the modified event is sent twice. + This assumes new instance of this class is started + whenever mapset is changed.""" + + def __init__(self, rcfile, mapset_path, event_handler): + FileSystemEventHandler.__init__(self) + self.event_handler = event_handler + self.mapset_path = mapset_path + self.rcfile_name = os.path.basename(rcfile) + self.modified_time = 0 + + def on_modified(self, event): + if ( + not event.is_directory + and os.path.basename(event.src_path) == self.rcfile_name + ): + timestamp = os.stat(event.src_path).st_mtime + if timestamp - self.modified_time < 0.5: + return + self.modified_time = timestamp + with open(event.src_path, "r") as f: + gisrc = {} + for line in f.readlines(): + key, val = line.split(":") + gisrc[key.strip()] = val.strip() + new = os.path.join( + gisrc["GISDBASE"], gisrc["LOCATION_NAME"], gisrc["MAPSET"] + ) + if new != self.mapset_path: + evt = currentMapsetChanged() + wx.PostEvent(self.event_handler, evt) + + +class MapWatch(PatternMatchingEventHandler): + """Monitors file events (create, delete, move files) using watchdog + to inform about changes in current mapset. One instance monitors + only one element (raster, vector, raster_3d). + Patterns are not used/needed in this case, use just '*' for matching + everything. When file/directory change is detected, wx event is dispatched + to event handler (can't use Signals because this is different thread), + containing info about the change.""" + + def __init__(self, patterns, element, event_handler): + PatternMatchingEventHandler.__init__(self, patterns=patterns) + self.element = element + self.event_handler = event_handler + + def on_created(self, event): + if ( + self.element == "vector" or self.element == "raster_3d" + ) and not event.is_directory: + return + evt = updateMapset( + src_path=event.src_path, + event_type=event.event_type, + is_directory=event.is_directory, + dest_path=None, + ) + wx.PostEvent(self.event_handler, evt) + + def on_deleted(self, event): + if ( + self.element == "vector" or self.element == "raster_3d" + ) and not event.is_directory: + return + evt = updateMapset( + src_path=event.src_path, + event_type=event.event_type, + is_directory=event.is_directory, + dest_path=None, + ) + wx.PostEvent(self.event_handler, evt) + + def on_moved(self, event): + if ( + self.element == "vector" or self.element == "raster_3d" + ) and not event.is_directory: + return + evt = updateMapset( + src_path=event.src_path, + event_type=event.event_type, + is_directory=event.is_directory, + dest_path=event.dest_path, + ) + wx.PostEvent(self.event_handler, evt) + + +class MapsetWatchdog: + """Current mapset and maps watchdog + + :param tuple elements_dir: tuple of element with dir tuples + (("raster", "cell"), ...) + :param object instance evt_handler: event handler object instance of + class + :param object instance giface: object instance of giface class + :param str patterns: map watchdog patterns with default value "*" all + """ + + def __init__(self, elements_dirs, evt_handler, giface, patterns="*"): + self._elements_dirs = elements_dirs + self._evt_handler = evt_handler + self._patterns = patterns + self._giface = giface + self._observer = None + + def ScheduleWatchCurrentMapset(self): + """Using watchdog library, sets up watching of current mapset folder + to detect changes not captured by other means (e.g. from command line). + Schedules 1 watches (raster). + If watchdog observers are active, it restarts the observers in + current mapset. + """ + global watchdog_used + if not watchdog_used: + return + + if self._observer and self._observer.is_alive(): + self._observer.stop() + self._observer.join() + self._observer.unschedule_all() + self._observer = Observer() + + gisenv = grass.gisenv() + mapset_path = os.path.join( + gisenv["GISDBASE"], gisenv["LOCATION_NAME"], gisenv["MAPSET"] + ) + rcfile = os.environ["GISRC"] + self._observer.schedule( + CurrentMapsetWatch(rcfile, mapset_path, self._evt_handler), + os.path.dirname(rcfile), + recursive=False, + ) + for element, directory in self._elements_dirs: + path = os.path.join(mapset_path, directory) + if not os.path.exists(path): + try: + os.mkdir(path) + except OSError: + pass + if os.path.exists(path): + self._observer.schedule( + MapWatch(self._patterns, element, self._evt_handler), + path=path, + recursive=False, + ) + try: + self._observer.start() + except OSError: + # in case inotify on linux exceeds limits + watchdog_used = False + self._giface.WriteWarning( + _( + "File size limit exceeded. The current mapset" + " and maps watchdog are disabled now." + ), + ) + return diff --git a/gui/wxpython/datacatalog/tree.py b/gui/wxpython/datacatalog/tree.py index 9adcaef1351..8d04f4e5129 100644 --- a/gui/wxpython/datacatalog/tree.py +++ b/gui/wxpython/datacatalog/tree.py @@ -23,23 +23,18 @@ import copy from multiprocessing import Process, Queue, cpu_count -watchdog_used = True -try: - from watchdog.observers import Observer - from watchdog.events import PatternMatchingEventHandler, FileSystemEventHandler -except ImportError: - watchdog_used = False - PatternMatchingEventHandler = object - FileSystemEventHandler = object - - import wx -from wx.lib.newevent import NewEvent from core.gcmd import RunCommand, GError, GMessage from core.utils import GetListOfLocations from core.debug import Debug from core.gthread import gThread +from core.watchdog import ( + EVT_UPDATE_MAPSET, + EVT_CURRENT_MAPSET_CHANGED, + MapsetWatchdog, + watchdog_used, +) from gui_core.dialogs import TextEntryDialog from core.giface import StandaloneGrassInterface from core.treemodel import TreeModel, DictNode @@ -80,10 +75,6 @@ from grass.exceptions import CalledModuleError -updateMapset, EVT_UPDATE_MAPSET = NewEvent() -currentMapsetChanged, EVT_CURRENT_MAPSET_CHANGED = NewEvent() - - def getLocationTree(gisdbase, location, queue, mapsets=None, lazy=False): """Creates dictionary with mapsets, elements, layers for given location. Returns tuple with the dictionary and error (or None)""" @@ -145,96 +136,6 @@ def getLocationTree(gisdbase, location, queue, mapsets=None, lazy=False): gscript.try_remove(tmp_gisrc_file) -class CurrentMapsetWatch(FileSystemEventHandler): - """Monitors rc file to check if mapset has been changed. - In that case wx event is dispatched to event handler. - Needs to check timestamp, because the modified event is sent twice. - This assumes new instance of this class is started - whenever mapset is changed.""" - - def __init__(self, rcfile, mapset_path, event_handler): - FileSystemEventHandler.__init__(self) - self.event_handler = event_handler - self.mapset_path = mapset_path - self.rcfile_name = os.path.basename(rcfile) - self.modified_time = 0 - - def on_modified(self, event): - if ( - not event.is_directory - and os.path.basename(event.src_path) == self.rcfile_name - ): - timestamp = os.stat(event.src_path).st_mtime - if timestamp - self.modified_time < 0.5: - return - self.modified_time = timestamp - with open(event.src_path, "r") as f: - gisrc = {} - for line in f.readlines(): - key, val = line.split(":") - gisrc[key.strip()] = val.strip() - new = os.path.join( - gisrc["GISDBASE"], gisrc["LOCATION_NAME"], gisrc["MAPSET"] - ) - if new != self.mapset_path: - evt = currentMapsetChanged() - wx.PostEvent(self.event_handler, evt) - - -class MapWatch(PatternMatchingEventHandler): - """Monitors file events (create, delete, move files) using watchdog - to inform about changes in current mapset. One instance monitors - only one element (raster, vector, raster_3d). - Patterns are not used/needed in this case, use just '*' for matching - everything. When file/directory change is detected, wx event is dispatched - to event handler (can't use Signals because this is different thread), - containing info about the change.""" - - def __init__(self, patterns, element, event_handler): - PatternMatchingEventHandler.__init__(self, patterns=patterns) - self.element = element - self.event_handler = event_handler - - def on_created(self, event): - if ( - self.element == "vector" or self.element == "raster_3d" - ) and not event.is_directory: - return - evt = updateMapset( - src_path=event.src_path, - event_type=event.event_type, - is_directory=event.is_directory, - dest_path=None, - ) - wx.PostEvent(self.event_handler, evt) - - def on_deleted(self, event): - if ( - self.element == "vector" or self.element == "raster_3d" - ) and not event.is_directory: - return - evt = updateMapset( - src_path=event.src_path, - event_type=event.event_type, - is_directory=event.is_directory, - dest_path=None, - ) - wx.PostEvent(self.event_handler, evt) - - def on_moved(self, event): - if ( - self.element == "vector" or self.element == "raster_3d" - ) and not event.is_directory: - return - evt = updateMapset( - src_path=event.src_path, - event_type=event.event_type, - is_directory=event.is_directory, - dest_path=event.dest_path, - ) - wx.PostEvent(self.event_handler, evt) - - class NameEntryDialog(TextEntryDialog): def __init__(self, element, mapset, env, **kwargs): TextEntryDialog.__init__(self, **kwargs) @@ -387,6 +288,16 @@ def __init__( self._lastWatchdogUpdate = gscript.clock() self._updateMapsetWhenIdle = None + # mapset watchdog + self._mapset_watchdog = MapsetWatchdog( + elements_dirs=( + ("raster", "cell"), + ("vector", "vector"), + ("raster_3d", "grid3"), + ), + evt_handler=self, + giface=self._giface, + ) # Get databases from settings # add current to settings if it's not included self.grassdatabases = self._getValidSavedGrassDBs() @@ -428,7 +339,6 @@ def __init__( self.Bind( EVT_CURRENT_MAPSET_CHANGED, lambda evt: self._updateAfterMapsetChanged() ) - self.observer = None def _resetSelectVariables(self): """Reset variables related to item selection.""" @@ -727,55 +637,6 @@ def _reloadTreeItems(self, full=False): return errors return None - def ScheduleWatchCurrentMapset(self): - """Using watchdog library, sets up watching of current mapset folder - to detect changes not captured by other means (e.g. from command line). - Schedules 3 watches (raster, vector, 3D raster). - If watchdog observers are active, it restarts the observers in current mapset. - Also schedules monitoring of rc file to detect mapset change. - """ - global watchdog_used - if not watchdog_used: - return - - if self.observer and self.observer.is_alive(): - self.observer.stop() - self.observer.join() - self.observer.unschedule_all() - self.observer = Observer() - - gisenv = gscript.gisenv() - mapset_path = os.path.join( - gisenv["GISDBASE"], gisenv["LOCATION_NAME"], gisenv["MAPSET"] - ) - rcfile = os.environ["GISRC"] - self.observer.schedule( - CurrentMapsetWatch(rcfile, mapset_path, self), - os.path.dirname(rcfile), - recursive=False, - ) - for element, directory in ( - ("raster", "cell"), - ("vector", "vector"), - ("raster_3d", "grid3"), - ): - path = os.path.join(mapset_path, directory) - if not os.path.exists(path): - try: - os.mkdir(path) - except OSError: - pass - if os.path.exists(path): - self.observer.schedule( - MapWatch("*", element, self), path=path, recursive=False - ) - try: - self.observer.start() - except OSError: - # in case inotify on linux exceeds limits - watchdog_used = False - return - def _onUpdateMapsetWhenIdle(self, event): """When idle, check if current mapset should be reloaded because there are skipped update events.""" @@ -906,7 +767,7 @@ def _loadItemsDone(self, event): if event.ret is not None: self._giface.WriteWarning("\n".join(event.ret)) self.UpdateCurrentDbLocationMapsetNode() - self.ScheduleWatchCurrentMapset() + self._mapset_watchdog.ScheduleWatchCurrentMapset() self.RefreshItems() self.ExpandCurrentMapset() self.loadingDone.emit() @@ -2047,7 +1908,7 @@ def _updateAfterMapsetChanged(self): self.RefreshNode(self.current_mapset_node, recursive=True) self.ExpandCurrentMapset() self.RefreshItems() - self.ScheduleWatchCurrentMapset() + self._mapset_watchdog.ScheduleWatchCurrentMapset() def OnMetadata(self, event): """Show metadata of any raster/vector/3draster""" diff --git a/gui/wxpython/lmgr/frame.py b/gui/wxpython/lmgr/frame.py index 28b1841bea3..e98512f2502 100644 --- a/gui/wxpython/lmgr/frame.py +++ b/gui/wxpython/lmgr/frame.py @@ -42,6 +42,11 @@ from core.gcmd import RunCommand, GError, GMessage from core.settings import UserSettings, GetDisplayVectSettings from core.utils import SetAddOnPath, GetLayerNameFromCmd, command2ltype, get_shell_pid +from core.watchdog import ( + EVT_UPDATE_MAPSET, + EVT_CURRENT_MAPSET_CHANGED, + MapsetWatchdog, +) from gui_core.preferences import MapsetAccess, PreferencesDialog from lmgr.layertree import LayerTree, LMIcons from lmgr.menudata import LayerManagerMenuData, LayerManagerModuleTree @@ -289,6 +294,19 @@ def show_menu_errors(messages): # redirect stderr to log area self._gconsole.Redirect() + # mapset watchdog + self._mapset_watchdog = MapsetWatchdog( + elements_dirs=(("raster", "cell"),), + evt_handler=self, + giface=self._giface, + ) + self._mapset_watchdog.ScheduleWatchCurrentMapset() + self.Bind( + EVT_UPDATE_MAPSET, + lambda evt: self._onMapsetWatchdog(evt.src_path, evt.dest_path), + ) + self.Bind(EVT_CURRENT_MAPSET_CHANGED, self._onMapsetChanged) + # fix goutput's pane size (required for Mac OSX)` self.goutput.SetSashPosition(int(self.GetSize()[1] * 0.8)) @@ -2315,3 +2333,17 @@ def MsgDisplayResolution(self, limitText=None): style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION | wx.CENTRE, ) return dlg + + def _onMapsetWatchdog(self, map_path, map_dest): + """Current mapset watchdog event handler + + :param str map_path: map path (map that is changed) + :param str map_dest: new map path + """ + self.statusbar.mask.dbChanged( + map=os.path.basename(map_path) if map_path else map_path, + newname=os.path.basename(map_dest) if map_dest else map_dest, + ) + + def _onMapsetChanged(self, event): + self._mapset_watchdog.ScheduleWatchCurrentMapset() diff --git a/gui/wxpython/lmgr/statusbar.py b/gui/wxpython/lmgr/statusbar.py index b35966afcf3..e8c62c36ede 100644 --- a/gui/wxpython/lmgr/statusbar.py +++ b/gui/wxpython/lmgr/statusbar.py @@ -22,6 +22,7 @@ import grass.script as gs from core.gcmd import RunCommand +from core.watchdog import watchdog_used from gui_core.wrap import Button @@ -83,10 +84,16 @@ def __init__(self, parent, giface): self.widget.SetForegroundColour(wx.Colour(255, 0, 0)) self.widget.SetToolTip(tip=_("Left mouse click to remove the MASK")) self.giface.currentMapsetChanged.connect(self.Refresh) - self.giface.grassdbChanged.connect(self._dbChanged) + if not watchdog_used: + self.giface.grassdbChanged.connect(self.dbChanged) self.Refresh() - def _dbChanged(self, map=None, newname=None): + def dbChanged(self, map=None, newname=None): + """Mapset files changed + + :param str map: map that is changed + :param str newname: new map + """ if map == self.mask_layer or newname == self.mask_layer: self.Refresh() self.giface.updateMap.emit() diff --git a/gui/wxpython/main_window/frame.py b/gui/wxpython/main_window/frame.py index e9ed76e0635..eee07982bf4 100644 --- a/gui/wxpython/main_window/frame.py +++ b/gui/wxpython/main_window/frame.py @@ -47,6 +47,11 @@ from core.gcmd import RunCommand, GError, GMessage from core.settings import UserSettings, GetDisplayVectSettings from core.utils import SetAddOnPath, GetLayerNameFromCmd, command2ltype, get_shell_pid +from core.watchdog import ( + EVT_UPDATE_MAPSET, + EVT_CURRENT_MAPSET_CHANGED, + MapsetWatchdog, +) from gui_core.preferences import MapsetAccess, PreferencesDialog from lmgr.layertree import LayerTree, LMIcons from lmgr.menudata import LayerManagerMenuData, LayerManagerModuleTree @@ -216,6 +221,18 @@ def show_menu_errors(messages): # redirect stderr to log area self._gconsole.Redirect() + # mapset watchdog + self._mapset_watchdog = MapsetWatchdog( + elements_dirs=(("raster", "cell"),), + evt_handler=self, + giface=self._giface, + ) + self._mapset_watchdog.ScheduleWatchCurrentMapset() + self.Bind( + EVT_UPDATE_MAPSET, + lambda evt: self._onMapsetWatchdog(evt.src_path, evt.dest_path), + ) + self.Bind(EVT_CURRENT_MAPSET_CHANGED, self._onMapsetChanged) # fix goutput's pane size (required for Mac OSX)` self.goutput.SetSashPosition(int(self.GetSize()[1] * 0.8)) @@ -2348,3 +2365,17 @@ def MsgDisplayResolution(self, limitText=None): style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION | wx.CENTRE, ) return dlg + + def _onMapsetWatchdog(self, map_path, map_dest): + """Current mapset watchdog event handler + + :param str map_path: map path (map that is changed) + :param str map_dest: new map path + """ + self.statusbar.mask.dbChanged( + map=os.path.basename(map_path) if map_path else map_path, + newname=os.path.basename(map_dest) if map_dest else map_dest, + ) + + def _onMapsetChanged(self, event): + self._mapset_watchdog.ScheduleWatchCurrentMapset()