diff --git a/LabGym/__main__.py b/LabGym/__main__.py index a4a26ed..a9aafe5 100644 --- a/LabGym/__main__.py +++ b/LabGym/__main__.py @@ -35,9 +35,11 @@ # Collect logrecords and defer handling until logging is configured. mylogging.defer() -# Log the loading of this module (by the module loader, on first import). +# Log the load of this module (by the module loader, on first import). +# Intentionally positioning these statements before other imports, against the +# guidance of PEP 8, to log the load before other imports log messages. logger = logging.getLogger(__name__) -logger.debug('loading %s', __file__) +logger.debug('%s', f'loading {__name__}') # Configure logging based on configfile, then handle collected logrecords. mylogging.configure() @@ -47,11 +49,13 @@ # Related third party imports. from packaging import version # Core utilities for Python packages import requests # Python HTTP for Humans. +from LabGym import mywx # on load, monkeypatch wx.App to be a strict-singleton +import wx # wxPython, Cross platform GUI toolkit for Python, "Phoenix" version # Local application/library specific imports. # pylint: disable-next=unused-import from LabGym import mypkg_resources # replace deprecated pkg_resources -from LabGym import __version__, gui_main, mywx, probes +from LabGym import __version__, gui_main, probes logger.debug('%s: %r', '(__name__, __package__)', (__name__, __package__)) @@ -85,7 +89,9 @@ def main() -> None: # Create a single persistent, wx.App instance, as it may be # needed for probe dialogs prior to calling gui_main.main_window. - mywx.App() + assert wx.GetApp() is None + wx.App() + mywx.bring_wxapp_to_foreground() # Perform some pre-op sanity checks and probes of outside resources. probes.probes() diff --git a/LabGym/config.py b/LabGym/config.py index 0c95ce4..0135d70 100644 --- a/LabGym/config.py +++ b/LabGym/config.py @@ -64,6 +64,9 @@ 'enable': { 'central_logger': True, # to disable central logger, user must opt out 'registration': True, # to disable registration, user must opt out + + # for now, user has to opt in for assessing locations of userdata + 'assess_userdata_folders': False, }, 'anonymous': False, diff --git a/LabGym/gui_main.py b/LabGym/gui_main.py index 9412142..21a7558 100644 --- a/LabGym/gui_main.py +++ b/LabGym/gui_main.py @@ -419,6 +419,9 @@ def on_page_close(self, event): def main_window(): """Display the main window.""" app = wx.GetApp() # reference to the currently running wx.App instance + if app is None: + app = wx.App() # new wx.App object + app.SetAppName("LabGym") # Set app name to influence WM_CLASS setup_application_icons() # Set up all platform-specific icons diff --git a/LabGym/mywx.py b/LabGym/mywx.py deleted file mode 100644 index 00a7172..0000000 --- a/LabGym/mywx.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Provide function to guard from multiple instantiations of wx.App - -Use - import mywx - app = mywx.App() -or - import mywx - mywx.App() -instead of - import wx - app = wx.App() -to avoid creating two wx.App objects at once. - -This implementation uses the common approach of using a module-level -variable to store the value after its initial creation. -This pattern is often referred to as memoization or lazy initialization. - -A side benefit of this implementation is that a reference to object -is preserved (as module variable _cached_app), so it doesn't get garbage -collected when used like - import mywx - mywx.App() - -"wx.App is supposed to be used as a singleton. It doesn't own the -frames that are created in its OnInit, but instead assumes that it is -supposed to manage and deliver events to all the windows that exist in -the application." - -"the OnInit is called during the construction of the App (after the -toolkit has been initialized) not during the MainLoop call." -""" - -import logging - -import wx - - -logger = logging.getLogger(__name__) - - -_cached_app = None - - -def App(): - """Return the wx.App object.""" - - global _cached_app - - if _cached_app is not None: - return _cached_app - - # Milestone -- This must be the first time running this function. - # Construct the app obj, cache it, and return it. - - app = wx.App() - logger.debug('%s: %r', 'app', app) - - _cached_app = app - return app diff --git a/LabGym/mywx/__init__.py b/LabGym/mywx/__init__.py new file mode 100644 index 0000000..b45b2ef --- /dev/null +++ b/LabGym/mywx/__init__.py @@ -0,0 +1,89 @@ +""" +Monkeypatch wx.App, and provide wx utility functions and wx.Dialog subclasses. + +Import this package before the first import of wx, to patch wx.App to be +a strict-singleton before an unpatched instance of wx.App can possibly +be created. + +Example -- Display a modal dialog. In this example, mywx is a +subpackage of mypkg (not in a dir in sys.path). + from mypkg import mywx # on load, monkeypatch wx.App to be a strict-singleton + import wx # wxPython, Cross platform GUI toolkit for Python, "Phoenix" version + + # Dialog obj requires that the wx.App obj exists already. + if not wx.GetApp(): + wx.App() + mywx.bring_wxapp_to_foreground() + + # Show modal dialog. + title = 'My Title' + msg = 'My message' + with mywx.OK_Dialog(None, title=title, msg=msg) as dlg: + result = dlg.ShowModal() # will return wx.ID_OK upon OK or dismiss + +Notes +* Why patch wx.App? + Because it's easy to misuse, producing delayed consequences that may + be difficult to diagnose. + + Scraped on 2025-12-10 from wxPython 4.2.3 documentation + https://docs.wxpython.org/wx.App.html + The wx.App class represents the application and is used to: + * bootstrap the wxPython system and initialize the underlying + gui toolkit + * set and get application-wide properties + * implement the native windowing system main message or event + loop, and to dispatch events to window instances + * etc. + + Every wx application must have a single wx.App instance, and all + creation of UI objects should be delayed until after the wx.App + object has been created in order to ensure that the gui platform + and wxWidgets have been fully initialized. + + Normally you would derive from this class and implement an + OnInit method that creates a frame and then calls + self.SetTopWindow(frame), however wx.App is also usable on its + own without derivation. + + Scraped on 2025-12-10 from wxPython discussion + https://discuss.wxpython.org/t/two-wx-app-instances-one-appx-mainloop-runs-both-app-instances/24934 + wx.App is supposed to be used as a singleton. It doesn't own + the frames that are created in its OnInit, but instead assumes + that it is supposed to manage and deliver events to all the + windows that exist in the application. + +* Why patch wx.App to be a "strict singleton" (that raises an + exception on a second instantiation attempt) instead of a singleton + that returns the existing instance? + Because the intention is to prevent the misuse of wx (according to + the lib documentation), not to accommodate the misuse of wx. + +* During development of a feature, more wx-related functions and + classes are being parked in mywx.custom out of convenience, but + there may be a better way to reorganize them after the feature code + is stable. +""" + +# Standard library imports. +import logging + +# Log the load of this module (by the module loader, on first import). +# Intentionally positioning these statements before other imports, against the +# guidance of PEP 8, to log the load before other imports log messages. +logger = logging.getLogger(__name__) +logger.debug('%s', f'loading {__name__}') + +# Related third party imports. +# None + +# Local application/library specific imports. +# On load of this package, import/load .patch, which monkeypatches wx.App. +from . import patch + +# Expose custom functions and classes as attributes of this package. +from .custom import ( + bring_wxapp_to_foreground, + OK_Dialog, + OK_Cancel_Dialog, + ) diff --git a/LabGym/mywx/custom.py b/LabGym/mywx/custom.py new file mode 100644 index 0000000..d91bd5f --- /dev/null +++ b/LabGym/mywx/custom.py @@ -0,0 +1,139 @@ +"""Provide wx utility functions and wx.Dialog subclasses. + +Public Functions + bring_wxapp_to_foreground -- Bring the wx app to the foreground. + +Public Classes + OK_Dialog -- A wx.Dialog with left-aligned msg, and centered OK button. + OK_Cancel_Dialog -- A wx.Dialog with left-aligned msg, and centered + OK and Cancel buttons. +""" + +# Allow use of newer syntax Python 3.10 type hints in Python 3.9. +from __future__ import annotations + +# Standard library imports. +import logging +import sys + +# Log the load of this module (by the module loader, on first import). +# Intentionally positioning these statements before other imports, against the +# guidance of PEP 8, to log the load before other imports log messages. +logger = logging.getLogger(__name__) +logger.debug('%s', f'loading {__name__}') + +# Related third party imports. +if sys.platform == 'darwin': # macOS + # AppKit is from package pyobjc-framework-Cocoa, "Wrappers for the + # Cocoa frameworks on macOS". + from AppKit import NSApp, NSApplication + +import wx # wxPython, Cross platform GUI toolkit for Python, "Phoenix" version + +# Local application/library specific imports. +# None + + +def bring_wxapp_to_foreground() -> None: + """Bring the wx app to the foreground. + + We want the wx app displayed to the user, not initially obscured by + windows from other apps. + + On macOS 12.7, with LabGym started from terminal, I'm seeing: + * the registration dialog is displayed, but doesn't have focus + and is under the windows of the active app (terminology?), + potentially hidden completely. + * a bouncing python rocketship icon in the tray. The bouncing + stops when you mouseover the icon. Also some other actions + can stop the bouncing or just pause the bouncing... weird. + + I wasn't able to resolve this undesirable behavior on macOS using + the wx.Dialog object's Raise, SetFocus, and Restore methods. + Instead, this approach worked for me... + "Calling NSApp().activateIgnoringOtherApps_(True) via AppKit: + This macOS workaround uses the AppKit module to explicitly + activate the application, ignoring other running applications. + This is typically done after your wxPython application has + started and its main window is shown." + """ + + if sys.platform == 'darwin': # macOS + NSApplication.sharedApplication() + NSApp().activateIgnoringOtherApps_(True) + + +class OK_Dialog(wx.Dialog): + """An OK dialog object, with the message text left-aligned. + + Why use a custom class instead of using wx.MessageDialog? + Because left-alignment of the message is preferred, and a + wx.MessageDialog cannot be customized to control the alignment of + its message text or buttons. + + (class purpose and functionality, and optionally its attributes and methods) + """ + + def __init__(self, parent, title='', msg=''): + super().__init__(parent, title=title) + + panel = wx.Panel(self) + main_sizer = wx.BoxSizer(wx.VERTICAL) + + # Add the msg + main_sizer.Add(wx.StaticText(panel, label=msg), + 0, # proportion (int). 0 means the item won't expand + # beyond its minimal size. + + # border on all sides, and align left + wx.ALL | wx.LEFT, + + 10, # width (in pixels) of the borders specified + ) + + # Create sizers for layout. + button_sizer = wx.StdDialogButtonSizer() + + # Create buttons, Add buttons to sizers, and Bind event handlers + self.add_buttons(panel, button_sizer) + + # Realize the sizer to apply platform-specific layout + button_sizer.Realize() + + # Add the button sizer to the main sizer + main_sizer.Add(button_sizer, 0, wx.ALL | wx.EXPAND, 10) + + panel.SetSizer(main_sizer) + main_sizer.Fit(self) + # self.SetSizerAndFit(main_sizer) # is this equivalent?? + + def add_buttons(self, panel, button_sizer): + """Create and add buttons to sizers, and bind event handlers. + + Create standard buttons with their respective IDs. + Add buttons to the StdDialogButtonSizer. + Bind event handlers for the buttons. + """ + # Create/Add/Bind for the OK button + ok_button = wx.Button(panel, wx.ID_OK) + button_sizer.AddButton(ok_button) + self.Bind(wx.EVT_BUTTON, self.on_ok, id=wx.ID_OK) + + def on_ok(self, event): + self.EndModal(wx.ID_OK) + + +class OK_Cancel_Dialog(OK_Dialog): + """An OK/Cancel dialog object, with the message text left-aligned.""" + + def add_buttons(self, panel, button_sizer): + # Create/Add/Bind for the OK button + super().add_buttons(panel, button_sizer) + + # Create/Add/Bind for the Cancel button + cancel_button = wx.Button(panel, wx.ID_CANCEL) + button_sizer.AddButton(cancel_button) + self.Bind(wx.EVT_BUTTON, self.on_cancel, id=wx.ID_CANCEL) + + def on_cancel(self, event): + self.EndModal(wx.ID_CANCEL) diff --git a/LabGym/mywx/patch.py b/LabGym/mywx/patch.py new file mode 100644 index 0000000..940fcc0 --- /dev/null +++ b/LabGym/mywx/patch.py @@ -0,0 +1,85 @@ +"""Monkeypatch wx.App to be a strict-singleton. + +Import this module before the first import of wx, to patch wx.App to be +a strict-singleton before an unpatched instance of wx.App can possibly +be created. +""" + +# Allow use of newer syntax Python 3.10 type hints in Python 3.9. +from __future__ import annotations + +# Standard library imports. +import logging +import sys +import textwrap + +# Log the load of this module (by the module loader, on first import). +# Intentionally positioning these statements before other imports, against the +# guidance of PEP 8, to log the load before other imports log messages. +logger = logging.getLogger(__name__) +logger.debug('%s', f'loading {__name__}') + +# Related third party imports. +# Verify the assumption that wx is not yet loaded, so there's been no +# opportunity to create an unpatched instance of wx.App, then import wx. +try: + assert 'wx' not in sys.modules + import wx + patched = False +except AssertionError as e: + # Rule out a false alarm by inspecting wx and finding its already patched. + import wx + if hasattr(wx, 'mywx_AppCount'): + logger.warning('%s', textwrap.fill(textwrap.dedent(f"""\ + Weird, wx.App is already patched. To the developer: Maybe + this module ({__name__}) was loaded earlier under a + different package name? Consistency in the imports is + recommended. ({e!r}) + """), width=400)) + patched = True + else: + raise + +# Local application/library specific imports. +# None + + +# class Singleton(wx.App): +# _instance = None # Class variable to hold the single instance +# +# def __new__(cls, *args, **kwargs): +# logger.debug('patched __new__ -- entered') +# if cls._instance is None: +# logger.debug('patched __new__ -- instantiating') +# cls._instance = super().__new__(cls) +# logger.debug(f'patched __new__ -- returning {cls._instance}') +# return cls._instance +# +# def __init__(self, *args, **kwargs): +# logger.debug('patched __init__ -- entered') +# if not hasattr(self, '_initialized'): +# logger.debug('patched __init__ -- initializing') +# super().__init__(*args, **kwargs) +# self._initialized = True +# wx.mywx_AppCount += 1 + + +class StrictSingleton(wx.App): + _instance = None # Class variable to hold the single instance + + def __new__(cls, *args, **kwargs): + logger.debug('patched __new__ -- entered') + if cls._instance is None: + logger.debug('patched __new__ -- instantiating') + cls._instance = super().__new__(cls) + else: + raise AssertionError('wx.App() is called once at most.') + logger.debug(f'patched __new__ -- returning {cls._instance}') + wx.mywx_AppCount += 1 + return cls._instance + + +if not patched: + # monkeypatch wx.App + wx.mywx_AppCount = 0 + wx.App = StrictSingleton diff --git a/LabGym/probes.py b/LabGym/probes.py index f53278a..7273c1c 100644 --- a/LabGym/probes.py +++ b/LabGym/probes.py @@ -10,6 +10,7 @@ # Standard library imports. # import getpass import logging +import os import platform # Log the load of this module (by the module loader, on first import). @@ -33,6 +34,7 @@ from LabGym import __version__ as version from LabGym import central_logging, registration from LabGym import config +from LabGym import userdata_survey def probes() -> None: @@ -47,6 +49,16 @@ def probes() -> None: _config = config.get_config() anonymous: bool = _config['anonymous'] registration_enable: bool = _config['enable']['registration'] + detectors_dir = _config['detectors'] + models_dir = _config['models'] + del _config + + # Check for user data in deprecated LabGym/detectors and LabGym/models + userdata_survey.survey( + labgym_dir=os.path.dirname(__file__), + detectors_dir=detectors_dir, + models_dir=models_dir, + ) # Check for cacert trouble which might be a fouled installation. probe_url_to_verify_cacert() diff --git a/LabGym/registration.py b/LabGym/registration.py index 1b31350..4009c6d 100644 --- a/LabGym/registration.py +++ b/LabGym/registration.py @@ -79,19 +79,17 @@ from zoneinfo import ZoneInfo # Related third party imports. -# pylint: disable=wrong-import-position -if sys.platform == 'darwin': # macOS -# pylint: enable=wrong-import-position - # AppKit is from package pyobjc-framework-Cocoa, "Wrappers for the - # Cocoa frameworks on macOS". - from AppKit import NSApp, NSApplication -import wx # wxPython, Cross platform GUI toolkit for Python, "Phoenix" version +# import wx # wxPython, Cross platform GUI toolkit for Python, "Phoenix" version import yaml # PyYAML, YAML parser and emitter for Python # Local application/library specific imports. from LabGym import __version__ as version from LabGym import central_logging from LabGym import config +from LabGym import mywx +import wx # wxPython, Cross platform GUI toolkit for Python, "Phoenix" version + +# import patch_wx, wx # wxPython, with wx.App patched to be a strict singleton logger = logging.getLogger(__name__) @@ -249,33 +247,6 @@ def GetInputValues(self, alt=None) -> dict | None: result = {} return result - def bring_to_foreground(self) -> None: - """Bring the window to the foreground. - - We want the form window displayed to the user. - - On macOS 12.7, with app started from terminal, I'm seeing: - * the registration dialog is displayed, but doesn't have focus - and is under the windows of the active app (terminology?), - potentially hidden completely. - * a bouncing python rocketship icon in the tray. The bouncing - stops when you mouseover the icon. Also some other actions - can stop the bouncing or just pause the bouncing... weird. - - I wasn't able to resolve this on macOS using the wx.Dialog - object's Raise, SetFocus, and Restore methods. - Instead, ... - "Calling NSApp().activateIgnoringOtherApps_(True) via - AppKit: This macOS workaround uses the AppKit module to - explicitly activate the application, ignoring other running - applications. This is typically done after your wxPython - application has started and its main window is shown." - """ - - if sys.platform == 'darwin': # macOS - NSApplication.sharedApplication() - NSApp().activateIgnoringOtherApps_(True) - def _get_reginfo_from_form() -> dict | None: """Display a reg form, get user input, and return reginfo. @@ -306,7 +277,7 @@ def _get_reginfo_from_form() -> dict | None: with RegFormDialog(None) as dlg: logger.debug('%s -- %s', 'Milestone ShowModal', 'calling...') - dlg.bring_to_foreground() + # mywx.bring_wxapp_to_foreground() if dlg.ShowModal() == wx.ID_OK: logger.debug('%s -- %s', 'Milestone ShowModal', 'returned') logger.debug('User pressed [Register]') diff --git a/LabGym/tests/Notes.pytest.txt b/LabGym/tests/Notes.pytest.txt new file mode 100644 index 0000000..06ce9f3 --- /dev/null +++ b/LabGym/tests/Notes.pytest.txt @@ -0,0 +1,8 @@ +Run all testfiles + pytest + +Run a specific testfile + pytest test_userdata_survey.py + +Run a specific testfunction in a testfile + pytest test_userdata_survey.py::test_survey_case4 diff --git a/LabGym/tests/test___main__.py b/LabGym/tests/test___main__.py index 8d9bade..26b4bb5 100644 --- a/LabGym/tests/test___main__.py +++ b/LabGym/tests/test___main__.py @@ -39,6 +39,9 @@ def test_main(monkeypatch): monkeypatch.setattr(__main__.probes, 'probes', lambda: None) monkeypatch.setattr(__main__.gui_main, 'main_window', lambda: None) + monkeypatch.setattr(__main__.wx, 'GetApp', lambda: None) + monkeypatch.setattr(__main__.wx, 'App', lambda: None) + # Act __main__.main() @@ -62,6 +65,9 @@ def test_main_current_labgym(monkeypatch): monkeypatch.setattr(__main__.probes, 'probes', lambda: None) monkeypatch.setattr(__main__.gui_main, 'main_window', lambda: None) + monkeypatch.setattr(__main__.wx, 'GetApp', lambda: None) + monkeypatch.setattr(__main__.wx, 'App', lambda: None) + # Act __main__.main() @@ -85,6 +91,9 @@ def test_main_stale_labgym(monkeypatch): monkeypatch.setattr(__main__.probes, 'probes', lambda: None) monkeypatch.setattr(__main__.gui_main, 'main_window', lambda: None) + monkeypatch.setattr(__main__.wx, 'GetApp', lambda: None) + monkeypatch.setattr(__main__.wx, 'App', lambda: None) + # Act __main__.main() diff --git a/LabGym/tests/test_mywx.py b/LabGym/tests/test_mywx.py new file mode 100644 index 0000000..494afa7 --- /dev/null +++ b/LabGym/tests/test_mywx.py @@ -0,0 +1,450 @@ +import logging +import os +from pathlib import Path +import re +import sys +import time + +import pytest # pytest: simple powerful testing with Python + +from LabGym import mywx # on load, monkeypatch wx.App to be a singleton +import wx # wxPython, Cross platform GUI toolkit for Python, "Phoenix" version + + +# testdir = Path(__file__[:-3]) # dir containing support files for unit tests +# assert testdir.is_dir() + + +@pytest.fixture(scope="module") # invoke once in the test module +def wx_app(): + # setup logic + app = wx.App() + + logging.debug(f'yield app ({app!r})') + yield app + + # teardown logic + # Ensure a graceful shutdown of a wxPython application by deferring + # the exit of the main event loop until the current event handling + # is complete. + + logging.debug(f'wx.CallAfter(app.ExitMainLoop)') + wx.CallAfter(app.ExitMainLoop) + logging.debug(f'app.MainLoop()') + app.MainLoop() # Ensure app processes pending events before exit. + + del app + wx.App._instance = None + + +def test_dummy(): + # Arrange + # Act + pass + # Assert not necessary. This unit test passes unless exception was raised. + + +def test_patched_wx_app_alfa(wx_app): + """The wx_app fixture works.""" + # Arrange + app = wx_app + + # Act + pass + + # Assert + assert app is not None and isinstance(app, wx.App) + assert app == wx.GetApp() + logging.debug(f'app: {app!r}') + + +def test_patched_wx_app_bravo(wx_app): + """The wx_app fixture works (still), for another test in the test module.""" + # Arrange + app = wx_app + + # Act + pass + + assert app is not None and isinstance(app, wx.App) + assert app == wx.GetApp() + logging.debug(f'app: {app!r}') + + +def test_patched_wx_app_charlie(wx_app): + """Second call to wx.App() raises an exception.""" + # Arrange + app = wx_app + assert app is not None and isinstance(app, wx.App) + assert app == wx.GetApp() + logging.debug(f'app: {app!r}') + + # Act & Assert + # Second call + with pytest.raises(AssertionError): + app2 = wx.App() # should produce assertion error + + # Third call + with pytest.raises(AssertionError): + app3 = wx.App() # should produce assertion error + + # wx.GetApp() still reports the single instance. + assert app == wx.GetApp() + + +def test_OK_Dialog_with_OK(wx_app): + """Show an OK_Dialog, press OK, and get OK.""" + # Arrange + app = wx_app + assert app is not None and isinstance(app, wx.App) + assert app == wx.GetApp() + logging.debug(f'app: {app!r}') + + msg = '\n'.join(['The quick brown fox', + 'jumps over the lazy dog.']) + dialog = mywx.OK_Dialog(None, title='My Title', msg=msg) + + def click_OK(): + click_event = wx.CommandEvent(wx.EVT_BUTTON.typeId, wx.ID_OK) + dialog.ProcessEvent(click_event) + + # CallAfter is a function used to schedule a callable to be executed + # on the main GUI thread after the current event hander and any + # pending event handlers have completed. + wx.CallLater(1000 * 4, click_OK) + + # The ShowModal() method is used to display a dialog box in a + # "modal" fashion. When a modal dialog is shown, it blocks user + # interaction with other windows in the application until the modal + # dialog is dismissed (closed by the user). + result = dialog.ShowModal() + + # Assert + assert result == wx.ID_OK + + # Teardown + dialog.Destroy() # request the dialog to self-destruct + + +def test_OK_Dialog_with_Close(wx_app): + """Show an OK_Dialog, Close, and get OK""" + # Arrange + app = wx_app + assert app is not None and isinstance(app, wx.App) + assert app == wx.GetApp() + logging.debug(f'app: {app!r}') + + msg = '\n'.join(['The quick brown fox', + 'jumps over the lazy dog.']) + dialog = mywx.OK_Dialog(None, title='My Title', msg=msg) + + # CallAfter is a function used to schedule a callable to be executed + # on the main GUI thread after the current event hander and any + # pending event handlers have completed. + wx.CallLater(1000 * 4, dialog.Close) + + # The ShowModal() method is used to display a dialog box in a + # "modal" fashion. When a modal dialog is shown, it blocks user + # interaction with other windows in the application until the modal + # dialog is dismissed (closed by the user). + result = dialog.ShowModal() + + # Assert + assert result == wx.ID_OK + + # Teardown + dialog.Destroy() # request the dialog to self-destruct + + +def test_OK_Cancel_Dialog_with_OK(wx_app): + """Show an OK_Cancel_Dialog, press OK, and get OK.""" + # Arrange + app = wx_app + assert app is not None and isinstance(app, wx.App) + assert app == wx.GetApp() + logging.debug(f'app: {app!r}') + + msg = '\n'.join(['The quick brown fox', + 'jumps over the lazy dog.']) + dialog = mywx.OK_Cancel_Dialog(None, title='My Title', msg=msg) + + def click_OK(): + click_event = wx.CommandEvent(wx.EVT_BUTTON.typeId, wx.ID_OK) + dialog.ProcessEvent(click_event) + + # CallAfter is a function used to schedule a callable to be executed + # on the main GUI thread after the current event hander and any + # pending event handlers have completed. + wx.CallLater(1000 * 4, click_OK) + + # The ShowModal() method is used to display a dialog box in a + # "modal" fashion. When a modal dialog is shown, it blocks user + # interaction with other windows in the application until the modal + # dialog is dismissed (closed by the user). + result = dialog.ShowModal() + + # Assert + assert result == wx.ID_OK + + # Teardown + dialog.Destroy() # request the dialog to self-destruct + + +def test_OK_Cancel_Dialog_with_Cancel(wx_app): + """Show an OK_Cancel_Dialog, press Cancel, and get Cancel.""" + # Arrange + app = wx_app + assert app is not None and isinstance(app, wx.App) + assert app == wx.GetApp() + logging.debug(f'app: {app!r}') + + msg = '\n'.join(['The quick brown fox', + 'jumps over the lazy dog.']) + dialog = mywx.OK_Cancel_Dialog(None, title='My Title', msg=msg) + + def click_Cancel(): + click_event = wx.CommandEvent(wx.EVT_BUTTON.typeId, wx.ID_CANCEL) + dialog.ProcessEvent(click_event) + + # CallAfter is a function used to schedule a callable to be executed + # on the main GUI thread after the current event hander and any + # pending event handlers have completed. + wx.CallLater(1000 * 4, click_Cancel) + + # The ShowModal() method is used to display a dialog box in a + # "modal" fashion. When a modal dialog is shown, it blocks user + # interaction with other windows in the application until the modal + # dialog is dismissed (closed by the user). + result = dialog.ShowModal() + + # Assert + assert result == wx.ID_CANCEL + + # Teardown + dialog.Destroy() # request the dialog to self-destruct + + +def test_OK_Cancel_Dialog_with_Close(wx_app): + """Show an OK_Cancel_Dialog, Close, and get Cancel (?!)""" + # Arrange + app = wx_app + assert app is not None and isinstance(app, wx.App) + assert app == wx.GetApp() + logging.debug(f'app: {app!r}') + + msg = '\n'.join(['The quick brown fox', + 'jumps over the lazy dog.']) + dialog = mywx.OK_Cancel_Dialog(None, title='My Title', msg=msg) + + # CallAfter is a function used to schedule a callable to be executed + # on the main GUI thread after the current event hander and any + # pending event handlers have completed. + wx.CallLater(1000 * 4, dialog.Close) + + # The ShowModal() method is used to display a dialog box in a + # "modal" fashion. When a modal dialog is shown, it blocks user + # interaction with other windows in the application until the modal + # dialog is dismissed (closed by the user). + result = dialog.ShowModal() + + # Assert + assert result == wx.ID_CANCEL + + # Teardown + dialog.Destroy() # request the dialog to self-destruct + + +# def test_mydialog_skip(wx_app): +# frame = wx.Frame(None) # parent for the dialog +# dialog = registration.RegFormDialog(frame) +# +# # def click_register(): +# def click_skip(): +# click_event = wx.CommandEvent(wx.EVT_BUTTON.typeId, wx.ID_CANCEL) +# dialog.ProcessEvent(click_event) +# +# # CallAfter is a function used to schedule a callable to be executed +# # on the main GUI thread after the current event hander and any +# # pending event handlers have completed. +# # wx.CallAfter(click_skip) +# wx.CallLater(1000, click_skip) +# +# # The ShowModal() method is used to display a dialog box in a +# # "modal" fashion. When a modal dialog is shown, it blocks user +# # interaction with other windows in the application until the modal +# # dialog is dismissed (closed by the user). +# result = dialog.ShowModal() +# +# # Assert +# assert result == wx.ID_CANCEL +# +# # Teardown +# dialog.Destroy() # request the dialog to self-destruct +# +# +# def test_mydialog_register(wx_app): +# frame = wx.Frame(None) # parent for the dialog +# dialog = registration.RegFormDialog(frame) +# +# def click_register(): +# click_event = wx.CommandEvent(wx.EVT_BUTTON.typeId, wx.ID_OK) +# dialog.ProcessEvent(click_event) +# +# def click_skip(): +# click_event = wx.CommandEvent(wx.EVT_BUTTON.typeId, wx.ID_CANCEL) +# dialog.ProcessEvent(click_event) +# +# def enter_name(): +# dialog.input_name.SetValue('Mark Wilson') +# def enter_affiliation(): +# dialog.input_affiliation.SetValue('Cupertino') +# def enter_email(): +# dialog.input_email.SetValue('Mark.Wilson@gmail.com') +# +# # CallAfter is a function used to schedule a callable to be executed +# # on the main GUI thread after the current event hander and any +# # pending event handlers have completed. +# # Use CallAfter to interact with the dialog *after* it's been shown. +# # This allows the dialog to become active before test code attempts +# # to interact with it. +# # wx.CallAfter(click_register) +# wx.CallLater(1000, enter_name) # delay in ms +# wx.CallLater(2000, enter_affiliation) # delay in ms +# wx.CallLater(3000, enter_email) # delay in ms +# wx.CallLater(5000, click_register) # delay in ms +# +# # The ShowModal() method is used to display a dialog box in a +# # "modal" fashion. When a modal dialog is shown, it blocks user +# # interaction with other windows in the application until the modal +# # dialog is dismissed (closed by the user). +# result = dialog.ShowModal() +# +# # Assert +# assert result == wx.ID_OK +# +# # Teardown +# dialog.Destroy() # request the dialog to self-destruct +# +# +# def test__get_reginfo_from_form(monkeypatch, wx_app, tmp_path): +# +# # frame = wx.Frame(None) # parent for the dialog +# # dialog = registration.RegFormDialog(frame) +# +# def click_register(dialog_obj): +# click_event = wx.CommandEvent(wx.EVT_BUTTON.typeId, wx.ID_OK) +# dialog_obj.ProcessEvent(click_event) +# +# def click_skip(dialog_obj): +# click_event = wx.CommandEvent(wx.EVT_BUTTON.typeId, wx.ID_CANCEL) +# dialog_obj.ProcessEvent(click_event) +# +# def enter_name(dialog_obj): +# dialog_obj.input_name.SetValue('Paul Baker') +# def enter_affiliation(dialog_obj): +# dialog_obj.input_affiliation.SetValue('Cupertino') +# def enter_email(dialog_obj): +# dialog_obj.input_email.SetValue('Paul.Baker@gmail.com') +# +# # CallAfter is a function used to schedule a callable to be executed +# # on the main GUI thread after the current event hander and any +# # pending event handlers have completed. +# # Use CallAfter to interact with the dialog *after* it's been shown. +# # This allows the dialog to become active before test code attempts +# # to interact with it. +# # wx.CallAfter(click_register) +# +# # wx.CallLater(1000, enter_name, h) # delay in ms +# # wx.CallLater(2000, enter_affiliation, h) # delay in ms +# # wx.CallLater(3000, enter_email, h) # delay in ms +# # wx.CallLater(5000, click_register, h) # delay in ms +# +# monkeypatch.setattr(registration.wx, 'App', lambda: None) +# h = registration.RegFormDialog(None) +# monkeypatch.setattr(registration, 'RegFormDialog', lambda arg1: h) +# +# wx.CallLater(1000, enter_name, h) # delay in ms +# wx.CallLater(2000, enter_affiliation, h) # delay in ms +# wx.CallLater(3000, enter_email, h) # delay in ms +# wx.CallLater(5000, click_register, h) # delay in ms +# +# # this works, but why not run registration itself? +# # reginfo = registration._get_reginfo_from_form() +# _config = { +# 'configdir': tmp_path, +# } +# monkeypatch.setattr(registration.config, 'get_config', lambda: _config) +# logging.debug('%s: %r', '_config', _config) +# +# registration.register(logging.getLogger()) +# +# # The ShowModal() method is used to display a dialog box in a +# # "modal" fashion. When a modal dialog is shown, it blocks user +# # interaction with other windows in the application until the modal +# # dialog is dismissed (closed by the user). +# # result = dialog.ShowModal() +# +# # Assert +# # assert result == wx.ID_OK +# +# # Teardown +# # dialog.Destroy() # request the dialog to self-destruct +# +# +# def test_get_reginfo_from_file(monkeypatch): +# # Arrange +# _config = { +# 'configdir': testdir, +# } +# monkeypatch.setattr(registration.config, 'get_config', lambda: _config) +# logging.debug('%s: %r', '_config', _config) +# +# # Act +# result = registration.get_reginfo_from_file() +# # Assert +# assert result.get('schema') == 'reginfo 2025-07-10' +# +# +# def test_is_registered(monkeypatch, tmp_path): +# # Arrange +# _config = { +# 'configdir': tmp_path, +# } +# monkeypatch.setattr(registration.config, 'get_config', lambda: _config) +# logging.debug('%s: %r', '_config', _config) +# +# # Act +# result = registration.is_registered() +# # Assert +# assert result == False +# +# +# # basicConfig here isn't effective, maybe pytest has already configured logging? +# # logging.basicConfig(level=logging.DEBUG) +# # so instead, use the root logger's setLevel method +# logging.getLogger().setLevel(logging.DEBUG) +# +# # the output from this debug statement is not accessible? +# # instead, perform inside a test function. +# # logging.debug('%s: %r', 'dir(LabGym)', dir(LabGym)) +# # +# # def test_inspect(): +# # logging.debug('%s: %r', 'dir(LabGym)', dir(LabGym)) +# # logging.debug('%s: %r', "os.getenv('PYTHONPATH')", os.getenv('PYTHONPATH')) +# +# +# def x_test_probes(monkeypatch): +# # Arrange +# _config = { +# 'anonymous': True, +# 'enable': {'registration': False, 'central_logger': False}, +# } +# monkeypatch.setattr(probes.config, 'get_config', lambda: _config) +# logging.debug('%s: %r', '_config', _config) +# monkeypatch.setattr(probes.central_logging.config, 'get_config', lambda: _config) +# +# # Act +# probes.probes() +# +# # Assert +# # the probes were run and didn't raise an exception. diff --git a/LabGym/tests/test_probes.py b/LabGym/tests/test_probes.py index e981eff..0bb0e02 100644 --- a/LabGym/tests/test_probes.py +++ b/LabGym/tests/test_probes.py @@ -31,16 +31,27 @@ # logging.debug('%s: %r', "os.getenv('PYTHONPATH')", os.getenv('PYTHONPATH')) -def test_probes(monkeypatch): +def test_probes(monkeypatch, tmp_path): # Arrange _config = { 'anonymous': True, 'enable': {'registration': False, 'central_logger': False}, } + + # prepare some userdata dirs outside of LabGym, and include in _config. + _detectors = str(tmp_path / 'detectors') + _models = str(tmp_path / 'models') + # Path(_detectors).mkdir() + # Path(_models).mkdir() + _config.update({'detectors': _detectors, 'models': _models}) + logging.debug('%s: %r', '_config', _config) + monkeypatch.setattr(probes.config, 'get_config', lambda: _config) logging.debug('%s: %r', '_config', _config) monkeypatch.setattr(probes.central_logging.config, 'get_config', lambda: _config) + monkeypatch.setattr(probes.userdata_survey, 'survey', lambda *args, **kwargs: None) + # Act probes.probes() diff --git a/LabGym/tests/test_registration.py b/LabGym/tests/test_registration.py index 3e8c16d..8725f08 100644 --- a/LabGym/tests/test_registration.py +++ b/LabGym/tests/test_registration.py @@ -6,6 +6,8 @@ import time import pytest # pytest: simple powerful testing with Python + +from LabGym import mywx # on load, monkeypatch wx.App to be a singleton import wx # wxPython, Cross platform GUI toolkit for Python, "Phoenix" version from LabGym import registration @@ -15,8 +17,7 @@ assert testdir.is_dir() -# @pytest.fixture(scope="module") # invoke once in the test module -@pytest.fixture() +@pytest.fixture(scope="module") # invoke once in the test module def wx_app(): # setup logic app = wx.App() @@ -30,6 +31,9 @@ def wx_app(): wx.CallAfter(app.ExitMainLoop) app.MainLoop() # Ensure app processes pending events before exit. + del app + wx.App._instance = None + def test_dummy(): # Arrange diff --git a/LabGym/tests/test_userdata_survey.py b/LabGym/tests/test_userdata_survey.py new file mode 100644 index 0000000..ca037f3 --- /dev/null +++ b/LabGym/tests/test_userdata_survey.py @@ -0,0 +1,267 @@ +import logging +import os +from pathlib import Path +import re +import sys +import textwrap +import time + +import pytest # pytest: simple powerful testing with Python + +from LabGym import mywx # on load, monkeypatch wx.App to be a singleton +import wx # wxPython, Cross platform GUI toolkit for Python, "Phoenix" version + +from LabGym import userdata_survey +from .exitstatus import exitstatus + + +# testdir = Path(__file__[:-3]) # dir containing support files for unit tests +# assert testdir.is_dir() + +@pytest.fixture(scope="module") # invoke once in the test module +def wx_app(): + # setup logic + app = wx.App() + + yield app + + # teardown logic + # Ensure a graceful shutdown of a wxPython application by deferring + # the exit of the main event loop until the current event handling + # is complete. + wx.CallAfter(app.ExitMainLoop) + app.MainLoop() # Ensure app processes pending events before exit. + + del app + wx.App._instance = None + + +delay = 2000 # msec + + +class AutoclickOK(mywx.OK_Dialog): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # set a delayed click... + wx.CallLater(delay, self.click_ok) + def click_ok(self): + click_event = wx.CommandEvent(wx.EVT_BUTTON.typeId, wx.ID_OK) + self.ProcessEvent(click_event) + +def test_dummy(): + # Arrange + # Act + pass + # Assert (optional). This unit test passes unless exception was raised. + + +def test_is_path_under(): + + # path1 and path2 are equivalent + assert userdata_survey.is_path_under('/a/b', '/a/b') == False + assert userdata_survey.is_path_under('/a/b', '/a/b/..') == False + + # path2 is under path1 + assert userdata_survey.is_path_under('/a/b', '/a/b/c') == True + assert userdata_survey.is_path_under('/a/b', '/a/b/c/d') == True + assert userdata_survey.is_path_under('/a/b', '/a/c/../b/d') == True + + # path2 is not under path1 + assert userdata_survey.is_path_under('/a/b/c', '/a/b') == False + assert userdata_survey.is_path_under('/a/b', '/a/c') == False + assert userdata_survey.is_path_under('/a/b', '/a/b/../c') == False + + +def test_is_path_equivalent(): + # path1 and path2 are equivalent + assert userdata_survey.is_path_equivalent('/a/b', '/a/b') == True + + # path1 and path2 are not equivalent + assert userdata_survey.is_path_equivalent('/a/b', '/a/c') == False + + +def test_resolve(): + result = userdata_survey.resolve('.') + assert Path(result).is_absolute() + + +# def dict2str(arg: dict, hanging_indent: str=' '*16) -> str: +def test_dict2str(): + myarg = {} + assert userdata_survey.dict2str(myarg) == '' + + # In Python, all standard dictionaries (dict) are ordered by + # insertion order starting from Python 3.7. + + myarg = {'a': 'A', 'c': 'C', 'b': 'B'} + hanging_indent = ' ' * 16 + expected = textwrap.dedent(f"""\ + a: A + {hanging_indent}c: C + {hanging_indent}b: B + """).strip() + result = userdata_survey.dict2str(myarg) + assert result == expected + + myarg = {'a': 'A', 'c': 'C', 'b': 'B'} + hanging_indent = ' ' * 2 + expected = textwrap.dedent(f"""\ + a: A + {hanging_indent}c: C + {hanging_indent}b: B + """).strip() + result = userdata_survey.dict2str(myarg, hanging_indent=hanging_indent) + assert result == expected + + +# def get_list_of_subdirs(parent_dir: str|Path) -> List[str]: +def test_get_list_of_subdirs(tmp_path): + (Path(tmp_path) / 'alfa').mkdir() + (Path(tmp_path) / 'bravo').mkdir() + (Path(tmp_path) / 'charlie').mkdir() + (Path(tmp_path) / '__pycache__').mkdir() + (Path(tmp_path) / '__init__.py').touch() + + expected = ['alfa', 'bravo', 'charlie'] + result = userdata_survey.get_list_of_subdirs(tmp_path) + assert result == expected + + +# def assert_userdata_dirs_are_separate( +def test_assert_userdata_dirs_are_separate( + monkeypatch, tmp_path, wx_app, caplog): + """Violate the assertion by passing in equivalent path1 & path2.""" + + # Arrange + # Use a custom self- OK-ing subclass of the dialog object. + monkeypatch.setattr(userdata_survey.mywx, 'OK_Dialog', AutoclickOK) + + # Act + with pytest.raises(SystemExit, + match="Bad configuration" + ) as e: + userdata_survey.assert_userdata_dirs_are_separate(tmp_path, tmp_path) + + # Assert + assert exitstatus(e.value) == 1 + + expected_msg = textwrap.dedent(""" + LabGym Configuration Error + The userdata folders must be separate. + """).strip() + assert expected_msg in caplog.text + + +# def survey( +def test_survey_case1(monkeypatch, tmp_path, wx_app, caplog): + """violate check 1, and get SystemExit.""" + # Arrange + # Use a custom self- OK-ing subclass of the dialog object. + monkeypatch.setattr(userdata_survey.mywx, 'OK_Dialog', AutoclickOK) + monkeypatch.setattr(userdata_survey.config, 'get_config', + lambda: {'enable': {'assess_userdata_folders': True}}) + + # prepare args + labgym = os.path.join(tmp_path, 'LabGym') + detectors = os.path.join(tmp_path, 'detectors') + models = os.path.join(tmp_path, 'detectors', 'models') + + # Act + with pytest.raises(SystemExit, + match="Bad configuration" + ) as e: + userdata_survey.survey(labgym, detectors, models) + + # Assert + assert exitstatus(e.value) == 1 + + expected_msg = textwrap.dedent(""" + LabGym Configuration Error + The userdata folders must be separate. + """).strip() + assert expected_msg in caplog.text + + +def test_survey_case2(monkeypatch, tmp_path, wx_app, caplog): + """violate check 2, and get Warning.""" + # Arrange + # Use a custom self- OK-ing subclass of the dialog object. + monkeypatch.setattr(userdata_survey.mywx, 'OK_Dialog', AutoclickOK) + monkeypatch.setattr(userdata_survey.config, 'get_config', + lambda: {'enable': {'assess_userdata_folders': True}}) + + # prepare args + labgym = os.path.join(tmp_path, 'LabGym') + detectors = os.path.join(tmp_path, 'detectors') + models = os.path.join(tmp_path, 'models') + + # Act + userdata_survey.survey(labgym, detectors, models) + + # Assert + expected_msg = \ + "External Userdata folders specified by config don't exist." + assert expected_msg in caplog.text + + +def test_survey_case3(monkeypatch, tmp_path, wx_app, caplog): + """violate check 3, and get Warning.""" + # Arrange + # Use a custom self- OK-ing subclass of the dialog object. + monkeypatch.setattr(userdata_survey.mywx, 'OK_Dialog', AutoclickOK) + monkeypatch.setattr(userdata_survey.config, 'get_config', + lambda: {'enable': {'assess_userdata_folders': True}}) + + # prepare args + labgym = os.path.join(tmp_path, 'LabGym') + detectors = os.path.join(tmp_path, 'LabGym', 'detectors') + models = os.path.join(tmp_path, 'LabGym', 'models') + + # Act + userdata_survey.survey(labgym, detectors, models) + + # Assert + expected_msg = "Found internal Userdata folders specified by config." + assert expected_msg in caplog.text + + +def test_survey_case4(monkeypatch, tmp_path, wx_app, caplog): + """violate check 4, and get Warning.""" + # Arrange + # Use a custom self- OK-ing subclass of the dialog object. + monkeypatch.setattr(userdata_survey.mywx, 'OK_Dialog', AutoclickOK) + monkeypatch.setattr(userdata_survey.config, 'get_config', + lambda: {'enable': {'assess_userdata_folders': True}}) + + # prepare args + labgym = os.path.join(tmp_path, 'LabGym') + detectors = os.path.join(tmp_path, 'detectors') + models = os.path.join(tmp_path, 'models') + # create the external userdata dirs + os.mkdir(detectors) + os.mkdir(models) + + # create four orphan userdata dirs + orphans = [ + os.path.join(labgym, 'detectors', 'detector1'), + os.path.join(labgym, 'detectors', 'detector2'), + os.path.join(labgym, 'detectors', '__pycache__'), + + os.path.join(labgym, 'models', 'model1'), + os.path.join(labgym, 'models', 'model2'), + os.path.join(labgym, 'models', '__pycache__'), + ] + for orphan in orphans: + os.makedirs(orphan) + + # also create some files which are not reported by this check... + (Path(labgym)/'detectors'/'__init__.py').touch() + (Path(labgym)/'models'/'__init__.py').touch() + + # Act + userdata_survey.survey(labgym, detectors, models) + + # Assert + print(caplog.text) + expected_msg = 'Found Userdata orphaned in old Userdata folders.' + assert expected_msg in caplog.text diff --git a/LabGym/userdata_survey.py b/LabGym/userdata_survey.py new file mode 100644 index 0000000..5f0eadc --- /dev/null +++ b/LabGym/userdata_survey.py @@ -0,0 +1,336 @@ +""" +Provide functions for flagging user data that is located internal to LabGym. + +Public functions + Specialized Functions + survey + assert_userdata_dirs_are_separate + offer_to_mkdir_userdata_dirs + advise_on_internal_userdata_dirs + get_instructions + warn_on_orphaned_userdata + + General-purpose Functions + is_path_under(path1: str|Path, path2: str|Path) -> bool + is_path_equivalent(path1: str|Path, path2: str|Path) -> bool + resolve(path1: str|Path) -> str + dict2str(arg: dict, hanging_indent: str=' '*16) -> str + get_list_of_subdirs(parent_dir: str|Path) -> List[str] + +Public classes: None + +Design issues +* Why is the user-facing text using the term "folder" instead of "dir" + or "directory"? + As Gemini says, + When authoring dialog text for display to the user, "folder" is + generally the better terminology for a general audience using a + graphical user interface (GUI), while "directory" is appropriate + for technical users or command-line interfaces (CLI). The + abbreviation "dir" should be avoided in user-facing text. + +* Why sometimes use a webbrowser instead of only dialog text? + Because + + The user can't select and copy the wx.Dialog text into a + clipboard (observed on MacOS). + + A wx.Dialog disappears when LabGym is quit. By displaying + instructions in a separate app, they can still be referenced + after LabGym is quit, until the user dismisses them. + + Formatting... It's more efficient to write content in html + instead of hand-formatting text for a wx.Dialog. + There are other possible approaches... prepare the instructions in + html, then use html2text library to get formatted text from the + html, and display that in a wx.Dialog. + +The path args for the functions in this module should be absolute +(full) paths, not relative (partial) paths. That's the assumption +during development. If that assumption is violated, are unintended +consequences possible? +Instead of answering that question, implement guards. +(1) Enforce with asserts? + assert Path(arg).is_absolute() +(2) Or, enforce with asserts in the Specialized functions, but not the + General-purpose functions? +(3) Or, guard by decorating selected functions, instead of individually + adding the right mix of assert statements to function bodies. +For now, choosing (2). + +Design with paths as strings, or, paths as pathlib.Path objects? +Since the paths are configured as strings, assume the calls from outside +this module pass strings, and inside this module, developer is free to +use pathlib where convenient. +In other words, for public functions, support string path args, and +optionally, extend to support Path object args. +""" + +# Allow use of newer syntax Python 3.10 type hints in Python 3.9. +from __future__ import annotations + +# Standard library imports. +import logging +import os +from pathlib import Path +import sys +import tempfile +import textwrap +import webbrowser + +# Related third party imports. +import wx # wxPython, Cross platform GUI toolkit for Python, "Phoenix" version + +# Local application/library specific imports. +from LabGym import config +from LabGym import mywx + + +logger = logging.getLogger(__name__) + + +def is_path_under(path1: str|Path, path2: str|Path) -> bool: + """Return True if path2 is under path1.""" + + p1 = Path(path1).resolve() + p2 = Path(path2).resolve() + return p1 in p2.parents + + +def is_path_equivalent(path1: str|Path, path2: str|Path) -> bool: + """Return True if path1 & path2 are equivalent.""" + + # if they both exist, are they the same? + if Path(path1).exists() and Path(path2).exists(): + return Path(path1).samefile(Path(path2)) + + # if neither exists, are the strings of the resolved paths the same? + if not Path(path1).exists() and not Path(path2).exists(): + return resolve(path1) == resolve(path2) + + # At this point, one exists and the other doesn't, therefore False + return False + + +def resolve(path1: str|Path) -> str: + """Return a string representation of the resolved string or Path obj.""" + + return str(Path(path1).resolve()) + + +def dict2str(arg: dict, hanging_indent: str=' '*16) -> str: + """Return a string representation of the dict, with a hanging indent. + + Return '' if the dict is empty. + + Why the hanging indent? So that it can be tuned to avoid fouling + the left-alignment when used inside a multiline string that will be + dedented. + """ + + result = ('\n' + hanging_indent).join( + [f'{key}: {value}' for key, value in arg.items()] + ) + + return result + + +def get_list_of_subdirs(parent_dir: str|Path) -> List[str]: + """Return a sorted list of strings of the names of the child dirs. + + ... excluding __pycache__. + If parent_dir is not an existing dir, then return an empty list. + """ + + parent_path = Path(parent_dir) + + if parent_path.is_dir(): + result = [item.name for item in parent_path.iterdir() + # if item.name not in ['__init__', '__init__.py', '__pycache__'] + if item.name not in ['__pycache__'] + and (parent_path / item).is_dir()] + else: + result = [] + + result.sort() + return result + + +def assert_userdata_dirs_are_separate( + detectors_dir: str, models_dir: str) -> None: + """Verify the separation of configuration's userdata dirs. + + If not separate, then display an error message, then sys.exit(). + + Enforce an expectation that the detectors_dir and models_dir are + separate, and do not have a "direct, lineal relationshop", where one + is under the other. + + """ + + # Enforce the expectation that the path args are absolute (full). + assert Path(detectors_dir).is_absolute() + assert Path(models_dir).is_absolute() + + if (is_path_equivalent(detectors_dir, models_dir) + or is_path_under(detectors_dir, models_dir) + or is_path_under(models_dir, detectors_dir)): + + # fatal configuration error + title = 'LabGym Configuration Error' + msg = textwrap.dedent(f"""\ + LabGym Configuration Error + The userdata folders must be separate. + The detectors folder is specified by config or defaults as + {str(detectors_dir)} + which resolves to + {str(resolve(detectors_dir))} + The models folder is specified by config or defaults as + {str(models_dir)} + which resolves to + {str(resolve(models_dir))} + """) + + logger.error('%s', msg) + + # Show the error msg with an OK_Dialog. + with mywx.OK_Dialog(None, title=title, msg=msg) as dlg: + result = dlg.ShowModal() # will return wx.ID_OK upon OK or dismiss + + sys.exit('Bad configuration') + + +def survey( + labgym_dir: str, + detectors_dir: str, + models_dir: str, + ) -> None: + """Warn (and display guidance?) if userdata dirs need reorganization. + + The locations of detectors and models dirs are specified by the + configuration, obtained from the configuration before calling this + function, and passed into this function. + + 1. Verify the separation of configuration's userdata dirs. + If not separate, then display an error message, then sys.exit(). + + 2. Check for userdata dirs that are defined/configured as + "external" to LabGym, but don't exist. If any, then warn. + + This action could be expanded -- offer to attempt mkdir? + + 3. Check for userdata dirs that are defined/configured as + "internal" to LabGym. If any, then warn. + + This action could be expanded -- + 3a. provide info and specific instructions to the user for + resolution. + 3b. (or,) for each internal userdata dir (detectors, models), + automatically + + mkdir new external dir + + update the config to point at new external dir + + for each subdir of internal dir + + copy subdir to new external dir + + back up existing original + + delete existing original + then exit (don't continue with old config!) + + 4. For any userdata dirs configured as external to LabGym tree, + if there is "orphaned" data, remaining in the "traditional" + location (internal, within the LabGym tree), then warn. + """ + + # Enforce the expectation that the path args are absolute (full). + assert Path(labgym_dir).is_absolute() + assert Path(detectors_dir).is_absolute() + assert Path(models_dir).is_absolute() + + logger.debug('%s: %r', 'labgym_dir', labgym_dir) + logger.debug('%s: %r', 'detectors_dir', detectors_dir) + logger.debug('%s: %r', 'models_dir', models_dir) + + userdata_dirs = { + 'detectors': detectors_dir, + 'models': models_dir, + } + internal_userdata_dirs = {key: value for key, value in userdata_dirs.items() + if is_path_under(labgym_dir, value)} + external_userdata_dirs = {key: value for key, value in userdata_dirs.items() + if not is_path_under(labgym_dir, value)} + + # Get all of the values needed from config.get_config(). + assess_userdata_folders: bool = config.get_config( + )['enable'].get('assess_userdata_folders', False) + + if assess_userdata_folders == False: + return + + # 1. Verify the separation of configuration's userdata dirs. + # If not separate, then display an error message, then sys.exit(). + assert_userdata_dirs_are_separate(detectors_dir, models_dir) + + # 2. Check for user data dirs that are defined/configured as + # "external", but don't exist. If any, then warn. + # (this action could be expanded -- offer to attempt mkdir?) + + missing_userdata_dirs = [value for value in external_userdata_dirs.values() + if not os.path.isdir(value)] + + if missing_userdata_dirs: + title = 'LabGym Configuration Warning' + msg = textwrap.dedent(f"""\ + External Userdata folders specified by config don't exist. + missing_userdata_dirs: {missing_userdata_dirs!r}' + """).strip() + + logger.warning('%s', msg) + + # Show the warning msg with an OK_Dialog. + with mywx.OK_Dialog(None, title=title, msg=textwrap.fill(msg)) as dlg: + result = dlg.ShowModal() # will return wx.ID_OK upon OK or dismiss + + # 3. If any userdata dirs are configured as located within the + # LabGym tree, then warn. + # (this could be enhanced -- provide info and specific instructions + # to the user for resolution.) + + if internal_userdata_dirs: + title = 'LabGym Configuration Warning' + msg = textwrap.dedent(f"""\ + Found internal Userdata folders specified by config. + The use of internal Userdata folders is deprecated. + internal_userdata_dirs: {internal_userdata_dirs!r} + """).strip() + + logger.warning('%s', msg) + + # Show the warning msg with an OK_Dialog. + with mywx.OK_Dialog(None, title=title, msg=textwrap.fill(msg)) as dlg: + result = dlg.ShowModal() # will return wx.ID_OK upon OK or dismiss + + # 4. For any userdata dirs configured as external to LabGym tree, + # if there is "orphaned" data, remaining in the "traditional" + # location (internal, within the LabGym tree), then warn. + + orphans = [] + if external_userdata_dirs: + if 'detectors' in external_userdata_dirs.keys(): + # contents of LabGym/detectors are orphans + old = Path(labgym_dir) / 'detectors' # old userdata dir + orphans.extend([ + str(old / subdir) for subdir in get_list_of_subdirs(old)]) + if 'models' in external_userdata_dirs.keys(): + # contents of LabGym/modelsare orphans + old = Path(labgym_dir) / 'models' # old userdata dir + orphans.extend([ + str(old / subdir) for subdir in get_list_of_subdirs(old)]) + + if orphans: + title = 'LabGym Configuration Warning' + msg = textwrap.dedent(f"""\ + Found Userdata orphaned in old Userdata folders. + orphans: {orphans!r} + """).strip() + + logger.warning('%s', msg) + + # Show the warning msg with an OK_Dialog. + with mywx.OK_Dialog(None, title=title, msg=textwrap.fill(msg)) as dlg: + result = dlg.ShowModal() # will return wx.ID_OK upon OK or dismiss diff --git a/tests/test_load.py b/tests/test_load.py index 5a6f506..16696ff 100644 --- a/tests/test_load.py +++ b/tests/test_load.py @@ -37,7 +37,8 @@ def test_import_LabGym_package(): # Prepare a list of all submodule py-files in LabGym dir, but not subdirs. pyfiles = glob.glob(os.path.join(os.path.dirname(LabGym.__file__), '*.py')) pyfiles.sort() # result from glob.glob() isn't sorted - submodules.extend([os.path.basename(f).rstrip('.py') for f in pyfiles]) + submodules.extend([os.path.basename(f).removesuffix('.py') + for f in pyfiles]) logging.debug('%s:\n%s', 'Milepost 0, submodules', textwrap.indent(pprint.pformat(submodules), ' '))