├── tests ├── __init__.py ├── unit_tests │ ├── __init__.py │ ├── test_webviz_settings.py │ ├── test_webviz_instance_info.py │ └── test_webviz_factory_registry.py ├── data │ ├── example_data.csv │ └── basic_example.yaml ├── conftest.py ├── test_schema.py ├── test_plugin_metadata.py ├── test_syntax_highlighter.py ├── test_example_wlf_plugin.py ├── test_example_plugin.py ├── test_portable.py ├── test_data_table.py ├── test_docker_setup.py ├── test_plugin_init.py ├── test_docstring.py └── test_table_plotter.py ├── webviz_config ├── _docs │ ├── __init__.py │ ├── static │ │ ├── webviz-doc.css │ │ ├── index.html │ │ └── README.md │ └── open_docs.py ├── generic_plugins │ ├── __init__.py │ ├── _example_wlf_plugin │ │ ├── _views │ │ │ ├── __init__.py │ │ │ ├── _table │ │ │ │ └── __init__.py │ │ │ └── _plot │ │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── _shared_settings │ │ │ ├── __init__.py │ │ │ └── _shared_settings.py │ │ ├── _shared_view_elements │ │ │ ├── __init__.py │ │ │ └── _text_view_element.py │ │ └── _plugin.py │ ├── _example_assets.py │ ├── _example_tour.py │ ├── _example_plugin.py │ ├── _example_data_download.py │ ├── _embed_pdf.py │ ├── _syntax_highlighter.py │ ├── _example_portable.py │ ├── _pivot_table.py │ ├── _banner_image.py │ └── _data_table.py ├── _deployment │ ├── __init__.py │ ├── radix_configuration.py │ ├── azure_configuration.py │ ├── interactive_terminal.py │ ├── radix_cli.py │ └── github_cli.py ├── static │ ├── .dockerignore │ ├── .gitignore │ ├── assets │ │ ├── webviz_config.css │ │ └── webviz_layout.css │ └── README.md ├── _dockerize │ ├── __init__.py │ ├── _pip_git_url.py │ └── _create_docker_setup.py ├── py.typed ├── __main__.py ├── utils │ ├── terminal_colors.py │ ├── _str_enum.py │ ├── __init__.py │ ├── _dash_component_utils.py │ ├── _deprecate_webviz_settings_attribute_in_dash_app.py │ ├── _silence_flask_startup.py │ ├── _available_port.py │ ├── _localhost_open_browser.py │ └── _callback_typecheck.py ├── webviz_plugin_subclasses │ ├── __init__.py │ ├── _layout_base_abc.py │ ├── _settings_group_abc.py │ └── _layout_unique_id.py ├── testing │ ├── _webviz_ids.py │ ├── __init__.py │ ├── _plugin.py │ └── _composite.py ├── templates │ ├── sidebar.md.jinja2 │ ├── README.md.jinja2 │ ├── plugin_deprecations.md.jinja2 │ ├── argument_deprecations.md.jinja2 │ ├── webviz-doc.js.jinja2 │ ├── feedback.md.jinja2 │ ├── plugin_docs.md.jinja2 │ ├── Dockerfile.jinja2 │ ├── radixconfig.yaml.jinja2 │ └── copy_data_template.py.jinja2 ├── _user_data_dir.py ├── _is_reload_process.py ├── themes │ ├── _default_theme.py │ └── __init__.py ├── __init__.py ├── webviz_factory.py ├── plugins │ ├── __init__.py │ └── _utils.py ├── common_cache.py ├── _webviz_settings_class.py ├── _user_preferences.py ├── _write_script.py ├── _shared_settings_subscriptions.py ├── _deprecation_store.py ├── deprecation_decorators.py ├── _localhost_token.py ├── webviz_instance_info.py ├── webviz_factory_registry.py ├── webviz_assets.py └── _build_webviz.py ├── pytest.ini ├── bandit.yml ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md └── workflows │ └── webviz-config.yml ├── examples ├── example_stylesheet.css ├── example_javascript.js ├── example.pdf ├── example_banner.png ├── example_data.csv ├── demo_portable.yaml ├── basic_example_wlf.yaml ├── example-markdown.md ├── basic_example.yaml └── basic_example_advanced_menu.yaml ├── assets ├── high-level-overview.png ├── before-after-settings.png ├── webviz-layout-overview.png ├── before-after-tabs-views.png └── before-after-plugin-actions.png ├── .gitignore ├── .pylintrc ├── mypy.ini ├── SECURITY.md ├── package.json ├── LICENSE ├── CODE_OF_CONDUCT.md └── setup.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webviz_config/_docs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests/ 3 | -------------------------------------------------------------------------------- /bandit.yml: -------------------------------------------------------------------------------- 1 | skips: ['B101', 'B113', 'B404', 'B603', 'B607'] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_wlf_plugin/_views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/example_stylesheet.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: green; 3 | } 4 | -------------------------------------------------------------------------------- /webviz_config/_deployment/__init__.py: -------------------------------------------------------------------------------- 1 | from .radix import main_radix_deployment 2 | -------------------------------------------------------------------------------- /webviz_config/static/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /webviz_config/static/.gitignore: -------------------------------------------------------------------------------- 1 | resources/ 2 | __pycache__/ 3 | *.pyc 4 | *~ 5 | -------------------------------------------------------------------------------- /examples/example_javascript.js: -------------------------------------------------------------------------------- 1 | alert("This loaded JavaScript file comes from a plugin."); 2 | -------------------------------------------------------------------------------- /examples/example.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/webviz-config/HEAD/examples/example.pdf -------------------------------------------------------------------------------- /webviz_config/_dockerize/__init__.py: -------------------------------------------------------------------------------- 1 | from ._create_docker_setup import create_docker_setup 2 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_wlf_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from ._plugin import ExampleWlfPlugin 2 | -------------------------------------------------------------------------------- /webviz_config/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file to indicate that this package supports typing, ref PEP 561 2 | -------------------------------------------------------------------------------- /examples/example_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/webviz-config/HEAD/examples/example_banner.png -------------------------------------------------------------------------------- /assets/high-level-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/webviz-config/HEAD/assets/high-level-overview.png -------------------------------------------------------------------------------- /assets/before-after-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/webviz-config/HEAD/assets/before-after-settings.png -------------------------------------------------------------------------------- /assets/webviz-layout-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/webviz-config/HEAD/assets/webviz-layout-overview.png -------------------------------------------------------------------------------- /assets/before-after-tabs-views.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/webviz-config/HEAD/assets/before-after-tabs-views.png -------------------------------------------------------------------------------- /webviz_config/__main__.py: -------------------------------------------------------------------------------- 1 | if __name__ == "__main__": 2 | from webviz_config.command_line import main 3 | 4 | main() 5 | -------------------------------------------------------------------------------- /assets/before-after-plugin-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equinor/webviz-config/HEAD/assets/before-after-plugin-actions.png -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_wlf_plugin/_shared_settings/__init__.py: -------------------------------------------------------------------------------- 1 | from ._shared_settings import SharedSettingsGroup 2 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_wlf_plugin/_shared_view_elements/__init__.py: -------------------------------------------------------------------------------- 1 | from ._text_view_element import TextViewElement 2 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_wlf_plugin/_views/_table/__init__.py: -------------------------------------------------------------------------------- 1 | from ._view import TableView, TableViewElement, TableViewSettingsGroup 2 | -------------------------------------------------------------------------------- /examples/example_data.csv: -------------------------------------------------------------------------------- 1 | Well,Segment,Average permeability (D),Initial reservoir pressure (bar) 2 | A-1H,A,1.4,380 3 | A-2H,A,2.2,350 4 | A-3H,A,0.1,252 5 | B-1H,B,0.8,248 6 | C-1H,C,1.2,538 7 | -------------------------------------------------------------------------------- /tests/data/example_data.csv: -------------------------------------------------------------------------------- 1 | Well,Segment,Average permeability (D),Initial reservoir pressure (bar) 2 | A-1H,A,1.4,380 3 | A-2H,A,2.2,350 4 | A-3H,A,0.1,252 5 | B-1H,B,0.8,248 6 | C-1H,C,1.2,538 7 | -------------------------------------------------------------------------------- /webviz_config/utils/terminal_colors.py: -------------------------------------------------------------------------------- 1 | RED = "\x1b[37;41m" 2 | GREEN = "\x1b[37;42m" 3 | YELLOW = "\x1b[37;43m" 4 | BLUE = "\x1b[37;44m" 5 | PURPLE = "\x1b[37;45m" 6 | 7 | BOLD = "\x1b[1m" 8 | END = "\x1b[0m" 9 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_wlf_plugin/_views/_plot/__init__.py: -------------------------------------------------------------------------------- 1 | from ._view import ( 2 | PlotView, 3 | PlotViewElement, 4 | PlotViewSettingsGroup, 5 | PlotViewElementSettingsGroup, 6 | Kindness, 7 | ) 8 | -------------------------------------------------------------------------------- /webviz_config/webviz_plugin_subclasses/__init__.py: -------------------------------------------------------------------------------- 1 | from ._settings_group_abc import SettingsGroupABC 2 | from ._views import ViewABC, ViewElementABC, ViewLayoutElement, LayoutElementType 3 | from ._layout_base_abc import LayoutBaseABC 4 | from ._layout_unique_id import LayoutUniqueId 5 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Sets the window size of the browser (crucial in --headless mode). 2 | from selenium.webdriver.chrome.options import Options 3 | 4 | 5 | def pytest_setup_options(): 6 | options = Options() 7 | options.add_argument("--window-size=1920,1080") 8 | return options 9 | -------------------------------------------------------------------------------- /webviz_config/utils/_str_enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class StrEnum(str, Enum): 5 | def __repr__(self) -> str: 6 | return self._value_ # pylint: disable=no-member 7 | 8 | def __str__(self) -> str: 9 | return self._value_ # pylint: disable=no-member 10 | -------------------------------------------------------------------------------- /webviz_config/testing/_webviz_ids.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class WebvizIds(str, Enum): 5 | LAYOUT_WRAPPER = "layoutWrapper" 6 | CONTENT_MANAGER = "webviz-content-manager" 7 | SETTINGS_DRAWER = "settings-drawer" 8 | PLUGINS_WRAPPER = "plugins-wrapper" 9 | SETTINGS_DRAWER_TOGGLE = ".WebvizSettingsDrawer__Toggle" 10 | -------------------------------------------------------------------------------- /webviz_config/templates/sidebar.md.jinja2: -------------------------------------------------------------------------------- 1 | * [Introduction](/) 2 | {%- for package in packages %} 3 | * [{{package}} package]({{package}}.md) 4 | {%- endfor %} 5 | {% if deprecated_plugins %} 6 | * [Deprecated plugins](plugin_deprecations.md) 7 | {% endif %} 8 | {% if deprecated_arguments %} 9 | * [Deprecated arguments](argument_deprecations.md) 10 | {% endif %} 11 | -------------------------------------------------------------------------------- /webviz_config/templates/README.md.jinja2: -------------------------------------------------------------------------------- 1 | # Plugin project {{ dist_name }} 2 | 3 | ?> :bookmark: This documentation is valid for version `{{ package_doc["dist_version"] }}` of `{{ dist_name}}`. 4 | 5 | {% if package_doc["doc"] is not none %} 6 | {{ package_doc["doc"] }} 7 | {% endif %} 8 | 9 | --- 10 | {% for plugin in package_doc["plugins"] %} 11 | {% include "plugin_docs.md.jinja2" %} 12 | {% endfor %} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .eggs 2 | *.egg-info 3 | *~ 4 | .mypy_cache 5 | __pycache__ 6 | node_modules 7 | venv 8 | .vscode 9 | .pytest_cache 10 | *.pyc 11 | .DS_Store 12 | dist 13 | build 14 | webviz_config/_docs/static/fonts 15 | webviz_config/_docs/static/INTRODUCTION.md 16 | webviz_config/_docs/static/*.js 17 | webviz_config/_docs/static/*.css 18 | !webviz_config/_docs/static/webviz-doc.js 19 | !webviz_config/_docs/static/webviz-doc.css 20 | -------------------------------------------------------------------------------- /webviz_config/_user_data_dir.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | 5 | def user_data_dir() -> Path: 6 | """Returns platform specific path to store user application data""" 7 | 8 | if sys.platform == "win32": 9 | return Path.home() / "AppData" / "webviz" 10 | 11 | if sys.platform == "darwin": 12 | return Path.home() / "Library" / "Application Support" / "webviz" 13 | 14 | return Path.home() / ".local" / "share" / "webviz" 15 | -------------------------------------------------------------------------------- /webviz_config/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from ._localhost_open_browser import LocalhostOpenBrowser 2 | from ._available_port import get_available_port 3 | from ._silence_flask_startup import silence_flask_startup 4 | from ._dash_component_utils import calculate_slider_step 5 | from ._deprecate_webviz_settings_attribute_in_dash_app import ( 6 | deprecate_webviz_settings_attribute_in_dash_app, 7 | ) 8 | from ._str_enum import StrEnum 9 | from ._callback_typecheck import callback_typecheck, ConversionError 10 | -------------------------------------------------------------------------------- /examples/demo_portable.yaml: -------------------------------------------------------------------------------- 1 | # This file demonstrates the most basic usage of webviz in a FMU setting 2 | # The configuration files uses YAML (https://en.wikipedia.org/wiki/YAML). 3 | 4 | title: Reek Webviz Demonstration 5 | 6 | pages: 7 | 8 | - title: Front page 9 | content: 10 | - ExamplePortable: 11 | some_number: 42 12 | - ExampleAssets: 13 | picture_path: ./example_banner.png 14 | css_path: ./example_stylesheet.css 15 | js_path: ./example_javascript.js 16 | -------------------------------------------------------------------------------- /webviz_config/templates/plugin_deprecations.md.jinja2: -------------------------------------------------------------------------------- 1 | {%- for dist_name, package_doc in packages.items() %} 2 | ### {{ dist_name }} package 3 | 4 | ?> :bookmark: This documentation is valid for version `{{ package_doc["dist_version"] }}` of `{{ dist_name}}`. 5 | 6 | {% if package_doc["doc"] is not none %} 7 | {{ package_doc["doc"] }} 8 | {% endif %} 9 | 10 | --- 11 | {% for plugin in package_doc["plugins"] if plugin["deprecated"]%} 12 | {% include "plugin_docs.md.jinja2" %} 13 | {% endfor %} 14 | 15 | 16 | {% endfor %} 17 | -------------------------------------------------------------------------------- /webviz_config/templates/argument_deprecations.md.jinja2: -------------------------------------------------------------------------------- 1 | {%- for dist_name, package_doc in packages.items() %} 2 | ### {{ dist_name }} package 3 | 4 | ?> :bookmark: This documentation is valid for version `{{ package_doc["dist_version"] }}` of `{{ dist_name}}`. 5 | 6 | {% if package_doc["doc"] is not none %} 7 | {{ package_doc["doc"] }} 8 | {% endif %} 9 | 10 | --- 11 | {% for plugin in package_doc["plugins"] if plugin["has_deprecated_arguments"]%} 12 | {% include "plugin_docs.md.jinja2" %} 13 | {% endfor %} 14 | 15 | {% endfor %} 16 | -------------------------------------------------------------------------------- /webviz_config/testing/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Generator 2 | 3 | # pylint: disable=too-few-public-methods 4 | class MissingWebvizTesting: 5 | def __init__(self, **kwargs: Any) -> None: 6 | raise RuntimeError( 7 | "webviz_config[tests] was not installed. " 8 | "Please install to use the webviz testing fixtures." 9 | ) 10 | 11 | 12 | try: 13 | from ._composite import WebvizComposite 14 | 15 | except ImportError: 16 | WebvizComposite = MissingWebvizTesting # type: ignore 17 | -------------------------------------------------------------------------------- /webviz_config/_is_reload_process.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def is_reload_process() -> bool: 5 | """Within the flask reload machinery, it is not straight forward to know 6 | if the code is run as the main process (i.e. the process the user directly 7 | started), or if the code is a "hot reload process" (see Flask 8 | documentation). 9 | 10 | This utility function will use the fact that the reload process 11 | sets an environment variable WERKZEUG_RUN_MAIN. 12 | """ 13 | 14 | return os.environ.get("WERKZEUG_RUN_MAIN") == "true" 15 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # As a temporary workaround for https://github.com/PyCQA/pylint/issues/4577 4 | init-hook = "import astroid; astroid.context.InferenceContext.max_inferred = 500" 5 | 6 | [MESSAGES CONTROL] 7 | 8 | disable = bad-continuation, missing-docstring, duplicate-code, unspecified-encoding 9 | enable = useless-suppression 10 | 11 | [DESIGN] 12 | 13 | max-args = 7 14 | max-attributes = 12 15 | min-public-methods = 1 16 | 17 | [BASIC] 18 | 19 | # Variable names which should always be accepted 20 | good-names = i, 21 | df, 22 | _ 23 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | *Insert a description of your pull request (PR) here, and check off the boxes below when they are done.* 2 | 3 | --- 4 | 5 | ### Contributor checklist 6 | 7 | - [ ] :tada: This PR closes #ISSUE_NUMBER. 8 | - [ ] :scroll: I have broken down my PR into the following tasks: 9 | - [ ] Task 1 10 | - [ ] Task 2 11 | - [ ] :robot: I have added tests, or extended existing tests, to cover any new features or bugs fixed in this PR. 12 | - [ ] :book: I have considered adding a new entry in `CHANGELOG.md`, and added it if should be communicated there. 13 | -------------------------------------------------------------------------------- /webviz_config/_docs/static/webviz-doc.css: -------------------------------------------------------------------------------- 1 | .app-name-link > img { 2 | width: 120px; 3 | margin-bottom: 10px; 4 | } 5 | 6 | button.docsify-copy-code-button { 7 | border-radius: 5px; 8 | } 9 | 10 | button { 11 | font-family: inherit; 12 | } 13 | 14 | .plugin-doc { 15 | background-color: white; 16 | margin-top: 20px; 17 | margin-bottom: 20px; 18 | padding-left: 10px; 19 | padding-right: 10px; 20 | border: 1px solid rgb(240, 240, 240); 21 | border-radius: 3px; 22 | } 23 | 24 | .plugin-doc:hover { 25 | box-shadow: 5px 5px 10px 3px rgba(230, 230, 230); 26 | } 27 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | disallow_untyped_defs = True 3 | show_error_codes = True 4 | warn_unused_ignores = True 5 | 6 | # Temporarily allow implicit optional until pydantic handles JSON schema generation. 7 | # mypy >= 0.990 has changed its default to no_implicit_optional=True. 8 | # When removed - utilize the following make the code base implicit optional 9 | # type hints PEP 484 compliant: 10 | # https://github.com/hauntsaninja/no_implicit_optional 11 | implicit_optional = True 12 | 13 | # https://github.com/Azure/azure-sdk-for-python/issues/20771 14 | [mypy-azure.storage.blob.*] 15 | ignore_errors = True 16 | -------------------------------------------------------------------------------- /webviz_config/themes/_default_theme.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import pathlib 3 | 4 | from plotly.io import templates 5 | 6 | from webviz_config import WebvizConfigTheme 7 | 8 | default_theme = WebvizConfigTheme(theme_name="default") 9 | 10 | default_theme.assets = glob.glob( 11 | str(pathlib.Path(__file__).resolve().parent / "default_assets" / "*") 12 | ) 13 | default_theme.assets.append( 14 | str( 15 | pathlib.Path(__file__).resolve().parent.parent 16 | / "_docs" 17 | / "static" 18 | / "webviz-logo.svg" 19 | ) 20 | ) 21 | 22 | default_theme.plotly_theme = templates["plotly"].to_plotly_json() 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /webviz_config/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version, PackageNotFoundError 2 | 3 | from ._theme_class import WebvizConfigTheme 4 | from ._webviz_settings_class import WebvizSettings 5 | from ._localhost_token import LocalhostToken 6 | from ._is_reload_process import is_reload_process 7 | from ._plugin_abc import WebvizPluginABC, EncodedFile, ZipFileMember 8 | from ._shared_settings_subscriptions import SHARED_SETTINGS_SUBSCRIPTIONS 9 | from .webviz_instance_info import WEBVIZ_INSTANCE_INFO 10 | from ._oauth2 import Oauth2 11 | 12 | try: 13 | __version__ = version("webviz-config") 14 | except PackageNotFoundError: 15 | # package is not installed 16 | pass 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **How to reproduce** 13 | Steps to reproduce the behavior: 14 | 1. Go to '...' 15 | 2. Click on '....' 16 | 3. Scroll down to '....' 17 | 4. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Additional context** 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /webviz_config/templates/webviz-doc.js.jinja2: -------------------------------------------------------------------------------- 1 | window.$docsify = { 2 | logo: "./webviz-logo.svg", 3 | homepage: "INTRODUCTION.md", 4 | name: "Webviz", 5 | loadSidebar: "sidebar.md", 6 | subMaxLevel: 4, 7 | copyCode: { 8 | buttonText : "Copy", 9 | }, 10 | tabs: { 11 | sync: false, 12 | theme: "material", 13 | tabHeadings: false 14 | }, 15 | search: { 16 | paths: {{ paths | tojson }}, 17 | depth: 6, 18 | hideOtherSidebarContent: true, 19 | maxAge: 30e3 // default cache maxage one day (8.64e7 ms) - old cached content can be confusing if user changes installed webviz plugins 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/basic_example_wlf.yaml: -------------------------------------------------------------------------------- 1 | # This file demonstrates the most basic usage of webviz in a FMU setting 2 | # The configuration files uses YAML (https://en.wikipedia.org/wiki/YAML). 3 | 4 | title: Reek Webviz Demonstration 5 | 6 | options: 7 | menu: 8 | show_logo: True 9 | bar_position: left 10 | drawer_position: left 11 | initially_pinned: True 12 | 13 | layout: 14 | - section: Section 15 | content: 16 | - page: Front page 17 | icon: home 18 | content: 19 | - ExampleWlfPlugin: 20 | title: Example Plugin 21 | contact_person: 22 | email: kari.nordmann@equinor.com 23 | name: Kari Nordmann 24 | phone: "000000000" 25 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_assets.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from dash import html 4 | 5 | from .. import WebvizPluginABC 6 | from ..webviz_assets import WEBVIZ_ASSETS 7 | 8 | 9 | class ExampleAssets(WebvizPluginABC): 10 | def __init__(self, picture_path: Path, css_path: Path = None, js_path: Path = None): 11 | super().__init__() 12 | 13 | self.asset_url = WEBVIZ_ASSETS.add(picture_path) 14 | 15 | if css_path is not None: 16 | WEBVIZ_ASSETS.add(css_path) 17 | 18 | if js_path is not None: 19 | WEBVIZ_ASSETS.add(js_path) 20 | 21 | @property 22 | def layout(self) -> html.Img: 23 | return html.Img(src=self.asset_url) 24 | -------------------------------------------------------------------------------- /webviz_config/static/assets/webviz_config.css: -------------------------------------------------------------------------------- 1 | ._banner_image { 2 | 3 | display: flex; 4 | justify-content: center; 5 | font-size: 300%; 6 | 7 | text-shadow: 0.05em 0.05em 0 rgba(0, 0, 0, 0.7); 8 | 9 | width: 100%; 10 | 11 | margin-bottom: 25px; 12 | 13 | background-size: cover; 14 | background-position: center; 15 | 16 | } 17 | 18 | ._markdown_image { 19 | 20 | display: block; 21 | margin: auto; 22 | max-width: 90%; 23 | max-height: 90vw; 24 | margin-top: 16px; 25 | margin-bottom: 16px; 26 | 27 | } 28 | 29 | ._markdown_image_caption { 30 | 31 | display: block; 32 | text-align: center; 33 | margin-top: 16px; 34 | margin-bottom: 32px; 35 | } 36 | -------------------------------------------------------------------------------- /webviz_config/utils/_dash_component_utils.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | 4 | def calculate_slider_step( 5 | min_value: float, max_value: float, steps: int = 100 6 | ) -> float: 7 | """Calculates a step value for use in e.g. dcc.RangeSlider() component 8 | that will always be rounded. 9 | 10 | The number of steps will be atleast the number 11 | of input steps, but might not be precisely the same due to use of the floor function. 12 | 13 | This function is necessary since there is currently no precision control in the underlying 14 | React component (https://github.com/react-component/slider/issues/275). 15 | 16 | """ 17 | 18 | return 10 ** math.floor(math.log10((max_value - min_value) / steps)) 19 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import pytest 3 | import yaml 4 | import jsonschema 5 | 6 | from webviz_config._docs._create_schema import create_schema 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "config_file_path", 11 | [ 12 | (pathlib.Path("examples") / "basic_example.yaml"), 13 | (pathlib.Path("examples") / "basic_example_advanced_menu.yaml"), 14 | ], 15 | ) 16 | def test_schema(config_file_path: pathlib.Path): 17 | """Tests both that the generated schema is valid, 18 | and that the input configuration is valid according to the schema. 19 | """ 20 | 21 | config = yaml.safe_load(config_file_path.read_text()) 22 | jsonschema.validate(instance=config, schema=create_schema()) 23 | -------------------------------------------------------------------------------- /webviz_config/webviz_factory.py: -------------------------------------------------------------------------------- 1 | class WebvizFactory: 2 | """Base class for all factories that want to register themselves in 3 | WebvizFactoryRegistry. 4 | 5 | Note that this functionality is provisional/experimental, and will not necessarily 6 | see a deprecation phase and "may deviate from the usual version semantics." 7 | """ 8 | 9 | def cleanup_resources_after_plugin_init(self) -> None: 10 | """Will be called after all plugins have been initialized to allow the factory 11 | to do clean-up of any temporary resources allocated during the initialization 12 | phase. The base implementation does nothing, override this function to 13 | perform factory-specific cleanup. 14 | """ 15 | -------------------------------------------------------------------------------- /tests/test_plugin_metadata.py: -------------------------------------------------------------------------------- 1 | import webviz_config 2 | from webviz_config.plugins import PLUGIN_PROJECT_METADATA 3 | 4 | 5 | def test_webviz_config_metadata(): 6 | 7 | metadata = PLUGIN_PROJECT_METADATA["webviz-config"] 8 | 9 | assert metadata["dist_version"] == webviz_config.__version__ 10 | assert metadata["documentation_url"] == "https://equinor.github.io/webviz-config" 11 | assert metadata["download_url"] == "https://pypi.org/project/webviz-config" 12 | assert metadata["source_url"] == "https://github.com/equinor/webviz-config" 13 | assert metadata["tracker_url"] == "https://github.com/equinor/webviz-config/issues" 14 | 15 | assert "dash" in metadata["dependencies"] # dash is a direct dependency 16 | assert "flask" in metadata["dependencies"] # flask is an indirect dependency 17 | -------------------------------------------------------------------------------- /webviz_config/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | """These are the basic Webviz configuration plugins, distributed through 2 | the utility itself. 3 | """ 4 | 5 | import abc 6 | from importlib.metadata import distributions 7 | 8 | from ._utils import load_webviz_plugins_with_metadata, PluginProjectMetaData 9 | 10 | 11 | ( 12 | PLUGIN_METADATA, 13 | PLUGIN_PROJECT_METADATA, 14 | plugin_entrypoints, 15 | ) = load_webviz_plugins_with_metadata(distributions()) 16 | 17 | __all__ = list(plugin_entrypoints.keys()) 18 | 19 | 20 | def __getattr__(name: str) -> abc.ABC: 21 | """Lazy load plugins, i.e. only import/load when a given plugin is requested.""" 22 | 23 | if name in __all__: 24 | return plugin_entrypoints[name].load() 25 | raise AttributeError(f"module {__name__!r} has no attribute {name!r}") 26 | -------------------------------------------------------------------------------- /webviz_config/common_cache.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import flask_caching 4 | 5 | 6 | class Cache(flask_caching.Cache): 7 | def __init__( 8 | self, 9 | ) -> None: 10 | 11 | super().__init__(config={"CACHE_TYPE": "simple", "DEFAULT_CACHE_TIMEOUT": 3600}) 12 | 13 | @property 14 | def TIMEOUT(self) -> int: # pylint: disable=invalid-name 15 | warnings.warn( 16 | "Default cache timeout is now initialized directly, and the TIMEOUT " 17 | "attribute is therefore deprecated, and will be removed. The timeout " 18 | "argument can still be used as input to @CACHE.memoize() with an input " 19 | "if you want to overwride the 3600 (seconds) webviz default.", 20 | DeprecationWarning, 21 | ) 22 | return 3600 23 | 24 | 25 | CACHE = Cache() 26 | -------------------------------------------------------------------------------- /webviz_config/themes/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from importlib.metadata import EntryPoint, entry_points 3 | 4 | from .. import WebvizConfigTheme 5 | from ._default_theme import default_theme 6 | 7 | installed_themes = {default_theme.theme_name: default_theme} 8 | 9 | __all__ = ["installed_themes"] 10 | 11 | 12 | def process_entry_point(entry_point: EntryPoint): 13 | theme = entry_point.load() 14 | 15 | globals()[entry_point.name] = theme 16 | __all__.append(entry_point.name) 17 | 18 | if isinstance(theme, WebvizConfigTheme): 19 | installed_themes[theme.theme_name] = theme 20 | 21 | 22 | if sys.version_info < (3, 10, 0): 23 | eps = entry_points().get("webviz_config_themes", []) 24 | else: 25 | eps = entry_points().select(group="webviz_config_themes") 26 | 27 | for ep in eps: 28 | process_entry_point(ep) 29 | -------------------------------------------------------------------------------- /tests/test_syntax_highlighter.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest import mock 3 | 4 | import dash 5 | 6 | from webviz_config.common_cache import CACHE 7 | from webviz_config.generic_plugins import _syntax_highlighter 8 | 9 | 10 | def test_syntax_highlighter(dash_duo): 11 | app = dash.Dash(__name__) 12 | app.config.suppress_callback_exceptions = True 13 | CACHE.init_app(app.server) 14 | code_file = Path("./tests/data/basic_example.yaml") 15 | with mock.patch( 16 | "webviz_config.generic_plugins._syntax_highlighter.get_path" 17 | ) as mock_path: 18 | mock_path.return_value = code_file 19 | page = _syntax_highlighter.SyntaxHighlighter(app, code_file) 20 | app.layout = page.layout 21 | dash_duo.start_server(app) 22 | assert not dash_duo.get_logs(), "browser console should contain no error" 23 | -------------------------------------------------------------------------------- /tests/test_example_wlf_plugin.py: -------------------------------------------------------------------------------- 1 | from webviz_config.testing import WebvizComposite 2 | from webviz_config.generic_plugins._example_wlf_plugin import ExampleWlfPlugin 3 | 4 | 5 | def test_example_wlf_plugin( 6 | _webviz_duo: WebvizComposite, 7 | ) -> None: 8 | plugin = ExampleWlfPlugin(title="hello") 9 | 10 | _webviz_duo.start_server(plugin) 11 | 12 | _webviz_duo.toggle_webviz_settings_drawer() 13 | 14 | _webviz_duo.toggle_webviz_settings_group( 15 | plugin.view("plot-view").settings_group_unique_id("plot-settings") 16 | ) 17 | 18 | component_id = _webviz_duo.view_settings_group_unique_component_id( 19 | view_id="plot-view", 20 | settings_group_id="plot-settings", 21 | component_unique_id="coordinates-selector", 22 | ) 23 | 24 | _webviz_duo.wait_for_contains_text(component_id, "x - y") 25 | assert not _webviz_duo.get_logs() 26 | -------------------------------------------------------------------------------- /webviz_config/testing/_plugin.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Generator 2 | 3 | import pytest 4 | 5 | from webviz_config.testing import WebvizComposite 6 | 7 | 8 | @pytest.fixture 9 | def _webviz_duo(request: Any, dash_thread_server: Any, tmpdir: Any) -> Generator: 10 | with WebvizComposite( 11 | dash_thread_server, 12 | browser=request.config.getoption("webdriver"), 13 | remote=request.config.getoption("remote"), 14 | remote_url=request.config.getoption("remote_url"), 15 | headless=request.config.getoption("headless"), 16 | options=request.config.hook.pytest_setup_options(), 17 | download_path=tmpdir.mkdir("download").strpath, 18 | percy_assets_root=request.config.getoption("percy_assets"), 19 | percy_finalize=request.config.getoption("nopercyfinalize"), 20 | pause=request.config.getoption("pause"), 21 | ) as duo: 22 | yield duo 23 | -------------------------------------------------------------------------------- /tests/test_example_plugin.py: -------------------------------------------------------------------------------- 1 | import dash 2 | 3 | from webviz_config.common_cache import CACHE 4 | from webviz_config.generic_plugins import _example_plugin 5 | 6 | 7 | def test_example_plugin(dash_duo): 8 | 9 | app = dash.Dash(__name__) 10 | app.config.suppress_callback_exceptions = True 11 | CACHE.init_app(app.server) 12 | title = "Example" 13 | page = _example_plugin.ExamplePlugin(app, title) 14 | app.layout = page.layout 15 | dash_duo.start_server(app) 16 | btn = dash_duo.find_element("#" + page.uuid("submit-button")) 17 | assert btn.text == "Submit" 18 | text = dash_duo.find_element("#" + page.uuid("output-state")) 19 | assert text.text == "Button has been pressed 0 times." 20 | btn.click() 21 | dash_duo.wait_for_contains_text( 22 | "#" + page.uuid("output-state"), "Button has been pressed 1 times", timeout=2 23 | ) 24 | assert text.text == "Button has been pressed 1 times." 25 | -------------------------------------------------------------------------------- /tests/test_portable.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess # nosec 3 | 4 | 5 | def test_portable(dash_duo, tmp_path): 6 | # Build a portable webviz from config file 7 | appdir = tmp_path / "app" 8 | subprocess.call( # nosec 9 | ["webviz", "build", "basic_example.yaml", "--portable", appdir], cwd="examples" 10 | ) 11 | 12 | # Import generated app 13 | sys.path.append(str(appdir)) 14 | from webviz_app import app # pylint: disable=import-error, import-outside-toplevel 15 | 16 | # Start and test app 17 | dash_duo.start_server(app) 18 | for page in [ 19 | "markdown-example", 20 | "table-example", 21 | "pdf-example", 22 | "syntax-highlighting-example", 23 | "plot-a-table", 24 | "pivot-table", 25 | ]: 26 | dash_duo.wait_for_element(f".Menu__Page[href='/{page}']").click() 27 | assert not dash_duo.get_logs(), "browser console should contain no error" 28 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | If you discover a security vulnerability in this project, please follow these steps to responsibly disclose it: 2 | 3 | 1. **Do not** create a public GitHub issue for the vulnerability. 4 | 5 | 2. Follow our guideline for Responsible Disclosure Policy at https://www.equinor.com/about-us/csirt to report the issue 6 | 7 | The following information will help us triage your report more quickly: 8 | 9 | - Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 10 | - Full paths of source file(s) related to the manifestation of the issue 11 | - The location of the affected source code (tag/branch/commit or direct URL) 12 | - Any special configuration required to reproduce the issue 13 | - Step-by-step instructions to reproduce the issue 14 | - Proof-of-concept or exploit code (if possible) 15 | - Impact of the issue, including how an attacker might exploit the issue 16 | - We prefer all communications to be in English. 17 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_wlf_plugin/_shared_view_elements/_text_view_element.py: -------------------------------------------------------------------------------- 1 | from typing import Type, Union 2 | from enum import Enum 3 | 4 | from dash.development.base_component import Component 5 | from dash import html 6 | 7 | from webviz_config.webviz_plugin_subclasses import ViewElementABC 8 | 9 | 10 | class TextViewElement(ViewElementABC): 11 | class Ids(str, Enum): 12 | TEXT = "text" 13 | 14 | def __init__(self) -> None: 15 | super().__init__() 16 | 17 | def inner_layout(self) -> Union[str, Type[Component]]: 18 | return html.Div( 19 | id=self.register_component_unique_id(TextViewElement.Ids.TEXT), 20 | children=[ 21 | html.H1("Hello"), 22 | """ 23 | This is an example plugin. 24 | Please have a look how views and settings are working in this new environment =). 25 | """, 26 | ], 27 | ) 28 | -------------------------------------------------------------------------------- /webviz_config/_webviz_settings_class.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import Dict, Any, Mapping 3 | 4 | from ._theme_class import WebvizConfigTheme 5 | 6 | 7 | class WebvizSettings: 8 | """This class contains global Webviz settings that will be made available 9 | to all plugins through the special argument named webviz_settings. 10 | """ 11 | 12 | def __init__(self, shared_settings: Dict[str, Any], theme: WebvizConfigTheme): 13 | if not isinstance(shared_settings, dict): 14 | raise TypeError("shared_settings must be of type dict") 15 | 16 | if not isinstance(theme, WebvizConfigTheme): 17 | raise TypeError("theme must be of type WebvizConfigTheme") 18 | 19 | self._shared_settings = shared_settings 20 | self._theme = theme 21 | 22 | @property 23 | def shared_settings(self) -> Mapping[str, Any]: 24 | return copy.deepcopy(self._shared_settings) 25 | 26 | @property 27 | def theme(self) -> WebvizConfigTheme: 28 | return copy.deepcopy(self._theme) 29 | -------------------------------------------------------------------------------- /webviz_config/utils/_deprecate_webviz_settings_attribute_in_dash_app.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import warnings 3 | 4 | import dash 5 | 6 | 7 | def _get_deprecated_webviz_settings(self: Any) -> dict: 8 | warnings.warn( 9 | "Accessing webviz_settings through the Dash application object has been deprecated, " 10 | "see https://github.com/equinor/webviz-config/pull/368", 11 | DeprecationWarning, 12 | stacklevel=2, 13 | ) 14 | # pylint: disable=protected-access 15 | return self._deprecated_webviz_settings 16 | 17 | 18 | def deprecate_webviz_settings_attribute_in_dash_app() -> None: 19 | """Helper that monkey patches dash.Dash application class so that access to 20 | the webviz_settings via the Dash application instance attribute is reported 21 | as being deprecated. 22 | """ 23 | dash.Dash.webviz_settings = property( 24 | _get_deprecated_webviz_settings, 25 | None, 26 | None, 27 | "Property to mark webviz_settings access as deprecated", 28 | ) 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webviz-config", 3 | "description": "Documentation system for webviz-config plugins", 4 | "scripts": { 5 | "postinstall": "cd ./node_modules; copyfiles --flat ./docsify/lib/docsify.min.js ./docsify/lib/plugins/search.min.js ./docsify/lib/themes/vue.css ./docsify-tabs/dist/docsify-tabs.min.js ./prismjs/components/prism-bash.min.js ./prismjs/components/prism-python.min.js ./prismjs/components/prism-yaml.min.js ./docsify-copy-code/dist/docsify-copy-code.min.js ./katex/dist/katex.min.css ./docsify-katex/dist/docsify-katex.js ../INTRODUCTION.md ../webviz_config/_docs/static/; copyfiles --flat ./katex/dist/fonts/*.woff2 ../webviz_config/_docs/static/fonts; cd .." 6 | }, 7 | "author": "Equinor", 8 | "license": "MIT", 9 | "dependencies": { 10 | "docsify": "^4.12.0", 11 | "docsify-copy-code": "^2.1.1", 12 | "docsify-katex": "^1.4.3", 13 | "docsify-tabs": "^1.4.4", 14 | "katex": "^0.12.0", 15 | "prismjs": "^1.27.0" 16 | }, 17 | "devDependencies": { 18 | "copyfiles": "^2.4.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_tour.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from dash import html 4 | 5 | from .. import WebvizPluginABC 6 | 7 | 8 | class ExampleTour(WebvizPluginABC): 9 | @property 10 | def tour_steps(self) -> List[dict]: 11 | return [ 12 | {"id": self.uuid("blue_text"), "content": "This is the first step"}, 13 | {"id": self.uuid("red_text"), "content": "This is the second step"}, 14 | ] 15 | 16 | @property 17 | def layout(self) -> html.Div: 18 | return html.Div( 19 | children=[ 20 | html.Span( 21 | "Here is some blue text to explain... ", 22 | id=self.uuid("blue_text"), 23 | style={"color": "blue"}, 24 | ), 25 | html.Span( 26 | " ...and here is some red text that also needs an explanation.", 27 | id=self.uuid("red_text"), 28 | style={"color": "red"}, 29 | ), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_plugin.py: -------------------------------------------------------------------------------- 1 | from dash import html, Dash, Input, Output 2 | 3 | from .. import WebvizPluginABC 4 | 5 | 6 | class ExamplePlugin(WebvizPluginABC): 7 | def __init__(self, app: Dash, title: str): 8 | super().__init__() 9 | 10 | self.title = title 11 | 12 | self.set_callbacks(app) 13 | 14 | @property 15 | def layout(self) -> html.Div: 16 | return html.Div( 17 | [ 18 | html.H1(self.title), 19 | html.Button( 20 | id=self.uuid("submit-button"), n_clicks=0, children="Submit" 21 | ), 22 | html.Div(id=self.uuid("output-state")), 23 | ] 24 | ) 25 | 26 | def set_callbacks(self, app: Dash) -> None: 27 | @app.callback( 28 | Output(self.uuid("output-state"), "children"), 29 | [Input(self.uuid("submit-button"), "n_clicks")], 30 | ) 31 | def _update_output(n_clicks: int) -> str: 32 | return f"Button has been pressed {n_clicks} times." 33 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_data_download.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from dash import html, Dash 4 | 5 | from .. import WebvizPluginABC, EncodedFile 6 | 7 | 8 | class ExampleDataDownload(WebvizPluginABC): 9 | def __init__(self, app: Dash, title: str): 10 | super().__init__() 11 | 12 | self.title = title 13 | self.set_callbacks(app) 14 | 15 | @property 16 | def layout(self) -> html.H1: 17 | return html.H1(self.title) 18 | 19 | def set_callbacks(self, app: Dash) -> None: 20 | @app.callback(self.plugin_data_output, self.plugin_data_requested) 21 | def _user_download_data(data_requested: bool) -> Optional[EncodedFile]: 22 | return ( 23 | WebvizPluginABC.plugin_compressed_data( 24 | filename="webviz-data.zip", 25 | content=[ 26 | {"filename": "some_file.txt", "content": "Some download data"} 27 | ], 28 | ) 29 | if data_requested 30 | else None 31 | ) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Equinor ASA 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_embed_pdf.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from dash import html 4 | 5 | from .. import WebvizPluginABC 6 | from ..webviz_assets import WEBVIZ_ASSETS 7 | 8 | 9 | class EmbedPdf(WebvizPluginABC): 10 | """Embeds a given PDF file into the page. 11 | 12 | !> Webviz does not scan your PDF for malicious code. Make sure it comes from a trusted source. 13 | --- 14 | 15 | * **`pdf_file`:** Path to the PDF file to include. Either absolute path or \ 16 | relative to the configuration file. 17 | * **`height`:** Height of the PDF object (in percent of viewport height). 18 | * **`width`:** Width of the PDF object (in percent of available space). 19 | 20 | """ 21 | 22 | def __init__(self, pdf_file: Path, height: int = 80, width: int = 100): 23 | 24 | super().__init__() 25 | 26 | self.pdf_url = WEBVIZ_ASSETS.add(pdf_file) 27 | self.height = height 28 | self.width = width 29 | 30 | @property 31 | def layout(self) -> html.Embed: 32 | 33 | style = {"height": f"{self.height}vh", "width": f"{self.width}%"} 34 | 35 | return html.Embed(src=self.pdf_url, style=style, type="application/pdf") 36 | -------------------------------------------------------------------------------- /webviz_config/_docs/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
Loading Webviz documentation...
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /webviz_config/utils/_silence_flask_startup.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import flask 4 | 5 | 6 | def silence_flask_startup() -> None: 7 | # pylint: disable=line-too-long 8 | """Calling this function monkey patches the function flask.cli.show_server_banner 9 | (https://github.com/pallets/flask/blob/a3f07829ca03bf312b12b3732e917498299fa82d/src/flask/cli.py#L657-L683) 10 | which by default outputs something like: 11 | 12 | * Serving Flask app "webviz_app" (lazy loading) 13 | * Environment: production 14 | WARNING: This is a development server. Do not use it in a production deployment. 15 | Use a production WSGI server instead. 16 | * Debug mode: off 17 | 18 | This warning is confusing to new users of flask and webviz-config, which thinks 19 | something is wrong (even though having development/debug mode turned off, and 20 | limit availability to localhost, is best practice wrt. security). 21 | 22 | After calling this function the exact lines above are not shown 23 | (all other information/output from the flask instance is untouched). 24 | """ 25 | 26 | def silent_function(*_args: Any, **_kwargs: Any) -> None: 27 | pass 28 | 29 | flask.cli.show_server_banner = silent_function 30 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_syntax_highlighter.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | from dash import dcc 5 | 6 | from .. import WebvizPluginABC 7 | from ..webviz_store import webvizstore 8 | 9 | 10 | class SyntaxHighlighter(WebvizPluginABC): 11 | """Adds support for syntax highlighting of code. Language is automatically detected. 12 | 13 | --- 14 | 15 | * **`filename`:** Path to a file containing the code to highlight. 16 | * **`dark_theme`:** If `True`, the code is shown with a dark theme. Default is \ 17 | `False`, giving a light theme. 18 | """ 19 | 20 | def __init__(self, filename: Path, dark_theme: bool = False): 21 | 22 | super().__init__() 23 | 24 | self.filename = filename 25 | self.config = {"theme": "dark"} if dark_theme else {"theme": "light"} 26 | 27 | def add_webvizstore(self) -> List[tuple]: 28 | return [(get_path, [{"path": self.filename}])] 29 | 30 | @property 31 | def layout(self) -> dcc.Markdown: 32 | return dcc.Markdown( 33 | f"```\n{get_path(self.filename).read_text()}\n```", 34 | highlight_config=self.config, 35 | ) 36 | 37 | 38 | @webvizstore 39 | def get_path(path: Path) -> Path: 40 | return path 41 | -------------------------------------------------------------------------------- /webviz_config/static/README.md: -------------------------------------------------------------------------------- 1 | This webviz instance has been automatically created from configuration file. 2 | 3 | ## Run locally 4 | 5 | You can run it locally with: 6 | 7 | cd THISFOLDER 8 | python ./webviz_app.py 9 | 10 | ## Upload and build into Azure Container registry 11 | 12 | If you want to upload it to e.g. Azure Container Registry, you can do e.g. 13 | 14 | cd THISFOLDER 15 | az acr build --registry $ACR_NAME --image $IMAGE_NAME . 16 | 17 | assuming you have set the environment variables $ACR_NAME and $IMAGE_NAME. 18 | 19 | ## Private plugin projects 20 | 21 | Note that if you have included plugins from private GitHub projects in your 22 | application, you will need to provide a build time environment variable to Docker e.g. like: 23 | 24 | docker build . --build-arg GITHUB_DEPLOY_KEYS=COMMA_SEPARATED_LIST_OF_DEPLOY_KEYS. 25 | 26 | To read more on GitHub deploy keys, see https://docs.github.com/en/free-pro-team@latest/developers/overview/managing-deploy-keys#deploy-keys. 27 | In order to add the multi-line deploy keys, you should `base64` encode the deploy key 28 | before giving it as the `GITHUB_DEPLOY_KEYS` variable. Multiple keys (in case you have 29 | multiple private repositiories as dependencies) can be joined together with a comma 30 | separator (,) before `base64` encoding. 31 | -------------------------------------------------------------------------------- /tests/data/basic_example.yaml: -------------------------------------------------------------------------------- 1 | # This file demonstrates the most basic usage of webviz in a FMU setting 2 | # The configuration files uses YAML (https://en.wikipedia.org/wiki/YAML). 3 | 4 | title: Reek Webviz Demonstration 5 | 6 | pages: 7 | 8 | - title: Front page 9 | content: 10 | - BannerImage: 11 | image: ./example_banner.png 12 | title: My banner image 13 | - Webviz created from configuration file. 14 | - Some other text, potentially with strange letters like Åre, Smørbukk Sør. 15 | 16 | - title: Markdown example 17 | content: 18 | - Markdown: 19 | markdown_file: ./example-markdown.md 20 | 21 | - title: PDF example 22 | content: 23 | - EmbedPdf: 24 | pdf_file: ./example.pdf 25 | 26 | - title: Syntax highlighting example 27 | content: 28 | - SyntaxHighlighter: 29 | filename: ./basic_example.yaml 30 | dark_theme: true 31 | 32 | - title: Plot a table 33 | content: 34 | - TablePlotter: 35 | csv_file: ./example_data.csv 36 | 37 | - title: Plot a table (locked) 38 | content: 39 | - TablePlotter: 40 | csv_file: ./example_data.csv 41 | lock: true 42 | plot_options: 43 | x: Well 44 | y: Initial reservoir pressure (bar) 45 | size: Average permeability (D) 46 | facet_col: Segment 47 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_portable.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pandas as pd 4 | 5 | from .. import WebvizPluginABC 6 | from ..webviz_store import webvizstore 7 | from ..common_cache import CACHE 8 | 9 | 10 | class ExamplePortable(WebvizPluginABC): 11 | def __init__(self, some_number: int): 12 | super().__init__() 13 | 14 | self.some_number = some_number 15 | self.some_string = "a" 16 | 17 | def add_webvizstore(self) -> List[tuple]: 18 | return [ 19 | ( 20 | input_data_function, 21 | [{"some_string": self.some_string, "some_number": self.some_number}], 22 | ) 23 | ] 24 | 25 | @property 26 | def layout(self) -> str: 27 | return str(input_data_function(self.some_string, some_number=self.some_number)) 28 | 29 | 30 | @CACHE.memoize() 31 | @webvizstore 32 | def input_data_function( 33 | some_string: str, some_number: int, some_bool: bool = True 34 | ) -> pd.DataFrame: 35 | print("This time I'm actually doing the calculation...") 36 | return pd.DataFrame( 37 | data={ 38 | "col1": [some_number, some_number * 2], 39 | "col2": [some_number * 3, some_number * 4], 40 | "col3": [some_string, some_string + "b"], 41 | "col4": [some_bool, not some_bool], 42 | } 43 | ) 44 | -------------------------------------------------------------------------------- /webviz_config/webviz_plugin_subclasses/_layout_base_abc.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Optional, Union 2 | import abc 3 | 4 | from ._layout_unique_id import LayoutUniqueId 5 | 6 | 7 | class LayoutBaseABC(abc.ABC): 8 | def __init__(self) -> None: 9 | self._unique_id = LayoutUniqueId() 10 | self._plugin_register_id_func: Optional[ 11 | Callable[[Union[str, List[str]]], None] 12 | ] = None 13 | self._plugin_get_store_unique_id_func: Optional[Callable[[str], str]] = None 14 | 15 | def _set_plugin_register_id_func( 16 | self, func: Callable[[Union[str, List[str]]], None] 17 | ) -> None: 18 | self._plugin_register_id_func = func 19 | 20 | def _set_unique_id(self, parent_unique_id: LayoutUniqueId) -> None: 21 | self._unique_id.adopt(parent_unique_id) 22 | 23 | if self._plugin_register_id_func: 24 | self._plugin_register_id_func(str(self._unique_id)) 25 | 26 | def get_unique_id(self) -> LayoutUniqueId: 27 | return self._unique_id 28 | 29 | def _set_plugin_get_store_unique_id_func(self, func: Callable[[str], str]) -> None: 30 | self._plugin_get_store_unique_id_func = func 31 | 32 | def get_store_unique_id(self, store_id: str) -> str: 33 | if self._plugin_get_store_unique_id_func: 34 | return self._plugin_get_store_unique_id_func(store_id) 35 | 36 | raise NotImplementedError("This element has not been added to a plugin yet.") 37 | -------------------------------------------------------------------------------- /webviz_config/utils/_available_port.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | from typing import Optional 4 | 5 | 6 | def get_available_port(preferred_port: Optional[int] = None) -> int: 7 | """Finds an available port for use in webviz on localhost. If a reload process, 8 | it will reuse the same port as found in the parent process by using an inherited 9 | environment variable. 10 | 11 | If preferred_port is given, ports in the range [preferred_port, preferred_port + 20) 12 | will be tried first, before an OS provided random port is used as fallback. 13 | """ 14 | 15 | def is_available(port: int) -> bool: 16 | with socket.socket() as sock: 17 | try: 18 | sock.bind(("localhost", port)) 19 | return True 20 | except OSError: 21 | return False 22 | 23 | if os.environ.get("WEBVIZ_PORT") is None: 24 | port = None 25 | 26 | if preferred_port is not None: 27 | for port_to_test in range(preferred_port, preferred_port + 20): 28 | if is_available(port_to_test): 29 | port = port_to_test 30 | break 31 | 32 | if port is None: 33 | with socket.socket() as sock: 34 | sock.bind(("localhost", 0)) 35 | port = sock.getsockname()[1] 36 | 37 | os.environ["WEBVIZ_PORT"] = str(port) 38 | return port 39 | 40 | return int(os.environ.get("WEBVIZ_PORT")) # type: ignore[arg-type] 41 | -------------------------------------------------------------------------------- /webviz_config/templates/feedback.md.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ### 🐞 Bug report: 6 | 7 | 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **How to reproduce** 13 | Steps to reproduce the behavior. 14 | 15 | **Expected behavior** 16 | A clear and concise description of what you expected to happen. 17 | 18 | **Screenshots** 19 | If applicable, add screenshots to help explain your problem. 20 | 21 | --- 22 | 23 | ### 💡 Feature request: 24 | 25 | 26 | 27 | **Is your feature request related to a problem? Please describe** 28 | A clear and concise description of what the problem is. 29 | 30 | **Describe the solution you'd like** 31 | A clear and concise description of what you want to happen. 32 | 33 | **Describe alternatives you've considered** 34 | A clear and concise description of any alternative solutions or features you've considered. 35 | 36 | --- 37 | 38 | ### 🔖 Version information 39 | 40 | * **Plugin name:** `{{plugin_name}}` 41 | * **Plugin project version:** `{{dist_name}}=={{dist_version}}` 42 | * **Plugin project dependencies:** 43 | ```{% for dependency, version in dependencies.items() %} 44 | {{dependency}}=={{version}} 45 | {%- endfor %} 46 | ``` 47 | -------------------------------------------------------------------------------- /tests/unit_tests/test_webviz_settings.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | import pytest 4 | 5 | from webviz_config import WebvizConfigTheme, WebvizSettings 6 | 7 | 8 | def test_construction_and_basic_access() -> None: 9 | # pylint: disable=unidiomatic-typecheck 10 | the_shared_settings = {"somenumber": 10, "somestring": "abc"} 11 | the_theme = WebvizConfigTheme("dummyThemeName") 12 | settings_obj = WebvizSettings(the_shared_settings, the_theme) 13 | 14 | copy_of_shared_settings = settings_obj.shared_settings 15 | assert copy_of_shared_settings is not the_shared_settings 16 | assert type(copy_of_shared_settings) == type(the_shared_settings) 17 | assert copy_of_shared_settings == the_shared_settings 18 | the_shared_settings["somestring"] = "MODIFIED" 19 | assert copy_of_shared_settings != the_shared_settings 20 | 21 | copy_of_theme = settings_obj.theme 22 | assert copy_of_theme is not the_theme 23 | assert type(copy_of_theme) == type(the_theme) 24 | assert copy_of_theme.__dict__ == the_theme.__dict__ 25 | the_theme.theme_name = "MODIFIED" 26 | assert copy_of_theme.__dict__ != the_theme.__dict__ 27 | 28 | 29 | def test_construction_with_invalid_types() -> None: 30 | with pytest.raises(TypeError): 31 | theme = WebvizConfigTheme("dummyThemeName") 32 | _settings_obj = WebvizSettings(cast(dict, None), theme) 33 | 34 | with pytest.raises(TypeError): 35 | shared_settings = {"somenumber": 10, "somestring": "abc"} 36 | _settings_obj = WebvizSettings(shared_settings, cast(WebvizConfigTheme, None)) 37 | -------------------------------------------------------------------------------- /tests/test_data_table.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pandas as pd 4 | import dash 5 | 6 | from webviz_config.common_cache import CACHE 7 | from webviz_config.generic_plugins import _data_table 8 | 9 | # mocked functions 10 | GET_DATA = "webviz_config.generic_plugins._data_table.get_data" 11 | 12 | 13 | def test_data_table(dash_duo): 14 | app = dash.Dash(__name__) 15 | app.config.suppress_callback_exceptions = True 16 | CACHE.init_app(app.server) 17 | code_file = "./tests/data/example_data.csv" 18 | with mock.patch(GET_DATA) as mock_path: 19 | mock_path.return_value = pd.read_csv(code_file) 20 | page = _data_table.DataTable(app, code_file) 21 | app.layout = page.layout 22 | dash_duo.start_server(app) 23 | assert not dash_duo.get_logs(), "browser console should contain no error" 24 | 25 | 26 | def test_data_table_with_settings(dash_duo): 27 | app = dash.Dash(__name__) 28 | app.css.config.serve_locally = True 29 | app.scripts.config.serve_locally = True 30 | app.config.suppress_callback_exceptions = True 31 | CACHE.init_app(app.server) 32 | code_file = "./tests/data/example_data.csv" 33 | with mock.patch(GET_DATA) as mock_path: 34 | mock_path.return_value = pd.read_csv(code_file) 35 | page = _data_table.DataTable( 36 | app, csv_file=code_file, sorting=False, filtering=False, pagination=False 37 | ) 38 | app.layout = page.layout 39 | dash_duo.start_server(app) 40 | assert not dash_duo.get_logs(), "browser console should contain no error" 41 | -------------------------------------------------------------------------------- /webviz_config/_user_preferences.py: -------------------------------------------------------------------------------- 1 | import json 2 | import webbrowser 3 | from typing import Optional 4 | 5 | from ._user_data_dir import user_data_dir 6 | from .themes import installed_themes 7 | 8 | USER_SETTINGS_FILE = user_data_dir() / "user_settings.json" 9 | 10 | 11 | def set_user_preferences( 12 | theme: Optional[str] = None, browser: Optional[str] = None 13 | ) -> None: 14 | 15 | preferences = ( 16 | json.loads(USER_SETTINGS_FILE.read_text()) 17 | if USER_SETTINGS_FILE.is_file() 18 | else {} 19 | ) 20 | 21 | new_preferences = {} 22 | 23 | if theme is not None: 24 | if theme not in installed_themes: 25 | raise ValueError( 26 | f"Theme {theme} is not one of the installed themes ({', '.join(installed_themes)})" 27 | ) 28 | new_preferences["theme"] = theme 29 | 30 | if browser is not None: 31 | try: 32 | webbrowser.get(using=browser) 33 | except webbrowser.Error as exc: 34 | raise ValueError( 35 | f"Could not find an installed browser with the name {browser}." 36 | ) from exc 37 | 38 | new_preferences["browser"] = browser 39 | 40 | if new_preferences: 41 | preferences.update(new_preferences) 42 | USER_SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) 43 | USER_SETTINGS_FILE.write_text(json.dumps(preferences)) 44 | 45 | 46 | def get_user_preference(setting: str) -> Optional[str]: 47 | return ( 48 | json.loads(USER_SETTINGS_FILE.read_text()).get(setting) 49 | if USER_SETTINGS_FILE.is_file() 50 | else None 51 | ) 52 | -------------------------------------------------------------------------------- /webviz_config/_dockerize/_pip_git_url.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from typing import Optional 3 | 4 | import requests 5 | 6 | 7 | def pip_git_url( 8 | version: str, source_url: str, git_pointer: Optional[str] = None 9 | ) -> str: 10 | """All webviz-config plugin projects are encouraged to be open source 11 | and on PyPI. Those that are not are encouraged to be on a git solution, 12 | and use quite standard setuptools_scm to give a runtime available version 13 | to the Python package. 14 | 15 | setuptool_scm enables us to get information regarding which hash/tag of the 16 | git repository to download + install in the Docker image, as installation from a 17 | clean repository state should give one of the following versions: 18 | 19 | no distance to tag: 20 | {tag} 21 | distance: 22 | {next_version}.dev{distance}+g{revision hash} 23 | 24 | If git_pointer argument is not None, this git ponter/reference is used instead 25 | of derived commit hash/tag from version. 26 | """ 27 | 28 | source_url = source_url.rstrip("/") 29 | 30 | if git_pointer is None: 31 | git_pointer = version.split("+g")[1] if "+" in version else version 32 | 33 | if requests.get(source_url).status_code != 200: 34 | warnings.warn( 35 | f"Could not find {source_url}, assuming private repository. Changing to " 36 | "SSH URL. If building Docker image you will need to provide a deploy key." 37 | ) 38 | for string in ["https://github.com/", "https://www.github.com/"]: 39 | source_url = source_url.replace(string, "ssh://git@github.com/") 40 | 41 | return f"git+{source_url}@{git_pointer}" 42 | -------------------------------------------------------------------------------- /tests/test_docker_setup.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import pytest 4 | 5 | from webviz_config._dockerize._create_docker_setup import get_python_requirements 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "distributions, requirements", 10 | [ 11 | ( 12 | { 13 | "webviz-config": { 14 | "download_url": "https://pypi.org/project/webviz-config", 15 | "source_url": None, 16 | "dist_version": "0.3.0", 17 | } 18 | }, 19 | ["webviz-config==0.3.0"], 20 | ), 21 | ( 22 | { 23 | "webviz-config": { 24 | "download_url": None, 25 | "source_url": None, 26 | "dist_version": "0.3.0", 27 | } 28 | }, 29 | [], 30 | ), 31 | ( 32 | { 33 | "webviz-config": { 34 | "download_url": None, 35 | "source_url": "https://github.com/equinor/webviz-config", 36 | "dist_version": "0.3.0", 37 | } 38 | }, 39 | ["git+https://github.com/equinor/webviz-config@0.3.0"], 40 | ), 41 | ], 42 | ) 43 | def test_derived_requirements(distributions, requirements): 44 | with warnings.catch_warnings(record=True) as record: 45 | warnings.simplefilter("always") 46 | assert set(requirements).issubset(get_python_requirements(distributions)) 47 | assert len(record) == len( 48 | [ 49 | metadata 50 | for metadata in distributions.values() 51 | if metadata["download_url"] is None and metadata["source_url"] is None 52 | ] 53 | ) 54 | -------------------------------------------------------------------------------- /webviz_config/_deployment/radix_configuration.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Dict 3 | 4 | from . import interactive_terminal 5 | from . import radix_cli 6 | 7 | 8 | def radix_configuration() -> Dict[str, str]: 9 | 10 | interactive_terminal.terminal_title("Radix cluster") 11 | radix_context = interactive_terminal.user_input_from_list( 12 | "WEBVIZ_RADIX_CONTEXT", "Radix context", ["production", "playground"] 13 | ) 14 | radix_subdomain = "radix.equinor.com" 15 | if radix_context == "playground": 16 | radix_subdomain = "playground." + radix_subdomain 17 | 18 | interactive_terminal.terminal_title("Radix AD group") 19 | radix_ad_group = interactive_terminal.user_input_from_stdin( 20 | "WEBVIZ_RADIX_AD_GROUP", 21 | "AD group object ID", 22 | ) 23 | 24 | interactive_terminal.terminal_title("Radix application name") 25 | radix_application_name = interactive_terminal.user_input_optional_reuse( 26 | "WEBVIZ_RADIX_APPLICATION", 27 | "Radix application", 28 | functools.partial(radix_cli.application_exists, context=radix_context), 29 | reuse_allowed=False, 30 | ) 31 | 32 | interactive_terminal.terminal_title("Configuration item") 33 | radix_configuration_item = interactive_terminal.user_input_from_stdin( 34 | "WEBVIZ_RADIX_CONFIGURATION_ITEM", 35 | "Configuration item", 36 | ) 37 | 38 | return { 39 | "context": radix_context, 40 | "application_name": radix_application_name, 41 | "configuration_item": radix_configuration_item, 42 | "app_url": f"https://{radix_application_name}.app.{radix_subdomain}", 43 | "webhook_receiver_url": f"https://webhook.{radix_subdomain}/events/github", 44 | "ad_group": radix_ad_group, 45 | } 46 | -------------------------------------------------------------------------------- /webviz_config/_docs/open_docs.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import pathlib 3 | import tempfile 4 | import argparse 5 | import logging 6 | 7 | import flask 8 | 9 | import webviz_config.utils 10 | from ._build_docs import build_docs 11 | 12 | 13 | def _start_doc_app(build_directory: pathlib.Path) -> None: 14 | 15 | app = flask.Flask(__name__, static_folder=str(build_directory), static_url_path="") 16 | app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0 17 | 18 | @app.route("/") 19 | def _index() -> str: 20 | return (build_directory / "index.html").read_text() 21 | 22 | webviz_config.utils.silence_flask_startup() 23 | logging.getLogger("werkzeug").setLevel(logging.WARNING) 24 | 25 | port = webviz_config.utils.get_available_port(preferred_port=5050) 26 | token = webviz_config.LocalhostToken(app, port).one_time_token 27 | webviz_config.utils.LocalhostOpenBrowser(port, token) 28 | 29 | app.run( 30 | host="localhost", 31 | port=port, 32 | debug=False, 33 | ) 34 | 35 | 36 | def open_docs(args: argparse.Namespace) -> None: 37 | 38 | if args.portable is None: 39 | build_directory = pathlib.Path(tempfile.mkdtemp()) 40 | else: 41 | build_directory = args.portable.resolve() 42 | if build_directory.exists(): 43 | if not args.force: 44 | raise ValueError( 45 | f"{build_directory} already exists. Either add --force or change output folder." 46 | ) 47 | shutil.rmtree(build_directory) 48 | build_directory.mkdir(parents=True) 49 | 50 | try: 51 | build_docs(build_directory) 52 | if not args.skip_open: 53 | _start_doc_app(build_directory) 54 | finally: 55 | if args.portable is None: 56 | shutil.rmtree(build_directory) 57 | -------------------------------------------------------------------------------- /webviz_config/_write_script.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import getpass 3 | import datetime 4 | import pathlib 5 | import argparse 6 | from typing import Dict, Tuple 7 | 8 | import yaml 9 | import jinja2 10 | 11 | from ._config_parser import ConfigParser 12 | 13 | 14 | def write_script( 15 | args: argparse.Namespace, 16 | build_directory: pathlib.Path, 17 | template_filename: str, 18 | output_filename: str, 19 | ) -> Tuple[set, Dict[str, dict]]: 20 | """Writes rendered script to build directory. Also returns information regarding 21 | assets and which plugins that are incluced in the user provided configuration file. 22 | """ 23 | 24 | config_parser = ConfigParser(args.yaml_file) 25 | configuration = config_parser.configuration 26 | 27 | configuration.update( 28 | { 29 | "author": getpass.getuser(), 30 | "config_folder": f"Path('{(args.yaml_file.resolve().parent.as_posix())}')", 31 | "current_date": datetime.date.today().strftime("%Y-%m-%d"), 32 | "debug": args.debug, 33 | "loglevel": args.loglevel, 34 | "portable": args.portable is not None, 35 | "shared_settings": config_parser.shared_settings, 36 | "sys_executable": sys.executable, 37 | "theme_name": args.theme, 38 | } 39 | ) 40 | 41 | if args.logconfig is not None: 42 | configuration["logging_config_dict"] = yaml.safe_load( 43 | args.logconfig.read_text() 44 | ) 45 | 46 | template_environment = jinja2.Environment( # nosec 47 | loader=jinja2.PackageLoader("webviz_config", "templates"), 48 | undefined=jinja2.StrictUndefined, 49 | autoescape=False, 50 | ) 51 | 52 | template = template_environment.get_template(template_filename) 53 | 54 | (build_directory / output_filename).write_text(template.render(configuration)) 55 | 56 | return config_parser.assets, config_parser.plugin_metadata 57 | -------------------------------------------------------------------------------- /webviz_config/_docs/static/README.md: -------------------------------------------------------------------------------- 1 | # Webviz introduction 2 | 3 | ## Usage {docsify-ignore} 4 | 5 | Assuming you have a configuration file `your_config.yml`, 6 | there are two main usages of `webviz`: 7 | 8 | ```bash 9 | webviz build your_config.yml 10 | ``` 11 | and 12 | ```bash 13 | webviz build your_config.yml --portable ./some_output_folder 14 | python ./some_output_folder/webviz_app.py 15 | ``` 16 | 17 | The latter is useful when one or more plugins included in the configuration need to do 18 | some time-consuming data aggregation on their own, before presenting it to the user. 19 | The time-consuming part will then be done in the `build` step, and you can run your 20 | created application as many time as you want afterwards, with as little waiting 21 | time as possible). 22 | 23 | The `--portable` way also has the benefit of creating a :whale: Docker setup for your 24 | application - ready to be deployed to e.g. a cloud provider. 25 | 26 | ### Fundamental configuration {docsify-ignore} 27 | 28 | A configuration consists of some mandatory properties, e.g. app title, 29 | and one or more pages. A page has a title and some content. 30 | Each page can contain as many plugins as you want. 31 | 32 | Plugins represent predefined content, which can take one or more arguments. 33 | Lists and descriptions of installed plugins can be found on the other subpages. 34 | 35 | Content which is not plugins is interpreted as text paragraphs. 36 | 37 | A simple example configuration: 38 | ```yaml 39 | # This is a webviz configuration file example. 40 | # The configuration files use the YAML standard (https://en.wikipedia.org/wiki/YAML). 41 | 42 | title: Reek Webviz Demonstration 43 | 44 | pages: 45 | 46 | - title: Front page 47 | content: 48 | - BannerImage: 49 | image: ./example_banner.png 50 | title: My banner image 51 | - Webviz created from a configuration file. 52 | 53 | - title: Markdown example 54 | content: 55 | - Markdown: 56 | markdown_file: ./example-markdown.md 57 | ``` 58 | -------------------------------------------------------------------------------- /webviz_config/templates/plugin_docs.md.jinja2: -------------------------------------------------------------------------------- 1 |
2 | 3 | #### {{ plugin["name"] }} 4 | {% if plugin["deprecated"] %} 5 |
6 | :warning: {{ plugin["deprecation_text_short"] }} 7 | 8 | {{ plugin["deprecation_text_long"] }} 9 |
10 | {% elif plugin["has_deprecated_arguments"] %} 11 | > :warning: At least one argument has a deprecation warning. 12 | {% endif %} 13 | 14 | 15 | {% if plugin["description"] is not none %} 16 | 17 | 18 | 19 | {{ plugin["description"] }} 20 | 21 | {% endif %} 22 | 23 | 24 | 25 | {% for arg, arg_info in plugin["arg_info"].items() %} 26 | {% if arg_info["deprecated"] %}>:warning: **`{{ arg }}`:** {% if arg_info["deprecation_message"] == "" %}Certain values for the argument have been deprecated and might soon not be accepted anymore. See function below for details.{% else %}{{ arg_info["deprecation_message"] }}{% endif %} 27 | {% endif %} 28 | {% endfor %} 29 | {% if plugin["has_deprecated_arguments"] %} 30 | --- 31 | {% endif %} 32 | {% if plugin["argument_description"] is not none %} 33 | {{ plugin["argument_description"] }} 34 | {% endif %} 35 | {% if plugin["deprecation_check_code"] != "" %} 36 | Function checking for deprecations: 37 | ```python 38 | {{ plugin["deprecation_check_code"] }} 39 | ``` 40 | --- 41 | {% endif %} 42 | --- 43 | How to use in YAML config file: 44 | ```yaml 45 | - {{ plugin["name"] }}: 46 | {%- for arg, arg_info in plugin["arg_info"].items() %} 47 | {{ arg }}: {{ arg_info["default"] | tojson if "default" in arg_info else "" }} # {{ "Deprecated" if arg_info["deprecated"] else "Required" if arg_info["required"] else "Optional" }}{{ ", type " + arg_info["typehint_string"] | string if "typehint_string" in arg_info }}. 48 | {%- endfor %} 49 | ``` 50 | 51 | {% if plugin["data_input"] is not none %} 52 | 53 | 54 | 55 | {{ plugin["data_input"] }} 56 | 57 | {% endif %} 58 | 59 | 60 | 61 |
62 | -------------------------------------------------------------------------------- /examples/example-markdown.md: -------------------------------------------------------------------------------- 1 | # This is a big title 2 | 3 | ## This is a somewhat smaller title 4 | 5 | ### This is a subsubtitle 6 | 7 | Hi from a Markdown text file containing Norwegian letters (æ ø å), some 8 | **bold** letters, _italic_ letters. _You can also **combine** them._ 9 | 10 | You can also add (potential multi-line) equations as illustrated here with the Maxwell equations, 11 | $$ 12 | \begin{aligned} 13 | \nabla \cdot \mathbf{E} &= \frac {\rho} {\varepsilon_0} \\\\ 14 | \nabla \cdot \mathbf{B} &= 0 \\\\ 15 | \nabla \times \mathbf{E} &= -\frac{\partial \mathbf{B}} {\partial t} \\\\ 16 | \nabla \times \mathbf{B} &= \mu_0\left(\mathbf{J} + \varepsilon_0 \frac{\partial \mathbf{E}} {\partial t} \right) 17 | \end{aligned} 18 | $$ 19 | You can also add inline math where you e.g. describe the different parameters that goes into 20 | the equations, like $\varepsilon_0$ being permittivity of free space. 21 | 22 | --- 23 | 24 | Horizontal line splitting two paragraphs. 25 | 26 | #### An unordered list 27 | 28 | * Item 1 29 | * Item 2 30 | * Item 2a 31 | * Item 2b 32 | 33 | #### An automatically ordered list 34 | 35 | 1. Item 1 36 | 1. Item 2 37 | 1. Item 2a 38 | 1. Item 2b 39 | 40 | #### An image with a caption 41 | 42 | ![width=40%,height=300px](./example_banner.png "Some caption") 43 | 44 | #### Quote 45 | 46 | > Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod 47 | > tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, 48 | > quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 49 | 50 | #### Collapsible text 51 | 52 |
53 | This is the title of some detailed information 54 | Here is some more information, which can be extended/collapsed on demand. 55 |
56 | 57 | #### An example table 58 | 59 | First Header | Second Header 60 | ------------ | ------------- 61 | Content Cell | Content Cell 62 | Content Cell | Content Cell 63 | 64 | #### Some code 65 | 66 | ```python 67 | def some_function(input: int) -> int: 68 | return 42 * input 69 | ``` -------------------------------------------------------------------------------- /webviz_config/_shared_settings_subscriptions.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import inspect 3 | import pathlib 4 | from typing import Callable, List, Dict 5 | 6 | 7 | class SharedSettingsSubscriptions: 8 | """The user can configure common settings, shared between different cointainers, 9 | under a key called `shared_settings` in the configuration file. Since it originates 10 | from a native yaml file, the content is dictionaries/strings/ints/floats/dates. 11 | 12 | Third-party plugin packages might want to check early if the `shared_settings` 13 | they use are reasonable, and/or do some transformations on them. 14 | """ 15 | 16 | def __init__(self) -> None: 17 | self._subscriptions: List[Dict] = [] 18 | 19 | def subscribe(self, key: str) -> Callable: 20 | """This is the decorator, which third-party plugin packages will use.""" 21 | 22 | def register(function: Callable) -> Callable: 23 | self._subscriptions.append({"key": key, "function": function}) 24 | return function 25 | 26 | return register 27 | 28 | def transformed_settings( 29 | self, shared_settings: Dict, config_folder: str, portable: bool 30 | ) -> Dict: 31 | """Called from the app template, which returns the `shared_settings` 32 | after all third-party package subscriptions have done their 33 | (optional) transfomrations. 34 | """ 35 | shared_settings = copy.deepcopy(shared_settings) 36 | 37 | for subscription in self._subscriptions: 38 | key, function = subscription["key"], subscription["function"] 39 | shared_settings[key] = function( 40 | *[ 41 | pathlib.Path(config_folder) 42 | if arg == "config_folder" 43 | else portable 44 | if arg == "portable" 45 | else shared_settings.get(arg) 46 | for arg in inspect.getfullargspec(function).args 47 | ] 48 | ) 49 | 50 | return shared_settings 51 | 52 | 53 | SHARED_SETTINGS_SUBSCRIPTIONS = SharedSettingsSubscriptions() 54 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_wlf_plugin/_shared_settings/_shared_settings.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from dash.development.base_component import Component 4 | from dash import html 5 | 6 | import webviz_core_components as wcc 7 | 8 | from webviz_config.utils import StrEnum 9 | from webviz_config.webviz_plugin_subclasses import SettingsGroupABC 10 | 11 | from .._views._plot import Kindness 12 | 13 | 14 | class SharedSettingsGroup(SettingsGroupABC): 15 | class Ids(StrEnum): 16 | KINDNESS_SELECTOR = "kindness-selector" 17 | POWER_SELECTOR = "power-selector" 18 | 19 | def __init__(self) -> None: 20 | super().__init__("Shared settings") 21 | 22 | def layout(self) -> List[Component]: 23 | return [ 24 | html.Div( 25 | children=[ 26 | wcc.Label("Kindness"), 27 | wcc.RadioItems( 28 | id=self.register_component_unique_id( 29 | SharedSettingsGroup.Ids.KINDNESS_SELECTOR 30 | ), 31 | options=[ 32 | { 33 | "label": a.value, 34 | "value": a.value, 35 | } 36 | for a in Kindness 37 | ], 38 | value="friendly", 39 | ), 40 | wcc.Label("Power"), 41 | wcc.RadioItems( 42 | id=self.register_component_unique_id( 43 | SharedSettingsGroup.Ids.POWER_SELECTOR 44 | ), 45 | options=[ 46 | { 47 | "label": "2", 48 | "value": 2, 49 | }, 50 | { 51 | "label": "3", 52 | "value": 3, 53 | }, 54 | ], 55 | value=2, 56 | ), 57 | ] 58 | ) 59 | ] 60 | -------------------------------------------------------------------------------- /webviz_config/_deprecation_store.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional, Dict, Callable, Union 2 | from dataclasses import dataclass 3 | 4 | 5 | @dataclass(frozen=True) 6 | class DeprecatedPlugin: 7 | class_reference: Any 8 | short_message: str 9 | long_message: str 10 | 11 | 12 | @dataclass(frozen=True) 13 | class DeprecatedArgument: 14 | method_reference: Any 15 | method_name: str 16 | argument_name: str 17 | argument_value: str 18 | short_message: str 19 | long_message: str 20 | 21 | 22 | @dataclass(frozen=True) 23 | class DeprecatedArgumentCheck: 24 | method_reference: Any 25 | method_name: str 26 | argument_names: List[str] 27 | callback: Callable 28 | callback_code: str 29 | 30 | 31 | class DeprecationStore: 32 | def __init__(self) -> None: 33 | self.stored_plugin_deprecations: Dict[Any, DeprecatedPlugin] = {} 34 | self.stored_plugin_argument_deprecations: List[ 35 | Union[DeprecatedArgument, DeprecatedArgumentCheck] 36 | ] = [] 37 | 38 | def register_deprecated_plugin(self, deprecated_plugin: DeprecatedPlugin) -> None: 39 | """This function is automatically called by the decorator 40 | @deprecated_plugin, registering the plugin it decorates. 41 | """ 42 | self.stored_plugin_deprecations[ 43 | deprecated_plugin.class_reference 44 | ] = deprecated_plugin 45 | 46 | def register_deprecated_plugin_argument( 47 | self, 48 | deprecated_plugin_argument: Union[DeprecatedArgument, DeprecatedArgumentCheck], 49 | ) -> None: 50 | """This function is automatically called by the decorator 51 | @deprecated_plugin_arguments, registering the __init__ function it decorates. 52 | """ 53 | self.stored_plugin_argument_deprecations.append(deprecated_plugin_argument) 54 | 55 | def get_stored_plugin_deprecation(self, plugin: Any) -> Optional[DeprecatedPlugin]: 56 | return self.stored_plugin_deprecations.get(plugin) 57 | 58 | def get_stored_plugin_argument_deprecations( 59 | self, method: Callable 60 | ) -> List[Union[DeprecatedArgument, DeprecatedArgumentCheck]]: 61 | return [ 62 | stored 63 | for stored in self.stored_plugin_argument_deprecations 64 | if stored.method_reference == method 65 | ] 66 | 67 | 68 | DEPRECATION_STORE = DeprecationStore() 69 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_pivot_table.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from pathlib import Path 3 | from typing import List, Optional 4 | 5 | import pandas as pd 6 | from dash import Dash 7 | import dash_pivottable 8 | 9 | from .. import WebvizPluginABC, EncodedFile 10 | from ..webviz_store import webvizstore 11 | from ..common_cache import CACHE 12 | 13 | 14 | class PivotTable(WebvizPluginABC): 15 | """Adds a pivot table to the webviz instance, using tabular data from a \ 16 | provided csv file. 17 | 18 | --- 19 | 20 | * **`csv_file`:** Path to the csv file containing the tabular data. Either absolute \ 21 | path or relative to the configuration file. 22 | * **`options`:** Additional options for the plot. See [dash-pivottable documentation]\ 23 | (https://github.com/plotly/dash-pivottable#references) for all possible options. 24 | """ 25 | 26 | def __init__(self, app: Dash, csv_file: Path, options: dict = None): 27 | 28 | super().__init__() 29 | 30 | self.csv_file = csv_file 31 | self.options = options if options is not None else {} 32 | 33 | self.set_callbacks(app) 34 | 35 | def add_webvizstore(self) -> List[tuple]: 36 | return [(get_data, [{"csv_file": self.csv_file}])] 37 | 38 | @property 39 | def layout(self) -> dash_pivottable.PivotTable: 40 | return generate_table(get_data(self.csv_file), **self.options) 41 | 42 | def set_callbacks(self, app: Dash) -> None: 43 | @app.callback(self.plugin_data_output, self.plugin_data_requested) 44 | def _user_download_data(data_requested: Optional[int]) -> Optional[EncodedFile]: 45 | return ( 46 | { 47 | "filename": "pivot-table.csv", 48 | "content": base64.b64encode( 49 | get_data(self.csv_file).to_csv(index=False).encode() 50 | ).decode("ascii"), 51 | "mime_type": "text/csv", 52 | } 53 | if data_requested 54 | else None 55 | ) 56 | 57 | 58 | def generate_table(dframe: pd.DataFrame, **options: str) -> dash_pivottable.PivotTable: 59 | return dash_pivottable.PivotTable( 60 | data=[dframe.columns.to_list()] + dframe.values.tolist(), **options 61 | ) 62 | 63 | 64 | @CACHE.memoize() 65 | @webvizstore 66 | def get_data(csv_file: Path) -> pd.DataFrame: 67 | return pd.read_csv(csv_file) 68 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_banner_image.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Dict 3 | 4 | from dash import html 5 | 6 | from .. import WebvizPluginABC 7 | from ..webviz_assets import WEBVIZ_ASSETS 8 | 9 | 10 | class BannerImage(WebvizPluginABC): 11 | """Adds a full width banner image, with an optional overlayed title. 12 | Useful on e.g. the front page for introducing a field or project. 13 | 14 | --- 15 | 16 | * **`image`:** Path to the picture you want to add. \ 17 | Either absolute path or relative to the configuration file. 18 | * **`title`:** Title which will be overlayed over the banner image. 19 | * **`color`:** Color to be used for the font. 20 | * **`shadow`:** Set to `False` if you do not want text shadow for the title. 21 | * **`height`:** Height of the banner image (in pixels). 22 | * **`title_position`:** Position of title (either `center`, `top` or `bottom`). 23 | """ 24 | 25 | CSS_TITLE_POSITIONS: Dict[str, str] = { 26 | "top": "start", 27 | "center": "center", 28 | "bottom": "end", 29 | } 30 | 31 | def __init__( 32 | self, 33 | image: Path, 34 | title: str = "", 35 | color: str = "white", 36 | shadow: bool = True, 37 | height: int = 300, 38 | title_position: str = "center", 39 | ): 40 | 41 | super().__init__() 42 | 43 | self.image = image 44 | self.title = title 45 | self.color = color 46 | self.shadow = shadow 47 | self.height = height 48 | 49 | try: 50 | self.css_title_position = BannerImage.CSS_TITLE_POSITIONS[title_position] 51 | except KeyError as exc: 52 | raise ValueError( 53 | f"{title_position} not a valid position for banner image title. " 54 | f"Valid options: {', '.join(BannerImage.CSS_TITLE_POSITIONS.keys())}" 55 | ) from exc 56 | 57 | self.image_url = WEBVIZ_ASSETS.add(image) 58 | 59 | @property 60 | def layout(self) -> html.Div: 61 | 62 | style = { 63 | "color": self.color, 64 | "backgroundImage": f"url({self.image_url})", 65 | "height": f"{self.height}px", 66 | "align-items": self.css_title_position, 67 | } 68 | 69 | if self.shadow: 70 | style["textShadow"] = "0.05em 0.05em 0" 71 | 72 | if self.color == "white": 73 | style["textShadow"] += " rgba(0, 0, 0, 0.7)" 74 | else: 75 | style["textShadow"] += " rgba(255, 255, 255, 0.7)" 76 | 77 | return html.Div(self.title, className="_banner_image", style=style) 78 | -------------------------------------------------------------------------------- /webviz_config/deprecation_decorators.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Union, Callable, Tuple, cast, Optional, List, Type 2 | import inspect 3 | 4 | from ._plugin_abc import WebvizPluginABC 5 | from . import _deprecation_store as _ds 6 | 7 | 8 | def deprecated_plugin( 9 | deprecation_info: str = "", 10 | ) -> Callable[[Type[WebvizPluginABC]], Type[WebvizPluginABC]]: 11 | def wrapper( 12 | original_plugin: Type[WebvizPluginABC], 13 | ) -> Type[WebvizPluginABC]: 14 | 15 | _ds.DEPRECATION_STORE.register_deprecated_plugin( 16 | _ds.DeprecatedPlugin( 17 | original_plugin, 18 | f"Plugin '{original_plugin.__name__}' has been deprecated.", 19 | deprecation_info, 20 | ) 21 | ) 22 | 23 | return original_plugin 24 | 25 | return wrapper 26 | 27 | 28 | def deprecated_plugin_arguments( 29 | check: Union[Dict[str, str], Callable[..., Optional[Tuple[str, str]]]] 30 | ) -> Callable: 31 | def decorator(original_init_method: Callable) -> Callable: 32 | original_method_args = inspect.getfullargspec(original_init_method).args 33 | 34 | if callable(check): 35 | check_args = inspect.getfullargspec(check).args 36 | verified_args: List[str] = [] 37 | for check_arg in check_args: 38 | for original_arg in original_method_args: 39 | if check_arg == original_arg: 40 | verified_args.append(check_arg) 41 | 42 | _ds.DEPRECATION_STORE.register_deprecated_plugin_argument( 43 | _ds.DeprecatedArgumentCheck( 44 | original_init_method, 45 | original_init_method.__name__, 46 | verified_args, 47 | check, 48 | inspect.getsource(check), 49 | ) 50 | ) 51 | 52 | elif isinstance(check, dict): 53 | for original_arg in original_method_args: 54 | if original_arg in check.keys(): 55 | short_message = cast(Tuple[str, str], check[original_arg])[0] 56 | long_message = cast(Tuple[str, str], check[original_arg])[1] 57 | _ds.DEPRECATION_STORE.register_deprecated_plugin_argument( 58 | _ds.DeprecatedArgument( 59 | original_init_method, 60 | original_init_method.__name__, 61 | original_arg, 62 | "", 63 | short_message, 64 | long_message, 65 | ) 66 | ) 67 | return original_init_method 68 | 69 | return decorator 70 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # How we work - Open, Collaborative, Courageous, Caring 2 | 3 | In Equinor, [how we deliver is as important as what we 4 | deliver](https://www.equinor.com/en/careers/our-culture.html). 5 | 6 | As a values-based organization, our code of conduct for open source projects is 7 | simply a reflection of our values and how we live up to these as teams as well 8 | as individuals. 9 | 10 | This set the expectations for how we collaborate in our open source projects, 11 | and applies to all community members and participants in any Equinor maintained 12 | project. 13 | 14 | In addition to any definition or interpretation of the open source code of 15 | conduct, the company wide [Equinor code of conduct](https://www.equinor.com/content/dam/statoil/documents/ethics/equinor-code-of-conduct.pdf) 16 | always applies to all employees and hired contractors. 17 | 18 | ## Handling issues within the communities 19 | 20 | We expect all to have a low threshold for raising issues, or in general discuss 21 | how we live up to our 22 | [values](https://www.equinor.com/en/about-us.html#our-values). Equally, we also 23 | encourage all community members to appreciate when concerns are raised, and make 24 | its best effort in solving them. 25 | 26 | As well as responsibility for what is delivered the project maintainers are also 27 | responsible creating an environment for proper handling of issues raised within 28 | the communities. 29 | 30 | ## Call out for assistance 31 | 32 | For any problem not directly resolvable within the community, we encourage you 33 | to call out for assistance. Getting an outsiders perspective on the topic might 34 | be just what is needed for you to proceed. 35 | 36 | Send an e-mail to [opensource@equinor.com](mailto:opensource@equinor.com) and 37 | invite for a discussion. The email will be handled by a team within the Equinor organization. 38 | 39 | Your request will be kept confidential from the team or community in question, 40 | unless you chose to disclose it yourself. 41 | 42 | ## Ethics helpline 43 | 44 | In Equinor, we want you to speak up whenever you see unethical behaviour that 45 | conflicts with our values or threatens our reputation. 46 | 47 | To underline this, we continuously encourage and remind our employees and any 48 | external third parties interacting with us to raise concerns or report any 49 | suspected or potential breaches of law or company policies. 50 | 51 | For any questions or issues that one suspect falls outside the scope of 52 | behaviour regulated, and handled within the open source code of conduct, or you 53 | wish to place an anonymous, confidential report we encourage to use the 54 | [Equinor Ethics helpline](https://secure.ethicspoint.eu/domain/media/en/gui/102166/index.html). 55 | 56 | This helpline is hosted by a third party helpline provider. 57 | -------------------------------------------------------------------------------- /webviz_config/webviz_plugin_subclasses/_settings_group_abc.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Optional, Type, Union 2 | import abc 3 | 4 | from dash.development.base_component import Component 5 | import webviz_core_components as wcc 6 | 7 | from ._layout_base_abc import LayoutBaseABC 8 | from ._layout_unique_id import LayoutUniqueId 9 | 10 | 11 | class SettingsGroupABC(LayoutBaseABC): 12 | def __init__(self, title: str) -> None: 13 | super().__init__() 14 | 15 | self.title = title 16 | self._layout_created: bool = False 17 | self._visible_in_views: List[str] = [] 18 | self._not_visible_in_views: List[str] = [] 19 | 20 | def _set_visible_in_views(self, visible_in_views: List[str]) -> None: 21 | self._visible_in_views = visible_in_views 22 | 23 | def _set_not_visible_in_views(self, not_visible_in_views: List[str]) -> None: 24 | self._not_visible_in_views = not_visible_in_views 25 | 26 | def _set_plugin_register_id_func( 27 | self, func: Callable[[Union[str, List[str]]], None] 28 | ) -> None: 29 | self._plugin_register_id_func = func 30 | 31 | def register_component_unique_id(self, component_name: str) -> str: 32 | uuid = self.component_unique_id(component_name).to_string() 33 | if self._plugin_register_id_func and not self._layout_created: 34 | self._plugin_register_id_func(uuid) 35 | 36 | return uuid 37 | 38 | def component_unique_id(self, component_name: str) -> LayoutUniqueId: 39 | component_uuid = LayoutUniqueId(other=self.get_unique_id()) 40 | component_uuid.set_component_id(component_name) 41 | return component_uuid 42 | 43 | @abc.abstractmethod 44 | def layout(self) -> Union[List[Component], Type[Component]]: 45 | raise NotImplementedError 46 | 47 | def _wrapped_layout( 48 | self, 49 | view_id: Optional[str] = "", 50 | plugin_id: Optional[str] = "", 51 | always_open: bool = False, 52 | ) -> Type[Component]: 53 | layout = self.layout() 54 | wrapped_layout = wcc.WebvizSettingsGroup( 55 | id=str(self.get_unique_id()), 56 | title=self.title, 57 | viewId=view_id, 58 | pluginId=plugin_id, 59 | visibleInViews=self._visible_in_views 60 | if len(self._visible_in_views) > 0 61 | else None, 62 | notVisibleInViews=self._not_visible_in_views 63 | if len(self._not_visible_in_views) > 0 64 | else None, 65 | alwaysOpen=always_open, 66 | children=layout if isinstance(layout, list) else [layout], 67 | ) 68 | self._layout_created = True 69 | return wrapped_layout 70 | 71 | def set_callbacks(self) -> None: 72 | pass 73 | -------------------------------------------------------------------------------- /webviz_config/static/assets/webviz_layout.css: -------------------------------------------------------------------------------- 1 | .styledLogo { 2 | border: none; 3 | background: transparent; 4 | outline: none; 5 | cursor: pointer; 6 | text-align: left; 7 | box-sizing: none; 8 | padding: 20px 30px; 9 | } 10 | 11 | .styledButton { 12 | border: none; 13 | background: transparent; 14 | outline: none; 15 | cursor: pointer; 16 | transition: background-color, color 200ms; 17 | text-align: left; 18 | box-sizing: none; 19 | font-size: 16px; 20 | font-family: var(--menuLinkFont); 21 | font-weight: var(--menuLinkFontWeight); 22 | font-style: var(--menuLinkFontStyle); 23 | color: var(--menuLinkColor); 24 | text-decoration: none; 25 | padding: 20px 30px; 26 | border-bottom: 1px solid white; 27 | } 28 | 29 | .styledButton:hover { 30 | color: var(--menuLinkHoverColor); 31 | background-color: var(--menuLinkBackgroundHover); 32 | } 33 | 34 | .selectedButton--selected { 35 | color: var(--menuLinkColorSelected); 36 | background-color: var(--menuLinkBackgroundSelected); 37 | } 38 | 39 | .layoutWrapper { 40 | display: flex; 41 | flex-direction: row; 42 | width: 100%; 43 | } 44 | .sideWrapper { 45 | display: flex; 46 | flex: 1; 47 | flex-direction: column; 48 | overflow-x: hidden; 49 | overflow-y: scroll; 50 | height: 98vh; 51 | min-width: 300px; 52 | scrollbar-width: thin; 53 | scrollbar-color: var(--menuBackground) var(--menuBackground); /* thumb and track color */ 54 | } 55 | 56 | .pageContent { 57 | display: flex; 58 | flex: 8; 59 | flex-direction: column; 60 | height: 100%; 61 | width: 100%; 62 | padding: 15px; 63 | } 64 | 65 | 66 | @media (min-width: 800px) { 67 | .sideWrapper { 68 | width: 300px; 69 | } 70 | } 71 | 72 | .sideWrapper:hover { 73 | scrollbar-color: var(--menuLinkColor) var(--menuBackground); /* thumb and track color */ 74 | } 75 | 76 | .sideWrapper::-webkit-scrollbar 77 | { 78 | width: 3px; 79 | background-color:var(--menuBackground); 80 | } 81 | 82 | .sideWrapper::-webkit-scrollbar-track 83 | { 84 | -webkit-box-shadow: inset 0 0 3px var(--menuBackground); 85 | background-color: var(--menuBackground); 86 | } 87 | 88 | .sideWrapper:hover::-webkit-scrollbar-track 89 | { 90 | -webkit-box-shadow: inset 0 0 3px var(--menuLinkBackgroundHover); 91 | background-color: var(--menuBackground); 92 | } 93 | 94 | .sideWrapper::-webkit-scrollbar-thumb 95 | { 96 | background-color: var(--menuBackground); 97 | } 98 | 99 | .sideWrapper:hover::-webkit-scrollbar-thumb 100 | { 101 | background-color: var(--menuLinkColor); 102 | } 103 | 104 | #logo { 105 | padding: 50px; 106 | border: none; 107 | max-width: 100%; 108 | height: auto; 109 | } 110 | -------------------------------------------------------------------------------- /tests/test_plugin_init.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from unittest import mock 3 | import importlib 4 | import importlib.metadata 5 | 6 | import webviz_config.plugins._utils 7 | 8 | 9 | class DistMock: 10 | # pylint: disable=too-few-public-methods 11 | def __init__(self, entry_points, name): 12 | self.metadata = {"name": name} 13 | 14 | self.entry_points = entry_points 15 | self.version = "123" 16 | 17 | 18 | plugin_entrypoint_mock1 = mock.Mock() 19 | plugin_entrypoint_mock1.group = "webviz_config_plugins" 20 | plugin_entrypoint_mock1.name = "SomePlugin1" 21 | 22 | plugin_entrypoint_mock2 = mock.Mock() 23 | plugin_entrypoint_mock2.group = "webviz_config_plugins" 24 | plugin_entrypoint_mock2.name = "SomePlugin2" 25 | 26 | dist_mock1 = DistMock([plugin_entrypoint_mock1], "dist_mock1") 27 | dist_mock2 = DistMock([plugin_entrypoint_mock1], "dist_mock2") 28 | dist_mock3 = DistMock([plugin_entrypoint_mock2], "dist_mock3") 29 | 30 | 31 | def test_no_warning(monkeypatch): 32 | # pylint: disable=protected-access 33 | monkeypatch.setattr(importlib.metadata, "requires", lambda x: []) 34 | importlib.reload(webviz_config.plugins._utils) 35 | 36 | with warnings.catch_warnings(record=True) as warn: 37 | ( 38 | metadata, 39 | _, 40 | plugin_entrypoints, 41 | ) = webviz_config.plugins._utils.load_webviz_plugins_with_metadata( 42 | [dist_mock1, dist_mock3] 43 | ) 44 | assert len(warn) == 0, "Too many warnings" 45 | 46 | assert len(metadata) == 2, "Wrong number of items in metadata" 47 | assert "SomePlugin1" in plugin_entrypoints 48 | assert "SomePlugin2" in plugin_entrypoints 49 | 50 | 51 | def test_warning_multiple(monkeypatch): 52 | # pylint: disable=protected-access 53 | monkeypatch.setattr(importlib.metadata, "requires", lambda x: []) 54 | importlib.reload(webviz_config.plugins._utils) 55 | 56 | with warnings.catch_warnings(record=True) as warn: 57 | ( 58 | metadata, 59 | _, 60 | plugin_entrypoints, 61 | ) = webviz_config.plugins._utils.load_webviz_plugins_with_metadata( 62 | [dist_mock1, dist_mock2] 63 | ) 64 | 65 | assert len(warn) == 1 66 | assert issubclass(warn[-1].category, RuntimeWarning) 67 | print(warn[-1].message) 68 | assert str(warn[-1].message) == ( 69 | "Multiple versions of plugin with name SomePlugin1. " 70 | "Already loaded from project dist_mock1. " 71 | "Overwriting using plugin with from project dist_mock2" 72 | ) 73 | 74 | assert len(metadata) == 1, "Wrong number of items in metadata" 75 | assert metadata["SomePlugin1"]["dist_name"] == "dist_mock2", "Wrong dist name" 76 | assert "SomePlugin1" in plugin_entrypoints 77 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_data_table.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from pathlib import Path 3 | from typing import List, Optional 4 | 5 | import pandas as pd 6 | from dash import dash_table, Dash 7 | 8 | from .. import WebvizPluginABC, EncodedFile 9 | from ..webviz_store import webvizstore 10 | from ..common_cache import CACHE 11 | 12 | 13 | class DataTable(WebvizPluginABC): 14 | """Adds a table to the webviz instance, using tabular data from a provided csv file. 15 | If feature is requested, the data could also come from a database. 16 | 17 | --- 18 | 19 | * **`csv_file`:** Path to the csv file containing the tabular data. Either absolute \ 20 | path or relative to the configuration file. 21 | * **`sorting`:** If `True`, the table can be sorted interactively based \ 22 | on data in the individual columns. 23 | * **`filtering`:** If `True`, the table can be filtered based on values in the \ 24 | individual columns. 25 | * **`pagination`:** If `True`, only a subset of the table is displayed at once. \ 26 | Different subsets can be viewed from 'previous/next' buttons 27 | """ 28 | 29 | def __init__( 30 | self, 31 | app: Dash, 32 | csv_file: Path, 33 | sorting: bool = True, 34 | filtering: bool = True, 35 | pagination: bool = True, 36 | ): 37 | 38 | super().__init__() 39 | 40 | self.csv_file = csv_file 41 | self.df = get_data(self.csv_file) 42 | self.sorting = sorting 43 | self.filtering = filtering 44 | self.pagination = pagination 45 | 46 | self.set_callbacks(app) 47 | 48 | def add_webvizstore(self) -> List[tuple]: 49 | return [(get_data, [{"csv_file": self.csv_file}])] 50 | 51 | @property 52 | def layout(self) -> dash_table.DataTable: 53 | return dash_table.DataTable( 54 | columns=[{"name": i, "id": i} for i in self.df.columns], 55 | data=self.df.to_dict("records"), 56 | sort_action="native" if self.sorting else "none", 57 | filter_action="native" if self.filtering else "none", 58 | page_action="native" if self.pagination else "none", 59 | ) 60 | 61 | def set_callbacks(self, app: Dash) -> None: 62 | @app.callback(self.plugin_data_output, self.plugin_data_requested) 63 | def _user_download_data(data_requested: Optional[int]) -> Optional[EncodedFile]: 64 | return ( 65 | { 66 | "filename": "data-table.csv", 67 | "content": base64.b64encode( 68 | get_data(self.csv_file).to_csv(index=False).encode() 69 | ).decode("ascii"), 70 | "mime_type": "text/csv", 71 | } 72 | if data_requested 73 | else None 74 | ) 75 | 76 | 77 | @CACHE.memoize() 78 | @webvizstore 79 | def get_data(csv_file: Path) -> pd.DataFrame: 80 | return pd.read_csv(csv_file) 81 | -------------------------------------------------------------------------------- /webviz_config/templates/Dockerfile.jinja2: -------------------------------------------------------------------------------- 1 | ###################### 2 | # Installation image # 3 | ###################### 4 | 5 | FROM python:{{python_version_major}}.{{python_version_minor}}-slim AS builder 6 | 7 | # Install git and ssh, in order to install Python 8 | # packages not available from PyPI. 9 | RUN apt-get update && \ 10 | apt-get install -y git {{ "ssh" if ssh_required else "" }} 11 | 12 | # Changing to non-root user early 13 | RUN useradd --create-home appuser 14 | WORKDIR /home/appuser 15 | USER appuser 16 | 17 | # Set environment variables 18 | ENV PATH="${PATH}:/home/appuser/.local/bin"{% if ssh_required %} \ 19 | GIT_SSH_COMMAND="ssh $(cat ssh_identity_files) -o IdentitiesOnly=yes -o UserKnownHostsFile=./temp-known-host" \ 20 | KNOWN_GITHUB_FINGERPRINT="SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8" 21 | 22 | # Read GitHub deploy key environment variable, and move to 23 | # temporary files which the ssh command can read from. 24 | ARG GITHUB_DEPLOY_KEYS 25 | RUN python -c "\ 26 | import base64, os, stat; from pathlib import Path; \ 27 | keys = base64.b64decode(os.environ['GITHUB_DEPLOY_KEYS']).decode('utf-8').split(','); \ 28 | paths = [f'github_deploy_key_{i}' for i, _ in enumerate(keys)]; \ 29 | [Path(path).write_text(key) for path, key in zip(paths, keys)]; \ 30 | [Path(path).chmod(stat.S_IRUSR) for path in paths]; \ 31 | Path('ssh_identity_files').write_text('-i ' + ' -i '.join(paths))" 32 | 33 | # Add github.com ssh as known host, and verify that fingerprint equals known value 34 | RUN FINGERPRINT=$(ssh-keyscan -t rsa github.com | tee ./temp-known-host | ssh-keygen -lf -) && \ 35 | if [ -n "${FINGERPRINT##*$KNOWN_GITHUB_FINGERPRINT*}" ] ;then \ 36 | echo "ERROR: GitHub SSH fingerprint does not match known fingerprint!"; \ 37 | exit 1; \ 38 | fi 39 | {% endif %} 40 | 41 | # Install relevant Python packages 42 | COPY --chown=appuser requirements.txt requirements.txt 43 | RUN pip install -r requirements.txt 44 | 45 | ############### 46 | # Final image # 47 | ############### 48 | 49 | FROM python:{{python_version_major}}.{{python_version_minor}}-slim 50 | 51 | # Changing to non-root user early 52 | RUN useradd --create-home --uid 1234 appuser 53 | USER 1234 54 | WORKDIR /home/appuser 55 | 56 | # Set environment variables 57 | ENV PATH="${PATH}:/home/appuser/.local/bin" \ 58 | PYTHONFAULTHANDLER=1 59 | 60 | # Copy over appuser installed Python packages 61 | COPY --chown=appuser --from=builder /home/appuser/.local /home/appuser/.local 62 | 63 | # Copy over the created Webviz application 64 | COPY --chown=appuser . dash_app 65 | 66 | EXPOSE 5000 67 | 68 | # Define startup command of container image 69 | CMD gunicorn \ 70 | --access-logfile "-" \ 71 | --bind 0.0.0.0:5000 \ 72 | --keep-alive 120 \ 73 | --preload \ 74 | --workers 2 \ 75 | --worker-class gthread \ 76 | --worker-tmp-dir /dev/shm \ 77 | --threads 2 \ 78 | --timeout 100000 \ 79 | "dash_app.webviz_app:server" 80 | -------------------------------------------------------------------------------- /examples/basic_example.yaml: -------------------------------------------------------------------------------- 1 | # This file demonstrates the most basic usage of webviz in a FMU setting 2 | # The configuration files uses YAML (https://en.wikipedia.org/wiki/YAML). 3 | 4 | title: Reek Webviz Demonstration 5 | 6 | options: 7 | menu: 8 | initially_pinned: True 9 | plotly_theme: 10 | yaxis: 11 | showgrid: True 12 | gridcolor: lightgrey 13 | 14 | layout: 15 | 16 | - page: Front page 17 | content: 18 | - BannerImage: 19 | image: ./example_banner.png 20 | title: My banner image 21 | - Webviz created from configuration file. 22 | - Some other text, potentially with strange letters like Åre, Smørbukk Sør. 23 | - And even math like $e^{\pi i} + 1 = 0$. 24 | 25 | - page: Markdown example 26 | content: 27 | - Markdown: 28 | markdown_file: ./example-markdown.md 29 | 30 | - page: Table example 31 | content: 32 | - DataTable: 33 | csv_file: ./example_data.csv 34 | 35 | - page: PDF example 36 | content: 37 | - EmbedPdf: 38 | pdf_file: ./example.pdf 39 | 40 | - page: Syntax highlighting example 41 | content: 42 | - SyntaxHighlighter: 43 | filename: ./basic_example.yaml 44 | 45 | - page: Plot a table 46 | content: 47 | - TablePlotter: 48 | csv_file: ./example_data.csv 49 | # Everything below are examples of optional settings 50 | filter_cols: 51 | - Well 52 | - Segment 53 | plot_options: 54 | type: bar 55 | facet_col: Well 56 | color: Segment 57 | barmode: group 58 | filter_defaults: 59 | Well: 60 | - A-1H 61 | - A-2H 62 | - C-1H 63 | column_color_discrete_maps: 64 | # Supports css color codes, rgb and hex code. 65 | # Note that hex code needs quotes '' to not be read as a comment 66 | Segment: 67 | A: '#ff0000' 68 | B: rgb(0,255,0) 69 | C: blue 70 | contact_person: 71 | name: Ola Nordmann 72 | phone: +47 12345678 73 | email: some@email.com 74 | 75 | - page: Plot a table (locked) 76 | content: 77 | - TablePlotter: 78 | csv_file: ./example_data.csv 79 | lock: true 80 | plot_options: 81 | x: Well 82 | y: Initial reservoir pressure (bar) 83 | size: Average permeability (D) 84 | filter_cols: 85 | - Well 86 | contact_person: 87 | name: Kari Nordmann 88 | phone: 12345678 89 | email: someother@email.com 90 | 91 | - page: Pivot Table 92 | content: 93 | - PivotTable: 94 | csv_file: ./example_data.csv 95 | options: 96 | cols: 97 | - Well 98 | rows: 99 | - Segment 100 | vals: 101 | - Average permeability (D) 102 | aggregatorName: Average 103 | rendererName: Table Heatmap 104 | -------------------------------------------------------------------------------- /webviz_config/templates/radixconfig.yaml.jinja2: -------------------------------------------------------------------------------- 1 | apiVersion: radix.equinor.com/v1 2 | kind: RadixApplication 3 | metadata: 4 | name: {{ application_name }} 5 | spec: 6 | environments: 7 | - name: prod 8 | build: 9 | from: main 10 | components: 11 | - name: auth 12 | image: quay.io/oauth2-proxy/oauth2-proxy:v7.1.3 13 | ports: 14 | - name: http 15 | port: 8000 16 | publicPort: http 17 | secrets: 18 | - OAUTH2_PROXY_CLIENT_SECRET 19 | - OAUTH2_PROXY_COOKIE_SECRET 20 | - OAUTH2_PROXY_REDIRECT_URL # Redirect URL a "secret" so it can be configured per cluster, but it's not sensitive information. 21 | environmentConfig: 22 | - environment: prod 23 | variables: 24 | OAUTH2_PROXY_SCOPE: openid offline_access profile {{ app_registration_id }}/user_impersonation email 25 | OAUTH2_PROXY_CLIENT_ID: {{ app_registration_id }} 26 | OAUTH2_PROXY_COOKIE_REFRESH: "60m" # How often should the token be refreshed. Default for Azure AD is currently 60m 27 | OAUTH2_PROXY_EMAIL_DOMAINS: "*" # Any email address registered in AD should be accepted 28 | OAUTH2_PROXY_HTTP_ADDRESS: "http://:8000" # The port oauth2_proxy listens on 29 | OAUTH2_PROXY_OIDC_ISSUER_URL: "https://login.microsoftonline.com/{{ tenant_id }}/v2.0" 30 | OAUTH2_PROXY_PASS_ACCESS_TOKEN: "true" # Pass the access token upstream (to the proxy component) 31 | OAUTH2_PROXY_PASS_BASIC_AUTH: "false" # Disable unused default 32 | OAUTH2_PROXY_PASS_USER_HEADERS: "false" # Disable unused default 33 | OAUTH2_PROXY_PROVIDER: "oidc" # The "azure" provider doesn't seem to work properly 34 | OAUTH2_PROXY_REDIS_CONNECTION_URL: "redis://auth-state:6379" # Where to store session info (the auth-state component) 35 | OAUTH2_PROXY_SESSION_STORE_TYPE: "redis" # We're using Redis for storing session info instead of cookies (cookies would get too big) 36 | OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true" # We don't want a "click to login" page; just issue a redirect 37 | OAUTH2_PROXY_UPSTREAMS: "http://main:5000" # Where authenticated requests are routed to: the web application 38 | 39 | - name: auth-state 40 | dockerfileName: auth-state.Dockerfile 41 | ports: 42 | - name: redis 43 | port: 6379 44 | 45 | - name: main 46 | src: "." 47 | ports: 48 | - name: http 49 | port: 5000 50 | environmentConfig: 51 | - environment: prod 52 | resources: 53 | requests: 54 | memory: "400Mi" 55 | cpu: "100m" 56 | limits: 57 | memory: "8G" 58 | cpu: "1000m" 59 | volumeMounts: 60 | - type: azure-blob 61 | name: appstorage 62 | storage: {{ azure_storage_container_name }} 63 | path: /home/appuser/dash_app/resources 64 | uid: 1234 65 | dnsAppAlias: 66 | environment: prod 67 | component: auth 68 | -------------------------------------------------------------------------------- /tests/test_docstring.py: -------------------------------------------------------------------------------- 1 | from webviz_config._docs._build_docs import _split_docstring 2 | 3 | 4 | def test_split_docstring(): 5 | tests = [ 6 | ( 7 | "This is a test with only description.", 8 | ["This is a test with only description."], 9 | ), 10 | ( 11 | ( 12 | " This is a test with only description, " 13 | "but which has leading and trailing spaces. " 14 | ), 15 | [ 16 | ( 17 | "This is a test with only description, " 18 | "but which has leading and trailing spaces." 19 | ) 20 | ], 21 | ), 22 | ( 23 | "Test with some newlines\n\n \n and a 4 space indent", 24 | ["Test with some newlines\n\n\nand a 4 space indent"], 25 | ), 26 | ( 27 | "Test with a \n 4 space and a \n 2 space indent\n", 28 | ["Test with a \n 4 space and a \n2 space indent"], 29 | ), 30 | ( 31 | "Test with a \n 4 space and a \n 2 space indent\n", 32 | ["Test with a \n 4 space and a \n2 space indent"], 33 | ), 34 | ( 35 | ( 36 | " This is a test with description, arguments and data input." 37 | "\n\n ---\n\n The docstring is indented by 4 spaces:\n " 38 | "* The argument list has a sub list indented by 8 spaces\n like this." 39 | "\n ---\n The data input section is not very interesting." 40 | ), 41 | [ 42 | "This is a test with description, arguments and data input.\n", 43 | ( 44 | "\nThe docstring is indented by 4 spaces:\n" 45 | "* The argument list has a sub list indented by 8 spaces\n like this." 46 | ), 47 | "The data input section is not very interesting.", 48 | ], 49 | ), 50 | ( 51 | ( 52 | "\tThis is a test with description, arguments and data input," 53 | " where indents are made with tabs and not spaces-" 54 | "\n\n\t---\n\n\tThe docstring is indented by 1 tab:\n" 55 | "\t* The argument list has a sub list indented by 2 tabs\n\t\tlike this." 56 | "\n\t---\n\tThe data input section is not very interesting." 57 | ), 58 | [ 59 | ( 60 | "This is a test with description, arguments and data input," 61 | " where indents are made with tabs and not spaces-\n" 62 | ), 63 | ( 64 | "\nThe docstring is indented by 1 tab:\n" 65 | "* The argument list has a sub list indented by 2 tabs\n\tlike this." 66 | ), 67 | "The data input section is not very interesting.", 68 | ], 69 | ), 70 | ( 71 | "This is a test with only description, which includes a linebreak.\n", 72 | ["This is a test with only description, which includes a linebreak."], 73 | ), 74 | ] 75 | for test in tests: 76 | assert _split_docstring(test[0]) == test[1] 77 | -------------------------------------------------------------------------------- /webviz_config/_deployment/azure_configuration.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Dict 3 | 4 | from . import interactive_terminal 5 | from . import azure_cli 6 | 7 | 8 | def azure_configuration() -> Dict[str, str]: 9 | # Select Azure subscription 10 | interactive_terminal.terminal_title("Azure subscription") 11 | subscription = interactive_terminal.user_input_from_list( 12 | env_var="WEBVIZ_AZURE_SUBSCRIPTION", 13 | noun="subscription", 14 | choices=azure_cli.subscriptions(), 15 | ) 16 | 17 | # Select resource group 18 | interactive_terminal.terminal_title("Azure resource group") 19 | resource_group = interactive_terminal.user_input_from_list( 20 | env_var="WEBVIZ_AZURE_RESOURCE_GROUP", 21 | noun="resource group", 22 | choices=azure_cli.resource_groups(subscription), 23 | ) 24 | 25 | # Choose app registration 26 | interactive_terminal.terminal_title("Azure app registration") 27 | display_name = interactive_terminal.user_input_optional_reuse( 28 | env_var="WEBVIZ_AZURE_APP_REGISTRATION_DISPLAY_NAME", 29 | noun="app registration", 30 | exists_function=azure_cli.existing_app_registration, 31 | ) 32 | 33 | # Choose Azure storage account 34 | storage_account_exists = functools.partial( 35 | azure_cli.storage_account_exists, 36 | subscription=subscription, 37 | resource_group=resource_group, 38 | ) 39 | 40 | interactive_terminal.terminal_title("Azure storage account") 41 | storage_account_name = interactive_terminal.user_input_optional_reuse( 42 | env_var="WEBVIZ_AZURE_STORAGE_ACCOUNT", 43 | noun="storage account", 44 | exists_function=storage_account_exists, 45 | regex="^[a-z0-9]{3,24}$", 46 | ) 47 | 48 | if not storage_account_exists(storage_account_name): 49 | azure_cli.create_storage_account( 50 | subscription, resource_group, storage_account_name 51 | ) 52 | print("✓ Created storage account.") 53 | 54 | # Choose Azure storage container 55 | storage_container_exists = functools.partial( 56 | azure_cli.storage_container_exists, 57 | account_name=storage_account_name, 58 | subscription=subscription, 59 | resource_group=resource_group, 60 | ) 61 | 62 | interactive_terminal.terminal_title("Azure storage container") 63 | 64 | storage_container_name = interactive_terminal.user_input_optional_reuse( 65 | env_var="WEBVIZ_AZURE_STORAGE_CONTAINER", 66 | noun="storage container", 67 | exists_function=storage_container_exists, 68 | ) 69 | 70 | if not storage_container_exists(storage_container_name): 71 | azure_cli.create_storage_container( 72 | subscription=subscription, 73 | resource_group=resource_group, 74 | storage_account=storage_account_name, 75 | container=storage_container_name, 76 | ) 77 | print(f"✓ Created storage container '{storage_container_name}'.") 78 | 79 | return { 80 | "subscription": subscription, 81 | "resource_group": resource_group, 82 | "storage_account_name": storage_account_name, 83 | "storage_account_key_secret": azure_cli.get_storage_account_access_key( 84 | subscription=subscription, 85 | resource_group=resource_group, 86 | account_name=storage_account_name, 87 | ), 88 | "storage_container_name": storage_container_name, 89 | "display_name": display_name, 90 | } 91 | -------------------------------------------------------------------------------- /examples/basic_example_advanced_menu.yaml: -------------------------------------------------------------------------------- 1 | # This file demonstrates the most basic usage of webviz in a FMU setting 2 | # The configuration files uses YAML (https://en.wikipedia.org/wiki/YAML). 3 | 4 | title: Reek Webviz Demonstration 5 | 6 | options: 7 | menu: 8 | show_logo: True 9 | bar_position: left 10 | drawer_position: left 11 | initially_pinned: True 12 | 13 | layout: 14 | - section: Section 15 | content: 16 | - page: Front page 17 | icon: home 18 | content: 19 | - BannerImage: 20 | image: ./example_banner.png 21 | title: My banner image 22 | - Webviz created from configuration file. 23 | - Some other text, potentially with strange letters like Åre, Smørbukk Sør. 24 | 25 | - group: Other 26 | icon: label 27 | content: 28 | - page: Markdown example 29 | content: 30 | - Markdown: 31 | markdown_file: ./example-markdown.md 32 | 33 | - page: PDF example 34 | content: 35 | - EmbedPdf: 36 | pdf_file: ./example.pdf 37 | 38 | - page: Syntax highlighting example 39 | content: 40 | - SyntaxHighlighter: 41 | filename: ./basic_example.yaml 42 | 43 | - group: Tables 44 | icon: table_chart 45 | content: 46 | - page: Table example 47 | content: 48 | - DataTable: 49 | csv_file: ./example_data.csv 50 | 51 | - page: Plot a table 52 | content: 53 | - TablePlotter: 54 | csv_file: ./example_data.csv 55 | # Everything below are examples of optional settings 56 | filter_cols: 57 | - Well 58 | - Segment 59 | - Average permeability (D) 60 | plot_options: 61 | type: bar 62 | facet_col: Well 63 | color: Segment 64 | barmode: group 65 | filter_defaults: 66 | Well: 67 | - A-1H 68 | - A-2H 69 | - C-1H 70 | column_color_discrete_maps: 71 | # Supports css color codes, rgb and hex code. 72 | # Note that hex code needs quotes '' to not be read as a comment 73 | Segment: 74 | A: '#ff0000' 75 | B: rgb(0,255,0) 76 | C: blue 77 | contact_person: 78 | name: Ola Nordmann 79 | phone: +47 12345678 80 | email: some@email.com 81 | 82 | - page: Plot a table (locked) 83 | content: 84 | - TablePlotter: 85 | csv_file: ./example_data.csv 86 | lock: true 87 | plot_options: 88 | x: Well 89 | y: Initial reservoir pressure (bar) 90 | size: Average permeability (D) 91 | facet_col: Segment 92 | contact_person: 93 | name: Kari Nordmann 94 | phone: 12345678 95 | email: someother@email.com 96 | 97 | - page: Pivot Table 98 | content: 99 | - PivotTable: 100 | csv_file: ./example_data.csv 101 | options: 102 | cols: 103 | - Well 104 | rows: 105 | - Segment 106 | vals: 107 | - Average permeability (D) 108 | aggregatorName: Average 109 | rendererName: Table Heatmap 110 | -------------------------------------------------------------------------------- /webviz_config/templates/copy_data_template.py.jinja2: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import logging.config 6 | import datetime 7 | from pathlib import Path 8 | 9 | import dash 10 | 11 | import webviz_config 12 | import webviz_config.plugins 13 | from webviz_config.themes import installed_themes 14 | from webviz_config.webviz_store import WEBVIZ_STORAGE 15 | from webviz_config.webviz_assets import WEBVIZ_ASSETS 16 | from webviz_config.common_cache import CACHE 17 | from webviz_config.webviz_instance_info import WebvizRunMode, WEBVIZ_INSTANCE_INFO 18 | from webviz_config.webviz_factory_registry import WEBVIZ_FACTORY_REGISTRY 19 | from webviz_config.utils import deprecate_webviz_settings_attribute_in_dash_app 20 | 21 | logging.basicConfig(level=logging.{{ loglevel }}) 22 | 23 | theme = webviz_config.WebvizConfigTheme("{{ theme_name }}") 24 | theme.from_json((Path(__file__).resolve().parent / "theme_settings.json").read_text()) 25 | theme.plotly_theme_layout_update({{ options.plotly_theme }}) 26 | 27 | dash._dash_renderer._set_react_version("18.2.0") 28 | 29 | app = dash.Dash() 30 | app.config.suppress_callback_exceptions = True 31 | 32 | # Trigger import of plugins used, such that SHARED_SETTINGS_SUBSCRIPTIONS 33 | # is populated before setting webviz_settings 34 | {% for page in pageContents %} 35 | {% for content in page.content %} 36 | {% if content is not string %} 37 | webviz_config.plugins.{{ content._call_signature[0].split('(')[0]}} 38 | {% endif %} 39 | {% endfor %} 40 | {% endfor %} 41 | 42 | # Create the common webviz_setting object that will get passed as an 43 | # argument to all plugins that request it. 44 | webviz_settings: webviz_config.WebvizSettings = webviz_config.WebvizSettings( 45 | shared_settings=webviz_config.SHARED_SETTINGS_SUBSCRIPTIONS.transformed_settings( 46 | {{ shared_settings }}, {{ config_folder }}, {{ False }} 47 | ), 48 | theme=theme, 49 | ) 50 | 51 | # Previously, webviz_settings was piggybacked onto the Dash application object. 52 | # For a period of time, keep it but mark access to the webviz_settings attribute 53 | # on the Dash application object as deprecated. 54 | deprecate_webviz_settings_attribute_in_dash_app() 55 | app._deprecated_webviz_settings = { 56 | "shared_settings" : webviz_settings.shared_settings, 57 | "theme" : webviz_settings.theme 58 | } 59 | 60 | {% if logging_config_dict is defined %} 61 | # Apply a logging config dict as specified via the --logconfig command line argument. 62 | logging.config.dictConfig({{ logging_config_dict }}) 63 | {% endif %} 64 | 65 | CACHE.init_app(app.server) 66 | 67 | storage_folder = Path(__file__).resolve().parent / "resources" / "webviz_storage" 68 | 69 | WEBVIZ_STORAGE.storage_folder = storage_folder 70 | 71 | WEBVIZ_INSTANCE_INFO.initialize( 72 | dash_app=app, 73 | run_mode=WebvizRunMode.BUILDING_PORTABLE, 74 | theme=theme, 75 | storage_folder=storage_folder 76 | ) 77 | 78 | WEBVIZ_FACTORY_REGISTRY.initialize({{ internal_factory_settings if internal_factory_settings is defined else None }}) 79 | 80 | plugins = [] 81 | 82 | {% for page in pageContents %} 83 | {% for content in page.content %} 84 | {% if content is not string %} 85 | plugins.append(webviz_config.plugins.{{ content._call_signature[0] }}) 86 | {% endif %} 87 | {% endfor %} 88 | {% endfor %} 89 | 90 | WEBVIZ_FACTORY_REGISTRY.cleanup_resources_after_plugin_init() 91 | 92 | for plugin in plugins: 93 | if hasattr(plugin, "add_webvizstore"): 94 | WEBVIZ_STORAGE.register_function_arguments(plugin.add_webvizstore()) 95 | 96 | WEBVIZ_ASSETS.make_portable(Path(__file__).resolve().parent / "resources" / "assets") 97 | 98 | WEBVIZ_STORAGE.build_store() 99 | -------------------------------------------------------------------------------- /webviz_config/_localhost_token.py: -------------------------------------------------------------------------------- 1 | import os 2 | import secrets 3 | 4 | import flask 5 | 6 | from ._is_reload_process import is_reload_process 7 | from ._oauth2 import Oauth2 8 | 9 | 10 | class LocalhostToken: 11 | """Uses a method similar to jupyter notebook. This method is only used during 12 | interactive usage on localhost, and the workflow is as follows: 13 | 14 | - During the flask app building, a one-time-token (ott) and a cookie_token 15 | is generated. 16 | - When the app is ready, the user needs to "login" using this 17 | one-time-token in the url (http://localhost:{port}?ott={token}) 18 | - If ott is valid - a cookie with a separate token is set, and the 19 | one-time-token is discarded. The cookie is then used for subsequent 20 | requests. 21 | 22 | If the user fails both providing a valid one-time-token and a valid cookie 23 | token, all localhost requests gets a 401. 24 | 25 | If the app is in non-portable mode, the one-time-token and 26 | cookie token are reused on app reload (in order to ensure live reload 27 | works without requiring new login). 28 | 29 | The port is used as a postfix on the cookie name in order to make sure that 30 | two different localhost applications running simultaneously do not interfere. 31 | """ 32 | 33 | def __init__(self, app: flask.app.Flask, port: int, oauth2: Oauth2 = None): 34 | self._app = app 35 | self._port = port 36 | self._oauth2 = oauth2 37 | 38 | if not is_reload_process(): 39 | # One time token (per run) user has to provide 40 | # when visiting the localhost app the first time. 41 | self._ott = os.environ["WEBVIZ_OTT"] = LocalhostToken.generate_token() 42 | 43 | # This is the cookie token set in the users browser after 44 | # successfully providing the one time token 45 | self._cookie_token = os.environ[ 46 | "WEBVIZ_COOKIE_TOKEN" 47 | ] = LocalhostToken.generate_token() 48 | 49 | else: 50 | self._ott = os.environ["WEBVIZ_OTT"] 51 | self._cookie_token = os.environ["WEBVIZ_COOKIE_TOKEN"] 52 | 53 | self._ott_validated = False 54 | self.set_request_decorators() 55 | 56 | @staticmethod 57 | def generate_token() -> str: 58 | return secrets.token_urlsafe(nbytes=64) 59 | 60 | @property 61 | def one_time_token(self) -> str: 62 | return self._ott 63 | 64 | def set_request_decorators(self) -> None: 65 | # pylint: disable=inconsistent-return-statements 66 | @self._app.before_request 67 | def _check_for_ott_or_cookie(): # type: ignore[no-untyped-def] 68 | 69 | if not self._ott_validated and self._ott == flask.request.args.get("ott"): 70 | self._ott_validated = True 71 | flask.g.set_cookie_token = True 72 | return flask.redirect(flask.request.base_url) 73 | 74 | if self._cookie_token == flask.request.cookies.get( 75 | f"cookie_token_{self._port}" 76 | ): 77 | self._ott_validated = True 78 | 79 | if self._oauth2: 80 | return self._oauth2.check_access_token() 81 | 82 | else: 83 | flask.abort(401) 84 | 85 | @self._app.after_request 86 | def _set_cookie_token_in_response( 87 | response: flask.wrappers.Response, 88 | ) -> flask.wrappers.Response: 89 | if flask.g.get("set_cookie_token", False): 90 | response.set_cookie( 91 | key=f"cookie_token_{self._port}", value=self._cookie_token 92 | ) 93 | return response 94 | -------------------------------------------------------------------------------- /webviz_config/webviz_instance_info.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from enum import Enum 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | from dash import Dash 7 | 8 | from ._theme_class import WebvizConfigTheme 9 | 10 | 11 | class WebvizRunMode(Enum): 12 | NON_PORTABLE = 1 13 | PORTABLE = 2 14 | BUILDING_PORTABLE = 3 15 | 16 | 17 | class WebvizInstanceInfo: 18 | """Contains global information regarding the running webviz app instance, exposed 19 | globally through WEBVIZ_INSTANCE_INFO. 20 | 21 | Note that this class utilizes a two-stage initialization approach which renders it 22 | useless until the initialize() method has been called. It is assumed that 23 | initialization will be done early during application execution, typically as part 24 | of the webviz_app.py / jinja2 template. 25 | """ 26 | 27 | __slots__ = ( 28 | "_is_initialized", 29 | "_dash_app", 30 | "_run_mode", 31 | "_theme", 32 | "_storage_folder", 33 | ) 34 | 35 | def __init__(self) -> None: 36 | self._is_initialized: bool = False 37 | self._dash_app: Optional[Dash] = None 38 | self._run_mode: Optional[WebvizRunMode] = None 39 | self._theme: Optional[WebvizConfigTheme] = None 40 | self._storage_folder: Optional[Path] = None 41 | 42 | def initialize( 43 | self, 44 | dash_app: Dash, 45 | run_mode: WebvizRunMode, 46 | theme: WebvizConfigTheme, 47 | storage_folder: Path, 48 | ) -> None: 49 | """This function is responsible for the actual initialization of the object instance. 50 | None of the access methods in this class are valid until initialize() has been called. 51 | This function will be called as part of the webviz_app.py / jinja2 template. 52 | """ 53 | if self._is_initialized: 54 | raise RuntimeError("Registry already initialized") 55 | 56 | if not isinstance(dash_app, Dash): 57 | raise TypeError("dash_app must be of type Dash") 58 | self._dash_app = dash_app 59 | 60 | if not isinstance(run_mode, WebvizRunMode): 61 | raise TypeError("run_mode must be of type WebvizRunMode") 62 | self._run_mode = run_mode 63 | 64 | if not isinstance(theme, WebvizConfigTheme): 65 | raise TypeError("theme must be of type WebvizConfigTheme") 66 | self._theme = theme 67 | 68 | if not isinstance(storage_folder, Path): 69 | raise TypeError("storage_folder must be of type Path") 70 | self._storage_folder = storage_folder 71 | 72 | self._is_initialized = True 73 | 74 | @property 75 | def dash_app(self) -> Dash: 76 | if not self._is_initialized or self._dash_app is None: 77 | raise RuntimeError("WebvizInstanceInfo is not yet initialized") 78 | 79 | return self._dash_app 80 | 81 | @property 82 | def run_mode(self) -> WebvizRunMode: 83 | if not self._is_initialized or self._run_mode is None: 84 | raise RuntimeError("WebvizInstanceInfo is not yet initialized") 85 | 86 | return self._run_mode 87 | 88 | @property 89 | def theme(self) -> WebvizConfigTheme: 90 | if not self._is_initialized or self._theme is None: 91 | raise RuntimeError("WebvizInstanceInfo is not yet initialized") 92 | 93 | # The theme class is mutable, so return a copy 94 | return copy.deepcopy(self._theme) 95 | 96 | @property 97 | def storage_folder(self) -> Path: 98 | if not self._is_initialized or self._storage_folder is None: 99 | raise RuntimeError("WebvizInstanceInfo is not yet initialized") 100 | 101 | return self._storage_folder 102 | 103 | 104 | WEBVIZ_INSTANCE_INFO = WebvizInstanceInfo() 105 | -------------------------------------------------------------------------------- /webviz_config/webviz_factory_registry.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Type, TypeVar 2 | import warnings 3 | 4 | from .webviz_factory import WebvizFactory 5 | from .webviz_instance_info import WebvizInstanceInfo, WEBVIZ_INSTANCE_INFO 6 | 7 | T = TypeVar("T", bound=WebvizFactory) 8 | 9 | 10 | class WebvizFactoryRegistry: 11 | """Global registry for factories that allows the actual factory instances 12 | to be shared between plugins. Also facilitates storage of optional factory 13 | settings that are read from the YAML config file for later consumption when 14 | an actual factory gets created and added to the registry. 15 | The registry is exposed globally through WEBVIZ_FACTORY_REGISTRY below. 16 | This is also the reason for the two-stage initialization approach. Note that 17 | the registry instance is useless until the initialize() method has been called. 18 | 19 | Note that this functionality is provisional/experimental, and will not necessarily 20 | see a deprecation phase and "may deviate from the usual version semantics." 21 | """ 22 | 23 | def __init__(self) -> None: 24 | self._is_initialized: bool = False 25 | self._factory_settings_dict: Dict[str, Any] = {} 26 | self._factories: Dict[Type, WebvizFactory] = {} 27 | 28 | def initialize( 29 | self, 30 | factory_settings_dict: Optional[Dict[str, Any]], 31 | ) -> None: 32 | """Does the actual initialization of the object instance. 33 | This function will be called as part of the webviz_app.py / jinja2 template. 34 | """ 35 | if self._is_initialized: 36 | raise RuntimeError("Registry already initialized") 37 | 38 | if factory_settings_dict: 39 | if not isinstance(factory_settings_dict, dict): 40 | raise TypeError("factory_settings_dict must be of type dict") 41 | self._factory_settings_dict = factory_settings_dict 42 | 43 | self._is_initialized = True 44 | 45 | def set_factory(self, factory_class: Type[T], factory_obj: T) -> None: 46 | if not self._is_initialized: 47 | raise RuntimeError("Illegal access, factory registry is not initialized") 48 | 49 | if not isinstance(factory_obj, factory_class): 50 | raise TypeError("The type of the factory does not match factory_class") 51 | 52 | self._factories[factory_class] = factory_obj 53 | 54 | def get_factory(self, factory_class: Type[T]) -> Optional[T]: 55 | if not self._is_initialized: 56 | raise RuntimeError("Illegal access, factory registry is not initialized") 57 | 58 | if not factory_class in self._factories: 59 | return None 60 | 61 | factory_obj = self._factories[factory_class] 62 | if not isinstance(factory_obj, factory_class): 63 | raise TypeError("The stored factory object has wrong type") 64 | 65 | return factory_obj 66 | 67 | def cleanup_resources_after_plugin_init(self) -> None: 68 | if not self._is_initialized: 69 | raise RuntimeError("Illegal access, factory registry is not initialized") 70 | 71 | for factory in self._factories.values(): 72 | factory.cleanup_resources_after_plugin_init() 73 | 74 | @property 75 | def all_factory_settings(self) -> Dict[str, Any]: 76 | if not self._is_initialized: 77 | raise RuntimeError("Illegal access, factory registry is not initialized") 78 | 79 | return self._factory_settings_dict 80 | 81 | @property 82 | def app_instance_info(self) -> WebvizInstanceInfo: 83 | warnings.warn( 84 | "Accessing WebvizInstanceInfo through WebvizFactoryRegistry has been deprecated, " 85 | "please use global WEBVIZ_INSTANCE_INFO instead", 86 | DeprecationWarning, 87 | ) 88 | 89 | return WEBVIZ_INSTANCE_INFO 90 | 91 | 92 | WEBVIZ_FACTORY_REGISTRY = WebvizFactoryRegistry() 93 | -------------------------------------------------------------------------------- /tests/unit_tests/test_webviz_instance_info.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import cast 3 | 4 | import pytest 5 | from dash import Dash 6 | 7 | from webviz_config import WebvizConfigTheme 8 | from webviz_config.webviz_instance_info import WebvizInstanceInfo, WebvizRunMode 9 | 10 | 11 | def test_construction_and_basic_access() -> None: 12 | my_dash_app = Dash("dummyAppName") 13 | my_run_mode = WebvizRunMode.NON_PORTABLE 14 | my_theme = WebvizConfigTheme("dummyThemeName") 15 | my_storage_folder = Path("dummyPath") 16 | 17 | instance_info = WebvizInstanceInfo() 18 | instance_info.initialize( 19 | dash_app=my_dash_app, 20 | run_mode=my_run_mode, 21 | theme=my_theme, 22 | storage_folder=my_storage_folder, 23 | ) 24 | 25 | assert instance_info.dash_app is my_dash_app 26 | assert instance_info.run_mode is WebvizRunMode.NON_PORTABLE 27 | assert instance_info.theme.__dict__ == my_theme.__dict__ 28 | assert instance_info.storage_folder == Path("dummyPath") 29 | 30 | 31 | def test_that_construction_with_invalid_types_throw() -> None: 32 | dash_app = Dash("dummyAppName") 33 | run_mode = WebvizRunMode.NON_PORTABLE 34 | theme = WebvizConfigTheme("dummyThemeName") 35 | storage_folder = Path("dummyPath") 36 | 37 | with pytest.raises(TypeError): 38 | WebvizInstanceInfo().initialize( 39 | dash_app=cast(Dash, None), 40 | run_mode=run_mode, 41 | theme=theme, 42 | storage_folder=storage_folder, 43 | ) 44 | with pytest.raises(TypeError): 45 | WebvizInstanceInfo().initialize( 46 | dash_app=dash_app, 47 | run_mode=cast(WebvizRunMode, None), 48 | theme=theme, 49 | storage_folder=storage_folder, 50 | ) 51 | with pytest.raises(TypeError): 52 | WebvizInstanceInfo().initialize( 53 | dash_app=dash_app, 54 | run_mode=run_mode, 55 | theme=cast(WebvizConfigTheme, None), 56 | storage_folder=storage_folder, 57 | ) 58 | with pytest.raises(TypeError): 59 | WebvizInstanceInfo().initialize( 60 | dash_app=dash_app, 61 | run_mode=run_mode, 62 | theme=theme, 63 | storage_folder=cast(Path, None), 64 | ) 65 | 66 | 67 | def test_immutability() -> None: 68 | my_dash_app = Dash("dummyAppName") 69 | my_run_mode = WebvizRunMode.NON_PORTABLE 70 | my_theme = WebvizConfigTheme("dummyThemeName") 71 | my_storage_folder = Path("dummyPath") 72 | 73 | instance_info = WebvizInstanceInfo() 74 | instance_info.initialize( 75 | dash_app=my_dash_app, 76 | run_mode=my_run_mode, 77 | theme=my_theme, 78 | storage_folder=my_storage_folder, 79 | ) 80 | 81 | # Ony allowed to initialize once 82 | with pytest.raises(RuntimeError): 83 | instance_info.initialize( 84 | dash_app=my_dash_app, 85 | run_mode=my_run_mode, 86 | theme=my_theme, 87 | storage_folder=my_storage_folder, 88 | ) 89 | 90 | # This is ok and necessary since we want to share the actual dash app 91 | assert instance_info.dash_app is my_dash_app 92 | 93 | # This two are also ok since integer enums and Path themselves is immutable 94 | assert instance_info.run_mode is my_run_mode 95 | assert instance_info.storage_folder is my_storage_folder 96 | 97 | returned_theme = instance_info.theme 98 | assert returned_theme is not my_theme 99 | assert isinstance(returned_theme, WebvizConfigTheme) 100 | assert returned_theme.__dict__ == my_theme.__dict__ 101 | my_theme.theme_name = "MODIFIED" 102 | assert returned_theme.__dict__ != my_theme.__dict__ 103 | 104 | with pytest.raises(AttributeError): 105 | # pylint: disable=assigning-non-slot 106 | instance_info.some_new_attribute = "myAttributeValue" # type: ignore[attr-defined] 107 | -------------------------------------------------------------------------------- /tests/test_table_plotter.py: -------------------------------------------------------------------------------- 1 | import time 2 | from pathlib import Path 3 | 4 | import dash 5 | from dash.testing.composite import DashComposite 6 | 7 | from webviz_config import WebvizSettings 8 | from webviz_config.common_cache import CACHE 9 | from webviz_config.themes import default_theme 10 | from webviz_config.generic_plugins import _table_plotter 11 | 12 | 13 | def test_table_plotter(dash_duo: DashComposite) -> None: 14 | 15 | app = dash.Dash(__name__) 16 | app.config.suppress_callback_exceptions = True 17 | CACHE.init_app(app.server) 18 | webviz_settings = WebvizSettings({}, default_theme) 19 | csv_file = Path("./tests/data/example_data.csv") 20 | page = _table_plotter.TablePlotter(app, webviz_settings, csv_file) 21 | app.layout = page.layout 22 | dash_duo.start_server(app) 23 | 24 | # Wait for the app to render(there is probably a better way...) 25 | time.sleep(5) 26 | 27 | # Checking that no plot options are defined 28 | assert not page.plot_options 29 | # Check that filter is not active 30 | assert not page.use_filter 31 | 32 | # Checking that the correct plot type is initialized 33 | plot_dd = dash_duo.find_element("#" + page.uuid("plottype")) 34 | assert plot_dd.text == "scatter" 35 | 36 | # Checking that only the relevant options are shown 37 | for plot_option in page.plot_args.keys(): 38 | plot_option_dd = dash_duo.find_element("#" + page.uuid(f"div-{plot_option}")) 39 | if plot_option not in page.plots["scatter"]: 40 | assert plot_option_dd.get_attribute("style") == "display: none;" 41 | 42 | # Checking that options are initialized correctly 43 | for option in ["x", "y"]: 44 | plot_option_dd = dash_duo.find_element("#" + page.uuid(f"dropdown-{option}")) 45 | assert plot_option_dd.text == "Well" 46 | 47 | 48 | def test_table_plotter_filter(dash_duo: DashComposite) -> None: 49 | 50 | app = dash.Dash(__name__) 51 | app.config.suppress_callback_exceptions = True 52 | CACHE.init_app(app.server) 53 | webviz_settings = WebvizSettings({}, default_theme) 54 | csv_file = Path("./tests/data/example_data.csv") 55 | page = _table_plotter.TablePlotter( 56 | app, webviz_settings, csv_file, filter_cols=["Well"] 57 | ) 58 | app.layout = page.layout 59 | dash_duo.start_server(app) 60 | 61 | # Wait for the app to render(there is probably a better way...) 62 | time.sleep(5) 63 | 64 | # Checking that no plot options are defined 65 | assert not page.plot_options 66 | # Check that filter is active 67 | assert page.use_filter 68 | assert page.filter_cols == ["Well"] 69 | 70 | # Checking that the correct plot type is initialized 71 | plot_dd = dash_duo.find_element("#" + page.uuid("plottype")) 72 | assert plot_dd.text == "scatter" 73 | 74 | # Checking that options are initialized correctly 75 | for option in ["x", "y"]: 76 | plot_option_dd = dash_duo.find_element("#" + page.uuid(f"dropdown-{option}")) 77 | assert plot_option_dd.text == "Well" 78 | 79 | 80 | def test_initialized_table_plotter(dash_duo: DashComposite) -> None: 81 | 82 | app = dash.Dash(__name__) 83 | app.css.config.serve_locally = True 84 | app.scripts.config.serve_locally = True 85 | app.config.suppress_callback_exceptions = True 86 | CACHE.init_app(app.server) 87 | webviz_settings = WebvizSettings({}, default_theme) 88 | csv_file = Path("./tests/data/example_data.csv") 89 | plot_options = dict( 90 | x="Well", 91 | y="Initial reservoir pressure (bar)", 92 | size="Average permeability (D)", 93 | facet_col="Segment", 94 | ) 95 | 96 | page = _table_plotter.TablePlotter( 97 | app, webviz_settings, csv_file, lock=True, plot_options=plot_options 98 | ) 99 | app.layout = page.layout 100 | dash_duo.start_server(app) 101 | 102 | # Wait for the app to render(there is probably a better way...) 103 | 104 | # Checking that plot options are defined 105 | assert page.plot_options == plot_options 106 | assert page.lock 107 | -------------------------------------------------------------------------------- /webviz_config/testing/_composite.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pathlib 4 | 5 | from dash.testing.composite import Browser 6 | import dash 7 | import webviz_core_components as wcc 8 | 9 | from webviz_config.common_cache import CACHE 10 | from webviz_config.themes import default_theme 11 | from webviz_config.webviz_factory_registry import WEBVIZ_FACTORY_REGISTRY 12 | from webviz_config.webviz_instance_info import WEBVIZ_INSTANCE_INFO, WebvizRunMode 13 | from webviz_config import WebvizPluginABC 14 | 15 | from ._webviz_ids import WebvizIds 16 | 17 | 18 | class WebvizComposite(Browser): 19 | def __init__(self, server: Any, **kwargs: Any) -> None: 20 | super().__init__(**kwargs) 21 | self.app = dash.Dash(__name__) 22 | self.server = server 23 | self.plugin: WebvizPluginABC 24 | self.init_app() 25 | 26 | def init_app(self) -> None: 27 | WEBVIZ_INSTANCE_INFO.initialize( 28 | dash_app=self.app, 29 | run_mode=WebvizRunMode.NON_PORTABLE, 30 | theme=default_theme, 31 | storage_folder=pathlib.Path(__file__).resolve().parent, 32 | ) 33 | try: 34 | WEBVIZ_FACTORY_REGISTRY.initialize(None) 35 | except RuntimeError: 36 | pass 37 | 38 | self.app.css.config.serve_locally = True 39 | self.app.scripts.config.serve_locally = True 40 | self.app.config.suppress_callback_exceptions = True 41 | CACHE.init_app(self.app.server) 42 | 43 | def start_server(self, plugin: WebvizPluginABC, **kwargs: Any) -> None: 44 | """Start the local server with app.""" 45 | 46 | self.app.layout = dash.html.Div( 47 | className=WebvizIds.LAYOUT_WRAPPER, 48 | children=[ 49 | wcc.WebvizContentManager( 50 | id=WebvizIds.CONTENT_MANAGER, 51 | children=[ 52 | wcc.WebvizSettingsDrawer( 53 | id=WebvizIds.SETTINGS_DRAWER, 54 | children=plugin.get_all_settings(), 55 | ), 56 | wcc.WebvizPluginsWrapper( 57 | id=WebvizIds.PLUGINS_WRAPPER, 58 | children=plugin.plugin_layout(), 59 | ), 60 | ], 61 | ), 62 | ], 63 | ) 64 | self.plugin = plugin 65 | # start server with app and pass Dash arguments 66 | self.server(self.app, **kwargs) 67 | 68 | # set the default server_url, it implicitly call wait_for_page 69 | self.server_url = self.server.url 70 | 71 | def toggle_webviz_settings_drawer(self) -> None: 72 | """Open the plugin settings drawer""" 73 | self.wait_for_element(WebvizIds.SETTINGS_DRAWER_TOGGLE).click() 74 | 75 | def toggle_webviz_settings_group(self, settings_group_id: str) -> None: 76 | """Open the respective settings group in the settings drawer""" 77 | self.wait_for_element( 78 | f"#{settings_group_id} > .WebvizSettingsGroup__Title" 79 | ).click() 80 | 81 | def shared_settings_group_unique_component_id( 82 | self, settings_group_id: str, component_unique_id: str 83 | ) -> str: 84 | """Returns the element id of a component in a shared settings group""" 85 | unique_id = ( 86 | self.plugin.shared_settings_group(settings_group_id) 87 | .component_unique_id(component_unique_id) 88 | .to_string() 89 | ) 90 | return f"#{unique_id}" 91 | 92 | def view_settings_group_unique_component_id( 93 | self, view_id: str, settings_group_id: str, component_unique_id: str 94 | ) -> str: 95 | """Returns the element id of a component in a view settings group""" 96 | unique_id = ( 97 | self.plugin.view(view_id) 98 | .settings_group(settings_group_id) 99 | .component_unique_id(component_unique_id) 100 | .to_string() 101 | ) 102 | return f"#{unique_id}" 103 | -------------------------------------------------------------------------------- /webviz_config/utils/_localhost_open_browser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import urllib 4 | import warnings 5 | import threading 6 | import webbrowser 7 | 8 | from .._is_reload_process import is_reload_process 9 | from .._user_preferences import get_user_preference 10 | from . import terminal_colors 11 | 12 | 13 | class LocalhostOpenBrowser: 14 | # pylint: disable=too-few-public-methods 15 | 16 | def __init__(self, port: int, token: str): 17 | self._port = port 18 | self._token = token 19 | 20 | if not is_reload_process(): 21 | # Only open new browser tab if not a reload process 22 | threading.Thread(target=self._timer).start() 23 | 24 | def _timer(self) -> None: 25 | """Waits until the app is ready, and then opens the page 26 | in the default browser. 27 | """ 28 | 29 | timeout = 120 # maximum number of seconds to wait before timeout 30 | 31 | for _ in range(timeout): 32 | if self._app_ready(): 33 | self._open_new_tab() 34 | return 35 | 36 | time.sleep(1) 37 | 38 | print( 39 | f"WARNING: Webviz application still not ready after {timeout}s.\n" 40 | "Will not open browser automatically. Your private one-time login link:\n" 41 | f"{self._url(with_token=True)}" 42 | ) 43 | 44 | def _url(self, with_token: bool = False) -> str: 45 | return ( 46 | f"http://localhost:{self._port}" 47 | + f"{'?ott=' + self._token if with_token else ''}" 48 | ) 49 | 50 | @staticmethod 51 | def _get_browser_controller() -> webbrowser.BaseBrowser: 52 | 53 | if get_user_preference("browser") is not None: 54 | try: 55 | return webbrowser.get(using=get_user_preference("browser")) 56 | except webbrowser.Error: 57 | warnings.warn("Could not find the user preferred browser.") 58 | 59 | for browser in ["firefox", "chrome", "chromium-browser"]: 60 | try: 61 | return webbrowser.get(using=browser) 62 | except webbrowser.Error: 63 | pass 64 | 65 | # Return default browser if none of the 66 | # preferred browsers are installed: 67 | return webbrowser.get() 68 | 69 | def _app_ready(self) -> bool: 70 | """Check if the flask instance is ready.""" 71 | 72 | no_proxy_env = os.environ.get("NO_PROXY") 73 | os.environ["NO_PROXY"] = "localhost" 74 | 75 | try: 76 | with urllib.request.urlopen(self._url()): # nosec 77 | app_ready = True 78 | except urllib.error.HTTPError: 79 | # The flask instance responded with some HTTP error (likely 401), 80 | # but is otherwise ready to accept connections 81 | app_ready = True 82 | except urllib.error.URLError: 83 | # The flask instance has not started 84 | app_ready = False 85 | except ConnectionResetError: 86 | # The flask instance has started but (correctly) abort 87 | # request due to "401 Unauthorized" 88 | app_ready = True 89 | finally: 90 | os.environ["NO_PROXY"] = no_proxy_env if no_proxy_env else "" 91 | 92 | return app_ready 93 | 94 | def _open_new_tab(self) -> None: 95 | """Open the url (with token) in the default browser.""" 96 | 97 | print( 98 | f"{terminal_colors.GREEN}{terminal_colors.BOLD}" 99 | f" Opening the application ({self._url()}) in your browser.\n" 100 | f"{terminal_colors.BLUE}" 101 | " Note that your browser might display security issues.\n" 102 | " See: https://equinor.github.io/webviz-config/#/?id=localhost-hsts\n" 103 | f"{terminal_colors.GREEN}" 104 | " Press CTRL + C in this terminal window to stop the application." 105 | f"{terminal_colors.END}" 106 | ) 107 | 108 | LocalhostOpenBrowser._get_browser_controller().open_new_tab( 109 | self._url(with_token=True) 110 | ) 111 | -------------------------------------------------------------------------------- /webviz_config/_dockerize/_create_docker_setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import warnings 4 | from pathlib import Path 5 | from typing import Dict, Set 6 | 7 | import jinja2 8 | import requests 9 | 10 | from ..plugins import PLUGIN_PROJECT_METADATA 11 | from ._pip_git_url import pip_git_url 12 | 13 | 14 | PYPI_URL_ROOT = "https://pypi.org" 15 | 16 | 17 | def create_docker_setup( 18 | build_directory: Path, plugin_metadata: Dict[str, dict] 19 | ) -> None: 20 | """Creates a Docker setup in build_directory. The input dictionary plugin_metadata 21 | is a dictionary of plugin metadata only for the plugins used in the generated 22 | application. This is used in order to automatically include the necessary 23 | plugin projects as Python requirements in the generated Docker setup. 24 | """ 25 | 26 | template_environment = jinja2.Environment( # nosec 27 | loader=jinja2.PackageLoader("webviz_config", "templates"), 28 | undefined=jinja2.StrictUndefined, 29 | autoescape=False, 30 | ) 31 | template = template_environment.get_template("Dockerfile.jinja2") 32 | 33 | distributions = { 34 | metadata["dist_name"]: PLUGIN_PROJECT_METADATA[metadata["dist_name"]] 35 | for metadata in plugin_metadata.values() 36 | } 37 | 38 | # Regardless of a standard webviz-config plugin is included in user's 39 | # configuration file, we still need to install the plugin framework webviz-config: 40 | distributions["webviz-config"] = PLUGIN_PROJECT_METADATA["webviz-config"] 41 | 42 | requirements = get_python_requirements(distributions) 43 | requirements.add("gunicorn") 44 | 45 | (build_directory / "requirements.txt").write_text( 46 | "\n".join(sorted(list(requirements))) 47 | ) 48 | 49 | (build_directory / "Dockerfile").write_text( 50 | template.render( 51 | { 52 | "python_version_major": sys.version_info.major, 53 | "python_version_minor": sys.version_info.minor, 54 | "ssh_required": any("ssh://" in req for req in requirements), 55 | } 56 | ) 57 | ) 58 | 59 | 60 | def get_python_requirements(distributions: dict) -> Set[str]: 61 | 62 | requirements = set() 63 | 64 | for dist_name, dist in distributions.items(): 65 | 66 | requirements.update( 67 | [ 68 | f"{dep}=={version}" 69 | for dep, version in PLUGIN_PROJECT_METADATA[dist_name][ 70 | "dependencies" 71 | ].items() 72 | if dep not in distributions 73 | ] 74 | ) 75 | 76 | if dist["download_url"] is None and dist["source_url"] is None: 77 | warnings.warn( 78 | f"Plugin distribution {dist_name} has no download/source URL specified. " 79 | "Will therefore not automatically become part of built Docker image." 80 | ) 81 | continue 82 | 83 | if dist["download_url"] is not None and dist["download_url"].startswith( 84 | PYPI_URL_ROOT 85 | ): 86 | pypi_data = requests.get(f"{PYPI_URL_ROOT}/pypi/{dist_name}/json").json() 87 | if dist["dist_version"] in pypi_data["releases"]: 88 | requirements.add(f"{dist_name}=={dist['dist_version']}") 89 | continue 90 | 91 | if dist["source_url"] is None: 92 | raise RuntimeError( 93 | f"Could not find version {dist['dist_version']} of {dist_name} on PyPI. " 94 | "Falling back to git source code is not possible since " 95 | "project_urls['Source'] is not defined in setup.py." 96 | ) 97 | 98 | requirements.add( 99 | pip_git_url( 100 | dist["dist_version"], 101 | source_url=os.environ.get( 102 | "SOURCE_URL_" + dist_name.upper().replace("-", "_"), 103 | dist["source_url"], 104 | ), 105 | git_pointer=os.environ.get( 106 | "GIT_POINTER_" + dist_name.upper().replace("-", "_") 107 | ), 108 | ) 109 | ) 110 | 111 | return requirements 112 | -------------------------------------------------------------------------------- /.github/workflows/webviz-config.yml: -------------------------------------------------------------------------------- 1 | name: webviz-config 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | release: 11 | types: 12 | - published 13 | schedule: 14 | # Run CI daily and check that tests are working with latest dependencies 15 | - cron: "0 0 * * *" 16 | 17 | jobs: 18 | webviz-config: 19 | runs-on: ubuntu-latest 20 | env: 21 | PYTHONWARNINGS: default # We want to see e.g. DeprecationWarnings 22 | strategy: 23 | matrix: 24 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 25 | 26 | steps: 27 | - name: 📖 Checkout commit locally 28 | uses: actions/checkout@v4 29 | 30 | - name: Set setuptools_scm version 31 | if: github.event_name == 'release' 32 | # Need to instruct setuptools_scm to use the GitHub provided tag, despite local git changes (due to build step) 33 | run: echo "SETUPTOOLS_SCM_PRETEND_VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV 34 | 35 | - name: 🐍 Set up Python ${{ matrix.python-version }} 36 | uses: actions/setup-python@v5 37 | with: 38 | python-version: ${{ matrix.python-version }} 39 | 40 | - name: 📦 Install npm dependencies 41 | run: | 42 | npm ci --ignore-scripts 43 | npm run postinstall 44 | 45 | - name: 📦 Install webviz-config with dependencies 46 | run: | 47 | pip install --upgrade pip 48 | pip install . 49 | pip install --pre --upgrade webviz-core-components # Testing against our latest release (including pre-releases) 50 | 51 | - name: 📦 Install extra deployment dependencies 52 | if: matrix.python-version != '3.10' # Check tests pass also without optional dependencies 53 | run: | 54 | pip install .[deployment] 55 | 56 | - name: 📦 Install test dependencies 57 | run: | 58 | pip install .[tests] 59 | wget https://chromedriver.storage.googleapis.com/$(wget https://chromedriver.storage.googleapis.com/LATEST_RELEASE -q -O -)/chromedriver_linux64.zip 60 | unzip chromedriver_linux64.zip 61 | 62 | - name: 🧾 List all installed packages 63 | run: pip freeze 64 | 65 | - name: 🕵️ Check code style & linting 66 | if: matrix.python-version == '3.8' 67 | run: | 68 | black --check webviz_config tests setup.py 69 | pylint webviz_config tests setup.py 70 | bandit -r -c ./bandit.yml webviz_config tests setup.py 71 | # mypy --package webviz_config --ignore-missing-imports --disallow-untyped-defs --show-error-codes 72 | 73 | - name: 🤖 Run tests 74 | run: | 75 | webviz preferences --theme default 76 | pytest ./tests --headless --forked --ignore ./tests/test_example_wlf_plugin.py 77 | webviz docs --portable ./docs_build --skip-open 78 | webviz schema 79 | 80 | - name: 🐳 Build Docker example image 81 | run: | 82 | export GIT_POINTER_WEBVIZ_CONFIG=$GITHUB_REF 83 | webviz build ./examples/basic_example.yaml --portable ./some_portable_app 84 | pushd ./some_portable_app 85 | docker build . 86 | rm -rf ./some_portable_app 87 | 88 | - name: 🚢 Build and deploy Python package 89 | if: github.event_name == 'release' && matrix.python-version == '3.8' 90 | env: 91 | TWINE_USERNAME: __token__ 92 | TWINE_PASSWORD: ${{ secrets.pypi_webviz_token }} 93 | run: | 94 | python -m pip install --upgrade setuptools wheel twine 95 | python setup.py sdist bdist_wheel 96 | twine upload dist/* 97 | 98 | - name: 📚 Update GitHub pages 99 | if: github.event_name == 'release' && matrix.python-version == '3.8' 100 | run: | 101 | cp -R ./docs_build ../docs_build 102 | 103 | git config --local user.email "webviz-github-action" 104 | git config --local user.name "webviz-github-action" 105 | git fetch origin gh-pages 106 | git checkout --track origin/gh-pages 107 | git clean -f -f -d -x 108 | git rm -r * 109 | 110 | cp -R ../docs_build/* . 111 | 112 | git add . 113 | 114 | if git diff-index --quiet HEAD; then 115 | echo "No changes in documentation. Skip documentation deploy." 116 | else 117 | git commit -m "Update Github Pages" 118 | git push "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" gh-pages 119 | fi 120 | -------------------------------------------------------------------------------- /webviz_config/_deployment/interactive_terminal.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from typing import List, Callable, Optional 4 | 5 | from webviz_config.utils import terminal_colors 6 | 7 | 8 | def terminal_title(title: str) -> None: 9 | print("\n" + terminal_colors.BOLD + title + terminal_colors.END) 10 | print("=" * len(title) + "\n") 11 | 12 | 13 | def terminal_yes_no(question: str) -> bool: 14 | while True: 15 | reply = input(question + " (y/n): ").lower().strip() 16 | if reply.startswith("y"): 17 | return True 18 | if reply.startswith("n"): 19 | return False 20 | 21 | 22 | def terminal_list_selector(options: List[str], display_string: str) -> str: 23 | 24 | for i, subscription in enumerate(options): 25 | print(f"{i+1}) {subscription}") 26 | print() 27 | 28 | selected_value = None 29 | while selected_value not in options: 30 | try: 31 | index = int(input(f"Choose {display_string} index to use: ")) 32 | return_value = selected_value = options[index - 1] 33 | except (IndexError, ValueError): 34 | print(f"Select an integer in range 1-{len(options)}.") 35 | 36 | print( 37 | "\n" 38 | + terminal_colors.BOLD 39 | + f"Selected {display_string}: {selected_value}" 40 | + terminal_colors.END 41 | ) 42 | return return_value 43 | 44 | 45 | def user_input_from_stdin(env_var: str, noun: str, regex: str = None) -> str: 46 | if env_var in os.environ: 47 | value = os.environ[env_var] 48 | print(f"Using {noun} '{value}' (from environment variable {env_var}).") 49 | if regex is not None and not re.fullmatch(regex, value): 50 | raise RuntimeError("Input not on expected format.") 51 | return value 52 | 53 | print(f"Tip: Default value can be set through env. variable {env_var}\n") 54 | while True: 55 | value = input(f"{noun.capitalize()}: ").strip() 56 | if regex is None or re.fullmatch(regex, value): 57 | return value 58 | print("Input not on expected format.") 59 | 60 | 61 | def user_input_from_list(env_var: str, noun: str, choices: List[str]) -> str: 62 | if env_var in os.environ: 63 | value = os.environ[env_var] 64 | print(f"Using {noun} '{value}' (from environment variable {env_var}).") 65 | if value not in choices: 66 | raise ValueError( 67 | f"Value '{value}' not among available choices ({', '.join(choices)})" 68 | ) 69 | return value 70 | 71 | print(f"Tip: Default value can be set through env. variable {env_var}\n") 72 | return terminal_list_selector(choices, noun) 73 | 74 | 75 | def user_input_optional_reuse( 76 | env_var: str, 77 | noun: str, 78 | exists_function: Callable, 79 | reuse_allowed: bool = True, 80 | regex: Optional[str] = None, 81 | ) -> str: 82 | if env_var in os.environ: 83 | value = os.environ[env_var] 84 | print(f"Using {noun} '{value}' (from environment variable {env_var}).") 85 | if not reuse_allowed and exists_function(value): 86 | raise RuntimeError(f"There already exists an {noun} with name '{value}'.") 87 | if regex is not None and not re.match(regex, value): 88 | raise RuntimeError( 89 | f"Value {value} does not satisfy regular expression {regex}" 90 | ) 91 | return value 92 | 93 | print(f"Tip: Default value can be set through env. variable {env_var}\n") 94 | 95 | while True: 96 | value = input(f"{noun.capitalize()}: ") 97 | 98 | if regex is not None and not re.match(regex, value): 99 | print(f"Value {value} does not satisfy regular expression {regex}") 100 | continue 101 | 102 | check = exists_function(value) 103 | 104 | if isinstance(check, tuple): 105 | exists, message = check 106 | else: 107 | exists = check 108 | message = None 109 | 110 | if exists: 111 | if not reuse_allowed: 112 | print( 113 | f"There already exists an {noun} with name '{value}'" 114 | if message is None 115 | else message 116 | ) 117 | continue 118 | print(f"Found existing {noun} with value '{value}'") 119 | reuse = terminal_yes_no(f"Do you want to reuse the existing {noun}") 120 | if reuse: 121 | return value 122 | continue 123 | 124 | print(f"{noun.capitalize()} name '{value}' is available.") 125 | return value 126 | -------------------------------------------------------------------------------- /tests/unit_tests/test_webviz_factory_registry.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import pytest 4 | 5 | from webviz_config.webviz_factory import WebvizFactory 6 | from webviz_config.webviz_factory_registry import WebvizFactoryRegistry 7 | 8 | 9 | # pylint: disable=no-self-use 10 | class FactoryA(WebvizFactory): 11 | def create_obj_a(self) -> str: 12 | return "A" 13 | 14 | 15 | class FactoryB(WebvizFactory): 16 | def create_obj_b(self) -> str: 17 | return "B" 18 | 19 | 20 | class FactoryBSub(FactoryB): 21 | def create_obj_b_sub(self) -> str: 22 | return "B_sub" 23 | 24 | 25 | # pylint: disable=too-few-public-methods 26 | class UnrelatedFactory: 27 | pass 28 | 29 | 30 | def create_initialized_registry() -> WebvizFactoryRegistry: 31 | registry = WebvizFactoryRegistry() 32 | registry.initialize({"MyFactory": "someValue"}) 33 | return registry 34 | 35 | 36 | def test_uninitialized_access() -> None: 37 | registry = WebvizFactoryRegistry() 38 | 39 | with pytest.raises(RuntimeError): 40 | _settings = registry.all_factory_settings 41 | 42 | 43 | def test_initialization_and_basic_access() -> None: 44 | registry = create_initialized_registry() 45 | 46 | settings = registry.all_factory_settings 47 | assert "MyFactory" in settings 48 | 49 | factory = registry.get_factory(FactoryA) 50 | assert factory is None 51 | 52 | 53 | def test_multiple_initializations() -> None: 54 | registry = WebvizFactoryRegistry() 55 | registry.initialize({"MyFactory": "someValue"}) 56 | 57 | with pytest.raises(RuntimeError): 58 | registry.initialize({"MyFactory": "someValue"}) 59 | 60 | 61 | def test_set_get_factory() -> None: 62 | registry = create_initialized_registry() 63 | 64 | factory_a = FactoryA() 65 | registry.set_factory(FactoryA, factory_a) 66 | 67 | factory_b = FactoryB() 68 | registry.set_factory(FactoryB, factory_b) 69 | 70 | f_a: Optional[FactoryA] = registry.get_factory(FactoryA) 71 | assert f_a is factory_a 72 | 73 | f_b: Optional[FactoryB] = registry.get_factory(FactoryB) 74 | assert f_b is factory_b 75 | 76 | 77 | def test_set_get_derived_factory() -> None: 78 | registry = create_initialized_registry() 79 | 80 | factory_b = FactoryB() 81 | factory_b_sub = FactoryBSub() 82 | 83 | registry.set_factory(FactoryB, factory_b) 84 | registry.set_factory(FactoryBSub, factory_b_sub) 85 | assert registry.get_factory(FactoryB) is factory_b 86 | assert registry.get_factory(FactoryBSub) is factory_b_sub 87 | 88 | # This should be fine 89 | registry.set_factory(FactoryB, factory_b_sub) 90 | assert registry.get_factory(FactoryB) is factory_b_sub 91 | 92 | # This should not be legal, but cannot currently figure out how 93 | # to disallow it using type hinting. Currently throws an exception 94 | with pytest.raises(TypeError): 95 | registry.set_factory(FactoryBSub, factory_b) 96 | 97 | 98 | def test_set_mismatched_factory_types() -> None: 99 | registry = create_initialized_registry() 100 | 101 | factory_a: FactoryA = FactoryA() 102 | factory_b: FactoryB = FactoryB() 103 | factory_b_sub: FactoryBSub = FactoryBSub() 104 | 105 | # These four are legal 106 | registry.set_factory(FactoryA, factory_a) 107 | registry.set_factory(FactoryB, factory_b) 108 | registry.set_factory(FactoryB, factory_b_sub) 109 | registry.set_factory(FactoryBSub, factory_b_sub) 110 | 111 | # These are rightly caught as errors by type checking since they don't derive from WebvizFactory 112 | # registry.set_factory(UnrelatedFactory, UnrelatedFactory()) 113 | # registry.set_factory(FactoryA, UnrelatedFactory()) 114 | # registry.set_factory(FactoryA, 123) 115 | 116 | # The following setter calls are illegal, but can't figure out how 117 | # to get type hinting to disallow them 118 | # For now, we rely on exceptions 119 | with pytest.raises(TypeError): 120 | registry.set_factory(FactoryA, factory_b) 121 | with pytest.raises(TypeError): 122 | registry.set_factory(FactoryBSub, factory_b) 123 | 124 | 125 | def test_get_mismatched_factory_types() -> None: 126 | registry = create_initialized_registry() 127 | 128 | _f_a: Optional[FactoryA] = None 129 | _f_b: Optional[FactoryB] = None 130 | _f_b_sub: Optional[FactoryBSub] = None 131 | 132 | # These four are legal and do indeed pass wrt type checking 133 | _f_a = registry.get_factory(FactoryA) 134 | _f_b = registry.get_factory(FactoryB) 135 | _f_b = registry.get_factory(FactoryBSub) 136 | _f_b_sub = registry.get_factory(FactoryBSub) 137 | 138 | # These are rightly caught as errors by type checking, good 139 | # _f_b = registry.get_factory(FactoryA) 140 | # _f_b_sub = registry.get_factory(FactoryB) 141 | -------------------------------------------------------------------------------- /webviz_config/_deployment/radix_cli.py: -------------------------------------------------------------------------------- 1 | import time 2 | import shutil 3 | import pathlib 4 | import subprocess 5 | 6 | 7 | def binary_available() -> bool: 8 | """Returns True if the Radix CLI binary (rx) is 9 | availabile in $PATH, otherwise returns False. 10 | """ 11 | return shutil.which("rx") is not None 12 | 13 | 14 | def _trigger_token_aquisition(update_only: bool = False) -> None: 15 | # There exist no pure login command in rx as of April 2021 16 | # See https://github.com/equinor/radix-cli/issues/20. 17 | # pylint: disable=subprocess-run-check 18 | subprocess.run( 19 | [ 20 | "rx", 21 | "get", 22 | "application", 23 | "--application", 24 | "dummy_name", 25 | "--context", 26 | "dummy_context", 27 | ], 28 | timeout=15 if update_only else None, 29 | capture_output=update_only, 30 | ) 31 | 32 | 33 | def logged_in() -> bool: 34 | 35 | _trigger_token_aquisition(update_only=True) 36 | 37 | config = pathlib.Path.home() / ".radix" / "config" 38 | return config.is_file() 39 | 40 | 41 | def log_in() -> None: 42 | _trigger_token_aquisition() 43 | 44 | 45 | def application_exists(application_name: str, context: str) -> bool: 46 | result = subprocess.run( 47 | [ 48 | "rx", 49 | "get", 50 | "application", 51 | "--application", 52 | application_name, 53 | "--context", 54 | context, 55 | ], 56 | capture_output=True, 57 | check=False, 58 | ) 59 | return not result.stderr 60 | 61 | 62 | def create_application( 63 | application_name: str, 64 | configuration_item: str, 65 | repository_url: str, 66 | shared_secret: str, 67 | context: str, 68 | ad_group: str, 69 | ) -> str: 70 | result = subprocess.run( 71 | [ 72 | "rx", 73 | "create", 74 | "application", 75 | "--application", 76 | application_name, 77 | "--config-branch", 78 | "main", 79 | "--configuration-item", 80 | configuration_item, 81 | "--repository", 82 | repository_url, 83 | "--shared-secret", 84 | shared_secret, 85 | "--context", 86 | context, 87 | "--ad-groups", 88 | ad_group, 89 | ], 90 | capture_output=True, 91 | check=True, 92 | ) 93 | 94 | stderr = result.stderr.decode() 95 | if not stderr.startswith("ssh-rsa"): 96 | pass # https://github.com/equinor/radix-cli/issues/48 97 | # error_message = stderr 98 | # if "registerApplicationBadRequest" in stderr: 99 | # error_message += ( 100 | # f"Is {repository_url} being used by another Radix application?" 101 | # ) 102 | # raise RuntimeError(error_message) 103 | 104 | return stderr 105 | 106 | 107 | def build_and_deploy_application(application_name: str, context: str) -> None: 108 | subprocess.run( 109 | [ 110 | "rx", 111 | "create", 112 | "job", 113 | "build-deploy", 114 | "--application", 115 | application_name, 116 | "--branch", 117 | "main", 118 | "--context", 119 | context, 120 | ], 121 | check=True, 122 | ) 123 | 124 | 125 | def set_radix_secret( 126 | application_name: str, 127 | environment: str, 128 | component: str, 129 | key: str, 130 | value: str, 131 | context: str, 132 | ) -> None: 133 | 134 | max_radix_build_time = 60 * 60 135 | sleep_per_attempt = 10 136 | 137 | for _ in range(max_radix_build_time // sleep_per_attempt): 138 | try: 139 | subprocess.run( 140 | [ 141 | "rx", 142 | "set", 143 | "environment-secret", 144 | "--await-reconcile", 145 | "--application", 146 | application_name, 147 | "--environment", 148 | environment, 149 | "--component", 150 | component, 151 | "--secret", 152 | key, 153 | "--value", 154 | value, 155 | "--context", 156 | context, 157 | ], 158 | capture_output=True, 159 | check=True, 160 | ) 161 | return 162 | except subprocess.CalledProcessError: 163 | time.sleep(sleep_per_attempt) 164 | 165 | raise RuntimeError("Failed setting Radix secret. Is Radix still building?") 166 | -------------------------------------------------------------------------------- /webviz_config/plugins/_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import warnings 3 | from typing import Dict, Iterable, Optional, Tuple, TypedDict 4 | 5 | from importlib.metadata import requires, version, PackageNotFoundError, EntryPoint 6 | 7 | 8 | class PluginProjectMetaData(TypedDict): 9 | dist_version: str 10 | dependencies: Dict[str, str] 11 | documentation_url: Optional[str] 12 | download_url: Optional[str] 13 | source_url: Optional[str] 14 | tracker_url: Optional[str] 15 | 16 | 17 | def _plugin_dist_dependencies(plugin_dist_name: str) -> Dict[str, str]: 18 | """Returns overview of all dependencies (indirect + direct) of a given 19 | plugin project installed in the current environment. 20 | 21 | Key is package name of dependency, value is (installed) version string. 22 | """ 23 | 24 | untraversed_dependencies = set([plugin_dist_name]) 25 | requirements = {} 26 | 27 | while untraversed_dependencies: 28 | sub_dependencies = requires(untraversed_dependencies.pop()) 29 | 30 | if sub_dependencies is None: 31 | continue 32 | 33 | for sub_dependency in sub_dependencies: 34 | split = re.split(r"[;<>~=()]", sub_dependency, 1) 35 | package_name = split[0].strip().replace("_", "-").lower() 36 | 37 | if package_name not in requirements: 38 | # Only include package in dependency list 39 | # if it is not an "extra" dependency... 40 | if len(split) == 1 or "extra" not in split[1]: 41 | try: 42 | # ...and if it is actually installed (there are dependencies 43 | # in setup.py that e.g. are not installed on certain Python 44 | # versions and operating system combinations). 45 | requirements[package_name] = version(package_name) 46 | untraversed_dependencies.add(package_name) 47 | except PackageNotFoundError: 48 | pass 49 | 50 | return {k: requirements[k] for k in sorted(requirements)} 51 | 52 | 53 | def load_webviz_plugins_with_metadata( 54 | distributions: Iterable, 55 | ) -> Tuple[Dict[str, dict], Dict[str, PluginProjectMetaData], Dict[str, EntryPoint]]: 56 | """Finds entry points corresponding to webviz-config plugins, 57 | and returns them as a dictionary (key is plugin name string, 58 | value is reference to entrypoint). 59 | 60 | Also returns a dictionary of plugin metadata. 61 | """ 62 | 63 | plugin_project_metadata: Dict[str, PluginProjectMetaData] = {} 64 | plugin_metadata: Dict[str, dict] = {} 65 | plugin_entrypoints: Dict[str, EntryPoint] = {} 66 | 67 | for dist in distributions: 68 | for entry_point in dist.entry_points: 69 | if entry_point.group == "webviz_config_plugins": 70 | dist_name = dist.metadata["name"] 71 | 72 | if ( 73 | entry_point.name in plugin_metadata 74 | and dist_name not in plugin_project_metadata 75 | ): 76 | warnings.warn( 77 | f"Multiple versions of plugin with name {entry_point.name}. Already " 78 | f"loaded from project {plugin_metadata[entry_point.name]['dist_name']}. " 79 | f"Overwriting using plugin with from project {dist_name}", 80 | RuntimeWarning, 81 | ) 82 | 83 | if dist_name not in plugin_project_metadata: 84 | project_urls = { 85 | value.split(",")[0]: value.split(",")[1].strip() 86 | for (key, value) in dist.metadata.items() 87 | if key == "Project-URL" 88 | } 89 | 90 | plugin_project_metadata[dist_name] = PluginProjectMetaData( 91 | { 92 | "dist_version": dist.version, 93 | "dependencies": _plugin_dist_dependencies(dist_name), 94 | "documentation_url": project_urls.get("Documentation"), 95 | "download_url": project_urls.get("Download"), 96 | "source_url": project_urls.get("Source"), 97 | "tracker_url": project_urls.get("Tracker"), 98 | } 99 | ) 100 | 101 | plugin_metadata[entry_point.name] = { 102 | "dist_name": dist.metadata["name"], 103 | } 104 | 105 | plugin_entrypoints[entry_point.name] = entry_point 106 | 107 | return (plugin_metadata, plugin_project_metadata, plugin_entrypoints) 108 | -------------------------------------------------------------------------------- /webviz_config/webviz_plugin_subclasses/_layout_unique_id.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | 4 | class LayoutUniqueId: 5 | def __init__( 6 | self, 7 | plugin_uuid: Optional[str] = None, 8 | view_id: Optional[str] = None, 9 | view_element_id: Optional[str] = None, 10 | settings_group_id: Optional[str] = None, 11 | component_id: Optional[str] = None, 12 | other: Optional["LayoutUniqueId"] = None, 13 | ) -> None: 14 | self._plugin_uuid = plugin_uuid 15 | self._view_id = view_id 16 | self._settings_group_id = settings_group_id 17 | self._view_element_id = view_element_id 18 | self._component_id = component_id 19 | 20 | if other: 21 | self.adopt(other) 22 | 23 | def get_plugin_uuid(self) -> Optional[str]: 24 | return self._plugin_uuid 25 | 26 | def get_view_unique_id(self) -> str: 27 | ids: List[str] = [] 28 | if not self._view_id: 29 | return "" 30 | 31 | if self._plugin_uuid: 32 | ids.append(self._plugin_uuid) 33 | if self._view_id: 34 | ids.append(self._view_id) 35 | 36 | return "-".join(ids) 37 | 38 | def get_view_id(self) -> Optional[str]: 39 | return self._view_id 40 | 41 | def get_view_element_unique_id(self) -> str: 42 | ids: List[str] = [] 43 | if self._plugin_uuid: 44 | ids.append(self._plugin_uuid) 45 | if self._view_id: 46 | ids.append(self._view_id) 47 | if self._view_element_id: 48 | ids.append(self._view_element_id) 49 | 50 | return "-".join(ids) 51 | 52 | def get_view_element_id(self) -> Optional[str]: 53 | return self._view_element_id 54 | 55 | def get_component_id(self) -> Optional[str]: 56 | return self._component_id 57 | 58 | def get_settings_group_id(self) -> Optional[str]: 59 | return self._settings_group_id 60 | 61 | def get_settings_group_unique_id(self) -> str: 62 | ids: List[str] = [] 63 | if self._plugin_uuid: 64 | ids.append(self._plugin_uuid) 65 | if self._view_id: 66 | ids.append(self._view_id) 67 | if self._view_element_id: 68 | ids.append(self._view_element_id) 69 | if self._settings_group_id: 70 | ids.append(self._settings_group_id) 71 | 72 | return "-".join(ids) 73 | 74 | def set_plugin_uuid(self, plugin_uuid: str) -> None: 75 | self._plugin_uuid = plugin_uuid 76 | 77 | def set_view_id(self, view_id: str) -> None: 78 | self._view_id = view_id 79 | 80 | def set_view_element_id(self, view_element_id: str) -> None: 81 | self._view_element_id = view_element_id 82 | 83 | def set_settings_group_id(self, settings_group_id: str) -> None: 84 | self._settings_group_id = settings_group_id 85 | 86 | def set_component_id(self, component_id: str) -> None: 87 | self._component_id = component_id 88 | 89 | def is_plugin(self) -> bool: 90 | return ( 91 | self._plugin_uuid is not None 92 | and self._view_id is None 93 | and self._settings_group_id is None 94 | ) 95 | 96 | def is_view(self) -> bool: 97 | return ( 98 | self._view_id is not None 99 | and self._view_element_id is None 100 | and self._settings_group_id is None 101 | ) 102 | 103 | def is_view_element(self) -> bool: 104 | return self._view_element_id is not None 105 | 106 | def is_settings_group(self) -> bool: 107 | return self._settings_group_id is not None 108 | 109 | def is_component(self) -> bool: 110 | return self._component_id is not None 111 | 112 | def adopt(self, other: "LayoutUniqueId") -> None: 113 | if self._plugin_uuid is None and other.get_plugin_uuid() is not None: 114 | self._plugin_uuid = other.get_plugin_uuid() 115 | 116 | if self._view_id is None and other.get_view_id() is not None: 117 | self._view_id = other.get_view_id() 118 | 119 | if self._view_element_id is None and other.get_view_element_id() is not None: 120 | self._view_element_id = other.get_view_element_id() 121 | 122 | if ( 123 | self._settings_group_id is None 124 | and other.get_settings_group_id() is not None 125 | ): 126 | self._settings_group_id = other.get_settings_group_id() 127 | 128 | if self._component_id is None and other.get_component_id() is not None: 129 | self._component_id = other.get_component_id() 130 | 131 | def __str__(self) -> str: 132 | return self.to_string() 133 | 134 | def to_string(self) -> str: 135 | ids: List[str] = [] 136 | if self._plugin_uuid: 137 | ids.append(self._plugin_uuid) 138 | if self._view_id: 139 | ids.append(self._view_id) 140 | if self._view_element_id: 141 | ids.append(self._view_element_id) 142 | if self._settings_group_id: 143 | ids.append(self._settings_group_id) 144 | if self._component_id: 145 | ids.append(self._component_id) 146 | 147 | return "-".join(ids) 148 | -------------------------------------------------------------------------------- /webviz_config/webviz_assets.py: -------------------------------------------------------------------------------- 1 | import re 2 | import shutil 3 | import pathlib 4 | from typing import Optional 5 | 6 | from tqdm import tqdm 7 | from dash import Dash 8 | import flask 9 | 10 | 11 | class WebvizAssets: 12 | """Dash applications by default host static resources from a folder called 13 | ./assets, relative to the root application folder. 14 | 15 | In order to facilitate hot reloading, and fast building of Webviz 16 | applications from the configuration file, this class facilitates handling 17 | of static assets. 18 | 19 | Individual plugins can add assets to a common instance of WebvizAssets 20 | by calling the .add(filename) function. This adds the resource, and 21 | at the same time returns the resource URI which the plugin can use. 22 | 23 | If the webviz instance is in non-portable mode, the Flask/Dash application 24 | is routed to the actual location of the files, making hot reload and 25 | testing fast. 26 | 27 | When creating a portable webviz instance however, the files are copied 28 | over the ./resources/assets folder, and normal Dash usage applies. 29 | 30 | In both portable and non-portable mode, WebvizAssets makes sure there are 31 | no name conflicts (i.e. it supports multiple assets on different paths, 32 | but with same filename) and also assignes URI friendly resource IDs. 33 | """ 34 | 35 | def __init__(self) -> None: 36 | self._assets: dict = {} 37 | self._portable = False 38 | 39 | @property 40 | def portable(self) -> bool: 41 | return self._portable 42 | 43 | @portable.setter 44 | def portable(self, portable: bool) -> None: 45 | self._portable = portable 46 | 47 | def _base_folder(self) -> str: 48 | return "assets" if self.portable else "temp" 49 | 50 | def add(self, filename: pathlib.Path) -> str: 51 | """Calling this function makes the filename given as input 52 | available as a hosted asset when the application is running. 53 | The returned string is a URI which the plugin optionally 54 | can use internally (e.g. as "src" in image elements). 55 | 56 | Calling this function with the same input path 57 | multiple times will return the same URI. 58 | 59 | Filenames added to WebvizAssets that ends with .css or .js 60 | are loaded automatically in the browser by Dash, 61 | both in non-portable and portable mode. 62 | """ 63 | 64 | path = pathlib.Path(filename) 65 | 66 | if filename not in self._assets.values(): 67 | assigned_id = self._generate_id(path.name) 68 | self._assets[assigned_id] = filename 69 | else: 70 | assigned_id = {v: k for k, v in self._assets.items()}[filename] 71 | 72 | return str(pathlib.Path(self._base_folder()) / assigned_id) 73 | 74 | def directly_host_assets(self, app: Dash) -> None: 75 | """In non-portable mode, this function can be called by the 76 | application. It routes the Dash application to the added assets on 77 | disk, making hot reloading and more interactive development of the 78 | application possible. 79 | """ 80 | 81 | if self._portable: 82 | raise RuntimeError( 83 | "The function WebvizAssets.directly_host_assets() " 84 | "method is only meaningful in a non-portable settings." 85 | ) 86 | 87 | @app.server.route(f"/{self._base_folder()}/") 88 | def _send_file(asset_id: str) -> Optional[flask.wrappers.Response]: 89 | if asset_id in self._assets: # Only serve white listed resources 90 | path = pathlib.Path(self._assets[asset_id]) 91 | return flask.send_from_directory(str(path.parent), path.name) 92 | return None 93 | 94 | # Add .css and .js files to auto-loaded Dash assets 95 | for asset_id, asset_path in self._assets.items(): 96 | if asset_path.suffix == ".css": 97 | app.config.external_stylesheets.append( 98 | f"./{self._base_folder()}/{asset_id}" 99 | ) 100 | elif asset_path.suffix == ".js": 101 | app.config.external_scripts.append( 102 | f"./{self._base_folder()}/{asset_id}" 103 | ) 104 | 105 | def make_portable(self, asset_folder: pathlib.Path) -> None: 106 | """Copy over all added assets to the given folder (asset_folder).""" 107 | 108 | for assigned_id, filename in tqdm( 109 | self._assets.items(), 110 | bar_format="{l_bar} {bar} | Copied {n_fmt}/{total_fmt}", 111 | ): 112 | tqdm.write(f"Copying over {filename}") 113 | shutil.copyfile(filename, asset_folder / assigned_id) 114 | 115 | def _generate_id(self, filename: str) -> str: 116 | """From the filename, create a safe resource id not already present""" 117 | asset_id = base_id = re.sub( 118 | "[^-a-z0-9._]+", "", filename.lower().replace(" ", "_") 119 | ) 120 | 121 | count = 1 122 | while asset_id in self._assets: 123 | count += 1 124 | asset_id = f"{base_id}{count}" 125 | 126 | return asset_id 127 | 128 | 129 | WEBVIZ_ASSETS = WebvizAssets() 130 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import pathlib 4 | 5 | from setuptools import setup, find_packages 6 | 7 | 8 | def get_long_description() -> str: 9 | """Converts relative repository links to absolute URLs 10 | if GITHUB_REPOSITORY and GITHUB_SHA environment variables exist. 11 | If not, it returns the raw content in README.md. 12 | """ 13 | 14 | raw_readme = pathlib.Path("README.md").read_text() 15 | 16 | repository = os.environ.get("GITHUB_REPOSITORY") 17 | sha = os.environ.get("GITHUB_SHA") 18 | 19 | if repository is not None and sha is not None: 20 | full_url = f"https://github.com/{repository}/blob/{sha}/" 21 | return re.sub(r"]\((?!https)", "](" + full_url, raw_readme) 22 | return raw_readme 23 | 24 | 25 | TESTS_REQUIRES = [ 26 | "bandit", 27 | "black>=22.12,<23", 28 | "dash[testing]", 29 | "jsonschema", 30 | "mypy", 31 | "pylint<=2.13.9", # Locked due to https://github.com/equinor/webviz-subsurface/issues/1052 32 | "pytest-xdist", 33 | "pytest-forked", 34 | "selenium", 35 | "types-bleach", 36 | "types-markdown", 37 | "types-pyyaml", 38 | "types-requests", 39 | ] 40 | 41 | # pylint: disable=line-too-long 42 | setup( 43 | name="webviz-config", 44 | description="Configuration file support for webviz", 45 | long_description=get_long_description(), 46 | long_description_content_type="text/markdown", 47 | url="https://github.com/equinor/webviz-config", 48 | author="R&T Equinor", 49 | packages=find_packages(exclude=["tests"]), 50 | package_data={ 51 | "webviz_config": [ 52 | "_docs/static/*", 53 | "_docs/static/fonts/*", 54 | "py.typed", 55 | "static/*", 56 | "static/.dockerignore", 57 | "static/.gitignore", 58 | "static/assets/*", 59 | "templates/*", 60 | "themes/default_assets/*", 61 | ] 62 | }, 63 | entry_points={ 64 | "console_scripts": ["webviz=webviz_config.command_line:main"], 65 | "webviz_config_plugins": [ 66 | "ExampleAssets = webviz_config.generic_plugins._example_assets:ExampleAssets", 67 | "ExampleWlfPlugin = webviz_config.generic_plugins._example_wlf_plugin:ExampleWlfPlugin", 68 | "ExampleDataDownload = webviz_config.generic_plugins._example_data_download:ExampleDataDownload", 69 | "ExamplePlugin = webviz_config.generic_plugins._example_plugin:ExamplePlugin", 70 | "ExamplePortable = webviz_config.generic_plugins._example_portable:ExamplePortable", 71 | "ExampleTour = webviz_config.generic_plugins._example_tour:ExampleTour", 72 | "BannerImage = webviz_config.generic_plugins._banner_image:BannerImage", 73 | "DataTable = webviz_config.generic_plugins._data_table:DataTable", 74 | "EmbedPdf = webviz_config.generic_plugins._embed_pdf:EmbedPdf", 75 | "Markdown = webviz_config.generic_plugins._markdown:Markdown", 76 | "SyntaxHighlighter = webviz_config.generic_plugins._syntax_highlighter:SyntaxHighlighter", 77 | "TablePlotter = webviz_config.generic_plugins._table_plotter:TablePlotter", 78 | "PivotTable = webviz_config.generic_plugins._pivot_table:PivotTable", 79 | ], 80 | "pytest11": ["webviz = webviz_config.testing._plugin"], 81 | }, 82 | install_requires=[ 83 | "bleach[css]>=5", 84 | "cryptography>=2.4", 85 | "dash>=2.0", 86 | "dash-pivottable>=0.0.2", 87 | "flask>=2.0", 88 | "flask-caching>=1.4", 89 | "flask-talisman>=0.6", 90 | "jinja2>=2.10", 91 | "markdown>=3.0", 92 | "msal>=1.5.0", 93 | "orjson>=3.3", 94 | "pandas>=1.0", 95 | "pyarrow>=0.16", 96 | "pyyaml>=5.1", 97 | "requests>=2.20", 98 | "tqdm>=4.8", 99 | "webviz-core-components>=0.6", 100 | "werkzeug>=2.0", 101 | ], 102 | extras_require={ 103 | "tests": TESTS_REQUIRES, 104 | "deployment": [ 105 | "aiohttp", # https://github.com/Azure/azure-sdk-for-python/issues/19201 106 | "azure-core", 107 | "azure-identity", 108 | "azure-mgmt-resource", 109 | "azure-mgmt-storage", 110 | "azure-storage-blob", 111 | ], 112 | }, 113 | setup_requires=["setuptools_scm~=3.2"], 114 | python_requires="~=3.8", 115 | use_scm_version=True, 116 | zip_safe=False, 117 | project_urls={ 118 | "Documentation": "https://equinor.github.io/webviz-config", 119 | "Download": "https://pypi.org/project/webviz-config", 120 | "Source": "https://github.com/equinor/webviz-config", 121 | "Tracker": "https://github.com/equinor/webviz-config/issues", 122 | }, 123 | classifiers=[ 124 | "Programming Language :: Python :: 3", 125 | "Operating System :: OS Independent", 126 | "Natural Language :: English", 127 | "Environment :: Web Environment", 128 | "Framework :: Dash", 129 | "Framework :: Flask", 130 | "Topic :: Multimedia :: Graphics", 131 | "Topic :: Scientific/Engineering", 132 | "Topic :: Scientific/Engineering :: Visualization", 133 | "License :: OSI Approved :: MIT License", 134 | ], 135 | ) 136 | -------------------------------------------------------------------------------- /webviz_config/generic_plugins/_example_wlf_plugin/_plugin.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from dash import Input 4 | 5 | from webviz_config import WebvizPluginABC 6 | from webviz_config.utils import StrEnum 7 | from ._views._plot import ( 8 | PlotView, 9 | PlotViewElement, 10 | PlotViewSettingsGroup, 11 | PlotViewElementSettingsGroup, 12 | ) 13 | from ._views._table import TableView, TableViewElement, TableViewSettingsGroup 14 | from ._shared_view_elements import TextViewElement 15 | from ._shared_settings import SharedSettingsGroup 16 | 17 | 18 | class ExampleWlfPlugin(WebvizPluginABC): 19 | class Ids(StrEnum): 20 | PLOT_VIEW = "plot-view" 21 | TABLE_VIEW = "table-view" 22 | SHARED_SETTINGS = "shared-settings" 23 | 24 | def __init__(self, title: str): 25 | super().__init__(stretch=True) 26 | 27 | self.data = [(x, x * x) for x in range(0, 10)] 28 | self.title = title 29 | 30 | self.settings_group = SharedSettingsGroup() 31 | self.add_shared_settings_group( 32 | self.settings_group, ExampleWlfPlugin.Ids.SHARED_SETTINGS 33 | ) 34 | 35 | self.add_view( 36 | PlotView( 37 | self.data, 38 | PlotView.Slots( 39 | kindness_selector=Input( 40 | self.settings_group.component_unique_id( 41 | SharedSettingsGroup.Ids.KINDNESS_SELECTOR 42 | ).to_string(), 43 | "value", 44 | ), 45 | power_selector=Input( 46 | self.settings_group.component_unique_id( 47 | SharedSettingsGroup.Ids.POWER_SELECTOR 48 | ).to_string(), 49 | "value", 50 | ), 51 | ), 52 | ), 53 | ExampleWlfPlugin.Ids.PLOT_VIEW, 54 | ) 55 | self.add_view( 56 | TableView( 57 | self.data, 58 | TableView.Slots( 59 | kindness_selector=Input( 60 | self.settings_group.component_unique_id( 61 | SharedSettingsGroup.Ids.KINDNESS_SELECTOR 62 | ).to_string(), 63 | "value", 64 | ), 65 | power_selector=Input( 66 | self.settings_group.component_unique_id( 67 | SharedSettingsGroup.Ids.POWER_SELECTOR 68 | ).to_string(), 69 | "value", 70 | ), 71 | ), 72 | ), 73 | ExampleWlfPlugin.Ids.TABLE_VIEW, 74 | ) 75 | 76 | @property 77 | def tour_steps(self) -> List[dict]: 78 | return [ 79 | { 80 | "id": self.view(ExampleWlfPlugin.Ids.PLOT_VIEW) 81 | .view_element(PlotView.Ids.TEXT) 82 | .component_unique_id(TextViewElement.Ids.TEXT), 83 | "content": "Greetings from your example plugin.", 84 | }, 85 | { 86 | "id": self.settings_group.component_unique_id("kindness-selector"), 87 | "content": "You can change here if this shall be friendly or not.", 88 | }, 89 | { 90 | "id": self.view(ExampleWlfPlugin.Ids.PLOT_VIEW) 91 | .view_element(PlotView.Ids.PLOT) 92 | .component_unique_id(PlotViewElement.Ids.GRAPH), 93 | "content": "Over here you see a plot that shows x² or x³.", 94 | }, 95 | { 96 | "id": self.settings_group.component_unique_id( 97 | SharedSettingsGroup.Ids.POWER_SELECTOR 98 | ), 99 | "content": "You can change here which exponent you prefer.", 100 | }, 101 | { 102 | "id": self.view(ExampleWlfPlugin.Ids.PLOT_VIEW) 103 | .settings_group(PlotView.Ids.PLOT_SETTINGS) 104 | .component_unique_id(PlotViewSettingsGroup.Ids.COORDINATES_SELECTOR), 105 | "content": "...and here you can swap the axes.", 106 | }, 107 | { 108 | "id": self.view(ExampleWlfPlugin.Ids.PLOT_VIEW) 109 | .view_element(PlotView.Ids.PLOT) 110 | .settings_group(PlotViewElement.Ids.PLOT_SETTINGS) 111 | .component_unique_id(PlotViewElementSettingsGroup.Ids.COLOR_SELECTOR), 112 | "content": "You can change here which color you prefer.", 113 | }, 114 | { 115 | "id": self.view(ExampleWlfPlugin.Ids.TABLE_VIEW) 116 | .view_element(TableView.Ids.TABLE) 117 | .component_unique_id(TableViewElement.Ids.TABLE), 118 | "content": "There is also a table visualizing the data.", 119 | }, 120 | { 121 | "id": self.view(ExampleWlfPlugin.Ids.TABLE_VIEW) 122 | .settings_group(TableView.Ids.TABLE_SETTINGS) 123 | .component_unique_id(TableViewSettingsGroup.Ids.ORDER_SELECTOR), 124 | "content": "You can change the order of the table here.", 125 | }, 126 | ] 127 | -------------------------------------------------------------------------------- /webviz_config/_deployment/github_cli.py: -------------------------------------------------------------------------------- 1 | import json 2 | import shutil 3 | import tempfile 4 | import subprocess 5 | from pathlib import Path 6 | 7 | 8 | def binary_available() -> bool: 9 | """Returns True if the GitHub CLI binary (gh) is 10 | availabile in $PATH, otherwise returns False. 11 | """ 12 | return shutil.which("gh") is not None 13 | 14 | 15 | def logged_in() -> bool: 16 | # pylint: disable=subprocess-run-check 17 | result = subprocess.run( 18 | ["gh", "auth", "status"], 19 | capture_output=True, 20 | ) 21 | return b"Logged in to github.com" in result.stderr 22 | 23 | 24 | def log_in() -> None: 25 | # pylint: disable=subprocess-run-check 26 | subprocess.run( 27 | ["gh", "auth", "login"], 28 | ) 29 | 30 | 31 | def repo_exists(github_slug: str) -> bool: 32 | # pylint: disable=subprocess-run-check 33 | result = subprocess.run( 34 | ["gh", "repo", "view", github_slug], 35 | capture_output=True, 36 | ) 37 | 38 | if not result.stderr: 39 | return True 40 | if b"Could not resolve" in result.stderr: 41 | return False 42 | raise RuntimeError(result.stderr) 43 | 44 | 45 | def create_github_repository(github_slug: str, directory: Path) -> Path: 46 | """Creates a new private github repository. github_slug is on format 47 | owner/reponame. 48 | """ 49 | if not "/" in github_slug: 50 | raise ValueError("repo_path argument should be on format owner/reponame") 51 | 52 | subprocess.run( 53 | ["gh", "repo", "create", github_slug, "--private", "--confirm"], 54 | capture_output=True, 55 | check=True, 56 | cwd=directory, 57 | ) 58 | 59 | subprocess.run( 60 | ["gh", "repo", "clone", github_slug], 61 | capture_output=True, 62 | check=True, 63 | cwd=directory, 64 | ) 65 | 66 | return directory / github_slug.split("/")[1] 67 | 68 | 69 | def turn_on_github_vulnerability_alers(directory: Path) -> None: 70 | subprocess.run( 71 | [ 72 | "gh", 73 | "api", 74 | "repos/:owner/:repo/vulnerability-alerts", 75 | "--method", 76 | "PUT", 77 | "--header", 78 | "Accept: application/vnd.github.dorian-preview+json", 79 | ], 80 | check=True, 81 | cwd=directory, 82 | ) 83 | 84 | 85 | def _call_post_api(endpoint: str, data: dict, directory: Path) -> None: 86 | subprocess.run( 87 | [ 88 | "gh", 89 | "api", 90 | endpoint, 91 | "--method", 92 | "POST", 93 | "--input", 94 | "-", 95 | "--silent", 96 | ], 97 | input=json.dumps(data), 98 | check=True, 99 | cwd=directory, 100 | text=True, 101 | ) 102 | 103 | 104 | def add_webhook(directory: Path, receiver_url: str, secret: str) -> None: 105 | data = { 106 | "name": "web", 107 | "active": True, 108 | "config": { 109 | "url": receiver_url, 110 | "content_type": "json", 111 | "secret": secret, 112 | }, 113 | } 114 | _call_post_api(data=data, endpoint="repos/:owner/:repo/hooks", directory=directory) 115 | 116 | 117 | def add_deploy_key(directory: Path, title: str, key: str) -> None: 118 | _call_post_api( 119 | data={"title": title, "key": key}, 120 | endpoint="repos/:owner/:repo/keys", 121 | directory=directory, 122 | ) 123 | 124 | 125 | def read_file_in_repository(github_slug: str, filename: str) -> str: 126 | with tempfile.TemporaryDirectory() as tmp_dir: 127 | temp_dir = Path(tmp_dir) 128 | subprocess.run( 129 | ["git", "clone", f"git@github.com:{github_slug}"], 130 | check=True, 131 | cwd=temp_dir, 132 | capture_output=True, 133 | ) 134 | clone_path = temp_dir / github_slug.split("/")[1] 135 | 136 | return (clone_path / Path(filename)).read_text() 137 | 138 | 139 | def commit_portable_webviz( 140 | github_slug: str, 141 | source_directory: Path, 142 | commit_message: str = "Initial commit", 143 | branch_name: str = "main", 144 | ) -> None: 145 | 146 | with tempfile.TemporaryDirectory() as tmp_dir: 147 | temp_dir = Path(tmp_dir) 148 | subprocess.run( 149 | ["git", "clone", f"git@github.com:{github_slug}"], 150 | check=True, 151 | cwd=temp_dir, 152 | capture_output=True, 153 | ) 154 | 155 | clone_path = temp_dir / github_slug.split("/")[1] 156 | 157 | shutil.copytree( 158 | source_directory, 159 | clone_path, 160 | dirs_exist_ok=True, 161 | ignore=shutil.ignore_patterns("resources"), 162 | ) 163 | 164 | commands = [ 165 | ["git", "add", "."], 166 | ["git", "commit", "-m", commit_message, "--allow-empty"], 167 | ["git", "branch", "-M", branch_name], 168 | ["git", "push", "-u", "origin", branch_name], 169 | ] 170 | for command in commands: 171 | subprocess.run( 172 | command, 173 | check=True, 174 | cwd=clone_path, 175 | capture_output=True, 176 | ) 177 | -------------------------------------------------------------------------------- /webviz_config/utils/_callback_typecheck.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=line-too-long 2 | from typing import Any, Callable, get_args, get_origin, _TypedDictMeta, TypeVar, Union # type: ignore[attr-defined] 3 | import inspect 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | class ConversionError(Exception): 9 | pass 10 | 11 | 12 | def _isinstance(arg: Any, annotation: Any) -> bool: 13 | # pylint: disable=too-many-return-statements, too-many-branches 14 | if annotation is type(None) or annotation is None: 15 | return arg is None 16 | 17 | if annotation is Any: 18 | return True 19 | 20 | if get_origin(annotation) is None: 21 | try: 22 | return isinstance(arg, annotation) 23 | except TypeError: 24 | return False 25 | 26 | if get_origin(annotation) == Union: 27 | for annotation_arg in get_args(annotation): 28 | if _isinstance(arg, annotation_arg): 29 | return True 30 | 31 | if get_origin(annotation) is list and isinstance(arg, list): 32 | result = True 33 | type_args = get_args(annotation) 34 | if len(type_args) == 1: 35 | for annotation_arg in arg: 36 | result &= _isinstance(annotation_arg, type_args[0]) 37 | return result 38 | 39 | if get_origin(annotation) is dict and isinstance(arg, dict): 40 | result = True 41 | type_args = get_args(annotation) 42 | if len(type_args) == 2: 43 | for key, value in arg.items(): 44 | result &= _isinstance(key, type_args[0]) 45 | result &= _isinstance(value, type_args[1]) 46 | return result 47 | 48 | return False 49 | 50 | 51 | def convert(arg: Any, convert_to: T) -> T: 52 | # pylint: disable=too-many-return-statements, too-many-branches 53 | additional_error_message: str = "" 54 | try: 55 | if _isinstance(arg, convert_to): 56 | return arg 57 | if ( 58 | inspect.isclass(convert_to) 59 | and not isinstance(convert_to, _TypedDictMeta) 60 | and arg is not None 61 | ): 62 | return convert_to(arg) 63 | if ( 64 | isinstance(convert_to, _TypedDictMeta) 65 | and "__annotations__" in dir(convert_to) 66 | and isinstance(arg, dict) 67 | ): 68 | new_dict = convert_to() 69 | for key, value in arg.items(): 70 | if key in list(convert_to.__annotations__.keys()): 71 | new_dict[key] = convert(value, convert_to.__annotations__[key]) 72 | else: 73 | raise Exception( 74 | f""" 75 | Key '{key}' not allowed in '{convert_to}'.\n 76 | Allowed keys are: {', '.join(list(convert_to.__annotations__.keys()))} 77 | """ 78 | ) 79 | 80 | if not convert_to.__total__ or len(new_dict.keys()) == len( 81 | convert_to.__annotations__.keys() 82 | ): 83 | return new_dict 84 | 85 | if convert_to is list and isinstance(arg, list): 86 | return arg # type: ignore[return-value] 87 | if get_origin(convert_to) is list and isinstance(arg, list): 88 | return [convert(a, get_args(convert_to)[0]) for a in arg] # type: ignore[return-value] 89 | if convert_to is dict and isinstance(arg, dict): 90 | return arg # type: ignore[return-value] 91 | if get_origin(convert_to) is dict and isinstance(arg, dict): 92 | if len(get_args(convert_to)) == 2: 93 | return { # type: ignore[return-value] 94 | convert(key, get_args(convert_to)[0]): convert( 95 | value, get_args(convert_to)[1] 96 | ) 97 | for key, value in arg.items() 98 | } 99 | if get_origin(convert_to) is Union and "__args__" in dir(convert_to): 100 | for convert_type in get_args(convert_to): 101 | try: 102 | return convert(arg, convert_type) 103 | except ConversionError: 104 | pass 105 | 106 | # pylint: disable=broad-except 107 | except Exception as exception: 108 | additional_error_message = f"\n\nMore details:\n{exception}" 109 | 110 | raise ConversionError( 111 | f"Argument of type '{type(arg)}' cannot be converted to type '{convert_to}'.{additional_error_message}" 112 | ) 113 | 114 | 115 | def callback_typecheck(func: Callable) -> Callable: 116 | signature = inspect.signature(func) 117 | parameters = list(signature.parameters.values()) 118 | 119 | def wrapper(*_args) -> signature.return_annotation: # type: ignore[no-untyped-def,name-defined] 120 | adjusted_args: list = [] 121 | 122 | for index, arg in enumerate(_args): 123 | try: 124 | adjusted_args.append(convert(arg, parameters[index].annotation)) 125 | except ConversionError as exception: 126 | raise ConversionError( 127 | f"Error while converting input to argument '{parameters[index].name}' of function '{func.__name__}' in file '{func.__globals__['__file__']}': {exception}" 128 | ) from exception 129 | 130 | return func(*adjusted_args) 131 | 132 | return wrapper 133 | -------------------------------------------------------------------------------- /webviz_config/_build_webviz.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import shutil 4 | import pathlib 5 | import tempfile 6 | import subprocess # nosec 7 | import argparse 8 | 9 | from yaml import YAMLError 10 | 11 | from ._config_parser import ParserError 12 | from ._write_script import write_script 13 | from ._dockerize import create_docker_setup 14 | from .themes import installed_themes 15 | from .utils import terminal_colors 16 | 17 | BUILD_FILENAME = "webviz_app.py" 18 | STATIC_FOLDER = pathlib.Path(__file__).resolve().parent / "static" 19 | 20 | 21 | # Restructure here before merge. 22 | # pylint: disable=too-many-branches 23 | def build_webviz(args: argparse.Namespace) -> None: 24 | 25 | if args.theme not in installed_themes: 26 | raise ValueError(f"Theme `{args.theme}` is not installed.") 27 | 28 | if args.portable is None: 29 | build_directory = pathlib.Path(tempfile.mkdtemp()) 30 | else: 31 | build_directory = args.portable.resolve() 32 | build_directory.mkdir(parents=True) 33 | 34 | shutil.copytree(STATIC_FOLDER / "assets", build_directory / "resources" / "assets") 35 | 36 | for asset in installed_themes[args.theme].assets: 37 | shutil.copy(asset, build_directory / "resources" / "assets") 38 | 39 | (build_directory / "theme_settings.json").write_text( 40 | installed_themes[args.theme].to_json() 41 | ) 42 | 43 | try: 44 | if args.portable: 45 | print( 46 | f"{terminal_colors.BLUE}{terminal_colors.BOLD}" 47 | "Saving requested data to build folder " 48 | "such that the webviz instance is portable." 49 | f"{terminal_colors.END}" 50 | ) 51 | 52 | write_script( 53 | args, build_directory, "copy_data_template.py.jinja2", "copy_data.py" 54 | ) 55 | 56 | if subprocess.call( # nosec 57 | [sys.executable, "copy_data.py"], cwd=build_directory 58 | ): 59 | # We simply exit here with a non-zero status in order to not clutter 60 | # the subprocess traceback with unnecessary information 61 | sys.exit(1) 62 | 63 | (build_directory / "copy_data.py").unlink() 64 | 65 | print( 66 | f"{terminal_colors.GREEN}{terminal_colors.BOLD}" 67 | "Finished data extraction. All output saved." 68 | f"{terminal_colors.END}" 69 | ) 70 | 71 | non_default_assets, plugin_metadata = write_script( 72 | args, build_directory, "webviz_template.py.jinja2", BUILD_FILENAME 73 | ) 74 | 75 | for asset in non_default_assets: 76 | shutil.copy(asset, build_directory / "resources" / "assets") 77 | 78 | if args.portable: 79 | for filename in ["README.md", ".dockerignore", ".gitignore"]: 80 | shutil.copy(STATIC_FOLDER / filename, build_directory) 81 | create_docker_setup(build_directory, plugin_metadata) 82 | else: 83 | run_webviz(args, build_directory) 84 | 85 | finally: 86 | if not args.portable: 87 | shutil.rmtree(build_directory) 88 | 89 | 90 | def run_webviz(args: argparse.Namespace, build_directory: pathlib.Path) -> None: 91 | 92 | print( 93 | f"{terminal_colors.YELLOW}" 94 | " Starting up your webviz application. Please wait..." 95 | f"{terminal_colors.END}" 96 | ) 97 | 98 | with subprocess.Popen( # nosec 99 | [sys.executable, BUILD_FILENAME], cwd=build_directory 100 | ) as app_process: 101 | 102 | lastmtime = args.yaml_file.stat().st_mtime 103 | 104 | while app_process.poll() is None: 105 | try: 106 | time.sleep(1) 107 | 108 | if lastmtime != args.yaml_file.stat().st_mtime: 109 | lastmtime = args.yaml_file.stat().st_mtime 110 | write_script( 111 | args, 112 | build_directory, 113 | "webviz_template.py.jinja2", 114 | BUILD_FILENAME, 115 | ) 116 | print( 117 | f"{terminal_colors.BLUE}{terminal_colors.BOLD}" 118 | " Rebuilt webviz dash app from configuration file" 119 | f"{terminal_colors.END}" 120 | ) 121 | 122 | except (ParserError, YAMLError) as excep: 123 | print( 124 | f"{excep} {terminal_colors.RED}{terminal_colors.BOLD}" 125 | "Fix the error and save the configuration file in " 126 | "order to trigger a new rebuild." 127 | f"{terminal_colors.END}" 128 | ) 129 | 130 | except KeyboardInterrupt: 131 | app_process.kill() 132 | print( 133 | f"\r{terminal_colors.BLUE}{terminal_colors.BOLD}" 134 | " Shutting down the webviz application on user request." 135 | f"{terminal_colors.END}" 136 | ) 137 | 138 | except Exception as excep: 139 | app_process.kill() 140 | print( 141 | f"{terminal_colors.RED}{terminal_colors.BOLD}" 142 | "Unexpected error. Killing the webviz application process." 143 | f"{terminal_colors.END}" 144 | ) 145 | raise excep 146 | --------------------------------------------------------------------------------