Repository: equinor/webviz-config

Issue:
Expose instance of `WebvizRunMode` during build and runtime
Plugins may have code in their` __init__` method which isn't required to run when building portables. E.g. if their are methods called during `__init__` which are also included in `add_webvizstore`, the code will be executed twice during portable build.

For the examples in `webviz-config`, the gain is negligable, but when processing large datasets in `webvizstore`-decorated functions, the difference in build time may be significant.


Discussion:
sigurdp: Should we also try and flag the magic `app` plugin parameter as deprecated now to try and nudge users into using the new structure, or should we hold back a bit?
anders-kiaer: > Should we also try and flag the magic app plugin parameter as deprecated now to try and nudge users into using the new structure, or should we hold back a bit?

We could perhaps deprecate it now (I guess we are pretty sure we don't want to go back to the "special `__init__` arguments approach).

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b7df40..fddd0af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [UNRELEASED] - YYYY-MM-DD
 
+### Changed
+- [#562](https://github.com/equinor/webviz-config/pull/562) - Added global WebvizInstanceInfo accessible through `WEBVIZ_INSTANCE_INFO`
+
 ## [0.3.7] - 2021-12-09
 
 ### Fixed
diff --git a/webviz_config/__init__.py b/webviz_config/__init__.py
index 2b8c887..1a3b311 100644
--- a/webviz_config/__init__.py
+++ b/webviz_config/__init__.py
@@ -11,6 +11,7 @@ from ._localhost_token import LocalhostToken
 from ._is_reload_process import is_reload_process
 from ._plugin_abc import WebvizPluginABC, EncodedFile, ZipFileMember
 from ._shared_settings_subscriptions import SHARED_SETTINGS_SUBSCRIPTIONS
+from .webviz_instance_info import WEBVIZ_INSTANCE_INFO
 from ._oauth2 import Oauth2
 
 try:
diff --git a/webviz_config/templates/copy_data_template.py.jinja2 b/webviz_config/templates/copy_data_template.py.jinja2
index ec16fa0..2b2fd2e 100644
--- a/webviz_config/templates/copy_data_template.py.jinja2
+++ b/webviz_config/templates/copy_data_template.py.jinja2
@@ -14,7 +14,7 @@ from webviz_config.themes import installed_themes
 from webviz_config.webviz_store import WEBVIZ_STORAGE
 from webviz_config.webviz_assets import WEBVIZ_ASSETS
 from webviz_config.common_cache import CACHE
-from webviz_config.webviz_instance_info import WebvizRunMode, WebvizInstanceInfo
+from webviz_config.webviz_instance_info import WebvizRunMode, WEBVIZ_INSTANCE_INFO
 from webviz_config.webviz_factory_registry import WEBVIZ_FACTORY_REGISTRY
 from webviz_config.utils import deprecate_webviz_settings_attribute_in_dash_app
 
@@ -55,13 +55,15 @@ storage_folder = Path(__file__).resolve().parent / "resources" / "webviz_storage
 
 WEBVIZ_STORAGE.storage_folder = storage_folder
 
-app_instance_info = WebvizInstanceInfo(WebvizRunMode.BUILDING_PORTABLE, storage_folder)
-{% if internal_factory_settings is defined %}
-factory_settings_dict = {{ internal_factory_settings }}
-{% else %}
-factory_settings_dict = None
-{% endif %}
-WEBVIZ_FACTORY_REGISTRY.initialize(app_instance_info, factory_settings_dict)
+WEBVIZ_INSTANCE_INFO.initialize(
+    dash_app=app,
+    run_mode=WebvizRunMode.BUILDING_PORTABLE,
+    theme=theme,
+    storage_folder=storage_folder
+)
+
+WEBVIZ_FACTORY_REGISTRY.initialize({{ internal_factory_settings if internal_factory_settings is defined else None }})
+
 
 # The lines below can be simplified when assignment
 # expressions become available in Python 3.8
diff --git a/webviz_config/templates/webviz_template.py.jinja2 b/webviz_config/templates/webviz_template.py.jinja2
index b6d2eb2..69449dd 100644
--- a/webviz_config/templates/webviz_template.py.jinja2
+++ b/webviz_config/templates/webviz_template.py.jinja2
@@ -21,7 +21,7 @@ from webviz_config.themes import installed_themes
 from webviz_config.common_cache import CACHE
 from webviz_config.webviz_store import WEBVIZ_STORAGE
 from webviz_config.webviz_assets import WEBVIZ_ASSETS
-from webviz_config.webviz_instance_info import WebvizRunMode, WebvizInstanceInfo
+from webviz_config.webviz_instance_info import WebvizRunMode, WEBVIZ_INSTANCE_INFO
 from webviz_config.webviz_factory_registry import WEBVIZ_FACTORY_REGISTRY
 from webviz_config.utils import deprecate_webviz_settings_attribute_in_dash_app
 
@@ -98,8 +98,14 @@ WEBVIZ_STORAGE.storage_folder = storage_folder
 WEBVIZ_ASSETS.portable = {{ portable }}
 
 run_mode = WebvizRunMode.PORTABLE if {{portable}} else WebvizRunMode.NON_PORTABLE
-app_instance_info = WebvizInstanceInfo(run_mode, storage_folder)
-WEBVIZ_FACTORY_REGISTRY.initialize(app_instance_info, {{ internal_factory_settings if internal_factory_settings is defined else None }})
+WEBVIZ_INSTANCE_INFO.initialize(
+    dash_app=app,
+    run_mode=run_mode,
+    theme=theme,
+    storage_folder=storage_folder
+)
+
+WEBVIZ_FACTORY_REGISTRY.initialize({{ internal_factory_settings if internal_factory_settings is defined else None }})
 
 use_oauth2 = False
 
diff --git a/webviz_config/webviz_factory_registry.py b/webviz_config/webviz_factory_registry.py
index 9bae1ad..29d2649 100644
--- a/webviz_config/webviz_factory_registry.py
+++ b/webviz_config/webviz_factory_registry.py
@@ -1,8 +1,8 @@
-from typing import Dict, TypeVar, Type, Optional, Any
+from typing import Any, Dict, Optional, Type, TypeVar
+import warnings
 
 from .webviz_factory import WebvizFactory
-from .webviz_instance_info import WebvizInstanceInfo
-
+from .webviz_instance_info import WebvizInstanceInfo, WEBVIZ_INSTANCE_INFO
 
 # pylint: disable=invalid-name
 T = TypeVar("T", bound=WebvizFactory)
@@ -23,13 +23,11 @@ class WebvizFactoryRegistry:
 
     def __init__(self) -> None:
         self._is_initialized: bool = False
-        self._app_instance_info: Optional[WebvizInstanceInfo] = None
         self._factory_settings_dict: Dict[str, Any] = {}
         self._factories: Dict[Type, WebvizFactory] = {}
 
     def initialize(
         self,
-        app_instance_info: WebvizInstanceInfo,
         factory_settings_dict: Optional[Dict[str, Any]],
     ) -> None:
         """Does the actual initialization of the object instance.
@@ -38,10 +36,6 @@ class WebvizFactoryRegistry:
         if self._is_initialized:
             raise RuntimeError("Registry already initialized")
 
-        if not isinstance(app_instance_info, WebvizInstanceInfo):
-            raise TypeError("app_instance_info must be of type WebvizInstanceInfo")
-        self._app_instance_info = app_instance_info
-
         if factory_settings_dict:
             if not isinstance(factory_settings_dict, dict):
                 raise TypeError("factory_settings_dict must be of type dict")
@@ -87,10 +81,13 @@ class WebvizFactoryRegistry:
 
     @property
     def app_instance_info(self) -> WebvizInstanceInfo:
-        if not self._is_initialized or self._app_instance_info is None:
-            raise RuntimeError("Factory registry is not initialized")
+        warnings.warn(
+            "Accessing WebvizInstanceInfo through WebvizFactoryRegistry has been deprecated, "
+            "please use global WEBVIZ_INSTANCE_INFO instead",
+            DeprecationWarning,
+        )
 
-        return self._app_instance_info
+        return WEBVIZ_INSTANCE_INFO
 
 
 WEBVIZ_FACTORY_REGISTRY = WebvizFactoryRegistry()
diff --git a/webviz_config/webviz_instance_info.py b/webviz_config/webviz_instance_info.py
index 069268b..a27c474 100644
--- a/webviz_config/webviz_instance_info.py
+++ b/webviz_config/webviz_instance_info.py
@@ -1,5 +1,11 @@
+import copy
 from enum import Enum
 from pathlib import Path
+from typing import Optional
+
+from dash import Dash
+
+from ._theme_class import WebvizConfigTheme
 
 
 class WebvizRunMode(Enum):
@@ -9,16 +15,90 @@ class WebvizRunMode(Enum):
 
 
 class WebvizInstanceInfo:
-    """Class containing global info regarding the running webviz app instance"""
+    """Contains global information regarding the running webviz app instance, exposed
+    globally through WEBVIZ_INSTANCE_INFO.
+
+    Note that this class utilizes a two-stage initialization approach which renders it
+    useless until the initialize() method has been called. It is assumed that
+    initialization will be done early during application execution, typically as part
+    of the webviz_app.py / jinja2 template.
+    """
+
+    __slots__ = (
+        "_is_initialized",
+        "_dash_app",
+        "_run_mode",
+        "_theme",
+        "_storage_folder",
+    )
+
+    def __init__(self) -> None:
+        self._is_initialized: bool = False
+        self._dash_app: Optional[Dash] = None
+        self._run_mode: Optional[WebvizRunMode] = None
+        self._theme: Optional[WebvizConfigTheme] = None
+        self._storage_folder: Optional[Path] = None
 
-    def __init__(self, run_mode: WebvizRunMode, storage_folder: Path):
+    def initialize(
+        self,
+        dash_app: Dash,
+        run_mode: WebvizRunMode,
+        theme: WebvizConfigTheme,
+        storage_folder: Path,
+    ) -> None:
+        """This function is responsible for the actual initialization of the object instance.
+        None of the access methods in this class are valid until initialize() has been called.
+        This function will be called as part of the webviz_app.py / jinja2 template.
+        """
+        if self._is_initialized:
+            raise RuntimeError("Registry already initialized")
+
+        if not isinstance(dash_app, Dash):
+            raise TypeError("dash_app must be of type Dash")
+        self._dash_app = dash_app
+
+        if not isinstance(run_mode, WebvizRunMode):
+            raise TypeError("run_mode must be of type WebvizRunMode")
         self._run_mode = run_mode
+
+        if not isinstance(theme, WebvizConfigTheme):
+            raise TypeError("theme must be of type WebvizConfigTheme")
+        self._theme = theme
+
+        if not isinstance(storage_folder, Path):
+            raise TypeError("storage_folder must be of type Path")
         self._storage_folder = storage_folder
 
+        self._is_initialized = True
+
+    @property
+    def dash_app(self) -> Dash:
+        if not self._is_initialized or self._dash_app is None:
+            raise RuntimeError("WebvizInstanceInfo is not yet initialized")
+
+        return self._dash_app
+
     @property
     def run_mode(self) -> WebvizRunMode:
+        if not self._is_initialized or self._run_mode is None:
+            raise RuntimeError("WebvizInstanceInfo is not yet initialized")
+
         return self._run_mode
 
+    @property
+    def theme(self) -> WebvizConfigTheme:
+        if not self._is_initialized or self._theme is None:
+            raise RuntimeError("WebvizInstanceInfo is not yet initialized")
+
+        # The theme class is mutable, so return a copy
+        return copy.deepcopy(self._theme)
+
     @property
     def storage_folder(self) -> Path:
+        if not self._is_initialized or self._storage_folder is None:
+            raise RuntimeError("WebvizInstanceInfo is not yet initialized")
+
         return self._storage_folder
+
+
+WEBVIZ_INSTANCE_INFO = WebvizInstanceInfo()


Test patch:
diff --git a/tests/unit_tests/test_webviz_factory_registry.py b/tests/unit_tests/test_webviz_factory_registry.py
index a368c05..25fa8d2 100644
--- a/tests/unit_tests/test_webviz_factory_registry.py
+++ b/tests/unit_tests/test_webviz_factory_registry.py
@@ -1,11 +1,10 @@
 from typing import Optional
-from pathlib import Path
+
 import pytest
 
 from webviz_config.webviz_factory import WebvizFactory
 from webviz_config.webviz_factory_registry import WebvizFactoryRegistry
-from webviz_config.webviz_instance_info import WebvizInstanceInfo
-from webviz_config.webviz_instance_info import WebvizRunMode
+
 
 # pylint: disable=no-self-use
 class FactoryA(WebvizFactory):
@@ -29,18 +28,14 @@ class UnrelatedFactory:
 
 
 def create_initialized_registry() -> WebvizFactoryRegistry:
-    instance_info = WebvizInstanceInfo(WebvizRunMode.NON_PORTABLE, Path("somePath"))
     registry = WebvizFactoryRegistry()
-    registry.initialize(instance_info, {"MyFactory": "someValue"})
+    registry.initialize({"MyFactory": "someValue"})
     return registry
 
 
 def test_uninitialized_access() -> None:
     registry = WebvizFactoryRegistry()
 
-    with pytest.raises(RuntimeError):
-        _inst_info = registry.app_instance_info
-
     with pytest.raises(RuntimeError):
         _settings = registry.all_factory_settings
 
@@ -48,9 +43,6 @@ def test_uninitialized_access() -> None:
 def test_initialization_and_basic_access() -> None:
     registry = create_initialized_registry()
 
-    inst_info = registry.app_instance_info
-    assert inst_info.run_mode == WebvizRunMode.NON_PORTABLE
-
     settings = registry.all_factory_settings
     assert "MyFactory" in settings
 
@@ -59,12 +51,11 @@ def test_initialization_and_basic_access() -> None:
 
 
 def test_multiple_initializations() -> None:
-    instance_info = WebvizInstanceInfo(WebvizRunMode.NON_PORTABLE, Path("somePath"))
     registry = WebvizFactoryRegistry()
-    registry.initialize(instance_info, {"MyFactory": "someValue"})
+    registry.initialize({"MyFactory": "someValue"})
 
     with pytest.raises(RuntimeError):
-        registry.initialize(instance_info, {"MyFactory": "someValue"})
+        registry.initialize({"MyFactory": "someValue"})
 
 
 def test_set_get_factory() -> None:
diff --git a/tests/unit_tests/test_webviz_instance_info.py b/tests/unit_tests/test_webviz_instance_info.py
new file mode 100644
index 0000000..3a5b0e5
--- /dev/null
+++ b/tests/unit_tests/test_webviz_instance_info.py
@@ -0,0 +1,106 @@
+from pathlib import Path
+from typing import cast
+
+import pytest
+from dash import Dash
+
+from webviz_config import WebvizConfigTheme
+from webviz_config.webviz_instance_info import WebvizInstanceInfo, WebvizRunMode
+
+
+def test_construction_and_basic_access() -> None:
+    my_dash_app = Dash("dummyAppName")
+    my_run_mode = WebvizRunMode.NON_PORTABLE
+    my_theme = WebvizConfigTheme("dummyThemeName")
+    my_storage_folder = Path("dummyPath")
+
+    instance_info = WebvizInstanceInfo()
+    instance_info.initialize(
+        dash_app=my_dash_app,
+        run_mode=my_run_mode,
+        theme=my_theme,
+        storage_folder=my_storage_folder,
+    )
+
+    assert instance_info.dash_app is my_dash_app
+    assert instance_info.run_mode is WebvizRunMode.NON_PORTABLE
+    assert instance_info.theme.__dict__ == my_theme.__dict__
+    assert instance_info.storage_folder == Path("dummyPath")
+
+
+def test_that_construction_with_invalid_types_throw() -> None:
+    dash_app = Dash("dummyAppName")
+    run_mode = WebvizRunMode.NON_PORTABLE
+    theme = WebvizConfigTheme("dummyThemeName")
+    storage_folder = Path("dummyPath")
+
+    with pytest.raises(TypeError):
+        WebvizInstanceInfo().initialize(
+            dash_app=cast(Dash, None),
+            run_mode=run_mode,
+            theme=theme,
+            storage_folder=storage_folder,
+        )
+    with pytest.raises(TypeError):
+        WebvizInstanceInfo().initialize(
+            dash_app=dash_app,
+            run_mode=cast(WebvizRunMode, None),
+            theme=theme,
+            storage_folder=storage_folder,
+        )
+    with pytest.raises(TypeError):
+        WebvizInstanceInfo().initialize(
+            dash_app=dash_app,
+            run_mode=run_mode,
+            theme=cast(WebvizConfigTheme, None),
+            storage_folder=storage_folder,
+        )
+    with pytest.raises(TypeError):
+        WebvizInstanceInfo().initialize(
+            dash_app=dash_app,
+            run_mode=run_mode,
+            theme=theme,
+            storage_folder=cast(Path, None),
+        )
+
+
+def test_immutability() -> None:
+    my_dash_app = Dash("dummyAppName")
+    my_run_mode = WebvizRunMode.NON_PORTABLE
+    my_theme = WebvizConfigTheme("dummyThemeName")
+    my_storage_folder = Path("dummyPath")
+
+    instance_info = WebvizInstanceInfo()
+    instance_info.initialize(
+        dash_app=my_dash_app,
+        run_mode=my_run_mode,
+        theme=my_theme,
+        storage_folder=my_storage_folder,
+    )
+
+    # Ony allowed to initialize once
+    with pytest.raises(RuntimeError):
+        instance_info.initialize(
+            dash_app=my_dash_app,
+            run_mode=my_run_mode,
+            theme=my_theme,
+            storage_folder=my_storage_folder,
+        )
+
+    # This is ok and necessary since we want to share the actual dash app
+    assert instance_info.dash_app is my_dash_app
+
+    # This two are also ok since integer enums and Path themselves is immutable
+    assert instance_info.run_mode is my_run_mode
+    assert instance_info.storage_folder is my_storage_folder
+
+    returned_theme = instance_info.theme
+    assert returned_theme is not my_theme
+    assert isinstance(returned_theme, WebvizConfigTheme)
+    assert returned_theme.__dict__ == my_theme.__dict__
+    my_theme.theme_name = "MODIFIED"
+    assert returned_theme.__dict__ != my_theme.__dict__
+
+    with pytest.raises(AttributeError):
+        # pylint: disable=assigning-non-slot
+        instance_info.some_new_attribute = "myAttributeValue"  # type: ignore[attr-defined]