From 67b2098045362c76ce226cc8087f67d8c5f22aca Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 13 May 2024 20:51:39 +0000 Subject: [PATCH 01/31] Reactpy configured at the baseline-level --- environment.yml | 1 + micro_environment.yml | 1 + tethys_portal/asgi.py | 2 ++ tethys_portal/settings.py | 9 ++++++++- tethys_portal/urls.py | 3 +++ 5 files changed, 15 insertions(+), 1 deletion(-) diff --git a/environment.yml b/environment.yml index 418a43c20..f1ad24942 100644 --- a/environment.yml +++ b/environment.yml @@ -65,6 +65,7 @@ dependencies: - django-analytical # track usage analytics - django-json-widget # enable json widget for app settings - djangorestframework # enable REST API framework + - reactpy-django # Map Layout - PyShp diff --git a/micro_environment.yml b/micro_environment.yml index 634a284da..cf0433c7b 100644 --- a/micro_environment.yml +++ b/micro_environment.yml @@ -36,3 +36,4 @@ dependencies: - django-bootstrap5 - django-model-utils - django-guardian + - reactpy-django diff --git a/tethys_portal/asgi.py b/tethys_portal/asgi.py index 334a2cb8e..d2436fe2d 100644 --- a/tethys_portal/asgi.py +++ b/tethys_portal/asgi.py @@ -8,10 +8,12 @@ from channels.routing import ProtocolTypeRouter, URLRouter from django.core.asgi import get_asgi_application from django.urls import re_path +from reactpy_django import REACTPY_WEBSOCKET_ROUTE def build_application(asgi_app): from tethys_apps.urls import app_websocket_urls, http_handler_patterns + app_websocket_urls.append(REACTPY_WEBSOCKET_ROUTE) application = ProtocolTypeRouter( { diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 743d70f77..405c63a0f 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -216,7 +216,7 @@ ) default_installed_apps = [ - "channels", + "daphne", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -233,6 +233,7 @@ "tethys_services", "tethys_quotas", "guardian", + "reactpy_django", ] for module in [ @@ -321,6 +322,12 @@ RESOURCE_QUOTA_HANDLERS + portal_config_settings.pop("RESOURCE_QUOTA_HANDLERS", []) ) +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer" + } +} + REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_AUTHENTICATION_CLASSES": ( diff --git a/tethys_portal/urls.py b/tethys_portal/urls.py index 0bd3604ca..fa1711868 100644 --- a/tethys_portal/urls.py +++ b/tethys_portal/urls.py @@ -313,3 +313,6 @@ name="login_prefix", ) ) + +urlpatterns.append(re_path("^reactpy/", include("reactpy_django.http.urls"))) + From a5856990c1e4afec47fff5676076dec19716ce35 Mon Sep 17 00:00:00 2001 From: Corey Krewson Date: Thu, 16 May 2024 09:08:24 -0500 Subject: [PATCH 02/31] Added RESelectInput react component to create a reach dropdown just like original tethys gizmo added on_click, on_change, and on_mouse_over kwargs --- tethys_gizmos/react_components/__init__.py | 12 ++ .../react_components/select_input.py | 125 ++++++++++++++++++ tethys_portal/asgi.py | 1 + tethys_portal/settings.py | 6 +- tethys_portal/urls.py | 1 - tethys_sdk/gizmos.py | 1 + 6 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 tethys_gizmos/react_components/__init__.py create mode 100644 tethys_gizmos/react_components/select_input.py diff --git a/tethys_gizmos/react_components/__init__.py b/tethys_gizmos/react_components/__init__.py new file mode 100644 index 000000000..d4ec82417 --- /dev/null +++ b/tethys_gizmos/react_components/__init__.py @@ -0,0 +1,12 @@ +""" +******************************************************************************** +* Name: react_components/__init__.py +* Author: Corey Krewson +* Created On: May 2024 +* Copyright: (c) Aquaveo 2024 +* License: BSD 3-Clause +******************************************************************************** +""" + +# flake8: noqa +from .select_input import * diff --git a/tethys_gizmos/react_components/select_input.py b/tethys_gizmos/react_components/select_input.py new file mode 100644 index 000000000..ba591cccf --- /dev/null +++ b/tethys_gizmos/react_components/select_input.py @@ -0,0 +1,125 @@ +from reactpy import html, component +import json +from reactpy_django.components import django_js +from tethys_portal.dependencies import vendor_static_dependencies + +vendor_js_dependencies = (vendor_static_dependencies["select2"].js_url,) +vendor_css_dependencies = (vendor_static_dependencies["select2"].css_url,) +gizmo_js_dependencies = ("tethys_gizmos/js/select_input.js",) + + +@component +def RESelectInput( + name, + display_text="", + initial=None, + multiple=False, + original=False, + select2_options=None, + options="", + disabled=False, + error="", + success="", + attributes=None, + classes="", + on_change=None, + on_click=None, + on_mouse_over=None, +): + # Setup/Fix variables and kwargs + initial = initial or [] + initial_is_iterable = isinstance(initial, (list, tuple, set, dict)) + placeholder = False if select2_options is None else "placeholder" in select2_options + select2_options = json.dumps(select2_options) + + # Setup div that will potentially contain the label, select input, and valid/invalid feedback + return_div = html.div() + return_div["children"] = [] + + # Add label to return div if a display text is given + if display_text: + return_div["children"].append( + html.label({"class_name": "form-label", "html_for": name}, display_text) + ) + + # Setup the select input attributes + select_classes = "".join( + [ + "form-select" if original else "tethys-select2", + " is-invalid" if error else "", + " is-valid" if success else "", + f" {classes}" if classes else "", + ] + ) + select_style = {} if original else {"width": "100%"} + select_attributes = { + "id": name, + "class_name": select_classes, + "name": name, + "style": select_style, + "multiple": multiple, + "disabled": disabled, + } + if select2_options: + select_attributes["data-select2-options"] = select2_options + if on_change: + select_attributes["on_change"] = on_change + if on_click: + select_attributes["on_click"] = on_click + if on_mouse_over: + select_attributes["on_mouse_over"] = on_mouse_over + if attributes: + for key, value in attributes.items(): + select_attributes[key] = value + + # Create the select input with the associated attributes + select = html.select( + select_attributes, + ) + + # Add options to the select input if they are provided + if options: + if placeholder: + select["children"] = [html.option()] + else: + select["children"] = [] + + for option, value in options: + select_option = html.option({"value": value}, option) + if initial_is_iterable: + if option in initial or value in initial: + select_option["attributes"]["selected"] = "selected" + else: + if option == initial or value == initial: + select_option["attributes"]["selected"] = "selected" + select["children"].append(select_option) + + # Create the div for the select input + input_group_classes = "".join( + ["input-group mb-3", " has-validation" if error or success else ""] + ) + input_group = html.div( + {"class_name": input_group_classes}, + select, + ) + + # add invalid-feedback div to the select input group if needed + if error: + input_group["children"].append( + html.div({"class_name": "invalid-feedback"}, error) + ) + + # add valid-feedback div to the select input group if needed + if success: + input_group["children"].append( + html.div({"class_name": "valid-feedback"}, success) + ) + + # add select input group div to the returned div + return_div["children"].append(input_group) + + # reload any gizmo JS dependencies after the react renders. This is required for the select2 dropdown to work + for gizmo_js in gizmo_js_dependencies: + return_div["children"].append(django_js(gizmo_js)) + + return return_div diff --git a/tethys_portal/asgi.py b/tethys_portal/asgi.py index d2436fe2d..740617f8d 100644 --- a/tethys_portal/asgi.py +++ b/tethys_portal/asgi.py @@ -13,6 +13,7 @@ def build_application(asgi_app): from tethys_apps.urls import app_websocket_urls, http_handler_patterns + app_websocket_urls.append(REACTPY_WEBSOCKET_ROUTE) application = ProtocolTypeRouter( diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 405c63a0f..983b5b77e 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -322,11 +322,7 @@ RESOURCE_QUOTA_HANDLERS + portal_config_settings.pop("RESOURCE_QUOTA_HANDLERS", []) ) -CHANNEL_LAYERS = { - "default": { - "BACKEND": "channels.layers.InMemoryChannelLayer" - } -} +CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), diff --git a/tethys_portal/urls.py b/tethys_portal/urls.py index fa1711868..3d56e3587 100644 --- a/tethys_portal/urls.py +++ b/tethys_portal/urls.py @@ -315,4 +315,3 @@ ) urlpatterns.append(re_path("^reactpy/", include("reactpy_django.http.urls"))) - diff --git a/tethys_sdk/gizmos.py b/tethys_sdk/gizmos.py index 582e3154d..16ebbb342 100644 --- a/tethys_sdk/gizmos.py +++ b/tethys_sdk/gizmos.py @@ -11,4 +11,5 @@ # flake8: noqa # DO NOT ERASE from tethys_gizmos.gizmo_options import * +from tethys_gizmos.react_components import * from tethys_gizmos.gizmo_options.base import TethysGizmoOptions, SecondaryGizmoOptions From debe2a4d1dbe21cdd5c02f28b228b52af8092a5e Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Sat, 17 Aug 2024 17:14:18 -0600 Subject: [PATCH 03/31] Integrates reactpy and implements app scaffold --- environment.yml | 4 +- micro_environment.yml | 4 +- tethys_apps/base/app_base.py | 8 +- tethys_apps/base/controller.py | 205 +++++++++++++++++ tethys_apps/base/url_map.py | 8 +- .../templates/tethys_apps/reactpy_base.html | 112 +++++++++ tethys_cli/scaffold_commands.py | 22 +- .../app_templates/reactpy/.gitignore | 11 + .../app_templates/reactpy/install.yml_tmpl | 17 ++ .../app_templates/reactpy/setup.py_tmpl | 31 +++ .../reactpy/tethysapp/+project+/__init__.py | 1 + .../reactpy/tethysapp/+project+/app.py_tmpl | 20 ++ .../reactpy/tethysapp/+project+/pages.py_tmpl | 18 ++ .../+project+/public/images/icon.png | Bin 0 -> 52534 bytes .../tethysapp/+project+/tests/__init__.py | 0 .../tethysapp/+project+/tests/tests.py_tmpl | 147 ++++++++++++ .../workspaces/app_workspace/.gitkeep | 0 .../workspaces/user_workspaces/.gitkeep | 0 tethys_cli/settings_commands.py | 4 +- tethys_components/__init__.py | 0 tethys_components/custom.py | 214 ++++++++++++++++++ tethys_components/hooks.py | 20 ++ tethys_components/layouts.py | 21 ++ tethys_components/library.py | 172 ++++++++++++++ .../reactjs_module_wrapper_template.js | 94 ++++++++ tethys_components/utils.py | 42 ++++ tethys_portal/asgi.py | 6 +- tethys_portal/dependencies.py | 4 +- tethys_portal/settings.py | 3 +- tethys_portal/urls.py | 3 +- tethys_sdk/components/__init__.py | 13 ++ tethys_sdk/components/utils.py | 2 + tethys_sdk/routing.py | 1 + 33 files changed, 1191 insertions(+), 16 deletions(-) create mode 100644 tethys_apps/templates/tethys_apps/reactpy_base.html create mode 100644 tethys_cli/scaffold_templates/app_templates/reactpy/.gitignore create mode 100644 tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl create mode 100644 tethys_cli/scaffold_templates/app_templates/reactpy/setup.py_tmpl create mode 100644 tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/__init__.py create mode 100644 tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/app.py_tmpl create mode 100644 tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/pages.py_tmpl create mode 100644 tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/public/images/icon.png create mode 100644 tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/tests/__init__.py create mode 100644 tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/tests/tests.py_tmpl create mode 100644 tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/workspaces/app_workspace/.gitkeep create mode 100644 tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/workspaces/user_workspaces/.gitkeep create mode 100644 tethys_components/__init__.py create mode 100644 tethys_components/custom.py create mode 100644 tethys_components/hooks.py create mode 100644 tethys_components/layouts.py create mode 100644 tethys_components/library.py create mode 100644 tethys_components/resources/reactjs_module_wrapper_template.js create mode 100644 tethys_components/utils.py create mode 100644 tethys_sdk/components/__init__.py create mode 100644 tethys_sdk/components/utils.py diff --git a/environment.yml b/environment.yml index f1ad24942..67d2fa703 100644 --- a/environment.yml +++ b/environment.yml @@ -21,8 +21,7 @@ dependencies: # core dependencies - django>=3.2,<6 - - channels - - daphne + - channels["daphne"] - setuptools_scm - pip - requests # required by lots of things @@ -65,7 +64,6 @@ dependencies: - django-analytical # track usage analytics - django-json-widget # enable json widget for app settings - djangorestframework # enable REST API framework - - reactpy-django # Map Layout - PyShp diff --git a/micro_environment.yml b/micro_environment.yml index cf0433c7b..1151e14b6 100644 --- a/micro_environment.yml +++ b/micro_environment.yml @@ -20,8 +20,7 @@ dependencies: # core dependencies - django>=3.2,<6 - - channels - - daphne + - channels["daphne"] - setuptools_scm - pip - requests # required by lots of things @@ -36,4 +35,3 @@ dependencies: - django-bootstrap5 - django-model-utils - django-guardian - - reactpy-django diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index fddeeb5b8..e5c235c76 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -36,7 +36,7 @@ tethys_log = logging.getLogger("tethys.app_base") -DEFAULT_CONTROLLER_MODULES = ["controllers", "consumers", "handlers"] +DEFAULT_CONTROLLER_MODULES = ["controllers", "consumers", "handlers", "pages"] class TethysBase(TethysBaseMixin): @@ -51,6 +51,8 @@ class TethysBase(TethysBaseMixin): root_url = "" index = None controller_modules = [] + default_layout = None + custom_css = [] def __init__(self): self._url_patterns = None @@ -76,6 +78,10 @@ def id(cls): """Returns ID of Django database object.""" return cls.db_object.id + @classproperty + def layout(cls): + return cls.default_layout + @classmethod def _resolve_ref_function(cls, ref, ref_type): """ diff --git a/tethys_apps/base/controller.py b/tethys_apps/base/controller.py index e176b2bfd..8068a3929 100644 --- a/tethys_apps/base/controller.py +++ b/tethys_apps/base/controller.py @@ -14,13 +14,17 @@ from django.views.generic import View from django.http import HttpRequest from django.contrib.auth import REDIRECT_FIELD_NAME +from django.conf import settings +from django.shortcuts import render +from tethys_components.library import Library as ComponentLibrary from tethys_cli.cli_colors import write_warning from tethys_quotas.decorators import enforce_quota from tethys_services.utilities import ensure_oauth2 from . import url_map_maker from .app_base import DEFAULT_CONTROLLER_MODULES + from .bokeh_handler import ( _get_bokeh_controller, with_workspaces as with_workspaces_decorator, @@ -37,6 +41,7 @@ from typing import Union, Any from collections.abc import Callable +from reactpy import component app_controllers_list = list() @@ -398,6 +403,127 @@ def wrapped(function_or_class): return wrapped if function_or_class is None else wrapped(function_or_class) +def page( + function_or_class: Union[ + Callable[[HttpRequest, ...], Any], TethysController + ] = None, + /, + *, + # UrlMap Overrides + name: str = None, + url: Union[str, list, tuple, dict, None] = None, + protocol: str = "http", + regex: Union[str, list, tuple] = None, + _handler: Union[str, Callable] = None, + _handler_type: str = None, + # login_required kwargs + login_required: bool = True, + redirect_field_name: str = REDIRECT_FIELD_NAME, + login_url: str = None, + # workspace decorators + app_workspace: bool = False, + user_workspace: bool = False, + # ensure_oauth2 kwarg + ensure_oauth2_provider: str = None, + # enforce_quota kwargs + enforce_quotas: Union[str, list, tuple, None] = None, + # permission_required kwargs + permissions_required: Union[str, list, tuple] = None, + permissions_use_or: bool = False, + permissions_message: str = None, + permissions_raise_exception: bool = False, + # additional kwargs to pass to TethysController.as_controller + layout="default", + title=None, + index=None, + custom_css=[], + custom_js=[] +) -> Callable: + """ + Decorator to register a function or TethysController class as a controller + (by automatically registering a UrlMap for it). + + Args: + name: Name of the url map. Letters and underscores only (_). Must be unique within the app. The default is the name of the function being decorated. + url: URL pattern to map the endpoint for the controller or consumer. If a `list` then a separate UrlMap is generated for each URL in the list. The first URL is given `name` and subsequent URLS are named `name` _1, `name` _2 ... `name` _n. Can also be passed as dict mapping names to URL patterns. In this case the `name` argument is ignored. + protocol: 'http' for controllers or 'websocket' for consumers. Default is http. + regex: Custom regex pattern(s) for url variables. If a string is provided, it will be applied to all variables. If a list or tuple is provided, they will be applied in variable order. + login_required: If user is required to be logged in to access the controller. Default is `True`. + redirect_field_name: URL query string parameter for the redirect path. Default is "next". + login_url: URL to send users to in order to authenticate. + app_workspace: Whether to pass the app workspace as an argument to the controller. + user_workspace: Whether to pass the user workspace as an argument to the controller. + ensure_oauth2_provider: An OAuth2 provider name to ensure is authenticated to access the controller. + enforce_quotas: The name(s) of quotas to enforce on the controller. + permissions_required: The name(s) of permissions that a user is required to have to access the controller. + permissions_use_or: When multiple permissions are provided and this is True, use OR comparison rather than AND comparison, which is default. + permissions_message: Override default message that is displayed to user when permission is denied. Default message is "We're sorry, but you are not allowed to perform this operation.". + permissions_raise_exception: Raise 403 error if True. Defaults to False. + layout: Layout within which the page content will be wrapped + title: Title of page as used in both the built-in Navigation component and the browser tab + index: Index of the page as used to determine the display order in the built-in Navigation component. Defaults to top-to-bottom as written in code. Pass -1 to remove from built-in Navigation component. + custom_css: A list of URLs to additional css files that should be rendered with the page. These will be rendered in the order provided. + custom_js: A list of URLs to additional js files that should be rendered with the page. These will be rendered in the order provided. + + **NOTE:** The :ref:`handler-decorator` should be used in favor of using the following arguments directly. + + Args: + _handler: Dot-notation path a handler function. A handler is associated to a specific controller and contains the main logic for creating and establishing a communication between the client and the server. + _handler_type: Tethys supported handler type. 'bokeh' is the only handler type currently supported. + """ # noqa: E501 + + permissions_required = _listify(permissions_required) + enforce_quota_codenames = _listify(enforce_quotas) + layout = f'{layout.__module__}.{layout.__name__}' if callable(layout) else layout + + def wrapped(function_or_class): + page_module_path = f'{function_or_class.__module__}.{function_or_class.__name__}' + url_map_kwargs_list = _get_url_map_kwargs_list( + function_or_class=function_or_class, + name=name, + url=url, + protocol=protocol, + regex=regex, + handler=_handler, + handler_type=_handler_type, + app_workspace=app_workspace, + user_workspace=user_workspace, + title=title, + index=index + ) + + def controller_wrapper(request): + controller = _global_page_component_controller + if permissions_required: + controller = permission_required( + *permissions_required, + use_or=permissions_use_or, + message=permissions_message, + raise_exception=permissions_raise_exception, + )(controller) + + for codename in enforce_quota_codenames: + controller = enforce_quota(codename)(controller) + + if ensure_oauth2_provider: + # this needs to come before login_required + controller = ensure_oauth2(ensure_oauth2_provider)(controller) + + if login_required: + # this should be at the end, so it's the first to be evaluated + controller = login_required_decorator( + redirect_field_name=redirect_field_name, login_url=login_url + )(controller) + + return controller(request, inspect.getsource(function_or_class), layout, page_module_path, url_map_kwargs_list[0]['title'], custom_css, custom_js) + + # UNCOMMENT IF WE DECIDE TO GO WITH USING THE COMPONENT FUNCITON DIRECTLY, AS OPPOSED TO WRAPPING + # IT WITH THE GLOBAL_COMPONENT FUNCTION + # register_component(component_module_path) + _process_url_kwargs(controller_wrapper, url_map_kwargs_list) + return function_or_class + + return wrapped if function_or_class is None else wrapped(function_or_class) controller_decorator = controller @@ -568,6 +694,20 @@ def wrapped(function): return wrapped if function is None else wrapped(function) +def _global_page_component_controller(request, component_source_code, layout, page_module_path, title=None, custom_css=[], custom_js=[]): + ComponentLibrary.refresh(new_identifier=page_module_path.split('.')[-1].replace('_', '-')) + ComponentLibrary.load_dependencies_from_source_code(component_source_code) + context = { + 'page_module_path_context_arg': page_module_path, + 'reactjs_version': ComponentLibrary.REACTJS_VERSION, + 'layout_context_arg': layout, + 'title': title, + 'custom_css': custom_css, + 'custom_js': custom_js + } + + return render(request, 'tethys_apps/reactpy_base.html', context) + def _get_url_map_kwargs_list( function_or_class: Union[ Callable[[HttpRequest, ...], Any], TethysController @@ -580,6 +720,8 @@ def _get_url_map_kwargs_list( handler_type: str = None, app_workspace=False, user_workspace=False, + title=None, + index=None ): final_urls = [] if url is not None: @@ -636,6 +778,9 @@ def _get_url_map_kwargs_list( f"{url_name}_{i}" if i else url_name: final_url for i, final_url in enumerate(final_urls) } + + if not title: + title = url_name.replace('_', ' ').title() return [ dict( @@ -646,6 +791,8 @@ def _get_url_map_kwargs_list( regex=regex, handler=handler, handler_type=handler_type, + title=title, + index=index ) for url_name, final_url in final_urls.items() ] @@ -772,3 +919,61 @@ def register_controllers( ) return url_maps + +@component +def page_component_wrapper(layout, page_module_path): + from reactpy_django.hooks import use_user # Avoid Django configuration error + path_parts = page_module_path.split('.') + + app_name = path_parts[1] + app_module_name = f'tethysapp.{app_name}.app' + app_module = __import__(app_module_name, fromlist=['App']) + if hasattr(settings, "DEBUG") and settings.DEBUG: + importlib.reload(app_module) + App = app_module.App() + + component_module_name = '.'.join(path_parts[:-1]) + component_name = path_parts[-1] + component_module = __import__(component_module_name, fromlist=[component_name]) + if hasattr(settings, "DEBUG") and settings.DEBUG: + importlib.reload(component_module) + Component = getattr(component_module, component_name) + + if layout is not None: + Layout = None + if layout == 'default': + if callable(App.layout): + Layout = App.layout + else: + layout_module_name = 'tethys_components.layouts' + layout_name = App.layout + else: + layout_module_path_parts = layout.split('.') + layout_module_name = '.'.join(layout_module_path_parts[:-1]) + layout_name = layout_module_path_parts[-1] + + if not Layout: + layout_module = __import__(layout_module_name, fromlist=[layout_name]) + Layout = getattr(layout_module, layout_name) + + user = use_user() + nav_links = [] + for url_map in sorted(App.registered_url_maps, key=lambda x: x.index if x.index is not None else 999): + if url_map.index == -1: continue # Do not render + nav_links.append( + { + 'title': url_map.title, + 'href': f'/apps/{App.root_url}/{url_map.name.replace('_', '-') + '/' if url_map.name != App.index else ""}' + } + ) + + return Layout( + { + 'app': App, + 'user': user, + 'nav-links': nav_links + }, + Component() + ) + else: + return Component() diff --git a/tethys_apps/base/url_map.py b/tethys_apps/base/url_map.py index fd89546fe..9c7068134 100644 --- a/tethys_apps/base/url_map.py +++ b/tethys_apps/base/url_map.py @@ -27,6 +27,8 @@ def __init__( regex=None, handler=None, handler_type=None, + title=None, + index=None ): """ Constructor @@ -39,6 +41,8 @@ def __init__( regex (str or iterable, optional): Custom regex pattern(s) for url variables. If a string is provided, it will be applied to all variables. If a list or tuple is provided, they will be applied in variable order. handler (str): Dot-notation path a handler function. A handler is associated to a specific controller and contains the main logic for creating and establishing a communication between the client and the server. handler_type (str): Tethys supported handler type. 'bokeh' is the only handler type currently supported. + title (str): The title to be used both in built-in Navigation components and in the browser tab + index (int): Used to determine the render order of nav items in built-in Navigation components. Defaults to the unpredictable processing order of the @page decorated functions. Set to -1 to remove from built-in Navigation components. """ # noqa: E501 # Validate if regex and ( @@ -57,6 +61,8 @@ def __init__( self.custom_match_regex = regex self.handler = handler self.handler_type = handler_type + self.title = title + self.index = index def __repr__(self): """ @@ -64,7 +70,7 @@ def __repr__(self): """ return ( f"" + f"handler={self.handler}, handler_type={self.handler_type}, title={self.title}, index={self.index}>" ) @staticmethod diff --git a/tethys_apps/templates/tethys_apps/reactpy_base.html b/tethys_apps/templates/tethys_apps/reactpy_base.html new file mode 100644 index 000000000..8368b809b --- /dev/null +++ b/tethys_apps/templates/tethys_apps/reactpy_base.html @@ -0,0 +1,112 @@ +{% load static tethys reactpy %} + + + + + + + {% if has_analytical %} + {% include "analytical_head_top.html" %} + {% endif %} + + + + + + + {{ title }} | {{ tethys_app.name }} + + {% if tethys_app.enable_feedback %} + + {% endif %} + + + + + {% if has_session_security %} + {% include 'session_security/all.html' %} + + {% endif %} + + {% if has_analytical %} + {% include "analytical_head_bottom.html" %} + {% endif %} + + + + {% if has_analytical %} + {% include "analytical_body_top.html" %} + {% endif %} + + {% component "tethys_apps.base.controller.page_component_wrapper" layout=layout_context_arg page_module_path=page_module_path_context_arg %} + + {% if has_terms %} + {% include "terms.html" %} + {% endif %} + + + + {% csrf_token %} + + {{ tethys.doc_cookies.script_tag|safe }} + + {% if tethys_app.enable_feedback %} + + {% endif %} + + {% if has_analytical %} + {% include "analytical_body_bottom.html" %} + {% endif %} + + \ No newline at end of file diff --git a/tethys_cli/scaffold_commands.py b/tethys_cli/scaffold_commands.py index 7c2331391..9176c44d5 100644 --- a/tethys_cli/scaffold_commands.py +++ b/tethys_cli/scaffold_commands.py @@ -34,7 +34,7 @@ def add_scaffold_parser(subparsers): "letters, numbers, and underscores allowed.", ) scaffold_parser.add_argument( - "-t", "--template", dest="template", help="Name of template to use." + "-t", "--template", dest="template", help="Name of template to use.", choices=os.listdir(APP_PATH) ) scaffold_parser.add_argument( "-e", "--extension", dest="extension", action="store_true" @@ -442,6 +442,26 @@ def scaffold_command(args): shutil.copy(template_file_path, project_file_path) write_pretty_output('Created: "{}"'.format(project_file_path), FG_WHITE) + + if template_name == 'reactpy': + from .settings_commands import read_settings, write_settings + from argparse import Namespace + tethys_settings = read_settings() + if 'INSTALLED_APPS' not in tethys_settings: + tethys_settings['INSTALLED_APPS'] = [] + if 'reactpy_django' not in tethys_settings['INSTALLED_APPS']: + tethys_settings['INSTALLED_APPS'].append('reactpy_django') + write_settings(tethys_settings) + + if template_name == 'reactpy': + from .settings_commands import read_settings, write_settings + from argparse import Namespace + tethys_settings = read_settings() + if 'INSTALLED_APPS' not in tethys_settings: + tethys_settings['INSTALLED_APPS'] = [] + if 'reactpy_django' not in tethys_settings['INSTALLED_APPS']: + tethys_settings['INSTALLED_APPS'].append('reactpy_django') + write_settings(tethys_settings) write_pretty_output( 'Successfully scaffolded new project "{}"'.format(project_name), FG_WHITE diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/.gitignore b/tethys_cli/scaffold_templates/app_templates/reactpy/.gitignore new file mode 100644 index 000000000..dda573c43 --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/.gitignore @@ -0,0 +1,11 @@ +*.pydevproject +*.project +*.egg-info +*.class +*.pyo +*.pyc +*.db +*.sqlite +*.DS_Store +.idea/ +services.yml \ No newline at end of file diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl b/tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl new file mode 100644 index 000000000..fbd197889 --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl @@ -0,0 +1,17 @@ +# This file should be committed to your app code. +version: 1.1 +# This should be greater or equal to your tethys-platform in your environment +tethys_version: ">=4.0.0" +# This should match the app - package name in your setup.py +name: {{ project }} + +requirements: + # Putting in a skip true param will skip the entire section. Ignoring the option will assume it be set to False + skip: false + conda: + channels: + packages: + + pip: + +post: \ No newline at end of file diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/setup.py_tmpl b/tethys_cli/scaffold_templates/app_templates/reactpy/setup.py_tmpl new file mode 100644 index 000000000..ef8ef99cb --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/setup.py_tmpl @@ -0,0 +1,31 @@ +from setuptools import setup, find_namespace_packages +from tethys_apps.app_installation import find_all_resource_files +from tethys_apps.base.app_base import TethysAppBase + +# -- Apps Definition -- # +app_package = '{{project}}' +release_package = f'{TethysAppBase.package_namespace}-{app_package}' + +# -- Python Dependencies -- # +dependencies = [] + +# -- Get Resource File -- # +resource_files = find_all_resource_files(app_package, TethysAppBase.package_namespace) + + +setup( + name=release_package, + version='0.0.1', + description='{{description|default('')}}', + long_description='', + keywords='', + author='{{author|default('')}}', + author_email='{{author_email|default('')}}', + url='', + license='{{license_name|default('')}}', + packages=find_namespace_packages(), + package_data={'': resource_files}, + include_package_data=True, + zip_safe=False, + install_requires=dependencies, +) diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/__init__.py b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/__init__.py new file mode 100644 index 000000000..c927d02de --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/__init__.py @@ -0,0 +1 @@ +# Included for native namespace package support diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/app.py_tmpl b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/app.py_tmpl new file mode 100644 index 000000000..0448526c8 --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/app.py_tmpl @@ -0,0 +1,20 @@ +from tethys_sdk.base import TethysAppBase + + +class App(TethysAppBase): + """ + Tethys app class for {{proper_name}}. + """ + + name = '{{proper_name}}' + description = '{{description|default("Place a brief description of your app here.")}}' + package = '{{project}}' # WARNING: Do not change this value + index = 'home' + icon = f'{package}/images/icon.png' + root_url = '{{project_url}}' + color = '{{color}}' + tags = '{{tags}}' + enable_feedback = False + feedback_emails = [] + exit_url = '/apps/' + default_layout = "NavHeader" diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/pages.py_tmpl b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/pages.py_tmpl new file mode 100644 index 000000000..4d6f89e74 --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/pages.py_tmpl @@ -0,0 +1,18 @@ +from tethys_sdk.routing import page +from tethys_sdk.components import lib +from tethys_sdk.components.utils import Props + +@page +def home(): + map_center, set_map_center = lib.hooks.use_state([39.254852, -98.593853]) + map_zoom, set_map_zoom = lib.hooks.use_state(4) + + return lib.html.div( + lib.pm.Map( + Props( + height="calc(100vh - 62px)", + defaultCenter=map_center, + defaultZoom=map_zoom + ) + ) + ) diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/public/images/icon.png b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/public/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..aa1fa8732d79fe7a02eda75d85a5de6aabb6226a GIT binary patch literal 52534 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 literal 0 HcmV?d00001 diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/tests/__init__.py b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/tests/tests.py_tmpl b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/tests/tests.py_tmpl new file mode 100644 index 000000000..8d4bd5b62 --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/tests/tests.py_tmpl @@ -0,0 +1,147 @@ +# Most of your test classes should inherit from TethysTestCase +from tethys_sdk.testing import TethysTestCase + +# For testing rendered HTML templates it may be helpful to use BeautifulSoup. +# from bs4 import BeautifulSoup +# For help, see https://www.crummy.com/software/BeautifulSoup/bs4/doc/ + + +""" +To run tests for an app: + + 1. Open a terminal and activate the Tethys environment:: + + conda activate tethys + + 2. In portal_config.yml make sure that the default database user is set to tethys_super or is a super user of the database + DATABASES: + default: + ENGINE: django.db.backends.postgresql_psycopg2 + NAME: tethys_platform + USER: tethys_super + PASSWORD: pass + HOST: 127.0.0.1 + PORT: 5435 + + 3. From the root directory of your app, run the ``tethys manage test`` command:: + + tethys manage test tethysapp//tests + + +To learn more about writing tests, see: + https://docs.tethysplatform.org/en/stable/tethys_sdk/testing.html +""" + +class {{class_name}}TestCase(TethysTestCase): + """ + In this class you may define as many functions as you'd like to test different aspects of your app. + Each function must start with the word "test" for it to be recognized and executed during testing. + You could also create multiple TethysTestCase classes within this or other python files to organize your tests. + """ + + def set_up(self): + """ + This function is not required, but can be used if any environmental setup needs to take place before + execution of each test function. Thus, if you have multiple test that require the same setup to run, + place that code here. For example, if you are testing against any persistent stores, you should call the + test database creation function here, like so: + + self.create_test_persistent_stores_for_app({{class_name}}) + + If you are testing against a controller that check for certain user info, you can create a fake test user and + get a test client, like so: + + #The test client simulates a browser that can navigate your app's url endpoints + self.c = self.get_test_client() + self.user = self.create_test_user(username="joe", password="secret", email="joe@some_site.com") + # To create a super_user, use "self.create_test_superuser(*params)" with the same params + + # To force a login for the test user + self.c.force_login(self.user) + + # If for some reason you do not want to force a login, you can use the following: + login_success = self.c.login(username="joe", password="secret") + + NOTE: You do not have place these functions here, but if they are not placed here and are needed + then they must be placed at the beginning of your individual test functions. Also, if a certain + setup does not apply to all of your functions, you should either place it directly in each + function it applies to, or maybe consider creating a new test file or test class to group similar + tests. + """ + pass + + def tear_down(self): + """ + This function is not required, but should be used if you need to tear down any environmental setup + that took place before execution of the test functions. If you are testing against any persistent + stores, you should call the test database destruction function from here, like so: + + self.destroy_test_persistent_stores_for_app({{class_name}}) + + NOTE: You do not have to set these functions up here, but if they are not placed here and are needed + then they must be placed at the very end of your individual test functions. Also, if certain + tearDown code does not apply to all of your functions, you should either place it directly in each + function it applies to, or maybe consider creating a new test file or test class to group similar + tests. + """ + pass + + def is_tethys_platform_great(self): + return True + + def test_if_tethys_platform_is_great(self): + """ + This is an example test function that can be modified to test a specific aspect of your app. + It is required that the function name begins with the word "test" or it will not be executed. + Generally, the code written here will consist of many assert methods. + A list of assert methods is included here for reference or to get you started: + assertEqual(a, b) a == b + assertNotEqual(a, b) a != b + assertTrue(x) bool(x) is True + assertFalse(x) bool(x) is False + assertIs(a, b) a is b + assertIsNot(a, b) a is not b + assertIsNone(x) x is None + assertIsNotNone(x) x is not None + assertIn(a, b) a in b + assertNotIn(a, b) a not in b + assertIsInstance(a, b) isinstance(a, b) + assertNotIsInstance(a, b) !isinstance(a, b) + Learn more about assert methods here: + https://docs.python.org/2.7/library/unittest.html#assert-methods + """ + + self.assertEqual(self.is_tethys_platform_great(), True) + self.assertNotEqual(self.is_tethys_platform_great(), False) + self.assertTrue(self.is_tethys_platform_great()) + self.assertFalse(not self.is_tethys_platform_great()) + self.assertIs(self.is_tethys_platform_great(), True) + self.assertIsNot(self.is_tethys_platform_great(), False) + + def test_home_controller(self): + """ + This is an example test function of how you might test a controller that returns an HTML template rendered + with context variables. + """ + + # If all test functions were testing controllers or required a test client for another reason, the following + # 3 lines of code could be placed once in the set_up function. Note that in that case, each variable should be + # prepended with "self." (i.e. self.c = ...) to make those variables "global" to this test class and able to be + # used in each separate test function. + c = self.get_test_client() + user = self.create_test_user(username="joe", password="secret", email="joe@some_site.com") + c.force_login(user) + + # Have the test client "browse" to your home page + response = c.get('/apps/{{project_url}}/') # The final '/' is essential for all pages/controllers + + # Test that the request processed correctly (with a 200 status code) + self.assertEqual(response.status_code, 200) + + ''' + NOTE: Next, you would likely test that your context variables returned as expected. That would look + something like the following: + + context = response.context + self.assertEqual(context['my_integer'], 10) + ''' diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/workspaces/app_workspace/.gitkeep b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/workspaces/app_workspace/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/workspaces/user_workspaces/.gitkeep b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/workspaces/user_workspaces/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_cli/settings_commands.py b/tethys_cli/settings_commands.py index 4be4c3181..fadd864a0 100644 --- a/tethys_cli/settings_commands.py +++ b/tethys_cli/settings_commands.py @@ -100,7 +100,7 @@ def set_settings(tethys_settings, kwargs): write_settings(tethys_settings) -def get_setting(tethys_settings, key): +def get_setting(tethys_settings, key, return_value=False): if key == "all": all_settings = { k: getattr(settings, k) @@ -111,6 +111,8 @@ def get_setting(tethys_settings, key): return try: value = getattr(settings, key) + if return_value: + return value write_info(f"{key}: {pformat(value)}") except AttributeError: result = _get_dict_key_handle(tethys_settings, key) diff --git a/tethys_components/__init__.py b/tethys_components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_components/custom.py b/tethys_components/custom.py new file mode 100644 index 000000000..7932de3c8 --- /dev/null +++ b/tethys_components/custom.py @@ -0,0 +1,214 @@ +import random + +from reactpy import component +from reactpy_django.hooks import use_location, use_query +from tethys_portal.settings import STATIC_URL +from .utils import Props +from .library import Library as lib + +@component +def Panel(props, *children): + show = props.pop('show', False) + set_show = props.pop('set-show', lambda x: x) + position = props.pop('position', 'bottom') + extent = props.pop('extent', '300px') + name = props.pop('name', 'Panel') + style = {} + if position in ['top', 'bottom']: + style['height'] = extent + else: + style['width'] = extent + + def handle_close(event): + set_show(False) + + return lib.html.div( + Props( + role="dialog", + aria_modal="true", + class_name=f"offcanvas offcanvas-{position}{' show' if show else ''}", + tabindex="-1", + style=Props( + visibility="visible" + ) | style + ), + lib.html.div( + Props( + class_name="offcanvas-header" + ), + lib.html.div( + Props( + class_name="offcanvas-title h5" + ), + name + ), + html.button( + Props( + type="button", + class_name="btn-close", + aria_label="Close", + on_click=handle_close + ) + ) + ), + lib.html.div( + Props( + class_name="offcanvas-body" + ), + *children + ) + ) + +# @component NOTE: Breaks if @component decorator applied +def HeaderButton(props, *children): + href = props.get('href') + shape = props.get('shape') + style = props.pop('style', {}) + class_name = props.pop('class_name', '') + + return lib.bs.Button( + Props( + href=href, + variant="light", + size="sm", + class_name=f"{class_name} styled-header-button", + style=Props( + background_color="rgba(255, 255, 255, 0.1)", + border="none", + color="white" + ) | style | (Props(border_radius="50%") if shape == 'circle' else {}) + ) | props, + *children + ) + +# @component NOTE: Breaks if @component decorator applied +def NavIcon(src, background_color): + return lib.html.img( + Props( + src=src, + class_name="d-inline-block align-top", + style={ + "padding": "0", + "height": "30px", + "border-radius": "50%", + "background": background_color + } + ) + ) + +@component +def NavMenu(props, *children): + nav_title = props.pop('nav-title') + + return lib.html.div( + lib.bs.Offcanvas( + Props( + id="offcanvasNavbar", + show=False + ) | props, + lib.bs.OffcanvasHeader( + Props(closeButton=True), + lib.bs.OffcanvasTitle(nav_title) + ), + lib.bs.OffcanvasBody(*children) + ) + ) + +def get_db_object(app): + return app.db_object + +@component +def HeaderWithNavBar(app, user, nav_links): + app_db_query = use_query(get_db_object, {'app': app}) + app_id = app_db_query.data.id if app_db_query.data else 999 + location = use_location() + + return lib.bs.Navbar( + Props( + fixed="top", + class_name="shadow", + expand=False, + variant="dark", + style=Props( + background=app.color, + min_height="56px" + ) + ), + lib.bs.Container( + Props( + as_="header", + fluid=True, + class_name="px-4" + ), + lib.bs.NavbarToggle( + Props( + aria_controls="offcanvasNavbar", + class_name="styled-header-button" + ) + ), + lib.bs.NavbarBrand( + Props(href=f'/apps/{app.root_url}/', class_name="mx-0 d-none d-sm-block", style=Props(color="white")), + NavIcon(src=f'{STATIC_URL}{app.icon}', background_color=app.color), + f' {app.name}' + ), + lib.bs.Form( + Props(inline="true"), + HeaderButton( + Props( + id="btn-app-settings", + href=f'/admin/tethys_apps/tethysapp/{app_id}/change/', + tooltipPlacement="bottom", + tooltipText="Settings", + class_name="me-2" + ), + lib.icons.Gear(Props(size="1.5rem")) + ) if user.is_staff else "", + HeaderButton( + Props( + id="btn-exit-app", + href=app.exit_url, + tooltipPlacement="bottom", + tooltipText="Exit" + ), + lib.icons.X(Props(size="1.5rem")) + ) + ), + lib.bs.NavbarOffcanvas( + Props( + id="offcanvasNavbar", + aria_labelledby="offcanvasNavbarLabel" + ), + lib.bs.OffcanvasHeader( + Props( + closeButton=True + ), + lib.bs.OffcanvasTitle( + Props( + id="offcanvasNavbarLabel" + ), + "Navigation" + ) + ), + lib.bs.OffcanvasBody( + lib.bs.Nav( + { + "variant": "pills", + "defaultActiveKey": f"/apps/{app.root_url}", + "class_name": "flex-column" + }, + [ + lib.bs.NavLink( + Props( + href=link["href"], + key=f'link-{index}', + active=location.pathname == link["href"], + style=Props(padding_left="10pt") + ), + link['title'] + ) for index, link in enumerate(nav_links) + ] + ) + ) + ) + ) + ) diff --git a/tethys_components/hooks.py b/tethys_components/hooks.py new file mode 100644 index 000000000..88f7e30ca --- /dev/null +++ b/tethys_components/hooks.py @@ -0,0 +1,20 @@ +from tethys_components.utils import use_workspace +from reactpy_django.hooks import ( + use_location, + use_origin, + use_scope, + use_connection, + use_query, + use_mutation, + use_user, + use_user_data, + use_channel_layer, + use_root_id +) +from reactpy import hooks as core_hooks +use_state = core_hooks.use_state +use_callback = core_hooks.use_callback +use_effect = core_hooks.use_effect +use_memo = core_hooks.use_memo +use_reducer = core_hooks.use_reducer +use_ref = core_hooks.use_ref diff --git a/tethys_components/layouts.py b/tethys_components/layouts.py new file mode 100644 index 000000000..53a4c9bf4 --- /dev/null +++ b/tethys_components/layouts.py @@ -0,0 +1,21 @@ +from reactpy import component, html +from tethys_components.utils import Props +from tethys_components.custom import HeaderWithNavBar + +@component +def NavHeader(props, *children): + app = props.get('app') + user = props.get('user') + nav_links = props.get('nav-links') + + return html.div( + Props(class_name="h-100"), + HeaderWithNavBar(app, user, nav_links), + html.div( + Props( + style=Props(padding_top="56px") + ), + *children + ), + ) + diff --git a/tethys_components/library.py b/tethys_components/library.py new file mode 100644 index 000000000..546cf702a --- /dev/null +++ b/tethys_components/library.py @@ -0,0 +1,172 @@ +from pathlib import Path +from reactpy import web +from jinja2 import Template +from re import findall +from unittest.mock import Mock + +TETHYS_COMPONENTS_ROOT_DPATH = Path(__file__).parent + +class ComponentLibrary: + EXPORT_NAME = 'main' + REACTJS_VERSION = '18.2.0' + REACTJS_DEPENDENCIES = [ + f'react@{REACTJS_VERSION}', + f'react-dom@{REACTJS_VERSION}', + f'react-is@{REACTJS_VERSION}', + '@restart/ui@1.6.8' + ] + PACKAGE_BY_ACCESSOR = { + 'bs': 'react-bootstrap@2.10.2', + 'pm': 'pigeon-maps@0.21.6', + 'rc': 'recharts@2.12.7', + 'ag': 'ag-grid-react@32.0.2', + 'rp': 'react-player@2.16.0', + # 'mui': '@mui/material@5.16.7', # This should work once esm releases their next version + 'chakra': '@chakra-ui/react@2.8.2', + 'icons': 'react-bootstrap-icons@1.11.4', + 'html': None, # Managed internally + 'tethys': None, # Managed internally, + 'hooks': None, # Managed internally + } + DEFAULTS = ['rp'] + STYLE_DEPS = { + 'ag': [ + 'https://unpkg.com/@ag-grid-community/styles@32.0.2/ag-grid.css', + 'https://unpkg.com/@ag-grid-community/styles@32.0.2/ag-theme-material.css' + ], + 'bs': ['https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css'] + } + INTERNALLY_MANAGED_PACKAGES = [key for key, val in PACKAGE_BY_ACCESSOR.items() if val is None] + ACCESOR_BY_PACKAGE = {val: key for key, val in PACKAGE_BY_ACCESSOR.items()} + _ALLOW_LOADING = False + components_by_package = {} + package_handles = {} + styles = [] + defaults = [] + + def __init__(self, package=None, parent_package=None): + self.package = package + self.parent_package = parent_package + + def __getattr__(self, attr): + if attr in self.PACKAGE_BY_ACCESSOR: + # First time accessing "X" library via lib.X (e.g. lib.bs) + if attr == 'tethys': + from tethys_components import custom + lib = custom + elif attr == 'html': + from reactpy import html + lib = html + elif attr == 'hooks': + from tethys_components import hooks + lib = hooks + else: + if attr not in self.package_handles: + self.package_handles[attr] = ComponentLibrary(self.package, parent_package=attr) + if attr in self.STYLE_DEPS: + self.styles.extend(self.STYLE_DEPS[attr]) + lib = self.package_handles[attr] + return lib + elif self.parent_package: + component = attr + package_name = self.PACKAGE_BY_ACCESSOR[self.parent_package] + if package_name not in self.components_by_package: + self.components_by_package[package_name] = [] + if component not in self.components_by_package[package_name]: + if self.parent_package in self.DEFAULTS: + self.defaults.append(component) + self.components_by_package[package_name].append(component) + module = web.module_from_string( + name=self.EXPORT_NAME, + content=self.get_reactjs_module_wrapper_js(), + resolve_exports=False, + replace=True + ) + setattr(self, attr, web.export(module, component)) + return getattr(self, attr) + else: + raise AttributeError(f"Invalid component library package: {attr}") + + @classmethod + def refresh(cls, new_identifier=None): + cls.components_by_package = {} + cls.package_handles = {} + cls.styles = [] + cls.defaults = [] + if new_identifier: + cls.EXPORT_NAME = new_identifier + + + @classmethod + def get_reactjs_module_wrapper_js(cls): + template_fpath = TETHYS_COMPONENTS_ROOT_DPATH / 'resources' / 'reactjs_module_wrapper_template.js' + with open(template_fpath) as f: + template = Template(f.read()) + + content = template.render({ + 'components_by_package': cls.components_by_package, + 'dependencies': cls.REACTJS_DEPENDENCIES, + 'named_defaults': cls.defaults, + 'style_deps': cls.styles + }) + + return content + + @classmethod + def register(cls, package, accessor, styles=[], use_default=False): + ''' + Example: + from tethys_sdk.components import lib + + lib.register('reactive-button@1.3.15', 'rb', use_default=True) + + # lib.rb.ReactiveButton can now be used in the code below + + @page + def test_reactive_button(): + state, set_state = hooks.use_state('idle'); + + def on_click_handler(event=None): + set_state('loading') + + return lib.rb.ReactiveButton( + Props( + buttonState=state, + idleText="Submit", + loadingText="Loading", + successText="Done", + onClick=on_click_handler + ) + ) + + ''' + if accessor in cls.PACKAGE_BY_ACCESSOR: + if cls.PACKAGE_BY_ACCESSOR[accessor] != package: + raise ValueError(f"Accessor {accessor} already exists on the component library. Please choose a new accessor.") + else: + return + cls.PACKAGE_BY_ACCESSOR[accessor] = package + if styles: + cls.STYLE_DEPS[accessor] = styles + if use_default: + cls.DEFAULTS.append(accessor) + + def load_dependencies_from_source_code(self, source_code): + ''' Pre-loads dependencies rather than on-the-fly + + This is necessary since loading on the fly does not work + for nested custom lib components being rendered for the first time after the initial + load. I spent hours trying to solve the problem of getting the ReactPy-Django Client + to re-fetch the Javascript containing the updated dependnecies, but I couldn't solve + it. This was the Plan B - and possibly the better plan since it doesn't require a change + to the ReactPy/ReactPy-Django source code. + ''' + matches = findall('lib\\.([^\\(]*)\\(', source_code) + for match in matches: + package_name, component_name = match.split('.') + if package_name in self.INTERNALLY_MANAGED_PACKAGES: continue + package = getattr(self, package_name) + getattr(package, component_name) + + +Library = ComponentLibrary() diff --git a/tethys_components/resources/reactjs_module_wrapper_template.js b/tethys_components/resources/reactjs_module_wrapper_template.js new file mode 100644 index 000000000..4c5534759 --- /dev/null +++ b/tethys_components/resources/reactjs_module_wrapper_template.js @@ -0,0 +1,94 @@ +{%- for package, components in components_by_package.items() %} +{% if components|length == 1 and components[0] in named_defaults -%} +import {{ components|join('') }} from "https://esm.sh/{{ package }}?deps={{ dependencies|join(',') }}&bundle_deps"; +{% else -%} +import {{ '{' }}{{ components|join(', ') }}{{ '}' }} from "https://esm.sh/{{ package }}?deps={{ dependencies|join(',') }}&exports={{ components|join(',') }}&bundle_deps"; +{% endif -%} +export {{ '{' }}{{ components|join(', ') }}{{ '}' }}; +{%- endfor %} + +{%- for style in style_deps %} +loadCSS("{{ style }}"); +{%- endfor %} + +function loadCSS(href) { + var head = document.getElementsByTagName('head')[0]; + + if (document.querySelectorAll(`link[href="${href}"]`).length === 0) { + // Creating link element + var style = document.createElement('link'); + style.id = href; + style.href = href; + style.type = 'text/css'; + style.rel = 'stylesheet'; + head.append(style); + } +} + +export default ({ children, ...props }) => { + const [{ component }, setComponent] = React.useState({}); + React.useEffect(() => { + import("https://esm.sh/{npm_package_name}?deps={dependencies}").then((module) => { + // dynamically load the default export since we don't know if it's exported. + setComponent({ component: module.default }); + }); + }); + return component + ? React.createElement(component, props, ...(children || [])) + : null; +}; + +export function bind(node, config) { + const root = ReactDOM.createRoot(node); + return { + create: (component, props, children) => + React.createElement(component, wrapEventHandlers(props), ...children), + render: (element) => root.render(element), + unmount: () => root.unmount() + }; +} + +function wrapEventHandlers(props) { + const newProps = Object.assign({}, props); + for (const [key, value] of Object.entries(props)) { + if (typeof value === "function") { + newProps[key] = makeJsonSafeEventHandler(value); + } + } + return newProps; +} + +function stringifyToDepth(val, depth, replacer, space) { + depth = isNaN(+depth) ? 1 : depth; + function _build(key, val, depth, o, a) { // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration) + return !val || typeof val != 'object' ? val : (a=Array.isArray(val), JSON.stringify(val, function(k,v){ if (a || depth > 0) { if (replacer) v=replacer(k,v); if (!k) return (a=Array.isArray(v),val=v); !o && (o=a?[]:{}); o[k] = _build(k, v, a?depth:depth-1); } }), o||(a?[]:{})); + } + return JSON.stringify(_build('', val, depth), null, space); +} + +function makeJsonSafeEventHandler(oldHandler) { + // Since we can't really know what the event handlers get passed we have to check if + // they are JSON serializable or not. We can allow normal synthetic events to pass + // through since the original handler already knows how to serialize those for us. + return function safeEventHandler() { + + var filteredArguments = []; + Array.from(arguments).forEach(function (arg) { + if (typeof arg === "object" && arg.nativeEvent) { + // this is probably a standard React synthetic event + filteredArguments.push(arg); + } else { + filteredArguments.push(JSON.parse(stringifyToDepth(arg, 3, (key, value) => { + if (key === '') return value; + try { + JSON.stringify(value); + return value; + } catch (err) { + return (typeof value === 'object') ? value : undefined; + } + }))) + } + }); + oldHandler(...Array.from(filteredArguments)); + }; +} \ No newline at end of file diff --git a/tethys_components/utils.py b/tethys_components/utils.py new file mode 100644 index 000000000..c2c709dc5 --- /dev/null +++ b/tethys_components/utils.py @@ -0,0 +1,42 @@ +import inspect +import os +from channels.db import database_sync_to_async +from reactpy_django.hooks import use_query + +async def get_workspace(app_package, user): + from tethys_apps.harvester import SingletonHarvester + for app_s in SingletonHarvester().apps: + if app_s.package == app_package: + if user: + workspace = await database_sync_to_async(app_s.get_user_workspace)(user) + else: + workspace = await database_sync_to_async(app_s.get_app_workspace)() + return workspace + +def use_workspace(user=None): + calling_fpath = inspect.stack()[1][0].f_code.co_filename + app_package = calling_fpath.split(f'{os.sep}tethysapp{os.sep}')[1].split(os.sep)[0] + + workspace_query = use_query(get_workspace, {'app_package': app_package, 'user': user}, postprocessor=None) + + return workspace_query.data + +def delayed_execute(seconds, callable, args=[]): + from threading import Timer + + t = Timer(seconds, callable, args) + t.start() + + +class Props(dict): + def __init__(self, **kwargs): + new_kwargs = {} + for k, v in kwargs.items(): + if k.endswith("_"): + new_kwargs[k[:-1]] = v + elif not k.startswith("on_") and k != "class_name": + new_kwargs[k.replace('_', '-')] = v + else: + new_kwargs[k] = "none" if v is None else v + setattr(self, k, v) + super(Props, self).__init__(**new_kwargs) diff --git a/tethys_portal/asgi.py b/tethys_portal/asgi.py index 740617f8d..6c9bd7e76 100644 --- a/tethys_portal/asgi.py +++ b/tethys_portal/asgi.py @@ -8,11 +8,15 @@ from channels.routing import ProtocolTypeRouter, URLRouter from django.core.asgi import get_asgi_application from django.urls import re_path -from reactpy_django import REACTPY_WEBSOCKET_ROUTE +from tethys_portal.optional_dependencies import has_module def build_application(asgi_app): from tethys_apps.urls import app_websocket_urls, http_handler_patterns + if has_module("reactpy_django"): + from reactpy_django import REACTPY_WEBSOCKET_ROUTE + from reactpy_django.utils import register_component + register_component('tethys_apps.base.controller.page_component_wrapper') app_websocket_urls.append(REACTPY_WEBSOCKET_ROUTE) diff --git a/tethys_portal/dependencies.py b/tethys_portal/dependencies.py index d9823b0b2..40988be96 100644 --- a/tethys_portal/dependencies.py +++ b/tethys_portal/dependencies.py @@ -163,8 +163,8 @@ def _get_url(self, url_type_or_path, version=None, debug=None, use_cdn=None): ), "bootstrap_icons": JsDelivrStaticDependency( npm_name="bootstrap-icons", - version="1.7.1", - css_path="font/bootstrap-icons.css", + version="1.11.3", + css_path="font/bootstrap-icons.min.css", # SRI for version 1.7.1 (version 1.8.0 is out) css_integrity="sha256-vjH7VdGY8KK8lp5whX56uTiObc5vJsK+qFps2Cfq5mY=", ), diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 983b5b77e..27e77a84b 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -232,8 +232,7 @@ "tethys_sdk", "tethys_services", "tethys_quotas", - "guardian", - "reactpy_django", + "guardian" ] for module in [ diff --git a/tethys_portal/urls.py b/tethys_portal/urls.py index 3d56e3587..aa41d9b1f 100644 --- a/tethys_portal/urls.py +++ b/tethys_portal/urls.py @@ -314,4 +314,5 @@ ) ) -urlpatterns.append(re_path("^reactpy/", include("reactpy_django.http.urls"))) +if has_module("reactpy_django"): + urlpatterns.append(re_path("^reactpy/", include("reactpy_django.http.urls"))) diff --git a/tethys_sdk/components/__init__.py b/tethys_sdk/components/__init__.py new file mode 100644 index 000000000..edcdb2eb9 --- /dev/null +++ b/tethys_sdk/components/__init__.py @@ -0,0 +1,13 @@ +""" +******************************************************************************** +* Name: components.py +* Author: Shawn Crawley +* Created On: 14 June 2024 +* License: BSD 2-Clause +******************************************************************************** +""" + +# flake8: noqa +# DO NOT ERASE +from tethys_components.library import Library as lib +from . import utils diff --git a/tethys_sdk/components/utils.py b/tethys_sdk/components/utils.py new file mode 100644 index 000000000..387f922b4 --- /dev/null +++ b/tethys_sdk/components/utils.py @@ -0,0 +1,2 @@ +from reactpy import component, event as event_decorator +from tethys_components.utils import Props, delayed_execute \ No newline at end of file diff --git a/tethys_sdk/routing.py b/tethys_sdk/routing.py index 790bd0be0..353eb16bd 100644 --- a/tethys_sdk/routing.py +++ b/tethys_sdk/routing.py @@ -11,6 +11,7 @@ from tethys_apps.base.controller import ( TethysController, controller, + page, consumer, handler, register_controllers, From f6cc1a9cfca9314eebae5315023ba11ac04ee704 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 19 Aug 2024 11:04:53 -0600 Subject: [PATCH 04/31] Handle reactpy-django at app install level --- tethys_cli/install_commands.py | 8 ++ tethys_cli/scaffold_commands.py | 20 --- .../app_templates/reactpy/install.yml_tmpl | 1 + tethys_gizmos/react_components/__init__.py | 12 -- .../react_components/select_input.py | 125 ------------------ tethys_portal/asgi.py | 4 +- tethys_sdk/gizmos.py | 1 - 7 files changed, 11 insertions(+), 160 deletions(-) delete mode 100644 tethys_gizmos/react_components/__init__.py delete mode 100644 tethys_gizmos/react_components/select_input.py diff --git a/tethys_cli/install_commands.py b/tethys_cli/install_commands.py index da3430ebb..1166e04c5 100644 --- a/tethys_cli/install_commands.py +++ b/tethys_cli/install_commands.py @@ -769,6 +769,14 @@ def install_command(args): if validate_schema("pip", requirements_config): write_msg("Running pip installation tasks...") call(["pip", "install", *requirements_config["pip"]]) + if 'reactpy-django' in requirements_config["pip"]: + from .settings_commands import read_settings, write_settings + tethys_settings = read_settings() + if 'INSTALLED_APPS' not in tethys_settings: + tethys_settings['INSTALLED_APPS'] = [] + if 'reactpy_django' not in tethys_settings['INSTALLED_APPS']: + tethys_settings['INSTALLED_APPS'].append('reactpy_django') + write_settings(tethys_settings) try: public_resources_dir = [ *Path().glob(str(Path("tethysapp", "*", "public"))), diff --git a/tethys_cli/scaffold_commands.py b/tethys_cli/scaffold_commands.py index 9176c44d5..cb95f9ef4 100644 --- a/tethys_cli/scaffold_commands.py +++ b/tethys_cli/scaffold_commands.py @@ -443,26 +443,6 @@ def scaffold_command(args): write_pretty_output('Created: "{}"'.format(project_file_path), FG_WHITE) - if template_name == 'reactpy': - from .settings_commands import read_settings, write_settings - from argparse import Namespace - tethys_settings = read_settings() - if 'INSTALLED_APPS' not in tethys_settings: - tethys_settings['INSTALLED_APPS'] = [] - if 'reactpy_django' not in tethys_settings['INSTALLED_APPS']: - tethys_settings['INSTALLED_APPS'].append('reactpy_django') - write_settings(tethys_settings) - - if template_name == 'reactpy': - from .settings_commands import read_settings, write_settings - from argparse import Namespace - tethys_settings = read_settings() - if 'INSTALLED_APPS' not in tethys_settings: - tethys_settings['INSTALLED_APPS'] = [] - if 'reactpy_django' not in tethys_settings['INSTALLED_APPS']: - tethys_settings['INSTALLED_APPS'].append('reactpy_django') - write_settings(tethys_settings) - write_pretty_output( 'Successfully scaffolded new project "{}"'.format(project_name), FG_WHITE ) diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl b/tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl index fbd197889..cf4b9ca4c 100644 --- a/tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl @@ -13,5 +13,6 @@ requirements: packages: pip: + - reactpy-django post: \ No newline at end of file diff --git a/tethys_gizmos/react_components/__init__.py b/tethys_gizmos/react_components/__init__.py deleted file mode 100644 index d4ec82417..000000000 --- a/tethys_gizmos/react_components/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -******************************************************************************** -* Name: react_components/__init__.py -* Author: Corey Krewson -* Created On: May 2024 -* Copyright: (c) Aquaveo 2024 -* License: BSD 3-Clause -******************************************************************************** -""" - -# flake8: noqa -from .select_input import * diff --git a/tethys_gizmos/react_components/select_input.py b/tethys_gizmos/react_components/select_input.py deleted file mode 100644 index ba591cccf..000000000 --- a/tethys_gizmos/react_components/select_input.py +++ /dev/null @@ -1,125 +0,0 @@ -from reactpy import html, component -import json -from reactpy_django.components import django_js -from tethys_portal.dependencies import vendor_static_dependencies - -vendor_js_dependencies = (vendor_static_dependencies["select2"].js_url,) -vendor_css_dependencies = (vendor_static_dependencies["select2"].css_url,) -gizmo_js_dependencies = ("tethys_gizmos/js/select_input.js",) - - -@component -def RESelectInput( - name, - display_text="", - initial=None, - multiple=False, - original=False, - select2_options=None, - options="", - disabled=False, - error="", - success="", - attributes=None, - classes="", - on_change=None, - on_click=None, - on_mouse_over=None, -): - # Setup/Fix variables and kwargs - initial = initial or [] - initial_is_iterable = isinstance(initial, (list, tuple, set, dict)) - placeholder = False if select2_options is None else "placeholder" in select2_options - select2_options = json.dumps(select2_options) - - # Setup div that will potentially contain the label, select input, and valid/invalid feedback - return_div = html.div() - return_div["children"] = [] - - # Add label to return div if a display text is given - if display_text: - return_div["children"].append( - html.label({"class_name": "form-label", "html_for": name}, display_text) - ) - - # Setup the select input attributes - select_classes = "".join( - [ - "form-select" if original else "tethys-select2", - " is-invalid" if error else "", - " is-valid" if success else "", - f" {classes}" if classes else "", - ] - ) - select_style = {} if original else {"width": "100%"} - select_attributes = { - "id": name, - "class_name": select_classes, - "name": name, - "style": select_style, - "multiple": multiple, - "disabled": disabled, - } - if select2_options: - select_attributes["data-select2-options"] = select2_options - if on_change: - select_attributes["on_change"] = on_change - if on_click: - select_attributes["on_click"] = on_click - if on_mouse_over: - select_attributes["on_mouse_over"] = on_mouse_over - if attributes: - for key, value in attributes.items(): - select_attributes[key] = value - - # Create the select input with the associated attributes - select = html.select( - select_attributes, - ) - - # Add options to the select input if they are provided - if options: - if placeholder: - select["children"] = [html.option()] - else: - select["children"] = [] - - for option, value in options: - select_option = html.option({"value": value}, option) - if initial_is_iterable: - if option in initial or value in initial: - select_option["attributes"]["selected"] = "selected" - else: - if option == initial or value == initial: - select_option["attributes"]["selected"] = "selected" - select["children"].append(select_option) - - # Create the div for the select input - input_group_classes = "".join( - ["input-group mb-3", " has-validation" if error or success else ""] - ) - input_group = html.div( - {"class_name": input_group_classes}, - select, - ) - - # add invalid-feedback div to the select input group if needed - if error: - input_group["children"].append( - html.div({"class_name": "invalid-feedback"}, error) - ) - - # add valid-feedback div to the select input group if needed - if success: - input_group["children"].append( - html.div({"class_name": "valid-feedback"}, success) - ) - - # add select input group div to the returned div - return_div["children"].append(input_group) - - # reload any gizmo JS dependencies after the react renders. This is required for the select2 dropdown to work - for gizmo_js in gizmo_js_dependencies: - return_div["children"].append(django_js(gizmo_js)) - - return return_div diff --git a/tethys_portal/asgi.py b/tethys_portal/asgi.py index 6c9bd7e76..2747bf7f9 100644 --- a/tethys_portal/asgi.py +++ b/tethys_portal/asgi.py @@ -16,9 +16,9 @@ def build_application(asgi_app): if has_module("reactpy_django"): from reactpy_django import REACTPY_WEBSOCKET_ROUTE from reactpy_django.utils import register_component + register_component('tethys_apps.base.controller.page_component_wrapper') - - app_websocket_urls.append(REACTPY_WEBSOCKET_ROUTE) + app_websocket_urls.append(REACTPY_WEBSOCKET_ROUTE) application = ProtocolTypeRouter( { diff --git a/tethys_sdk/gizmos.py b/tethys_sdk/gizmos.py index 16ebbb342..582e3154d 100644 --- a/tethys_sdk/gizmos.py +++ b/tethys_sdk/gizmos.py @@ -11,5 +11,4 @@ # flake8: noqa # DO NOT ERASE from tethys_gizmos.gizmo_options import * -from tethys_gizmos.react_components import * from tethys_gizmos.gizmo_options.base import TethysGizmoOptions, SecondaryGizmoOptions From 9324df4037a58d22a1acaf46c43c42575b5913fc Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 19 Aug 2024 12:56:19 -0600 Subject: [PATCH 05/31] Bugfixes from fresh test There were a few bugs found when installing from a fresh test, namely: * The version of daphne installed by default didn't meet requirements * There was some experimental reactpy core development that I never backed out --- environment.yml | 3 ++- micro_environment.yml | 3 ++- tethys_components/library.py | 7 +++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/environment.yml b/environment.yml index 67d2fa703..147bb0ec0 100644 --- a/environment.yml +++ b/environment.yml @@ -21,7 +21,8 @@ dependencies: # core dependencies - django>=3.2,<6 - - channels["daphne"] + - channels + - daphne>=4.1 - setuptools_scm - pip - requests # required by lots of things diff --git a/micro_environment.yml b/micro_environment.yml index 1151e14b6..09b34025e 100644 --- a/micro_environment.yml +++ b/micro_environment.yml @@ -20,7 +20,8 @@ dependencies: # core dependencies - django>=3.2,<6 - - channels["daphne"] + - channels + - daphne>=4.1 - setuptools_scm - pip - requests # required by lots of things diff --git a/tethys_components/library.py b/tethys_components/library.py index 546cf702a..d366b124d 100644 --- a/tethys_components/library.py +++ b/tethys_components/library.py @@ -3,6 +3,10 @@ from jinja2 import Template from re import findall from unittest.mock import Mock +import logging + +reactpy_web_logger = logging.getLogger('reactpy.web.module') +reactpy_web_logger.setLevel(logging.WARN) TETHYS_COMPONENTS_ROOT_DPATH = Path(__file__).parent @@ -79,8 +83,7 @@ def __getattr__(self, attr): module = web.module_from_string( name=self.EXPORT_NAME, content=self.get_reactjs_module_wrapper_js(), - resolve_exports=False, - replace=True + resolve_exports=False ) setattr(self, attr, web.export(module, component)) return getattr(self, attr) From 521eef0bd4532378bb303fe427bd1a889313805d Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Fri, 23 Aug 2024 15:56:19 -0600 Subject: [PATCH 06/31] Initial wave of tests and resulting refactors/fixes --- environment.yml | 3 +- .../test_base/test_page_handler.py | 237 ++++++++++++++++++ tethys_apps/base/app_base.py | 28 ++- tethys_apps/base/controller.py | 137 ++-------- tethys_apps/base/page_handler.py | 57 +++++ .../templates/tethys_apps/reactpy_base.html | 9 +- .../reactpy/tethysapp/+project+/app.py_tmpl | 1 + tethys_components/custom.py | 2 +- tethys_components/layouts.py | 13 + tethys_components/library.py | 78 +++++- tethys_portal/asgi.py | 4 +- 11 files changed, 436 insertions(+), 133 deletions(-) create mode 100644 tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py create mode 100644 tethys_apps/base/page_handler.py diff --git a/environment.yml b/environment.yml index 147bb0ec0..67d2fa703 100644 --- a/environment.yml +++ b/environment.yml @@ -21,8 +21,7 @@ dependencies: # core dependencies - django>=3.2,<6 - - channels - - daphne>=4.1 + - channels["daphne"] - setuptools_scm - pip - requests # required by lots of things diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py b/tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py new file mode 100644 index 000000000..e5cbf1b6f --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py @@ -0,0 +1,237 @@ +import unittest +from unittest import mock + +from tethys_apps.base import page_handler +from importlib import reload +import tethys_apps.base.controller as tethys_controller + +class TestPageHandler(unittest.TestCase): + def setUp(self) -> None: + # Do cleanup first so it is ready if an exception is raised + def kill_patches(): # Create a cleanup callback that undoes our patches + mock.patch.stopall() # Stops all patches started with start() + reload(page_handler) # Reload our UUT module which restores the original decorator + self.addCleanup(kill_patches) # We want to make sure this is run so we do this in addCleanup instead of tearDown + + # Now patch the decorator where the decorator is being imported from + mock.patch('reactpy.component', lambda x: x).start() # The lambda makes our decorator into a pass-thru. Also, don't forget to call start() + reload(page_handler) + + @mock.patch("tethys_apps.base.page_handler.render") + @mock.patch("tethys_apps.base.page_handler.ComponentLibrary") + @mock.patch("tethys_apps.base.page_handler.get_active_app") + @mock.patch("tethys_apps.base.page_handler.get_layout_component") + def test_global_page_component_controller(self, mock_get_layout, mock_get_app, mock_lib, mock_render): + # FUNCTION ARGS + request = mock.MagicMock() + layout = 'test_layout' + component_func = mock.MagicMock() + component_source_code = 'test123' + title = 'test_title' + custom_css = ['custom.css'] + custom_js = ['custom.js'] + + # MOCK INTERNALS + mock_get_app.return_value = "app object" + component_func.__name__ = 'my_mock_component_func' + expected_return_value = "Expected return value" + mock_render.return_value = expected_return_value + mock_get_layout.return_value = "my_layout_func" + + # EXECUTE FUNCTION + response = page_handler._global_page_component_controller( + request=request, + layout=layout, + component_func=component_func, + component_source_code=component_source_code, + title=title, + custom_css=custom_css, + custom_js=custom_js + ) + + # EVALUATE EXECUTION + mock_get_app.assert_called_once_with(request=request, get_class=True) + mock_get_layout.assert_called_once_with(mock_get_app(), layout) + mock_lib.refresh.assert_called_with(new_identifier='my-mock-component-func') + mock_lib.load_dependencies_from_source_code.assert_called_with(component_source_code) + render_called_with_args = mock_render.call_args.args + self.assertEqual(render_called_with_args[0], request) + self.assertEqual(render_called_with_args[1], 'tethys_apps/reactpy_base.html') + render_context = render_called_with_args[2] + self.assertListEqual(list(render_context.keys()), ['app', 'layout_func', 'component_func', 'reactjs_version', 'title', 'custom_css', 'custom_js']) + self.assertEqual(render_context['app'], 'app object') + self.assertEqual(render_context['layout_func'](), 'my_layout_func') + self.assertEqual(render_context['component_func'](), component_func) + self.assertEqual(render_context['reactjs_version'], mock_lib.REACTJS_VERSION) + self.assertEqual(render_context['title'], title) + self.assertEqual(render_context['custom_css'], custom_css) + self.assertEqual(render_context['custom_js'], custom_js) + self.assertEqual(response, expected_return_value) + + def test_page_component_wrapper__layout_none( + self + ): + # FUNCTION ARGS + app = mock.MagicMock() + user = mock.MagicMock() + layout = None + component = mock.MagicMock() + component_return_val = "rendered_component" + component.return_value = component_return_val + + return_value = page_handler.page_component_wrapper(app, user, layout, component) + + self.assertEqual(return_value, component_return_val) + + def test_page_component_wrapper__layout_not_none( + self + ): + # FUNCTION ARGS + app = mock.MagicMock() + app.restered_url_maps = [] + user = mock.MagicMock() + layout = mock.MagicMock() + layout_return_val = "returned_layout" + layout.return_value = layout_return_val + component = mock.MagicMock() + component_return_val = "rendered_component" + component.return_value = component_return_val + + return_value = page_handler.page_component_wrapper(app, user, layout, component) + + self.assertEqual(return_value, layout_return_val) + layout.assert_called_once_with({'app': app, 'user': user, 'nav-links': app.navigation_links}, component_return_val) + + @mock.patch('tethys_apps.base.controller._process_url_kwargs') + @mock.patch('tethys_apps.base.controller._global_page_component_controller') + @mock.patch('tethys_apps.base.controller.permission_required') + @mock.patch('tethys_apps.base.controller.enforce_quota') + @mock.patch('tethys_apps.base.controller.ensure_oauth2') + @mock.patch('tethys_apps.base.controller.login_required_decorator') + @mock.patch('tethys_apps.base.controller._get_url_map_kwargs_list') + def test_page_with_permissions( + self, + mock_get_url_map_kwargs_list, + mock_login_required_decorator, + mock_ensure_oauth2, + mock_enforce_quota, + mock_permission_required, + mock_global_page_component, + mock_process_kwargs + ): + layout = "MyLayout" + title = 'My Cool Page' + index = 0 + custom_css = ['custom.css'] + custom_js = ['custom.js'] + function = lambda x: x + return_value = tethys_controller.page( + permissions_required=['test_permission'], + enforce_quotas=['test_quota'], + ensure_oauth2_provider=['test_oauth2_provider'], + layout=layout, + title=title, + index=index, + custom_css=custom_css, + custom_js=custom_js + )(function) + self.assertTrue(callable(return_value)) + mock_request = mock.MagicMock() + mock_process_kwargs.assert_called_once() + process_kwargs_args = mock_process_kwargs.call_args.args + self.assertTrue(callable(process_kwargs_args[0])) + self.assertEqual( + process_kwargs_args[0](mock_request), + mock_login_required_decorator()()() + ) + mock_permission_required.assert_called_once() + mock_enforce_quota.assert_called_once() + mock_ensure_oauth2.assert_called_once() + self.assertEqual(mock_login_required_decorator.call_count, 2) + mock_get_url_map_kwargs_list.assert_called_once_with( + function_or_class=function, + name=None, + url=None, + protocol="http", + regex=None, + title=title, + index=index + ) + + @mock.patch('tethys_apps.base.controller._process_url_kwargs') + @mock.patch('tethys_apps.base.controller._global_page_component_controller') + @mock.patch('tethys_apps.base.controller._get_url_map_kwargs_list') + def test_page_with_defaults( + self, + mock_get_url_map_kwargs_list, + mock_global_page_component, + mock_process_kwargs + ): + function = lambda x: x + return_value = tethys_controller.page()(function) + self.assertTrue(callable(return_value)) + mock_request = mock.MagicMock() + mock_process_kwargs.assert_called_once() + process_kwargs_args = mock_process_kwargs.call_args.args + self.assertTrue(callable(process_kwargs_args[0])) + self.assertEqual( + process_kwargs_args[0](mock_request), + mock_global_page_component( + mock_request, + layout="default", + component_func=function, + component_source_code="lambda x: x", + title=mock_get_url_map_kwargs_list[0]['title'], + custom_css=[], + custom_js=[] + ) + ) + mock_get_url_map_kwargs_list.assert_called_once_with( + function_or_class=function, + name=None, + url=None, + protocol="http", + regex=None, + title=None, + index=None + ) + + @mock.patch('tethys_apps.base.controller._process_url_kwargs') + @mock.patch('tethys_apps.base.controller._global_page_component_controller') + @mock.patch('tethys_apps.base.controller._get_url_map_kwargs_list') + def test_page_with_handler( + self, + mock_get_url_map_kwargs_list, + mock_global_page_component, + mock_process_kwargs + ): + component_function = lambda x: x + handler_function = mock.MagicMock() + return_value = tethys_controller.page(handler=handler_function)(component_function) + self.assertTrue(callable(return_value)) + mock_request = mock.MagicMock() + mock_process_kwargs.assert_called_once() + process_kwargs_args = mock_process_kwargs.call_args.args + self.assertTrue(callable(process_kwargs_args[0])) + mock_global_page_component.assert_not_called() + self.assertEqual( + process_kwargs_args[0](mock_request), + handler_function( + mock_request, + layout="default", + component_func=component_function, + component_source_code="lambda x: x", + title=mock_get_url_map_kwargs_list[0]['title'], + custom_css=[], + custom_js=[] + ) + ) + mock_get_url_map_kwargs_list.assert_called_once_with( + function_or_class=component_function, + name=None, + url=None, + protocol="http", + regex=None, + title=None, + index=None + ) diff --git a/tethys_apps/base/app_base.py b/tethys_apps/base/app_base.py index 220eefc7b..740ddd56e 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -58,8 +58,6 @@ class TethysBase(TethysBaseMixin): root_url = "" index = None controller_modules = [] - default_layout = None - custom_css = [] def __init__(self): self._url_patterns = None @@ -109,10 +107,6 @@ def id(cls): """Returns ID of Django database object.""" return cls.db_object.id - @classproperty - def layout(cls): - return cls.default_layout - @classmethod def _resolve_ref_function(cls, ref, ref_type): """ @@ -593,6 +587,8 @@ class TethysAppBase(TethysBase): feedback_emails = [] enabled = True show_in_apps_library = True + default_layout = None + nav_links = [] def __str__(self): """ @@ -615,6 +611,26 @@ def db_model(cls): from tethys_apps.models import TethysApp return TethysApp + + @property + def navigation_links(self): + nav_links = self.nav_links + if self.nav_links == 'auto': + nav_links = [] + for url_map in sorted(self.registered_url_maps, key=lambda x: x.index if x.index is not None else 999): + if url_map.index == -1: continue # Do not render + nav_links.append( + { + 'title': url_map.title, + 'href': f'/apps/{self.root_url}/{url_map.name.replace('_', '-') + '/' if url_map.name != self.index else ""}' + } + ) + self.set_nav_links(nav_links) + return nav_links + + @classmethod + def set_nav_links(cls, nav_links): + cls.nav_links = nav_links def custom_settings(self): """ diff --git a/tethys_apps/base/controller.py b/tethys_apps/base/controller.py index 693c532d5..bf4f7f49f 100644 --- a/tethys_apps/base/controller.py +++ b/tethys_apps/base/controller.py @@ -14,10 +14,7 @@ from django.views.generic import View from django.http import HttpRequest from django.contrib.auth import REDIRECT_FIELD_NAME -from django.conf import settings -from django.shortcuts import render -from tethys_components.library import Library as ComponentLibrary from tethys_cli.cli_colors import write_warning from tethys_quotas.decorators import enforce_quota from tethys_services.utilities import ensure_oauth2 @@ -25,7 +22,7 @@ from . import url_map_maker from .app_base import DEFAULT_CONTROLLER_MODULES - +from .page_handler import _global_page_component_controller from .bokeh_handler import ( _get_bokeh_controller, with_workspaces as with_workspaces_decorator, @@ -40,8 +37,6 @@ from typing import Union, Any from collections.abc import Callable -from reactpy import component - app_controllers_list = list() @@ -444,25 +439,18 @@ def wrapped(function_or_class): return wrapped if function_or_class is None else wrapped(function_or_class) def page( - function_or_class: Union[ - Callable[[HttpRequest, ...], Any], TethysController - ] = None, + component_function: Callable = None, /, *, # UrlMap Overrides name: str = None, url: Union[str, list, tuple, dict, None] = None, - protocol: str = "http", regex: Union[str, list, tuple] = None, - _handler: Union[str, Callable] = None, - _handler_type: str = None, + handler: Union[str, Callable] = None, # login_required kwargs login_required: bool = True, redirect_field_name: str = REDIRECT_FIELD_NAME, login_url: str = None, - # workspace decorators - app_workspace: bool = False, - user_workspace: bool = False, # ensure_oauth2 kwarg ensure_oauth2_provider: str = None, # enforce_quota kwargs @@ -480,19 +468,17 @@ def page( custom_js=[] ) -> Callable: """ - Decorator to register a function or TethysController class as a controller + Decorator to register a function as a Page in the ReactPy paradigm (by automatically registering a UrlMap for it). Args: name: Name of the url map. Letters and underscores only (_). Must be unique within the app. The default is the name of the function being decorated. url: URL pattern to map the endpoint for the controller or consumer. If a `list` then a separate UrlMap is generated for each URL in the list. The first URL is given `name` and subsequent URLS are named `name` _1, `name` _2 ... `name` _n. Can also be passed as dict mapping names to URL patterns. In this case the `name` argument is ignored. - protocol: 'http' for controllers or 'websocket' for consumers. Default is http. regex: Custom regex pattern(s) for url variables. If a string is provided, it will be applied to all variables. If a list or tuple is provided, they will be applied in variable order. + handler: Dot-notation path a handler function that will process the actual request. This is for an escape-hatch pattern to get back to Django templating. login_required: If user is required to be logged in to access the controller. Default is `True`. redirect_field_name: URL query string parameter for the redirect path. Default is "next". login_url: URL to send users to in order to authenticate. - app_workspace: Whether to pass the app workspace as an argument to the controller. - user_workspace: Whether to pass the user workspace as an argument to the controller. ensure_oauth2_provider: An OAuth2 provider name to ensure is authenticated to access the controller. enforce_quotas: The name(s) of quotas to enforce on the controller. permissions_required: The name(s) of permissions that a user is required to have to access the controller. @@ -504,36 +490,24 @@ def page( index: Index of the page as used to determine the display order in the built-in Navigation component. Defaults to top-to-bottom as written in code. Pass -1 to remove from built-in Navigation component. custom_css: A list of URLs to additional css files that should be rendered with the page. These will be rendered in the order provided. custom_js: A list of URLs to additional js files that should be rendered with the page. These will be rendered in the order provided. - - **NOTE:** The :ref:`handler-decorator` should be used in favor of using the following arguments directly. - - Args: - _handler: Dot-notation path a handler function. A handler is associated to a specific controller and contains the main logic for creating and establishing a communication between the client and the server. - _handler_type: Tethys supported handler type. 'bokeh' is the only handler type currently supported. """ # noqa: E501 - permissions_required = _listify(permissions_required) enforce_quota_codenames = _listify(enforce_quotas) - layout = f'{layout.__module__}.{layout.__name__}' if callable(layout) else layout - def wrapped(function_or_class): - page_module_path = f'{function_or_class.__module__}.{function_or_class.__name__}' + def wrapped(component_function): + component_source_code = inspect.getsource(component_function) url_map_kwargs_list = _get_url_map_kwargs_list( - function_or_class=function_or_class, + function_or_class=component_function, name=name, url=url, - protocol=protocol, + protocol="http", regex=regex, - handler=_handler, - handler_type=_handler_type, - app_workspace=app_workspace, - user_workspace=user_workspace, title=title, index=index ) def controller_wrapper(request): - controller = _global_page_component_controller + controller = handler or _global_page_component_controller if permissions_required: controller = permission_required( *permissions_required, @@ -554,16 +528,20 @@ def controller_wrapper(request): controller = login_required_decorator( redirect_field_name=redirect_field_name, login_url=login_url )(controller) - - return controller(request, inspect.getsource(function_or_class), layout, page_module_path, url_map_kwargs_list[0]['title'], custom_css, custom_js) + return controller( + request, + layout=layout, + component_func=component_function, + component_source_code=component_source_code, + title=url_map_kwargs_list[0]['title'], + custom_css=custom_css, + custom_js=custom_js + ) - # UNCOMMENT IF WE DECIDE TO GO WITH USING THE COMPONENT FUNCITON DIRECTLY, AS OPPOSED TO WRAPPING - # IT WITH THE GLOBAL_COMPONENT FUNCTION - # register_component(component_module_path) _process_url_kwargs(controller_wrapper, url_map_kwargs_list) - return function_or_class + return component_function - return wrapped if function_or_class is None else wrapped(function_or_class) + return wrapped if component_function is None else wrapped(component_function) controller_decorator = controller @@ -750,21 +728,6 @@ def wrapped(function): return wrapped if function is None else wrapped(function) - -def _global_page_component_controller(request, component_source_code, layout, page_module_path, title=None, custom_css=[], custom_js=[]): - ComponentLibrary.refresh(new_identifier=page_module_path.split('.')[-1].replace('_', '-')) - ComponentLibrary.load_dependencies_from_source_code(component_source_code) - context = { - 'page_module_path_context_arg': page_module_path, - 'reactjs_version': ComponentLibrary.REACTJS_VERSION, - 'layout_context_arg': layout, - 'title': title, - 'custom_css': custom_css, - 'custom_js': custom_js - } - - return render(request, 'tethys_apps/reactpy_base.html', context) - def _get_url_map_kwargs_list( function_or_class: Union[ Callable[[HttpRequest, ...], Any], TethysController @@ -976,61 +939,3 @@ def register_controllers( ) return url_maps - -@component -def page_component_wrapper(layout, page_module_path): - from reactpy_django.hooks import use_user # Avoid Django configuration error - path_parts = page_module_path.split('.') - - app_name = path_parts[1] - app_module_name = f'tethysapp.{app_name}.app' - app_module = __import__(app_module_name, fromlist=['App']) - if hasattr(settings, "DEBUG") and settings.DEBUG: - importlib.reload(app_module) - App = app_module.App() - - component_module_name = '.'.join(path_parts[:-1]) - component_name = path_parts[-1] - component_module = __import__(component_module_name, fromlist=[component_name]) - if hasattr(settings, "DEBUG") and settings.DEBUG: - importlib.reload(component_module) - Component = getattr(component_module, component_name) - - if layout is not None: - Layout = None - if layout == 'default': - if callable(App.layout): - Layout = App.layout - else: - layout_module_name = 'tethys_components.layouts' - layout_name = App.layout - else: - layout_module_path_parts = layout.split('.') - layout_module_name = '.'.join(layout_module_path_parts[:-1]) - layout_name = layout_module_path_parts[-1] - - if not Layout: - layout_module = __import__(layout_module_name, fromlist=[layout_name]) - Layout = getattr(layout_module, layout_name) - - user = use_user() - nav_links = [] - for url_map in sorted(App.registered_url_maps, key=lambda x: x.index if x.index is not None else 999): - if url_map.index == -1: continue # Do not render - nav_links.append( - { - 'title': url_map.title, - 'href': f'/apps/{App.root_url}/{url_map.name.replace('_', '-') + '/' if url_map.name != App.index else ""}' - } - ) - - return Layout( - { - 'app': App, - 'user': user, - 'nav-links': nav_links - }, - Component() - ) - else: - return Component() diff --git a/tethys_apps/base/page_handler.py b/tethys_apps/base/page_handler.py new file mode 100644 index 000000000..7cda09ebf --- /dev/null +++ b/tethys_apps/base/page_handler.py @@ -0,0 +1,57 @@ +from django.shortcuts import render +from tethys_components.library import Library as ComponentLibrary +from reactpy import component +from tethys_apps.utilities import get_active_app +from tethys_components.layouts import get_layout_component + +def _global_page_component_controller( + request, + layout, + component_func, + component_source_code, + title=None, + custom_css=[], + custom_js=[] +): + app = get_active_app(request=request, get_class=True) + layout_func = get_layout_component(app, layout) + ComponentLibrary.refresh(new_identifier=component_func.__name__.replace('_', '-')) + ComponentLibrary.load_dependencies_from_source_code(component_source_code) + + context = { + 'app': app, + 'layout_func': lambda: layout_func, + 'component_func': lambda: component_func, + 'reactjs_version': ComponentLibrary.REACTJS_VERSION, + 'title': title, + 'custom_css': custom_css, + 'custom_js': custom_js, + } + + return render(request, 'tethys_apps/reactpy_base.html', context) + +@component +def page_component_wrapper(app, user, layout, component): + """ + ReactPy Component that wraps every custom user page + + The path to this component is hard-coded in tethys_apps/reactpy_base.html + and the component is registered on server startup in tethys_portal/asgi.py + + Args: + app(TethysApp instance): The app rendering the page + user(Django User object): The loggin in user acessing the page + layout(func or None): The layout component, if any, that the page content will be nested in + component(func): The page component to render + """ + if layout is not None: + return layout( + { + 'app': app, + 'user': user, + 'nav-links': app.navigation_links + }, + component() + ) + else: + return component() diff --git a/tethys_apps/templates/tethys_apps/reactpy_base.html b/tethys_apps/templates/tethys_apps/reactpy_base.html index 8368b809b..279a4b006 100644 --- a/tethys_apps/templates/tethys_apps/reactpy_base.html +++ b/tethys_apps/templates/tethys_apps/reactpy_base.html @@ -20,6 +20,9 @@ {% endif %} + {% for css in custom_css %} + + {% endfor %} " diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 06444aec5..13c68fac8 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -10,7 +10,7 @@ import importlib import logging -import os +from os import environ from pathlib import Path import pkgutil @@ -41,8 +41,8 @@ def get_tethys_src_dir(): Returns: str: path to TETHYS_SRC. """ - default = os.path.dirname(os.path.dirname(__file__)) - return os.environ.get("TETHYS_SRC", default) + default = Path(__file__).parents[1] + return environ.get("TETHYS_SRC", str(default)) def get_tethys_home_dir(): @@ -52,26 +52,26 @@ def get_tethys_home_dir(): Returns: str: path to TETHYS_HOME. """ - env_tethys_home = os.environ.get("TETHYS_HOME") + env_tethys_home = environ.get("TETHYS_HOME") # Return environment value if set if env_tethys_home: return env_tethys_home # Initialize to default TETHYS_HOME - tethys_home = os.path.join(os.path.expanduser("~"), ".tethys") + tethys_home = Path.home() / ".tethys" try: - conda_env_name = os.environ.get("CONDA_DEFAULT_ENV") + conda_env_name = environ.get("CONDA_DEFAULT_ENV") if conda_env_name != "tethys": - tethys_home = os.path.join(tethys_home, conda_env_name) + tethys_home = tethys_home / conda_env_name except Exception: tethys_log.warning( f"Running Tethys outside of active Conda environment detected. Using default " f'TETHYS_HOME "{tethys_home}". Set TETHYS_HOME environment to override.' ) - return tethys_home + return str(tethys_home) def relative_to_tethys_home(path, as_str=False): @@ -115,14 +115,14 @@ def get_directories_in_tethys(directory_names, with_app_name=False): for potential_dir in potential_dirs: for directory_name in directory_names: # Only check directories - if os.path.isdir(potential_dir): + if Path(potential_dir).is_dir(): match_dir = safe_join(potential_dir, directory_name) - if match_dir not in match_dirs and os.path.isdir(match_dir): + if match_dir not in match_dirs and Path(match_dir).is_dir(): if not with_app_name: match_dirs.append(match_dir) else: - match_dirs.append((os.path.basename(potential_dir), match_dir)) + match_dirs.append((Path(potential_dir).name, match_dir)) return match_dirs @@ -694,31 +694,29 @@ def delete_secrets(app_name): def secrets_signed_unsigned_value(name, value, tethys_app_package_name, is_signing): return_string = "" TETHYS_HOME = get_tethys_home_dir() + secrets_path = Path(TETHYS_HOME) / "secrets.yml" signer = Signer() try: - if not os.path.exists(os.path.join(TETHYS_HOME, "secrets.yml")): + if not secrets_path.exists(): return_string = sign_and_unsign_secret_string(signer, value, is_signing) else: - with open(os.path.join(TETHYS_HOME, "secrets.yml")) as secrets_yaml: - secret_app_settings = ( - yaml.safe_load(secrets_yaml).get("secrets", {}) or {} - ) - if bool(secret_app_settings): - if tethys_app_package_name in secret_app_settings: - if ( - "custom_settings_salt_strings" - in secret_app_settings[tethys_app_package_name] - ): - app_specific_settings = secret_app_settings[ - tethys_app_package_name - ]["custom_settings_salt_strings"] - if name in app_specific_settings: - app_custom_setting_salt_string = app_specific_settings[ - name - ] - if app_custom_setting_salt_string != "": - signer = Signer(salt=app_custom_setting_salt_string) - return_string = sign_and_unsign_secret_string(signer, value, is_signing) + secret_app_settings = (yaml.safe_load(secrets_path.read_text()) or {}).get( + "secrets", {} + ) + if bool(secret_app_settings): + if tethys_app_package_name in secret_app_settings: + if ( + "custom_settings_salt_strings" + in secret_app_settings[tethys_app_package_name] + ): + app_specific_settings = secret_app_settings[ + tethys_app_package_name + ]["custom_settings_salt_strings"] + if name in app_specific_settings: + app_custom_setting_salt_string = app_specific_settings[name] + if app_custom_setting_salt_string != "": + signer = Signer(salt=app_custom_setting_salt_string) + return_string = sign_and_unsign_secret_string(signer, value, is_signing) except signing.BadSignature: raise TethysAppSettingNotAssigned( f"The salt string for the setting {name} has been changed or lost, please enter the secret custom settings in the application settings again." diff --git a/tethys_cli/app_settings_commands.py b/tethys_cli/app_settings_commands.py index cb71dad28..5ff7e7a60 100644 --- a/tethys_cli/app_settings_commands.py +++ b/tethys_cli/app_settings_commands.py @@ -1,4 +1,3 @@ -import os import json from pathlib import Path from django.core.exceptions import ValidationError, ObjectDoesNotExist @@ -244,11 +243,10 @@ def app_settings_set_command(args): try: value_json = "{}" if setting.type_custom_setting == "JSON": - if os.path.exists(actual_value): - with open(actual_value) as json_file: - write_warning("File found, extracting JSON data") - value_json = json.load(json_file) - + try_path = Path(actual_value) + if try_path.exists(): + write_warning("File found, extracting JSON data") + value_json = json.loads(try_path.read_text()) setting.value = value_json else: try: diff --git a/tethys_cli/cli_helpers.py b/tethys_cli/cli_helpers.py index 7e4431e5a..b1e5edb1a 100644 --- a/tethys_cli/cli_helpers.py +++ b/tethys_cli/cli_helpers.py @@ -1,6 +1,6 @@ -import os import sys import subprocess +from os import devnull from pathlib import Path from functools import wraps @@ -41,19 +41,19 @@ def get_manage_path(args): Validate user defined manage path, use default, or throw error """ # Determine path to manage.py file - manage_path = os.path.join(get_tethys_src_dir(), "tethys_portal", "manage.py") + manage_path = f"{get_tethys_src_dir()}/tethys_portal/manage.py" # Check for path option if hasattr(args, "manage"): manage_path = args.manage or manage_path # Throw error if path is not valid - if not os.path.isfile(manage_path): + if not Path(manage_path).is_file(): with pretty_output(FG_RED) as p: p.write('ERROR: Can\'t open file "{0}", no such file.'.format(manage_path)) exit(1) - return manage_path + return str(manage_path) def run_process(process): @@ -72,7 +72,7 @@ def supress_stdout(func): @wraps(func) def wrapped(*args, **kwargs): stdout = sys.stdout - sys.stdout = open(os.devnull, "w") + sys.stdout = open(devnull, "w") result = func(*args, **kwargs) sys.stdout = stdout return result diff --git a/tethys_cli/docker_commands.py b/tethys_cli/docker_commands.py index 16a61ea72..47c573333 100644 --- a/tethys_cli/docker_commands.py +++ b/tethys_cli/docker_commands.py @@ -8,9 +8,9 @@ ******************************************************************************** """ -import os import json from abc import ABC, abstractmethod +from pathlib import Path import getpass from tethys_cli.cli_colors import write_pretty_output, write_error, write_warning @@ -482,7 +482,7 @@ def get_container_options(self, defaults): if mount_data_dir.lower() == "y": tethys_home = get_tethys_home_dir() - default_mount_location = os.path.join(tethys_home, "geoserver", "data") + default_mount_location = str(Path(tethys_home) / "geoserver" / "data") gs_data_volume = "/var/geoserver/data" mount_location = UserInputHelper.get_valid_directory_input( prompt="Specify location to bind data directory", @@ -643,7 +643,7 @@ def get_container_options(self, defaults): if mount_data_dir.lower() == "y": tethys_home = get_tethys_home_dir() - default_mount_location = os.path.join(tethys_home, "thredds") + default_mount_location = str(Path(tethys_home) / "thredds") thredds_data_volume = "/usr/local/tomcat/content/thredds" mount_location = UserInputHelper.get_valid_directory_input( prompt="Specify location to bind the THREDDS data directory", @@ -985,23 +985,23 @@ def get_valid_directory_input(prompt, default=None): pre_prompt = "" prompt = "{} [{}]: ".format(prompt, default) while True: - value = input("{}{}".format(pre_prompt, prompt)) or str(default) + raw_value = input("{}{}".format(pre_prompt, prompt)) or str(default) + path = Path(raw_value) - if os.path.abspath(__file__).startswith(os.path.abspath(os.sep)): - if len(value) > 0 and not value.startswith(os.path.abspath(os.sep)): - value = os.path.join(os.path.abspath(os.sep), value) + if len(raw_value) > 0 and not path.is_absolute(): + path = path.absolute() - if not os.path.isdir(value): + if not path.is_dir(): try: - os.makedirs(value) + path.mkdir(parents=True) except OSError as e: - write_pretty_output("{0}: {1}".format(repr(e), value)) + write_pretty_output("{0}: {1}".format(repr(e), path)) pre_prompt = "Please provide a valid directory\n" continue break - return value + return str(path) def log_pull_stream(stream): diff --git a/tethys_cli/gen_commands.py b/tethys_cli/gen_commands.py index ddb57835c..c16adc546 100644 --- a/tethys_cli/gen_commands.py +++ b/tethys_cli/gen_commands.py @@ -9,9 +9,9 @@ """ import json -import os import string import random +from os import environ from datetime import datetime from pathlib import Path from subprocess import call, run @@ -41,7 +41,7 @@ ("run_command", "Commands"), from_module="conda.cli.python_api" ) -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") +environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") GEN_APACHE_OPTION = "apache" GEN_APACHE_SERVICE_OPTION = "apache_service" @@ -213,7 +213,7 @@ def add_gen_parser(subparsers): def get_environment_value(value_name): - value = os.environ.get(value_name) + value = environ.get(value_name) if value is not None: return value else: @@ -281,8 +281,8 @@ def gen_apache_service(args): def gen_asgi_service(args): nginx_user = "" nginx_conf_path = "/etc/nginx/nginx.conf" - if os.path.exists(nginx_conf_path): - with open(nginx_conf_path, "r") as nginx_conf: + if Path(nginx_conf_path).exists(): + with Path(nginx_conf_path).open() as nginx_conf: for line in nginx_conf.readlines(): tokens = line.split() if len(tokens) > 0 and tokens[0] == "user": @@ -435,8 +435,8 @@ def derive_version_from_conda_environment(dep_str, level="none"): def gen_meta_yaml(args): filename = "micro_environment.yml" if args.micro else "environment.yml" package_name = "micro-tethys-platform" if args.micro else "tethys-platform" - environment_file_path = os.path.join(TETHYS_SRC, filename) - with open(environment_file_path, "r") as env_file: + environment_file_path = Path(TETHYS_SRC) / filename + with Path(environment_file_path).open() as env_file: environment = yaml.safe_load(env_file) dependencies = environment.get("dependencies", []) @@ -533,42 +533,42 @@ def get_destination_path(args, check_existence=True): destination_file = FILE_NAMES[args.type] # Default destination path is the tethys_portal source dir - destination_dir = TETHYS_HOME + destination_dir = Path(TETHYS_HOME) # Make the Tethys Home directory if it doesn't exist yet. - if not os.path.isdir(destination_dir): - os.makedirs(destination_dir, exist_ok=True) + if not destination_dir.is_dir(): + destination_dir.mkdir(parents=True, exist_ok=True) if args.type in [GEN_SERVICES_OPTION, GEN_INSTALL_OPTION]: - destination_dir = os.getcwd() + destination_dir = Path.cwd() elif args.type == GEN_META_YAML_OPTION: - destination_dir = os.path.join(TETHYS_SRC, "conda.recipe") + destination_dir = Path(TETHYS_SRC) / "conda.recipe" elif args.type == GEN_PACKAGE_JSON_OPTION: - destination_dir = os.path.join(TETHYS_SRC, "tethys_portal", "static") + destination_dir = Path(TETHYS_SRC) / "tethys_portal" / "static" elif args.type == GEN_REQUIREMENTS_OPTION: - destination_dir = TETHYS_SRC + destination_dir = Path(TETHYS_SRC) if args.directory: - destination_dir = os.path.abspath(args.directory) + destination_dir = Path(args.directory).absolute() - if not os.path.isdir(destination_dir): + if not destination_dir.is_dir(): write_error('ERROR: "{0}" is not a valid directory.'.format(destination_dir)) exit(1) - destination_path = os.path.join(destination_dir, destination_file) + destination_path = destination_dir / destination_file if check_existence: check_for_existing_file(destination_path, destination_file, args.overwrite) - return destination_path + return str(destination_path) def check_for_existing_file(destination_path, destination_file, overwrite): # Check for pre-existing file - if os.path.isfile(destination_path): + if destination_path.is_file(): valid_inputs = ("y", "n", "yes", "no") no_inputs = ("n", "no") @@ -590,17 +590,14 @@ def check_for_existing_file(destination_path, destination_file, overwrite): def render_template(file_type, context, destination_path): # Determine template path - gen_templates_dir = os.path.join( - os.path.abspath(os.path.dirname(__file__)), "gen_templates" - ) - template_path = os.path.join(gen_templates_dir, file_type) + gen_templates_dir = Path(__file__).parent.absolute() / "gen_templates" + template_path = gen_templates_dir / file_type # Parse template - template = Template(open(template_path).read()) + template = Template(template_path.read_text()) # Render template and write to file if template: - with open(destination_path, "w") as f: - f.write(template.render(context)) + Path(destination_path).write_text(template.render(context)) def write_path_to_console(file_path): diff --git a/tethys_cli/install_commands.py b/tethys_cli/install_commands.py index 95ad9f7fe..ea0677f1a 100644 --- a/tethys_cli/install_commands.py +++ b/tethys_cli/install_commands.py @@ -1,7 +1,7 @@ import yaml import json -import os import getpass +from os import devnull from pathlib import Path from subprocess import call, Popen, PIPE, STDOUT from argparse import Namespace @@ -31,7 +31,7 @@ ("run_command", "Commands"), from_module="conda.cli.python_api" ) -FNULL = open(os.devnull, "w") +FNULL = open(devnull, "w") def add_install_parser(subparsers): @@ -392,8 +392,7 @@ def run_interactive_services(app_name): "Please provide a file containing a Json (e.g: /home/user/myjsonfile.json): " ) try: - with open(json_path) as json_file: - value = json.load(json_file) + value = json.loads(Path(json_path).read_text()) except FileNotFoundError: write_warning("The current file path was not found") else: @@ -697,7 +696,7 @@ def install_command(args): """ app_name = None skip_config = False - file_path = Path("./install.yml") if args.file is None else Path(args.file) + file_path = Path("./install.yml" if args.file is None else args.file) # Check for install.yml file if not file_path.exists(): @@ -856,11 +855,11 @@ def install_command(args): if validate_schema("post", install_options): write_msg("Running post installation tasks...") for post in install_options["post"]: - command = file_path.resolve().parent / post + path_to_post = file_path.resolve().parent / post # Attempting to run processes. - if command.name.endswith(".py"): - command = f"{sys.executable} {command}" - process = Popen(str(command), shell=True, stdout=PIPE) + if path_to_post.name.endswith(".py"): + path_to_post = f"{sys.executable} {path_to_post}" + process = Popen(str(path_to_post), shell=True, stdout=PIPE) stdout = process.communicate()[0] write_msg("Post Script Result: {}".format(stdout)) write_success(f"Successfully installed {app_name}.") @@ -878,10 +877,10 @@ def assign_json_value(value): # Check if the value is a file path if isinstance(value, str): try: - if os.path.isfile(value): - with open(value) as file: - json_data = json.load(file) - return json_data + try_path = Path(value) + if try_path.is_file(): + json_data = json.loads(try_path.read_text()) + return json_data else: # Check if the value is a valid JSON string json_data = json.loads(value) diff --git a/tethys_cli/scaffold_commands.py b/tethys_cli/scaffold_commands.py index 84f1c774d..a3efa5a99 100644 --- a/tethys_cli/scaffold_commands.py +++ b/tethys_cli/scaffold_commands.py @@ -1,8 +1,8 @@ -import os import re import logging import random import shutil +from pathlib import Path from jinja2 import Template from tethys_cli.cli_colors import write_pretty_output, FG_RED, FG_YELLOW, FG_WHITE @@ -15,11 +15,9 @@ EXTENSION_TEMPLATES_DIR = "extension_templates" APP_TEMPLATES_DIR = "app_templates" TEMPLATE_SUFFIX = "_tmpl" -APP_PATH = os.path.join( - os.path.dirname(__file__), SCAFFOLD_TEMPLATES_DIR, APP_TEMPLATES_DIR -) -EXTENSION_PATH = os.path.join( - os.path.dirname(__file__), SCAFFOLD_TEMPLATES_DIR, EXTENSION_TEMPLATES_DIR +APP_PATH = Path(__file__).parent / SCAFFOLD_TEMPLATES_DIR / APP_TEMPLATES_DIR +EXTENSION_PATH = ( + Path(__file__).parent / SCAFFOLD_TEMPLATES_DIR / EXTENSION_TEMPLATES_DIR ) @@ -36,7 +34,7 @@ def add_scaffold_parser(subparsers): scaffold_parser.add_argument( "prefix", nargs="?", - default=os.getcwd(), + default=str(Path.cwd()), help="The absolute path to the directory within which the new app should be scaffolded.", ) scaffold_parser.add_argument( @@ -44,7 +42,7 @@ def add_scaffold_parser(subparsers): "--template", dest="template", help="Name of template to use.", - choices=os.listdir(APP_PATH), + choices=[p.name for p in APP_PATH.iterdir()], ) scaffold_parser.add_argument( "-e", "--extension", dest="extension", action="store_true" @@ -185,15 +183,15 @@ def scaffold_command(args): if args.extension: is_extension = True template_name = args.template - template_root = os.path.join(EXTENSION_PATH, args.template) + template_root = EXTENSION_PATH / args.template else: template_name = args.template - template_root = os.path.join(APP_PATH, args.template) + template_root = APP_PATH / args.template log.debug("Template root directory: {}".format(template_root)) # Validate template - if not os.path.isdir(template_root): + if not template_root.is_dir(): write_pretty_output( 'Error: "{}" is not a valid template.'.format(template_name), FG_WHITE ) @@ -251,11 +249,9 @@ def scaffold_command(args): default_proper_name = " ".join(title_case_project_name) class_name = "".join(title_case_project_name) default_theme_color = get_random_color() - project_root = os.path.join(args.prefix, project_dir) + project_root = Path(args.prefix) / project_dir - write_pretty_output( - 'Creating new Tethys project at "{0}".'.format(project_root), FG_WHITE - ) + write_pretty_output(f'Creating new Tethys project at "{project_root}".', FG_WHITE) # Get metadata from user if not is_extension: @@ -379,12 +375,12 @@ def scaffold_command(args): context[item["name"]] = response - log.debug("Template context: {}".format(context)) + log.debug(f"Template context: {context}") - log.debug("Project root path: {}".format(project_root)) + log.debug(f"Project root path: {project_root}") # Create root directory - if os.path.isdir(project_root): + if project_root.is_dir(): if not args.overwrite: valid = False negative_choices = ["n", "no", ""] @@ -396,10 +392,7 @@ def scaffold_command(args): try: response = ( input( - 'Directory "{}" already exists. ' - "Would you like to overwrite it? [Y/n]: ".format( - project_root - ) + f'Directory "{project_root}" already exists. Would you like to overwrite it? [Y/n]: ' ) or default ) @@ -415,44 +408,45 @@ def scaffold_command(args): exit(0) try: - shutil.rmtree(project_root) + shutil.rmtree(str(project_root)) except OSError: write_pretty_output( - 'Error: Unable to overwrite "{}". ' - "Please remove the directory and try again.".format(project_root), + f'Error: Unable to overwrite "{project_root}". Please remove the directory and try again.', FG_YELLOW, ) exit(1) # Walk the template directory, creating the templates and directories in the new project as we go - for curr_template_root, _, template_files in os.walk(template_root): - curr_project_root = curr_template_root.replace(template_root, project_root) + for curr_template_root, _, template_files in template_root.walk(): + curr_project_root = str(curr_template_root).replace( + str(template_root), str(project_root) + ) curr_project_root = render_path(curr_project_root, context) + curr_project_root = Path(curr_project_root) # Create Root Directory - os.makedirs(curr_project_root) - write_pretty_output('Created: "{}"'.format(curr_project_root), FG_WHITE) + curr_project_root.mkdir(parents=True) + write_pretty_output(f'Created: "{curr_project_root}"', FG_WHITE) # Create Files for template_file in template_files: needs_rendering = template_file.endswith(TEMPLATE_SUFFIX) - template_file_path = os.path.join(curr_template_root, template_file) + template_file_path = curr_template_root / template_file project_file = template_file.replace(TEMPLATE_SUFFIX, "") - project_file_path = os.path.join(curr_project_root, project_file) + project_file_path = curr_project_root / project_file # Load the template - log.debug('Loading template: "{}"'.format(template_file_path)) + log.debug(f'Loading template: "{template_file_path}"') if needs_rendering: - with open(template_file_path, "r") as tf: - template = Template(tf.read()) - with open(project_file_path, "w") as pf: - pf.write(template.render(context)) + project_file_path.write_text( + Template(template_file_path.read_text()).render(context) + ) else: - shutil.copy(template_file_path, project_file_path) + shutil.copy(str(template_file_path), str(project_file_path)) - write_pretty_output('Created: "{}"'.format(project_file_path), FG_WHITE) + write_pretty_output(f'Created: "{project_file_path}"', FG_WHITE) write_pretty_output( - 'Successfully scaffolded new project "{}"'.format(project_name), FG_WHITE + f'Successfully scaffolded new project "{project_name}"', FG_WHITE ) diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/README.rst_tmpl b/tethys_cli/scaffold_templates/app_templates/reactpy/README.rst_tmpl new file mode 100644 index 000000000..a53224f7c --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/README.rst_tmpl @@ -0,0 +1,4 @@ +{{proper_name}} +{{'=' * proper_name|length}} + +{{description}} diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl b/tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl index 79e753dc1..470de9d9b 100644 --- a/tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl @@ -13,7 +13,4 @@ requirements: packages: pip: - - reactpy-django - -post: - - ./post_install.py \ No newline at end of file + - reactpy-django \ No newline at end of file diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/post_install.py b/tethys_cli/scaffold_templates/app_templates/reactpy/post_install.py deleted file mode 100644 index 9c9b0fd57..000000000 --- a/tethys_cli/scaffold_templates/app_templates/reactpy/post_install.py +++ /dev/null @@ -1,8 +0,0 @@ -from tethys_cli.settings_commands import read_settings, write_settings - -tethys_settings = read_settings() -if "INSTALLED_APPS" not in tethys_settings: - tethys_settings["INSTALLED_APPS"] = [] -if "reactpy_django" not in tethys_settings["INSTALLED_APPS"]: - tethys_settings["INSTALLED_APPS"].append("reactpy_django") - write_settings(tethys_settings) diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/pyproject.toml_tmpl b/tethys_cli/scaffold_templates/app_templates/reactpy/pyproject.toml_tmpl new file mode 100644 index 000000000..c83424af9 --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/pyproject.toml_tmpl @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "{{project_dir}}" +description = "{{description|default('')}}" +readme = "README.rst" +license = {text = "{{license_name|default('')}}"} +keywords = [{{', '.join(tags.split(','))}}] +authors = [ + {name = "{{author|default('')}}", email = "{{author_email|default('')}}"}, +] +classifiers = [ + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: {{license_name}}", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", +] +dynamic = ["version"] diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/setup.py_tmpl b/tethys_cli/scaffold_templates/app_templates/reactpy/setup.py_tmpl deleted file mode 100644 index ef8ef99cb..000000000 --- a/tethys_cli/scaffold_templates/app_templates/reactpy/setup.py_tmpl +++ /dev/null @@ -1,31 +0,0 @@ -from setuptools import setup, find_namespace_packages -from tethys_apps.app_installation import find_all_resource_files -from tethys_apps.base.app_base import TethysAppBase - -# -- Apps Definition -- # -app_package = '{{project}}' -release_package = f'{TethysAppBase.package_namespace}-{app_package}' - -# -- Python Dependencies -- # -dependencies = [] - -# -- Get Resource File -- # -resource_files = find_all_resource_files(app_package, TethysAppBase.package_namespace) - - -setup( - name=release_package, - version='0.0.1', - description='{{description|default('')}}', - long_description='', - keywords='', - author='{{author|default('')}}', - author_email='{{author_email|default('')}}', - url='', - license='{{license_name|default('')}}', - packages=find_namespace_packages(), - package_data={'': resource_files}, - include_package_data=True, - zip_safe=False, - install_requires=dependencies, -) diff --git a/tethys_cli/site_commands.py b/tethys_cli/site_commands.py index 794aa665a..9d7784c78 100644 --- a/tethys_cli/site_commands.py +++ b/tethys_cli/site_commands.py @@ -302,18 +302,19 @@ def gen_site_content(args): if args.from_file: portal_yaml = Path(get_tethys_home_dir()) / "portal_config.yml" if portal_yaml.exists(): - with portal_yaml.open() as f: - site_settings = yaml.safe_load(f).get("site_settings", {}) - for category in SITE_SETTING_CATEGORIES: - category_settings = site_settings.pop(category, {}) - update_site_settings_content( - category_settings, warn_if_setting_not_found=True - ) - for category in site_settings: - write_warning( - f"WARNING: the portal_config.yml file contains an invalid category in site_settings." - f'"{category}" is not one of {SITE_SETTING_CATEGORIES}.' - ) + site_settings = yaml.safe_load(portal_yaml.read_text()).get( + "site_settings", {} + ) + for category in SITE_SETTING_CATEGORIES: + category_settings = site_settings.pop(category, {}) + update_site_settings_content( + category_settings, warn_if_setting_not_found=True + ) + for category in site_settings: + write_warning( + f"WARNING: the portal_config.yml file contains an invalid category in site_settings." + f'"{category}" is not one of {SITE_SETTING_CATEGORIES}.' + ) else: valid_inputs = ("y", "n", "yes", "no") no_inputs = ("n", "no") diff --git a/tethys_cli/start_commands.py b/tethys_cli/start_commands.py index b62372dd3..616212d64 100644 --- a/tethys_cli/start_commands.py +++ b/tethys_cli/start_commands.py @@ -1,4 +1,5 @@ -import os +from os import chdir +from pathlib import Path import webbrowser from argparse import Namespace from tethys_apps.utilities import get_installed_tethys_items @@ -61,7 +62,7 @@ def quickstart_command(args): tethys_portal_settings={}, ) portal_config_path = get_destination_path(portal_config_args, check_existence=False) - if os.path.exists(portal_config_path): + if Path(portal_config_path).exists(): write_warning( 'An existing portal configuration was already found. Please use "tethys start" instead to start your server.' ) @@ -82,7 +83,7 @@ def quickstart_command(args): no_confirmation=False, ) db_config_options = process_args(db_config_args) - if not os.path.exists(db_config_options["db_name"]): + if not Path(db_config_options["db_name"]).exists(): configure_tethys_db(**db_config_options) setup_django() @@ -93,12 +94,12 @@ def quickstart_command(args): name="hello_world", extension=False, template="default", - prefix=os.getcwd(), + prefix=str(Path.cwd()), use_defaults=True, overwrite=False, ) scaffold_command(app_scaffold_args) - os.chdir(f"{APP_PREFIX}-hello_world") + chdir(f"{APP_PREFIX}-hello_world") app_install_args = Namespace( develop=True, file=None, diff --git a/tethys_cli/test_command.py b/tethys_cli/test_command.py index a634333c3..979b76555 100644 --- a/tethys_cli/test_command.py +++ b/tethys_cli/test_command.py @@ -1,4 +1,5 @@ -import os +from pathlib import Path +from os import devnull, environ import webbrowser import subprocess from tethys_cli.manage_commands import get_manage_path, run_process @@ -6,7 +7,7 @@ from tethys_apps.utilities import get_tethys_src_dir TETHYS_SRC_DIRECTORY = get_tethys_src_dir() -FNULL = open(os.devnull, "w") +FNULL = open(devnull, "w") def add_test_parser(subparsers): @@ -55,12 +56,12 @@ def check_and_install_prereqs(tests_path): raise ImportError except ImportError: write_warning("Test App not found. Installing.....") - setup_path = os.path.join(tests_path, "apps", "tethysapp-test_app") + setup_path = tests_path / "apps" / "tethysapp-test_app" subprocess.call( ["pip", "install", "-e", "."], stdout=FNULL, stderr=subprocess.STDOUT, - cwd=setup_path, + cwd=str(setup_path), ) try: @@ -70,12 +71,12 @@ def check_and_install_prereqs(tests_path): raise ImportError except ImportError: write_warning("Test Extension not found. Installing.....") - setup_path = os.path.join(tests_path, "extensions", "tethysext-test_extension") + setup_path = Path(tests_path) / "extensions" / "tethysext-test_extension" subprocess.call( ["pip", "install", "-e", "."], stdout=FNULL, stderr=subprocess.STDOUT, - cwd=setup_path, + cwd=str(setup_path), ) @@ -83,7 +84,7 @@ def test_command(args): args.manage = False # Get the path to manage.py manage_path = get_manage_path(args) - tests_path = os.path.join(TETHYS_SRC_DIRECTORY, "tests") + tests_path = Path(TETHYS_SRC_DIRECTORY) / "tests" try: check_and_install_prereqs(tests_path) @@ -102,7 +103,7 @@ def test_command(args): extension_package_tag = "tethysext." if args.coverage or args.coverage_html: - os.environ["TETHYS_TEST_DIR"] = tests_path + environ["TETHYS_TEST_DIR"] = str(tests_path) if args.file and app_package_tag in args.file: app_package_parts = args.file.split(app_package_tag) app_name = app_package_parts[1].split(".")[0] @@ -120,19 +121,19 @@ def test_command(args): core_extension_package, extension_package ) else: - config_opt = "--rcfile={0}".format(os.path.join(tests_path, "coverage.cfg")) + config_opt = "--rcfile={0}".format(tests_path / "coverage.cfg") primary_process = ["coverage", "run", config_opt, manage_path, "test"] if args.file: - if os.path.isfile(args.file): - path, file_name = os.path.split(args.file) - primary_process.extend([path, "--pattern", file_name]) + fpath = Path(args.file) + if fpath.is_file(): + primary_process.extend([str(fpath.parent), "--pattern", str(fpath.name)]) else: primary_process.append(args.file) elif args.unit: - primary_process.append(os.path.join(tests_path, "unit_tests")) + primary_process.append(str(tests_path / "unit_tests")) elif args.gui: - primary_process.append(os.path.join(tests_path, "gui_tests")) + primary_process.append(str(tests_path / "gui_tests")) if args.verbosity: primary_process.extend(["-v", args.verbosity]) @@ -158,7 +159,7 @@ def test_command(args): [ "coverage", "html", - "--directory={0}".format(os.path.join(tests_path, report_dirname)), + "--directory={0}".format(tests_path / report_dirname), ] ) else: @@ -166,14 +167,12 @@ def test_command(args): try: status = run_process( - ["open", os.path.join(tests_path, report_dirname, index_fname)] + ["open", str(tests_path / report_dirname / index_fname)] ) if status != 0: raise Exception except Exception: - webbrowser.open_new_tab( - os.path.join(tests_path, report_dirname, index_fname) - ) + webbrowser.open_new_tab(str(tests_path / report_dirname / index_fname)) # Removing Test App try: diff --git a/tethys_components/utils.py b/tethys_components/utils.py index 964666baf..77e161fd2 100644 --- a/tethys_components/utils.py +++ b/tethys_components/utils.py @@ -1,5 +1,5 @@ import inspect -import os +from pathlib import Path from channels.db import database_sync_to_async @@ -18,8 +18,12 @@ async def get_workspace(app_package, user): def use_workspace(user=None): from reactpy_django.hooks import use_query - calling_fpath = inspect.stack()[1][0].f_code.co_filename - app_package = calling_fpath.split(f"{os.sep}tethysapp{os.sep}")[1].split(os.sep)[0] + calling_fpath = Path(inspect.stack()[1][0].f_code.co_filename) + app_package = [ + p.name + for p in [calling_fpath] + list(calling_fpath.parents) + if p.parent.name == "tethysapp" + ][0] workspace_query = use_query( get_workspace, {"app_package": app_package, "user": user}, postprocessor=None diff --git a/tethys_compute/models/condor/condor_base.py b/tethys_compute/models/condor/condor_base.py index 1cc5c5b3d..160677b9e 100644 --- a/tethys_compute/models/condor/condor_base.py +++ b/tethys_compute/models/condor/condor_base.py @@ -7,7 +7,6 @@ ******************************************************************************** """ -import os from abc import abstractmethod from pathlib import Path from functools import partial @@ -175,7 +174,7 @@ def _get_logs(self): Get logs contents for condor job. """ log_files = self._log_files() - log_path = os.path.join(self.workspace, self.remote_id) + log_path = Path(self.workspace) / self.remote_id log_contents = self._get_lazy_log_content(log_files, self.read_file, log_path) # Check to see if local log files exist. If not get log contents from remote. logs_exist = self._check_local_logs_exist(log_contents) @@ -217,5 +216,5 @@ def _check_local_logs_exist(log_contents): log_exists = list() for func in log_funcs: filename = func.args[0] - log_exists.append(os.path.exists(filename)) + log_exists.append(Path(filename).exists()) return any(log_exists) diff --git a/tethys_compute/models/condor/condor_py_job.py b/tethys_compute/models/condor/condor_py_job.py index 4eec52029..eb838ca44 100644 --- a/tethys_compute/models/condor/condor_py_job.py +++ b/tethys_compute/models/condor/condor_py_job.py @@ -8,7 +8,7 @@ """ from tethys_portal.optional_dependencies import optional_import -import os +from pathlib import Path from django.db import models @@ -105,7 +105,7 @@ def remote_input_files(self, remote_input_files): @property def initial_dir(self): - return os.path.join(self.workspace, self.condorpy_job.initial_dir) + return str(Path(self.workspace) / self.condorpy_job.initial_dir) def get_attribute(self, attribute): return self.condorpy_job.get(attribute) diff --git a/tethys_compute/models/condor/condor_workflow.py b/tethys_compute/models/condor/condor_workflow.py index 2f55efb24..5a3820e89 100644 --- a/tethys_compute/models/condor/condor_workflow.py +++ b/tethys_compute/models/condor/condor_workflow.py @@ -9,7 +9,7 @@ import shutil import logging -import os +from pathlib import Path from django.db.models.signals import pre_save, pre_delete from django.dispatch import receiver @@ -102,9 +102,9 @@ def _log_files(self): } for job_node in self.nodes: job_name = job_node.name - log_file_path = os.path.join(job_name, "logs", "*.log") - error_file_path = os.path.join(job_name, "logs", "*.err") - out_file_path = os.path.join(job_name, "logs", "*.out") + log_file_path = str(Path(job_name) / "logs" / "*.log") + error_file_path = str(Path(job_name) / "logs" / "*.err") + out_file_path = str(Path(job_name) / "logs" / "*.out") log_folder_list[job_name] = { "log": log_file_path, "error": error_file_path, diff --git a/tethys_gizmos/templatetags/tethys_gizmos.py b/tethys_gizmos/templatetags/tethys_gizmos.py index f6edb5bf6..f43c12e57 100644 --- a/tethys_gizmos/templatetags/tethys_gizmos.py +++ b/tethys_gizmos/templatetags/tethys_gizmos.py @@ -8,11 +8,11 @@ ******************************************************************************** """ -import os import json import time import inspect from datetime import datetime +from pathlib import Path from django.conf import settings from django import template from django.template.loader import get_template @@ -59,8 +59,8 @@ ): GIZMO_NAME_MAP[cls.gizmo_name] = cls gizmo_module_path = gizmo_module.__path__[0] - EXTENSION_PATH_MAP[cls.gizmo_name] = os.path.abspath( - os.path.dirname(gizmo_module_path) + EXTENSION_PATH_MAP[cls.gizmo_name] = str( + Path(gizmo_module_path).parent.absolute() ) except ImportError: # TODO: Add Log? @@ -255,15 +255,15 @@ def render(self, context): # Derive path to gizmo template if self.gizmo_name not in EXTENSION_PATH_MAP: # Determine path to gizmo template - gizmo_templates_root = os.path.join("tethys_gizmos", "gizmos") + gizmo_templates_root = str(Path("tethys_gizmos/gizmos")) else: - gizmo_templates_root = os.path.join( - EXTENSION_PATH_MAP[self.gizmo_name], "templates", "gizmos" + gizmo_templates_root = str( + Path(EXTENSION_PATH_MAP[self.gizmo_name]) / "templates" / "gizmos" ) gizmo_file_name = "{0}.html".format(self.gizmo_name) - template_name = os.path.join(gizmo_templates_root, gizmo_file_name) + template_name = str(Path(gizmo_templates_root) / gizmo_file_name) # reset gizmo_name in case Node is rendered with different options self._load_gizmo_name(None) diff --git a/tethys_layouts/views/map_layout.py b/tethys_layouts/views/map_layout.py index 48f5b69cd..c383555c5 100644 --- a/tethys_layouts/views/map_layout.py +++ b/tethys_layouts/views/map_layout.py @@ -13,7 +13,7 @@ from io import BytesIO import json import logging -import os +from pathlib import Path import requests import tempfile import uuid @@ -832,7 +832,7 @@ def convert_geojson_to_shapefile(self, request, *args, **kwargs): with tempfile.TemporaryDirectory() as tmpdir: shp_base = layer_id + "_" + json_type - shp_file = os.path.join(tmpdir, shp_base) + shp_file = str(Path(tmpdir) / shp_base) with shapefile.Writer(shp_file, shape_types[json_type]) as shpfile_obj: shpfile_obj.autoBalance = 1 diff --git a/tethys_portal/asgi.py b/tethys_portal/asgi.py index 93ac39059..a2d9d192b 100644 --- a/tethys_portal/asgi.py +++ b/tethys_portal/asgi.py @@ -3,7 +3,7 @@ defined in the ASGI_APPLICATION setting. """ -import os +from os import environ from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter from django.core.asgi import get_asgi_application @@ -37,7 +37,7 @@ def build_application(asgi_app): return application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") +environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") # This needs to be called before any model imports asgi_app = get_asgi_application() diff --git a/tethys_portal/manage.py b/tethys_portal/manage.py index 14780f64e..056b14a7c 100644 --- a/tethys_portal/manage.py +++ b/tethys_portal/manage.py @@ -9,12 +9,12 @@ ******************************************************************************** """ -import os -import sys +from os import environ +from sys import argv if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") + environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") from django.core.management import execute_from_command_line - execute_from_command_line(sys.argv) + execute_from_command_line(argv) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index b8bb687c7..9158c9d37 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -21,11 +21,11 @@ """ # Build paths inside the project like this: BASE_DIR / '...' -import os import sys import yaml import logging import datetime as dt +from os import getenv from pathlib import Path from importlib import import_module from importlib.machinery import SourceFileLoader @@ -201,7 +201,7 @@ "django", { "handlers": ["console_simple"], - "level": os.getenv("DJANGO_LOG_LEVEL", "WARNING"), + "level": getenv("DJANGO_LOG_LEVEL", "WARNING"), }, ) LOGGERS.setdefault( @@ -220,6 +220,7 @@ ) default_installed_apps = [ + "channels", "daphne", "django.contrib.admin", "django.contrib.auth", @@ -254,6 +255,7 @@ "django_recaptcha", "social_django", "termsandconditions", + "reactpy_django", ]: if has_module(module): default_installed_apps.append(module) From b5493d274501b4f6f13f3fa8717d0b1a718a8afe Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 3 Oct 2024 16:36:06 -0600 Subject: [PATCH 19/31] Revert spot where os.path had been converted to pathlib.Path In this one instance, Path.exists throws a "File too long" error on Unix machines, while os.path.exists dose not. I couldn't think of a good way around that for now. --- .../test_tethys_apps/test_base/test_workspace.py | 2 +- .../test_tethys_cli/test_app_settings_command.py | 15 ++++++++++----- .../test_models/test_CondorJob.py | 2 +- .../test_models/test_CondorWorkflow.py | 2 +- tethys_cli/app_settings_commands.py | 10 ++++++---- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py b/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py index 14058f625..776389e02 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py @@ -73,7 +73,7 @@ def test_TethysWorkspace(self): file_list = ["test1.txt", "test2.txt"] for file_name in file_list: # Create file - (self.test_root / file_name).write_text("") + (self.test_root / file_name).touch("") # Test files with full path result = base_workspace.TethysWorkspace(path=str(self.test_root)).files( diff --git a/tests/unit_tests/test_tethys_cli/test_app_settings_command.py b/tests/unit_tests/test_tethys_cli/test_app_settings_command.py index 2375a34b4..3ad55e0b7 100644 --- a/tests/unit_tests/test_tethys_cli/test_app_settings_command.py +++ b/tests/unit_tests/test_tethys_cli/test_app_settings_command.py @@ -588,10 +588,11 @@ def test_app_settings_set_json_with_variable_error( mock_exit.assert_called_with(1) @mock.patch( - "tethys_cli.app_settings_commands.Path.read_text", - return_value='{"key_test":"value_test"}', + "tethys_cli.app_settings_commands.open", + new_callable=mock.mock_open, + read_data='{"key_test":"value_test"}', ) - @mock.patch("tethys_cli.app_settings_commands.Path.exists") + @mock.patch("tethys_cli.app_settings_commands.os.path.exists") @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @mock.patch("tethys_cli.cli_colors.pretty_output") @@ -753,8 +754,12 @@ def test_app_settings_set_bad_value_json_from_variable( mock_exit.assert_called_with(1) - @mock.patch("tethys_cli.app_settings_commands.Path.open", return_value="2") - @mock.patch("tethys_cli.app_settings_commands.Path.exists") + @mock.patch( + "tethys_cli.app_settings_commands.open", + new_callable=mock.mock_open, + read_data="2", + ) + @mock.patch("tethys_cli.app_settings_commands.os.path.exists") @mock.patch("tethys_cli.app_settings_commands.write_success") @mock.patch("tethys_cli.app_settings_commands.write_error") @mock.patch("tethys_cli.cli_colors.pretty_output") diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_CondorJob.py b/tests/unit_tests/test_tethys_compute/test_models/test_CondorJob.py index 3b9ddc69a..df9f7dabd 100644 --- a/tests/unit_tests/test_tethys_compute/test_models/test_CondorJob.py +++ b/tests/unit_tests/test_tethys_compute/test_models/test_CondorJob.py @@ -102,7 +102,7 @@ def test_condor_job_pre_delete(self, mock_co): if not self.workspace_dir.exists(): self.workspace_dir.mkdir(parents=True) file_path = self.workspace_dir / "test_file.txt" - file_path.write_text("") + file_path.touch() self.condorjob.delete() diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflow.py b/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflow.py index 4445b6f66..0758f66a2 100644 --- a/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflow.py +++ b/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflow.py @@ -201,7 +201,7 @@ def test_condor_job_pre_delete(self, mock_co): if not self.workspace_dir.exists(): self.workspace_dir.mkdir(parents=True) file_path = self.workspace_dir / "test_file.txt" - file_path.write_text("") + file_path.touch() self.condorworkflow.delete() diff --git a/tethys_cli/app_settings_commands.py b/tethys_cli/app_settings_commands.py index 5ff7e7a60..cb71dad28 100644 --- a/tethys_cli/app_settings_commands.py +++ b/tethys_cli/app_settings_commands.py @@ -1,3 +1,4 @@ +import os import json from pathlib import Path from django.core.exceptions import ValidationError, ObjectDoesNotExist @@ -243,10 +244,11 @@ def app_settings_set_command(args): try: value_json = "{}" if setting.type_custom_setting == "JSON": - try_path = Path(actual_value) - if try_path.exists(): - write_warning("File found, extracting JSON data") - value_json = json.loads(try_path.read_text()) + if os.path.exists(actual_value): + with open(actual_value) as json_file: + write_warning("File found, extracting JSON data") + value_json = json.load(json_file) + setting.value = value_json else: try: From 4c9697b84df15492f84d72cafde53a1375559e61 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 3 Oct 2024 16:44:56 -0600 Subject: [PATCH 20/31] Remove erroneous argument --- tests/unit_tests/test_tethys_apps/test_base/test_workspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py b/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py index 776389e02..a152a34c6 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py @@ -73,7 +73,7 @@ def test_TethysWorkspace(self): file_list = ["test1.txt", "test2.txt"] for file_name in file_list: # Create file - (self.test_root / file_name).touch("") + (self.test_root / file_name).touch() # Test files with full path result = base_workspace.TethysWorkspace(path=str(self.test_root)).files( From ae1d6887aae890897457afd63add2bfe132127b2 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 7 Oct 2024 08:03:44 -0600 Subject: [PATCH 21/31] Fix bug with pathlib update to static_finders --- .../test_tethys_apps/test_static_finders.py | 36 ++++++++++--------- tethys_apps/static_finders.py | 9 ++--- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/tests/unit_tests/test_tethys_apps/test_static_finders.py b/tests/unit_tests/test_tethys_apps/test_static_finders.py index 668f93023..0c5f7f293 100644 --- a/tests/unit_tests/test_tethys_apps/test_static_finders.py +++ b/tests/unit_tests/test_tethys_apps/test_static_finders.py @@ -25,44 +25,48 @@ def test_init(self): def test_find(self): tethys_static_finder = TethysStaticFinder() path = Path("test_app") / "css" / "main.css" - ret = tethys_static_finder.find(path) - self.assertEqual(str(self.root / "css" / "main.css").lower(), ret.lower()) + path_ret = tethys_static_finder.find(path) + self.assertEqual(self.root / "css" / "main.css", path_ret) + str_ret = tethys_static_finder.find(str(path)) + self.assertEqual(self.root / "css" / "main.css", str_ret) def test_find_all(self): tethys_static_finder = TethysStaticFinder() path = Path("test_app") / "css" / "main.css" - ret = tethys_static_finder.find(path, all=True) - self.assertIn( - str(self.root / "css" / "main.css").lower(), - list(map(lambda x: x.lower(), ret)), - ) + path_ret = tethys_static_finder.find(path, all=True) + self.assertIn(self.root / "css" / "main.css", path_ret) + str_ret = tethys_static_finder.find(str(path), all=True) + self.assertIn(self.root / "css" / "main.css", str_ret) def test_find_location_with_no_prefix(self): prefix = None path = Path("css") / "main.css" tethys_static_finder = TethysStaticFinder() - ret = tethys_static_finder.find_location(str(self.root), path, prefix) - - self.assertEqual(str(self.root / path), ret) + path_ret = tethys_static_finder.find_location(self.root, path, prefix) + self.assertEqual(self.root / path, path_ret) + str_ret = tethys_static_finder.find_location(str(self.root), path, prefix) + self.assertEqual(self.root / path, str_ret) def test_find_location_with_prefix_not_in_path(self): prefix = "tethys_app" path = Path("css") / "main.css" tethys_static_finder = TethysStaticFinder() - ret = tethys_static_finder.find_location(str(self.root), path, prefix) - - self.assertIsNone(ret) + path_ret = tethys_static_finder.find_location(self.root, path, prefix) + self.assertIsNone(path_ret) + str_ret = tethys_static_finder.find_location(str(self.root), path, prefix) + self.assertIsNone(str_ret) def test_find_location_with_prefix_in_path(self): prefix = "tethys_app" path = Path("tethys_app") / "css" / "main.css" tethys_static_finder = TethysStaticFinder() - ret = tethys_static_finder.find_location(str(self.root), path, prefix) - - self.assertEqual(str(self.root / "css" / "main.css"), ret) + path_ret = tethys_static_finder.find_location(self.root, path, prefix) + self.assertEqual(self.root / "css" / "main.css", path_ret) + str_ret = tethys_static_finder.find_location(str(self.root), path, prefix) + self.assertEqual(self.root / "css" / "main.css", str_ret) def test_list(self): tethys_static_finder = TethysStaticFinder() diff --git a/tethys_apps/static_finders.py b/tethys_apps/static_finders.py index 498d41086..bdd621373 100644 --- a/tethys_apps/static_finders.py +++ b/tethys_apps/static_finders.py @@ -23,7 +23,7 @@ class TethysStaticFinder(BaseFinder): This finder search for static files in a directory called 'public' or 'static'. """ - def __init__(self, apps=None, *args, **kwargs): + def __init__(self, *args, **kwargs): # List of locations with static files self.locations = get_directories_in_tethys( ("static", "public"), with_app_name=True @@ -57,13 +57,14 @@ def find_location(self, root, path, prefix=None): Finds a requested static file in a location, returning the found absolute path (or ``None`` if no match). """ + path = Path(path) if prefix: prefix = Path(f"{prefix}/") - if not Path(path).is_relative_to(prefix): + if not path.is_relative_to(prefix): return None path = path.relative_to(prefix) - path = safe_join(str(root), str(path)) - if Path(path).exists(): + path = Path(safe_join(str(root), str(path))) + if path.exists(): return path def list(self, ignore_patterns): From 46b4f8302cbff5d7631934d3f50332c0c1d6e573 Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Fri, 11 Oct 2024 11:33:43 -0600 Subject: [PATCH 22/31] Update tests/unit_tests/test_tethys_apps/test_template_loaders.py Co-authored-by: sdc50 --- tests/unit_tests/test_tethys_apps/test_template_loaders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_tethys_apps/test_template_loaders.py b/tests/unit_tests/test_tethys_apps/test_template_loaders.py index f5644b6e1..e24922b14 100644 --- a/tests/unit_tests/test_tethys_apps/test_template_loaders.py +++ b/tests/unit_tests/test_tethys_apps/test_template_loaders.py @@ -66,7 +66,7 @@ def test_get_template_sources(self, mock_gdt, _): expected_template_name ): self.assertEqual( - str(Path(Path.home() / "foo" / "template1" / "foo")), + str(Path.home() / "foo" / "template1" / "foo"), origin.name, ) self.assertEqual("foo", origin.template_name) From 1bdcdcf97556989ddd57f0df111fad479f3d5f4a Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Fri, 11 Oct 2024 11:33:58 -0600 Subject: [PATCH 23/31] Update tethys_cli/cli_helpers.py Co-authored-by: sdc50 --- tethys_cli/cli_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tethys_cli/cli_helpers.py b/tethys_cli/cli_helpers.py index b1e5edb1a..ec73eba1f 100644 --- a/tethys_cli/cli_helpers.py +++ b/tethys_cli/cli_helpers.py @@ -41,7 +41,7 @@ def get_manage_path(args): Validate user defined manage path, use default, or throw error """ # Determine path to manage.py file - manage_path = f"{get_tethys_src_dir()}/tethys_portal/manage.py" + manage_path = Path(get_tethys_src_dir()) / "tethys_portal" / "manage.py" # Check for path option if hasattr(args, "manage"): From 9122f42ce707dd0e4d8e6b83aec56813bcf3a5e9 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 17 Oct 2024 14:44:52 -0600 Subject: [PATCH 24/31] Additional tweaks per feedback Adds tests for remaining tethys_component reactpy files Adds reactpy-django to standard install Fixes broken support for variable in url for pages Adds react-loading-overlay and react-map-gl to built-in ComponentLibrary support Fixes buggy use_workspace --- environment.yml | 4 + tests/coverage.cfg | 3 +- .../test_base/test_page_handler.py | 45 +++++++++- .../test_tethys_components/test_custom.py | 84 +++++++++++++++++++ .../test_tethys_components/test_layouts.py | 16 ++++ .../test_tethys_components/test_library.py | 2 +- .../test_tethys_components/test_utils.py | 17 ++-- .../test_views/test_accounts.py | 3 + .../test_tethys_portal/test_views/test_psa.py | 4 + .../test_views/test_user.py | 3 + tethys_apps/base/controller.py | 3 +- tethys_apps/base/page_handler.py | 8 +- .../templates/tethys_apps/reactpy_base.html | 2 +- tethys_components/custom.py | 7 +- tethys_components/library.py | 11 ++- tethys_components/utils.py | 33 ++++---- 16 files changed, 203 insertions(+), 42 deletions(-) create mode 100644 tests/unit_tests/test_tethys_components/test_custom.py create mode 100644 tests/unit_tests/test_tethys_components/test_layouts.py diff --git a/environment.yml b/environment.yml index 67d2fa703..d7a851692 100644 --- a/environment.yml +++ b/environment.yml @@ -104,3 +104,7 @@ dependencies: - factory_boy - flake8 - flake8-bugbear + + # reactpy dependencies + - pip: + - reactpy-django diff --git a/tests/coverage.cfg b/tests/coverage.cfg index dca6481c0..6c209d771 100644 --- a/tests/coverage.cfg +++ b/tests/coverage.cfg @@ -3,8 +3,7 @@ [run] source = $TETHYS_TEST_DIR/../tethys_apps $TETHYS_TEST_DIR/../tethys_cli - $TETHYS_TEST_DIR/../tethys_components/library.py - $TETHYS_TEST_DIR/../tethys_components/utils.py + $TETHYS_TEST_DIR/../tethys_components $TETHYS_TEST_DIR/../tethys_compute $TETHYS_TEST_DIR/../tethys_config $TETHYS_TEST_DIR/../tethys_gizmos diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py b/tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py index 16468252f..e7d8855f1 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py @@ -1,3 +1,4 @@ +import sys from unittest import TestCase, mock from importlib import reload @@ -61,6 +62,7 @@ def test_global_page_controller( "title", "custom_css", "custom_js", + "extras" ], ) self.assertEqual(render_context["app"], "app object") @@ -80,9 +82,6 @@ def setUpClass(cls): mock_has_module = mock.patch("tethys_portal.optional_dependencies.has_module") mock_has_module.return_value = True mock_has_module.start() - # mock.patch("builtins.__import__").start() - import sys - mock_reactpy = mock.MagicMock() sys.modules["reactpy"] = mock_reactpy mock_reactpy.component = lambda x: x @@ -91,6 +90,7 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): mock.patch.stopall() + del sys.modules["reactpy"] reload(page_handler) def test_page_component_wrapper__layout_none(self): @@ -105,11 +105,26 @@ def test_page_component_wrapper__layout_none(self): return_value = page_handler.page_component_wrapper(app, user, layout, component) self.assertEqual(return_value, component_return_val) + + def test_page_component_wrapper__layout_none_with_extras(self): + # FUNCTION ARGS + app = mock.MagicMock() + user = mock.MagicMock() + layout = None + extras = {"extra1": "val1", "extra2": 2} + component = mock.MagicMock() + component_return_val = "rendered_component" + component.return_value = component_return_val + + return_value = page_handler.page_component_wrapper(app, user, layout, component, extras) + + self.assertEqual(return_value, component_return_val) + component.assert_called_once_with(extra1="val1", extra2=2) def test_page_component_wrapper__layout_not_none(self): # FUNCTION ARGS app = mock.MagicMock() - app.restered_url_maps = [] + app.registered_url_maps = [] user = mock.MagicMock() layout = mock.MagicMock() layout_return_val = "returned_layout" @@ -125,6 +140,28 @@ def test_page_component_wrapper__layout_not_none(self): {"app": app, "user": user, "nav-links": app.navigation_links}, component_return_val, ) + + def test_page_component_wrapper__layout_not_none_with_extras(self): + # FUNCTION ARGS + app = mock.MagicMock() + app.registered_url_maps = [] + user = mock.MagicMock() + layout = mock.MagicMock() + layout_return_val = "returned_layout" + layout.return_value = layout_return_val + extras = {"extra1": "val1", "extra2": 2} + component = mock.MagicMock() + component_return_val = "rendered_component" + component.return_value = component_return_val + + return_value = page_handler.page_component_wrapper(app, user, layout, component, extras) + + self.assertEqual(return_value, layout_return_val) + layout.assert_called_once_with( + {"app": app, "user": user, "nav-links": app.navigation_links}, + component_return_val, + ) + component.assert_called_once_with(extra1="val1", extra2=2) class TestPage(TestCase): diff --git a/tests/unit_tests/test_tethys_components/test_custom.py b/tests/unit_tests/test_tethys_components/test_custom.py new file mode 100644 index 000000000..800be646c --- /dev/null +++ b/tests/unit_tests/test_tethys_components/test_custom.py @@ -0,0 +1,84 @@ +from tethys_components import custom +from tethys_components.library import Library as lib +from unittest import TestCase, mock, IsolatedAsyncioTestCase +from importlib import reload +import asyncio + + +class TestCustomComponents(IsolatedAsyncioTestCase): + @classmethod + def setUpClass(cls): + mock.patch('reactpy.component', new_callable=lambda: lambda x: x).start() + reload(custom) + + @classmethod + def tearDownClass(cls): + mock.patch.stopall() + reload(custom) + lib.refresh() + + def test_Panel_defaults(self): + test_component = custom.Panel({}) + self.assertIsInstance(test_component, dict) + self.assertIn('tagName', test_component) + self.assertIn('attributes', test_component) + self.assertIn('children', test_component) + + async def test_Panel_all_props_provided(self): + test_set_show = mock.MagicMock() + props = { + "show": True, + "set-show": test_set_show, + "position": "right", + "extent": "30vw", + "name": "Test Panel 123" + } + test_component = custom.Panel(props) + self.assertIsInstance(test_component, dict) + self.assertIn('tagName', test_component) + self.assertIn('attributes', test_component) + self.assertIn('children', test_component) + test_set_show.assert_not_called() + event_handler = test_component['children'][0]['children'][1]['eventHandlers']['on_click'] + self.assertTrue(callable(event_handler.function)) + await event_handler.function([None]) + test_set_show.assert_called_once_with(False) + + def test_HeaderButton(self): + test_component = custom.HeaderButton({}) + self.assertIsInstance(test_component, dict) + self.assertIn('tagName', test_component) + self.assertIn('attributes', test_component) + + def test_NavIcon(self): + test_component = custom.NavIcon('test_src', 'test_color') + self.assertIsInstance(test_component, dict) + self.assertIn('tagName', test_component) + self.assertIn('attributes', test_component) + + def test_NavMenu(self): + test_component = custom.NavMenu({}) + self.assertIsInstance(test_component, dict) + self.assertIn('tagName', test_component) + self.assertIn('children', test_component) + + def test_HeaderWithNavBar(self): + custom.lib.hooks = mock.MagicMock() + custom.lib.hooks.use_query().data.id = 10 + test_app = mock.MagicMock(icon="icon.png", color="test_color") + test_user = mock.MagicMock() + test_nav_links = [mock.MagicMock(), mock.MagicMock(), mock.MagicMock()] + test_component = custom.HeaderWithNavBar(test_app, test_user, test_nav_links) + self.assertIsInstance(test_component, dict) + self.assertIn('tagName', test_component) + self.assertIn('attributes', test_component) + self.assertIn('children', test_component) + del custom.lib.hooks + + def test_get_db_object(self): + test_app = mock.MagicMock() + return_val = custom.get_db_object(test_app) + self.assertEqual(return_val, test_app.db_object) + + def test_hooks(self): + custom.lib.hooks # should not fail diff --git a/tests/unit_tests/test_tethys_components/test_layouts.py b/tests/unit_tests/test_tethys_components/test_layouts.py new file mode 100644 index 000000000..30c23a60b --- /dev/null +++ b/tests/unit_tests/test_tethys_components/test_layouts.py @@ -0,0 +1,16 @@ +from tethys_components import layouts +from unittest import TestCase, mock +from reactpy.core.component import Component + + +class TestComponentLayouts(TestCase): + + @mock.patch("tethys_components.layouts.HeaderWithNavBar", return_value={}) + def test_NavHeader(self, _): + test_layout = layouts.NavHeader({ + 'app': mock.MagicMock(), + 'user': mock.MagicMock(), + 'nav-links': mock.MagicMock() + }) + self.assertIsInstance(test_layout, Component) + self.assertIsInstance(test_layout.render(), dict) diff --git a/tests/unit_tests/test_tethys_components/test_library.py b/tests/unit_tests/test_tethys_components/test_library.py index af72b0173..b94bcd5fb 100644 --- a/tests/unit_tests/test_tethys_components/test_library.py +++ b/tests/unit_tests/test_tethys_components/test_library.py @@ -70,7 +70,7 @@ def test_standard_library_workflow(self): ) self.assertIn("does_not_exist", lib.STYLE_DEPS) self.assertListEqual(lib.STYLE_DEPS["does_not_exist"], ["my_style.css"]) - self.assertListEqual(lib.DEFAULTS, ["rp", "does_not_exist"]) + self.assertListEqual(lib.DEFAULTS, ["rp", "mapgl", "does_not_exist"]) # REGISTER AGAIN EXACTLY lib.register( diff --git a/tests/unit_tests/test_tethys_components/test_utils.py b/tests/unit_tests/test_tethys_components/test_utils.py index 97e83fbfd..001c54159 100644 --- a/tests/unit_tests/test_tethys_components/test_utils.py +++ b/tests/unit_tests/test_tethys_components/test_utils.py @@ -50,20 +50,21 @@ def test_get_workspace_for_user(self): def test_use_workspace(self, mock_inspect): mock_import = mock.patch("builtins.__import__").start() try: - mock_inspect.stack().__getitem__().__getitem__().f_code.co_filename = str( + mock_stack_item_1 = mock.MagicMock() + mock_stack_item_1.__getitem__().f_code.co_filename = "throws_exception" + mock_stack_item_2 = mock.MagicMock() + mock_stack_item_2.__getitem__().f_code.co_filename = str( TEST_APP_DIR ) + mock_inspect.stack.return_value = [mock_stack_item_1, mock_stack_item_2] workspace = utils.use_workspace("john") self.assertEqual( mock_import.call_args_list[-1][0][0], "reactpy_django.hooks" ) - self.assertEqual(mock_import.call_args_list[-1][0][3][0], "use_query") - mock_import().use_query.assert_called_once_with( - utils.get_workspace, - {"app_package": "test_app", "user": "john"}, - postprocessor=None, - ) - self.assertEqual(workspace, mock_import().use_query().data) + self.assertEqual(mock_import.call_args_list[-1][0][3][0], "use_memo") + mock_import().use_memo.assert_called_once() + self.assertIn('. at', str(mock_import().use_memo.call_args_list[0])) + self.assertEqual(workspace, mock_import().use_memo()) finally: mock.patch.stopall() diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_accounts.py b/tests/unit_tests/test_tethys_portal/test_views/test_accounts.py index 133be036b..cd39a887e 100644 --- a/tests/unit_tests/test_tethys_portal/test_views/test_accounts.py +++ b/tests/unit_tests/test_tethys_portal/test_views/test_accounts.py @@ -1,3 +1,4 @@ +import sys import unittest from unittest import mock @@ -5,6 +6,8 @@ # Fixes the Cache-Control error in tests. Must appear before view imports. mock.patch("django.views.decorators.cache.never_cache", lambda x: x).start() +if 'tethys_portal.views.accounts' in sys.modules: + del sys.modules['tethys_portal.views.accounts'] from tethys_portal.views.accounts import login_view, register, logout_view # noqa: E402 diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_psa.py b/tests/unit_tests/test_tethys_portal/test_views/test_psa.py index a093bfde0..467f87585 100644 --- a/tests/unit_tests/test_tethys_portal/test_views/test_psa.py +++ b/tests/unit_tests/test_tethys_portal/test_views/test_psa.py @@ -1,4 +1,5 @@ import unittest +import sys from unittest import mock from django.http import HttpResponseBadRequest @@ -16,6 +17,9 @@ mock.patch("django.views.decorators.cache.never_cache", lambda x: x).start() mock.patch("social_django.utils.psa", side_effect=mock_decorator).start() +if 'tethys_portal.views.psa' in sys.modules: + del sys.modules['tethys_portal.views.psa'] + from tethys_portal.views.psa import tenant, auth, complete # noqa: E402 diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_user.py b/tests/unit_tests/test_tethys_portal/test_views/test_user.py index b5aedfd10..6866468bf 100644 --- a/tests/unit_tests/test_tethys_portal/test_views/test_user.py +++ b/tests/unit_tests/test_tethys_portal/test_views/test_user.py @@ -1,9 +1,12 @@ +import sys import unittest from unittest import mock from django.test import override_settings # Fixes the Cache-Control error in tests. Must appear before view imports. mock.patch("django.views.decorators.cache.never_cache", lambda x: x).start() +if 'tethys_portal.views.user' in sys.modules: + del sys.modules['tethys_portal.views.user'] from tethys_portal.views.user import ( # noqa: E402 profile, diff --git a/tethys_apps/base/controller.py b/tethys_apps/base/controller.py index 953db6c97..c1f9d6a9e 100644 --- a/tethys_apps/base/controller.py +++ b/tethys_apps/base/controller.py @@ -511,7 +511,7 @@ def wrapped(component_function): index=index, ) - def controller_wrapper(request): + def controller_wrapper(request, **kwargs): controller = handler or global_page_controller if permissions_required: controller = permission_required( @@ -541,6 +541,7 @@ def controller_wrapper(request): title=url_map_kwargs_list[0]["title"], custom_css=custom_css, custom_js=custom_js, + **kwargs, ) _process_url_kwargs(controller_wrapper, url_map_kwargs_list) diff --git a/tethys_apps/base/page_handler.py b/tethys_apps/base/page_handler.py index cd8edf4cc..395f5113d 100644 --- a/tethys_apps/base/page_handler.py +++ b/tethys_apps/base/page_handler.py @@ -13,6 +13,7 @@ def global_page_controller( title=None, custom_css=None, custom_js=None, + **kwargs ): app = get_active_app(request=request, get_class=True) layout_func = get_layout_component(app, layout) @@ -27,6 +28,7 @@ def global_page_controller( "title": title, "custom_css": custom_css or [], "custom_js": custom_js or [], + "extras": kwargs, } return render(request, "tethys_apps/reactpy_base.html", context) @@ -36,7 +38,7 @@ def global_page_controller( from reactpy import component @component - def page_component_wrapper(app, user, layout, component): + def page_component_wrapper(app, user, layout, component, extras=None): """ ReactPy Component that wraps every custom user page @@ -52,7 +54,7 @@ def page_component_wrapper(app, user, layout, component): if layout is not None: return layout( {"app": app, "user": user, "nav-links": app.navigation_links}, - component(), + component(**extras) if extras else component(), ) else: - return component() + return component(**extras) if extras else component() diff --git a/tethys_apps/templates/tethys_apps/reactpy_base.html b/tethys_apps/templates/tethys_apps/reactpy_base.html index 2a35f14cb..d36823e12 100644 --- a/tethys_apps/templates/tethys_apps/reactpy_base.html +++ b/tethys_apps/templates/tethys_apps/reactpy_base.html @@ -92,7 +92,7 @@ {% include "analytical_body_top.html" %} {% endif %} - {% component "tethys_apps.base.page_handler.page_component_wrapper" app=app user=request.user layout=layout_func component=component_func %} + {% component "tethys_apps.base.page_handler.page_component_wrapper" app=app user=request.user layout=layout_func component=component_func extras=extras %} {% if has_terms %} {% include "terms.html" %} diff --git a/tethys_components/custom.py b/tethys_components/custom.py index e03a25ab9..74dc66bc9 100644 --- a/tethys_components/custom.py +++ b/tethys_components/custom.py @@ -1,5 +1,4 @@ from reactpy import component -from reactpy_django.hooks import use_location, use_query from tethys_portal.settings import STATIC_URL from .utils import Props from .library import Library as lib @@ -89,7 +88,7 @@ def NavIcon(src, background_color): @component def NavMenu(props, *children): - nav_title = props.pop("nav-title") + nav_title = props.pop("nav-title", "Navigation") return lib.html.div( lib.bs.Offcanvas( @@ -108,9 +107,9 @@ def get_db_object(app): @component def HeaderWithNavBar(app, user, nav_links): - app_db_query = use_query(get_db_object, {"app": app}) + app_db_query = lib.hooks.use_query(get_db_object, {"app": app}) app_id = app_db_query.data.id if app_db_query.data else 999 - location = use_location() + location = lib.hooks.use_location() return lib.bs.Navbar( Props( diff --git a/tethys_components/library.py b/tethys_components/library.py index bef5d4fa5..a91b23d8b 100644 --- a/tethys_components/library.py +++ b/tethys_components/library.py @@ -35,8 +35,10 @@ class ComponentLibrary: "bs": "react-bootstrap@2.10.2", "pm": "pigeon-maps@0.21.6", "rc": "recharts@2.12.7", - "ag": "ag-grid-react@32.0.2", + "ag": "ag-grid-react@32.2.0", "rp": "react-player@2.16.0", + "lo": "react-loading-overlay-ts@2.0.2", + "mapgl": "react-map-gl@7.1.7/maplibre", # 'mui': '@mui/material@5.16.7', # This should work once esm releases their next version "chakra": "@chakra-ui/react@2.8.2", "icons": "react-bootstrap-icons@1.11.4", @@ -44,15 +46,16 @@ class ComponentLibrary: "tethys": None, # Managed internally, "hooks": None, # Managed internally } - DEFAULTS = ["rp"] + DEFAULTS = ["rp", "mapgl"] STYLE_DEPS = { "ag": [ - "https://unpkg.com/@ag-grid-community/styles@32.0.2/ag-grid.css", - "https://unpkg.com/@ag-grid-community/styles@32.0.2/ag-theme-material.css", + "https://unpkg.com/@ag-grid-community/styles@32.2.0/ag-grid.css", + "https://unpkg.com/@ag-grid-community/styles@32.2.0/ag-theme-quartz.css", ], "bs": [ "https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" ], + "mapgl": ["https://unpkg.com/maplibre-gl@4.7.0/dist/maplibre-gl.css"], } INTERNALLY_MANAGED_PACKAGES = [ key for key, val in PACKAGE_BY_ACCESSOR.items() if val is None diff --git a/tethys_components/utils.py b/tethys_components/utils.py index 77e161fd2..0537a35c7 100644 --- a/tethys_components/utils.py +++ b/tethys_components/utils.py @@ -1,3 +1,4 @@ +import asyncio import inspect from pathlib import Path from channels.db import database_sync_to_async @@ -16,20 +17,24 @@ async def get_workspace(app_package, user): def use_workspace(user=None): - from reactpy_django.hooks import use_query - - calling_fpath = Path(inspect.stack()[1][0].f_code.co_filename) - app_package = [ - p.name - for p in [calling_fpath] + list(calling_fpath.parents) - if p.parent.name == "tethysapp" - ][0] - - workspace_query = use_query( - get_workspace, {"app_package": app_package, "user": user}, postprocessor=None - ) - - return workspace_query.data + from reactpy_django.hooks import use_memo + app_package = None + + for item in inspect.stack(): + try: + calling_fpath = Path(item[0].f_code.co_filename) + app_package = [ + p.name + for p in [calling_fpath] + list(calling_fpath.parents) + if p.parent.name == "tethysapp" + ][0] + break + except IndexError: + pass + + workspace = use_memo(lambda: asyncio.run(get_workspace(app_package, user))) + + return workspace def delayed_execute(seconds, callable, args=None): From 305a910f444eaf07c8d86adba827329d47df3721 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 17 Oct 2024 15:08:56 -0600 Subject: [PATCH 25/31] black and flake8 --- .../test_base/test_page_handler.py | 14 +- .../test_tethys_components/test_custom.py | 47 +- .../test_tethys_components/test_layouts.py | 12 +- .../test_tethys_components/test_utils.py | 9 +- .../test_views/test_accounts.py | 4 +- .../test_tethys_portal/test_views/test_psa.py | 4 +- .../test_views/test_user.py | 4 +- tethys_components/save_for_recipes.py | 498 ++++++++++++++++++ tethys_components/utils.py | 1 + 9 files changed, 550 insertions(+), 43 deletions(-) create mode 100644 tethys_components/save_for_recipes.py diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py b/tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py index e7d8855f1..35d841df4 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py @@ -62,7 +62,7 @@ def test_global_page_controller( "title", "custom_css", "custom_js", - "extras" + "extras", ], ) self.assertEqual(render_context["app"], "app object") @@ -105,7 +105,7 @@ def test_page_component_wrapper__layout_none(self): return_value = page_handler.page_component_wrapper(app, user, layout, component) self.assertEqual(return_value, component_return_val) - + def test_page_component_wrapper__layout_none_with_extras(self): # FUNCTION ARGS app = mock.MagicMock() @@ -116,7 +116,9 @@ def test_page_component_wrapper__layout_none_with_extras(self): component_return_val = "rendered_component" component.return_value = component_return_val - return_value = page_handler.page_component_wrapper(app, user, layout, component, extras) + return_value = page_handler.page_component_wrapper( + app, user, layout, component, extras + ) self.assertEqual(return_value, component_return_val) component.assert_called_once_with(extra1="val1", extra2=2) @@ -140,7 +142,7 @@ def test_page_component_wrapper__layout_not_none(self): {"app": app, "user": user, "nav-links": app.navigation_links}, component_return_val, ) - + def test_page_component_wrapper__layout_not_none_with_extras(self): # FUNCTION ARGS app = mock.MagicMock() @@ -154,7 +156,9 @@ def test_page_component_wrapper__layout_not_none_with_extras(self): component_return_val = "rendered_component" component.return_value = component_return_val - return_value = page_handler.page_component_wrapper(app, user, layout, component, extras) + return_value = page_handler.page_component_wrapper( + app, user, layout, component, extras + ) self.assertEqual(return_value, layout_return_val) layout.assert_called_once_with( diff --git a/tests/unit_tests/test_tethys_components/test_custom.py b/tests/unit_tests/test_tethys_components/test_custom.py index 800be646c..fce12e0c5 100644 --- a/tests/unit_tests/test_tethys_components/test_custom.py +++ b/tests/unit_tests/test_tethys_components/test_custom.py @@ -1,16 +1,15 @@ from tethys_components import custom from tethys_components.library import Library as lib -from unittest import TestCase, mock, IsolatedAsyncioTestCase +from unittest import mock, IsolatedAsyncioTestCase from importlib import reload -import asyncio class TestCustomComponents(IsolatedAsyncioTestCase): @classmethod def setUpClass(cls): - mock.patch('reactpy.component', new_callable=lambda: lambda x: x).start() + mock.patch("reactpy.component", new_callable=lambda: lambda x: x).start() reload(custom) - + @classmethod def tearDownClass(cls): mock.patch.stopall() @@ -20,9 +19,9 @@ def tearDownClass(cls): def test_Panel_defaults(self): test_component = custom.Panel({}) self.assertIsInstance(test_component, dict) - self.assertIn('tagName', test_component) - self.assertIn('attributes', test_component) - self.assertIn('children', test_component) + self.assertIn("tagName", test_component) + self.assertIn("attributes", test_component) + self.assertIn("children", test_component) async def test_Panel_all_props_provided(self): test_set_show = mock.MagicMock() @@ -31,15 +30,17 @@ async def test_Panel_all_props_provided(self): "set-show": test_set_show, "position": "right", "extent": "30vw", - "name": "Test Panel 123" + "name": "Test Panel 123", } test_component = custom.Panel(props) self.assertIsInstance(test_component, dict) - self.assertIn('tagName', test_component) - self.assertIn('attributes', test_component) - self.assertIn('children', test_component) + self.assertIn("tagName", test_component) + self.assertIn("attributes", test_component) + self.assertIn("children", test_component) test_set_show.assert_not_called() - event_handler = test_component['children'][0]['children'][1]['eventHandlers']['on_click'] + event_handler = test_component["children"][0]["children"][1]["eventHandlers"][ + "on_click" + ] self.assertTrue(callable(event_handler.function)) await event_handler.function([None]) test_set_show.assert_called_once_with(False) @@ -47,20 +48,20 @@ async def test_Panel_all_props_provided(self): def test_HeaderButton(self): test_component = custom.HeaderButton({}) self.assertIsInstance(test_component, dict) - self.assertIn('tagName', test_component) - self.assertIn('attributes', test_component) + self.assertIn("tagName", test_component) + self.assertIn("attributes", test_component) def test_NavIcon(self): - test_component = custom.NavIcon('test_src', 'test_color') + test_component = custom.NavIcon("test_src", "test_color") self.assertIsInstance(test_component, dict) - self.assertIn('tagName', test_component) - self.assertIn('attributes', test_component) + self.assertIn("tagName", test_component) + self.assertIn("attributes", test_component) def test_NavMenu(self): test_component = custom.NavMenu({}) self.assertIsInstance(test_component, dict) - self.assertIn('tagName', test_component) - self.assertIn('children', test_component) + self.assertIn("tagName", test_component) + self.assertIn("children", test_component) def test_HeaderWithNavBar(self): custom.lib.hooks = mock.MagicMock() @@ -70,15 +71,15 @@ def test_HeaderWithNavBar(self): test_nav_links = [mock.MagicMock(), mock.MagicMock(), mock.MagicMock()] test_component = custom.HeaderWithNavBar(test_app, test_user, test_nav_links) self.assertIsInstance(test_component, dict) - self.assertIn('tagName', test_component) - self.assertIn('attributes', test_component) - self.assertIn('children', test_component) + self.assertIn("tagName", test_component) + self.assertIn("attributes", test_component) + self.assertIn("children", test_component) del custom.lib.hooks def test_get_db_object(self): test_app = mock.MagicMock() return_val = custom.get_db_object(test_app) self.assertEqual(return_val, test_app.db_object) - + def test_hooks(self): custom.lib.hooks # should not fail diff --git a/tests/unit_tests/test_tethys_components/test_layouts.py b/tests/unit_tests/test_tethys_components/test_layouts.py index 30c23a60b..3b3ea78d2 100644 --- a/tests/unit_tests/test_tethys_components/test_layouts.py +++ b/tests/unit_tests/test_tethys_components/test_layouts.py @@ -7,10 +7,12 @@ class TestComponentLayouts(TestCase): @mock.patch("tethys_components.layouts.HeaderWithNavBar", return_value={}) def test_NavHeader(self, _): - test_layout = layouts.NavHeader({ - 'app': mock.MagicMock(), - 'user': mock.MagicMock(), - 'nav-links': mock.MagicMock() - }) + test_layout = layouts.NavHeader( + { + "app": mock.MagicMock(), + "user": mock.MagicMock(), + "nav-links": mock.MagicMock(), + } + ) self.assertIsInstance(test_layout, Component) self.assertIsInstance(test_layout.render(), dict) diff --git a/tests/unit_tests/test_tethys_components/test_utils.py b/tests/unit_tests/test_tethys_components/test_utils.py index 001c54159..525d7fc12 100644 --- a/tests/unit_tests/test_tethys_components/test_utils.py +++ b/tests/unit_tests/test_tethys_components/test_utils.py @@ -53,9 +53,7 @@ def test_use_workspace(self, mock_inspect): mock_stack_item_1 = mock.MagicMock() mock_stack_item_1.__getitem__().f_code.co_filename = "throws_exception" mock_stack_item_2 = mock.MagicMock() - mock_stack_item_2.__getitem__().f_code.co_filename = str( - TEST_APP_DIR - ) + mock_stack_item_2.__getitem__().f_code.co_filename = str(TEST_APP_DIR) mock_inspect.stack.return_value = [mock_stack_item_1, mock_stack_item_2] workspace = utils.use_workspace("john") self.assertEqual( @@ -63,7 +61,10 @@ def test_use_workspace(self, mock_inspect): ) self.assertEqual(mock_import.call_args_list[-1][0][3][0], "use_memo") mock_import().use_memo.assert_called_once() - self.assertIn('. at', str(mock_import().use_memo.call_args_list[0])) + self.assertIn( + ". at", + str(mock_import().use_memo.call_args_list[0]), + ) self.assertEqual(workspace, mock_import().use_memo()) finally: mock.patch.stopall() diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_accounts.py b/tests/unit_tests/test_tethys_portal/test_views/test_accounts.py index cd39a887e..3cfe1ed4b 100644 --- a/tests/unit_tests/test_tethys_portal/test_views/test_accounts.py +++ b/tests/unit_tests/test_tethys_portal/test_views/test_accounts.py @@ -6,8 +6,8 @@ # Fixes the Cache-Control error in tests. Must appear before view imports. mock.patch("django.views.decorators.cache.never_cache", lambda x: x).start() -if 'tethys_portal.views.accounts' in sys.modules: - del sys.modules['tethys_portal.views.accounts'] +if "tethys_portal.views.accounts" in sys.modules: + del sys.modules["tethys_portal.views.accounts"] from tethys_portal.views.accounts import login_view, register, logout_view # noqa: E402 diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_psa.py b/tests/unit_tests/test_tethys_portal/test_views/test_psa.py index 467f87585..89e5b0ed6 100644 --- a/tests/unit_tests/test_tethys_portal/test_views/test_psa.py +++ b/tests/unit_tests/test_tethys_portal/test_views/test_psa.py @@ -17,8 +17,8 @@ mock.patch("django.views.decorators.cache.never_cache", lambda x: x).start() mock.patch("social_django.utils.psa", side_effect=mock_decorator).start() -if 'tethys_portal.views.psa' in sys.modules: - del sys.modules['tethys_portal.views.psa'] +if "tethys_portal.views.psa" in sys.modules: + del sys.modules["tethys_portal.views.psa"] from tethys_portal.views.psa import tenant, auth, complete # noqa: E402 diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_user.py b/tests/unit_tests/test_tethys_portal/test_views/test_user.py index 6866468bf..b677da1e3 100644 --- a/tests/unit_tests/test_tethys_portal/test_views/test_user.py +++ b/tests/unit_tests/test_tethys_portal/test_views/test_user.py @@ -5,8 +5,8 @@ # Fixes the Cache-Control error in tests. Must appear before view imports. mock.patch("django.views.decorators.cache.never_cache", lambda x: x).start() -if 'tethys_portal.views.user' in sys.modules: - del sys.modules['tethys_portal.views.user'] +if "tethys_portal.views.user" in sys.modules: + del sys.modules["tethys_portal.views.user"] from tethys_portal.views.user import ( # noqa: E402 profile, diff --git a/tethys_components/save_for_recipes.py b/tethys_components/save_for_recipes.py new file mode 100644 index 000000000..d61c34e6f --- /dev/null +++ b/tethys_components/save_for_recipes.py @@ -0,0 +1,498 @@ +import random + +from reactpy import component, html, hooks +from reactpy_django.hooks import use_location, use_query +from tethys_portal.settings import STATIC_URL +from .utils import Props +from .library import Library as lib + + +@component +def LeafletMap(props={}): + height = props.get("height", "500px") + position = [51.505, -0.09] + return html.div( + html.link( + Props( + rel="stylesheet", + href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css", + integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=", + crossorigin="", + ) + ), + html.script( + Props( + src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js", + integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=", + crossorigin="", + ) + ), + lib.lm.MapContainer( + Props( + style=Props(height=height), + center=position, + zoom=13, + scrollWheelZoom=True, + ), + lib.lm.TileLayer( + Props( + attribution='© OpenStreetMap contributors', + url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + ) + ), + lib.lm.Marker( + Props(position=position), + lib.lm.Popup( + "A pretty CSS3 popup. ", html.br(), "Easily customizable." + ), + ), + ), + ) + + +from tethys_sdk.components import page + +lib.register("reactive-button@1.3.15", "rb", use_default=True) + + +@page +def test_reactive_button(): + state, set_state = lib.hooks.use_state("idle") + + def on_click_handler(event=None): + set_state("loading") + + return lib.rb.ReactiveButton( + Props( + buttonState=state, + idleText="Submit", + loadingText="Loading", + successText="Done", + onClick=on_click_handler, + ) + ) + + +@page +def map(): + geojson, set_geojson = lib.hooks.use_state(None) + show_chart, set_show_chart = lib.hooks.use_state(False) + chart_data, set_chart_data = lib.hooks.use_state(None) + feature_data, set_feature_data = lib.hooks.use_state(None) + map_center, set_map_center = lib.hooks.use_state([39.254852, -98.593853]) + map_zoom, set_map_zoom = lib.hooks.use_state(4) + load_layer, set_load_layer = lib.hooks.use_state(False) + map_bounds, set_map_bounds = lib.hooks.use_state({}) + + def handle_feature_click(event): + import random + + set_show_chart(True) + set_chart_data( + [ + { + "name": f"Thing {i}", + "uv": random.randint(0, 10000), + "pv": random.randint(0, 10000), + } + for i in range(0, random.randint(10, 125)) + ] + ) + set_feature_data(event["payload"]["properties"]) + + def handle_bounds_changed(event): + if not event["initial"]: + set_map_center(event["center"]) + set_map_zoom(event["zoom"]) + set_map_bounds(event["bounds"]) + + if event["zoom"] >= 9: + set_load_layer(True) + else: + set_load_layer(False) + set_geojson(None) + + def get_geojson(): + if load_layer: + import requests + + ymax, xmax = map_bounds["ne"] + ymin, xmin = map_bounds["sw"] + r = requests.get( + f"https://maps.water.noaa.gov/server/rest/services/nwm/ana_inundation_extent/FeatureServer/0/query?geometry=%7B%0D%0A++%22xmin%22+%3A+{xmin}%2C+%0D%0A++%22ymin%22+%3A+{ymin}%2C%0D%0A++%22xmax%22+%3A+{xmax}%2C%0D%0A++%22ymax%22+%3A+{ymax}%2C%0D%0A++%22spatialReference%22+%3A+%7B%22wkid%22+%3A+4326%7D%0D%0A%7D&geometryType=esriGeometryEnvelope&spatialRel=esriSpatialRelIntersects&outFields=*&returnGeometry=true&outSR=&f=geojson" + ) + gjson = r.json() + if "features" in gjson and len(gjson["features"]) > 0: + set_geojson(r.json()) + set_load_layer(False) + + lib.hooks.use_effect(get_geojson, dependencies=[load_layer]) + + return lib.html.div( + ( + lib.html.div( + Props( + style=Props( + position="fixed", bottom="20px", left="20px", z_index="99999" + ) + ), + lib.html.span("LOADING..."), + ) + if load_layer + else "" + ), + lib.pm.Map( + Props( + height="calc(100vh - 62px)", + defaultCenter=map_center, + defaultZoom=map_zoom, + onBoundsChanged=handle_bounds_changed, + ), + lib.pm.ZoomControl(), + lib.pm.GeoJson( + Props( + data=geojson, + onClick=handle_feature_click, + svgAttributes=Props( + fill="blue", + strokeWidth="0", + stroke="black", + ), + ) + ), + ), + lib.tethys.Panel( + Props( + show=show_chart, + set_show=set_show_chart, + position="end", + extent="30vw", + name="Props", + ), + ( + lib.html.div( + [ + lib.html.div( + lib.html.span(key.title()), ": ", lib.html.span(val) + ) + for key, val in feature_data.items() + ] + ) + if feature_data + else "" + ), + lib.html.br() if chart_data else "", + lib.tethys.SimpleLineChart(chart_data) if chart_data else "", + ), + ) + + +@page +def bootstrap_cards_example(): + return lib.bs.Card( + Props(style=Props(width="18rem")), + lib.bs.CardImg( + Props( + variant="top", + src="https://upload.wikimedia.org/wikipedia/commons/6/63/Logo_La_Linea_100x100.png?20190604153842", + ) + ), + lib.bs.CardBody( + lib.bs.CardTitle("Card Title"), + lib.bs.CardText( + "Some quick example text to build on the card title and make up the" + "bulk of the card's content." + ), + ), + lib.bs.ListGroup( + Props(className="list-group-flush"), + lib.bs.ListGroupItem("Cras justo odio"), + lib.bs.ListGroupItem("Dapibus ac facilisis in"), + lib.bs.ListGroupItem("Vestibulum at eros"), + ), + lib.bs.CardBody( + lib.bs.CardLink(Props(href="#"), "Card Link"), + lib.bs.CardLink(Props(href="#"), "Another Link"), + ), + ) + + +@page +def recharts_treemap_example(): + data = [ + { + "name": "axis", + "children": [ + {"name": "Axes", "size": 1302}, + {"name": "Axis", "size": 24593}, + {"name": "AxisGridLine", "size": 652}, + {"name": "AxisLabel", "size": 636}, + {"name": "CartesianAxes", "size": 6703}, + ], + }, + { + "name": "controls", + "children": [ + {"name": "AnchorControl", "size": 2138}, + {"name": "ClickControl", "size": 3824}, + {"name": "Control", "size": 1353}, + {"name": "ControlList", "size": 4665}, + {"name": "DragControl", "size": 2649}, + {"name": "ExpandControl", "size": 2832}, + {"name": "HoverControl", "size": 4896}, + {"name": "IControl", "size": 763}, + {"name": "PanZoomControl", "size": 5222}, + {"name": "SelectionControl", "size": 7862}, + {"name": "TooltipControl", "size": 8435}, + ], + }, + { + "name": "data", + "children": [ + {"name": "Data", "size": 20544}, + {"name": "DataList", "size": 19788}, + {"name": "DataSprite", "size": 10349}, + {"name": "EdgeSprite", "size": 3301}, + {"name": "NodeSprite", "size": 19382}, + { + "name": "render", + "children": [ + {"name": "ArrowType", "size": 698}, + {"name": "EdgeRenderer", "size": 5569}, + {"name": "IRenderer", "size": 353}, + {"name": "ShapeRenderer", "size": 2247}, + ], + }, + {"name": "ScaleBinding", "size": 11275}, + {"name": "Tree", "size": 7147}, + {"name": "TreeBuilder", "size": 9930}, + ], + }, + { + "name": "events", + "children": [ + {"name": "DataEvent", "size": 7313}, + {"name": "SelectionEvent", "size": 6880}, + {"name": "TooltipEvent", "size": 3701}, + {"name": "VisualizationEvent", "size": 2117}, + ], + }, + { + "name": "legend", + "children": [ + {"name": "Legend", "size": 20859}, + {"name": "LegendItem", "size": 4614}, + {"name": "LegendRange", "size": 10530}, + ], + }, + { + "name": "operator", + "children": [ + { + "name": "distortion", + "children": [ + {"name": "BifocalDistortion", "size": 4461}, + {"name": "Distortion", "size": 6314}, + {"name": "FisheyeDistortion", "size": 3444}, + ], + }, + { + "name": "encoder", + "children": [ + {"name": "ColorEncoder", "size": 3179}, + {"name": "Encoder", "size": 4060}, + {"name": "PropertyEncoder", "size": 4138}, + {"name": "ShapeEncoder", "size": 1690}, + {"name": "SizeEncoder", "size": 1830}, + ], + }, + { + "name": "filter", + "children": [ + {"name": "FisheyeTreeFilter", "size": 5219}, + {"name": "GraphDistanceFilter", "size": 3165}, + {"name": "VisibilityFilter", "size": 3509}, + ], + }, + {"name": "IOperator", "size": 1286}, + { + "name": "label", + "children": [ + {"name": "Labeler", "size": 9956}, + {"name": "RadialLabeler", "size": 3899}, + {"name": "StackedAreaLabeler", "size": 3202}, + ], + }, + { + "name": "layout", + "children": [ + {"name": "AxisLayout", "size": 6725}, + {"name": "BundledEdgeRouter", "size": 3727}, + {"name": "CircleLayout", "size": 9317}, + {"name": "CirclePackingLayout", "size": 12003}, + {"name": "DendrogramLayout", "size": 4853}, + {"name": "ForceDirectedLayout", "size": 8411}, + {"name": "IcicleTreeLayout", "size": 4864}, + {"name": "IndentedTreeLayout", "size": 3174}, + {"name": "Layout", "size": 7881}, + {"name": "NodeLinkTreeLayout", "size": 12870}, + {"name": "PieLayout", "size": 2728}, + {"name": "RadialTreeLayout", "size": 12348}, + {"name": "RandomLayout", "size": 870}, + {"name": "StackedAreaLayout", "size": 9121}, + {"name": "TreeMapLayout", "size": 9191}, + ], + }, + {"name": "Operator", "size": 2490}, + {"name": "OperatorList", "size": 5248}, + {"name": "OperatorSequence", "size": 4190}, + {"name": "OperatorSwitch", "size": 2581}, + {"name": "SortOperator", "size": 2023}, + ], + }, + ] + return lib.bs.Container( + Props(style=Props(height="90vh")), + lib.rc.ResponsiveContainer( + Props(width="100%", height="100%"), + lib.rc.Treemap( + Props( + width=400, + height=200, + data=data, + dataKey="size", + aspectRatio=4 / 3, + stroke="#fff", + fill="#8884d8", + ) + ), + ), + ) + + +# @component NOTE: Breaks if @component decorator applied +def ButtonWithTooltip(button_props, tooltip_props, *children): + from time import sleep + + event, set_event = hooks.use_state({}) + + def show_tooltip(event): + sleep(0.4) + set_event(event) + if "on_mouse_enter" in button_props: + button_props["on_mouse_enter"]() + + def hide_tooltip(event): + sleep(0.25) + set_event({}) + if "on_mouse_leave" in button_props: + button_props["on_mouse_leave"]() + + return lib.html.div( + lib.bs.Button( + Props( + variant="success", + on_mouse_enter=show_tooltip, + on_mouse_leave=hide_tooltip, + ), + *children, + ( + lib.html.div( + Props( + style=Props( + background="rgba(250,250,250,0)", + position="absolute", + top=event["y"], + left=event["x"], + display="flex", + flex_flow="column nowrap", + align_items="center", + ) + ), + lib.html.div( + Props( + style=Props( + width=0, + height=0, + border_left="5px solid transparent", + border_right="5px solid transparent", + border_bottom="5px solid black", + ) + ) + ), + lib.html.div( + Props( + style=Props( + background="black", + color="white", + padding="5pt", + font_size="12pt", + ) + ), + ( + tooltip_props["text"] + if "text" in tooltip_props + else 'Could not find prop "text" on tooltip_props' + ), + ), + ) + if event + else "" + ), + ) + ) + + +@component # NOTE: Breaks if @component decorator applied +def OlMap(props, *children): + load_js, set_load_js = hooks.use_state(False) + + def delay_load_script(event): + set_load_js(True) + + def handle_map_click(event): + pass + + return lib.html.div( + lib.html.div( + Props( + id="map", + class_name="map", + style=Props(width="100%", position="absolute", top=0, bottom=0), + on_click=handle_map_click, + ) + ), + lib.html.div( + Props(on_load=delay_load_script), + html.script(Props(src="https://cdn.jsdelivr.net/npm/ol@v9.2.4/dist/ol.js")), + html.link( + Props( + rel="stylesheet", + href="https://cdn.jsdelivr.net/npm/ol@v9.2.4/ol.css", + ) + ), + ( + html.script( + """ + const MAP = new ol.Map({ + target: 'map', + layers: [ + new ol.layer.Tile({ + source: new ol.source.OSM(), + }), + ], + view: new ol.View({ + center: [0, 0], + zoom: 2, + }), + }); + MAP.on('click', function (e) { + console.log(e); + }); + """ + ) + if load_js and set_load_js(False) == None + else "" + ), + ), + ) diff --git a/tethys_components/utils.py b/tethys_components/utils.py index 0537a35c7..3a428c804 100644 --- a/tethys_components/utils.py +++ b/tethys_components/utils.py @@ -18,6 +18,7 @@ async def get_workspace(app_package, user): def use_workspace(user=None): from reactpy_django.hooks import use_memo + app_package = None for item in inspect.stack(): From 0718fd53b3c7b88d39a6977b5926a2cf7eae03a7 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 17 Oct 2024 15:15:12 -0600 Subject: [PATCH 26/31] remove file that was unintentionally committed --- tethys_components/save_for_recipes.py | 498 -------------------------- 1 file changed, 498 deletions(-) delete mode 100644 tethys_components/save_for_recipes.py diff --git a/tethys_components/save_for_recipes.py b/tethys_components/save_for_recipes.py deleted file mode 100644 index d61c34e6f..000000000 --- a/tethys_components/save_for_recipes.py +++ /dev/null @@ -1,498 +0,0 @@ -import random - -from reactpy import component, html, hooks -from reactpy_django.hooks import use_location, use_query -from tethys_portal.settings import STATIC_URL -from .utils import Props -from .library import Library as lib - - -@component -def LeafletMap(props={}): - height = props.get("height", "500px") - position = [51.505, -0.09] - return html.div( - html.link( - Props( - rel="stylesheet", - href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css", - integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=", - crossorigin="", - ) - ), - html.script( - Props( - src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js", - integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=", - crossorigin="", - ) - ), - lib.lm.MapContainer( - Props( - style=Props(height=height), - center=position, - zoom=13, - scrollWheelZoom=True, - ), - lib.lm.TileLayer( - Props( - attribution='© OpenStreetMap contributors', - url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - ) - ), - lib.lm.Marker( - Props(position=position), - lib.lm.Popup( - "A pretty CSS3 popup. ", html.br(), "Easily customizable." - ), - ), - ), - ) - - -from tethys_sdk.components import page - -lib.register("reactive-button@1.3.15", "rb", use_default=True) - - -@page -def test_reactive_button(): - state, set_state = lib.hooks.use_state("idle") - - def on_click_handler(event=None): - set_state("loading") - - return lib.rb.ReactiveButton( - Props( - buttonState=state, - idleText="Submit", - loadingText="Loading", - successText="Done", - onClick=on_click_handler, - ) - ) - - -@page -def map(): - geojson, set_geojson = lib.hooks.use_state(None) - show_chart, set_show_chart = lib.hooks.use_state(False) - chart_data, set_chart_data = lib.hooks.use_state(None) - feature_data, set_feature_data = lib.hooks.use_state(None) - map_center, set_map_center = lib.hooks.use_state([39.254852, -98.593853]) - map_zoom, set_map_zoom = lib.hooks.use_state(4) - load_layer, set_load_layer = lib.hooks.use_state(False) - map_bounds, set_map_bounds = lib.hooks.use_state({}) - - def handle_feature_click(event): - import random - - set_show_chart(True) - set_chart_data( - [ - { - "name": f"Thing {i}", - "uv": random.randint(0, 10000), - "pv": random.randint(0, 10000), - } - for i in range(0, random.randint(10, 125)) - ] - ) - set_feature_data(event["payload"]["properties"]) - - def handle_bounds_changed(event): - if not event["initial"]: - set_map_center(event["center"]) - set_map_zoom(event["zoom"]) - set_map_bounds(event["bounds"]) - - if event["zoom"] >= 9: - set_load_layer(True) - else: - set_load_layer(False) - set_geojson(None) - - def get_geojson(): - if load_layer: - import requests - - ymax, xmax = map_bounds["ne"] - ymin, xmin = map_bounds["sw"] - r = requests.get( - f"https://maps.water.noaa.gov/server/rest/services/nwm/ana_inundation_extent/FeatureServer/0/query?geometry=%7B%0D%0A++%22xmin%22+%3A+{xmin}%2C+%0D%0A++%22ymin%22+%3A+{ymin}%2C%0D%0A++%22xmax%22+%3A+{xmax}%2C%0D%0A++%22ymax%22+%3A+{ymax}%2C%0D%0A++%22spatialReference%22+%3A+%7B%22wkid%22+%3A+4326%7D%0D%0A%7D&geometryType=esriGeometryEnvelope&spatialRel=esriSpatialRelIntersects&outFields=*&returnGeometry=true&outSR=&f=geojson" - ) - gjson = r.json() - if "features" in gjson and len(gjson["features"]) > 0: - set_geojson(r.json()) - set_load_layer(False) - - lib.hooks.use_effect(get_geojson, dependencies=[load_layer]) - - return lib.html.div( - ( - lib.html.div( - Props( - style=Props( - position="fixed", bottom="20px", left="20px", z_index="99999" - ) - ), - lib.html.span("LOADING..."), - ) - if load_layer - else "" - ), - lib.pm.Map( - Props( - height="calc(100vh - 62px)", - defaultCenter=map_center, - defaultZoom=map_zoom, - onBoundsChanged=handle_bounds_changed, - ), - lib.pm.ZoomControl(), - lib.pm.GeoJson( - Props( - data=geojson, - onClick=handle_feature_click, - svgAttributes=Props( - fill="blue", - strokeWidth="0", - stroke="black", - ), - ) - ), - ), - lib.tethys.Panel( - Props( - show=show_chart, - set_show=set_show_chart, - position="end", - extent="30vw", - name="Props", - ), - ( - lib.html.div( - [ - lib.html.div( - lib.html.span(key.title()), ": ", lib.html.span(val) - ) - for key, val in feature_data.items() - ] - ) - if feature_data - else "" - ), - lib.html.br() if chart_data else "", - lib.tethys.SimpleLineChart(chart_data) if chart_data else "", - ), - ) - - -@page -def bootstrap_cards_example(): - return lib.bs.Card( - Props(style=Props(width="18rem")), - lib.bs.CardImg( - Props( - variant="top", - src="https://upload.wikimedia.org/wikipedia/commons/6/63/Logo_La_Linea_100x100.png?20190604153842", - ) - ), - lib.bs.CardBody( - lib.bs.CardTitle("Card Title"), - lib.bs.CardText( - "Some quick example text to build on the card title and make up the" - "bulk of the card's content." - ), - ), - lib.bs.ListGroup( - Props(className="list-group-flush"), - lib.bs.ListGroupItem("Cras justo odio"), - lib.bs.ListGroupItem("Dapibus ac facilisis in"), - lib.bs.ListGroupItem("Vestibulum at eros"), - ), - lib.bs.CardBody( - lib.bs.CardLink(Props(href="#"), "Card Link"), - lib.bs.CardLink(Props(href="#"), "Another Link"), - ), - ) - - -@page -def recharts_treemap_example(): - data = [ - { - "name": "axis", - "children": [ - {"name": "Axes", "size": 1302}, - {"name": "Axis", "size": 24593}, - {"name": "AxisGridLine", "size": 652}, - {"name": "AxisLabel", "size": 636}, - {"name": "CartesianAxes", "size": 6703}, - ], - }, - { - "name": "controls", - "children": [ - {"name": "AnchorControl", "size": 2138}, - {"name": "ClickControl", "size": 3824}, - {"name": "Control", "size": 1353}, - {"name": "ControlList", "size": 4665}, - {"name": "DragControl", "size": 2649}, - {"name": "ExpandControl", "size": 2832}, - {"name": "HoverControl", "size": 4896}, - {"name": "IControl", "size": 763}, - {"name": "PanZoomControl", "size": 5222}, - {"name": "SelectionControl", "size": 7862}, - {"name": "TooltipControl", "size": 8435}, - ], - }, - { - "name": "data", - "children": [ - {"name": "Data", "size": 20544}, - {"name": "DataList", "size": 19788}, - {"name": "DataSprite", "size": 10349}, - {"name": "EdgeSprite", "size": 3301}, - {"name": "NodeSprite", "size": 19382}, - { - "name": "render", - "children": [ - {"name": "ArrowType", "size": 698}, - {"name": "EdgeRenderer", "size": 5569}, - {"name": "IRenderer", "size": 353}, - {"name": "ShapeRenderer", "size": 2247}, - ], - }, - {"name": "ScaleBinding", "size": 11275}, - {"name": "Tree", "size": 7147}, - {"name": "TreeBuilder", "size": 9930}, - ], - }, - { - "name": "events", - "children": [ - {"name": "DataEvent", "size": 7313}, - {"name": "SelectionEvent", "size": 6880}, - {"name": "TooltipEvent", "size": 3701}, - {"name": "VisualizationEvent", "size": 2117}, - ], - }, - { - "name": "legend", - "children": [ - {"name": "Legend", "size": 20859}, - {"name": "LegendItem", "size": 4614}, - {"name": "LegendRange", "size": 10530}, - ], - }, - { - "name": "operator", - "children": [ - { - "name": "distortion", - "children": [ - {"name": "BifocalDistortion", "size": 4461}, - {"name": "Distortion", "size": 6314}, - {"name": "FisheyeDistortion", "size": 3444}, - ], - }, - { - "name": "encoder", - "children": [ - {"name": "ColorEncoder", "size": 3179}, - {"name": "Encoder", "size": 4060}, - {"name": "PropertyEncoder", "size": 4138}, - {"name": "ShapeEncoder", "size": 1690}, - {"name": "SizeEncoder", "size": 1830}, - ], - }, - { - "name": "filter", - "children": [ - {"name": "FisheyeTreeFilter", "size": 5219}, - {"name": "GraphDistanceFilter", "size": 3165}, - {"name": "VisibilityFilter", "size": 3509}, - ], - }, - {"name": "IOperator", "size": 1286}, - { - "name": "label", - "children": [ - {"name": "Labeler", "size": 9956}, - {"name": "RadialLabeler", "size": 3899}, - {"name": "StackedAreaLabeler", "size": 3202}, - ], - }, - { - "name": "layout", - "children": [ - {"name": "AxisLayout", "size": 6725}, - {"name": "BundledEdgeRouter", "size": 3727}, - {"name": "CircleLayout", "size": 9317}, - {"name": "CirclePackingLayout", "size": 12003}, - {"name": "DendrogramLayout", "size": 4853}, - {"name": "ForceDirectedLayout", "size": 8411}, - {"name": "IcicleTreeLayout", "size": 4864}, - {"name": "IndentedTreeLayout", "size": 3174}, - {"name": "Layout", "size": 7881}, - {"name": "NodeLinkTreeLayout", "size": 12870}, - {"name": "PieLayout", "size": 2728}, - {"name": "RadialTreeLayout", "size": 12348}, - {"name": "RandomLayout", "size": 870}, - {"name": "StackedAreaLayout", "size": 9121}, - {"name": "TreeMapLayout", "size": 9191}, - ], - }, - {"name": "Operator", "size": 2490}, - {"name": "OperatorList", "size": 5248}, - {"name": "OperatorSequence", "size": 4190}, - {"name": "OperatorSwitch", "size": 2581}, - {"name": "SortOperator", "size": 2023}, - ], - }, - ] - return lib.bs.Container( - Props(style=Props(height="90vh")), - lib.rc.ResponsiveContainer( - Props(width="100%", height="100%"), - lib.rc.Treemap( - Props( - width=400, - height=200, - data=data, - dataKey="size", - aspectRatio=4 / 3, - stroke="#fff", - fill="#8884d8", - ) - ), - ), - ) - - -# @component NOTE: Breaks if @component decorator applied -def ButtonWithTooltip(button_props, tooltip_props, *children): - from time import sleep - - event, set_event = hooks.use_state({}) - - def show_tooltip(event): - sleep(0.4) - set_event(event) - if "on_mouse_enter" in button_props: - button_props["on_mouse_enter"]() - - def hide_tooltip(event): - sleep(0.25) - set_event({}) - if "on_mouse_leave" in button_props: - button_props["on_mouse_leave"]() - - return lib.html.div( - lib.bs.Button( - Props( - variant="success", - on_mouse_enter=show_tooltip, - on_mouse_leave=hide_tooltip, - ), - *children, - ( - lib.html.div( - Props( - style=Props( - background="rgba(250,250,250,0)", - position="absolute", - top=event["y"], - left=event["x"], - display="flex", - flex_flow="column nowrap", - align_items="center", - ) - ), - lib.html.div( - Props( - style=Props( - width=0, - height=0, - border_left="5px solid transparent", - border_right="5px solid transparent", - border_bottom="5px solid black", - ) - ) - ), - lib.html.div( - Props( - style=Props( - background="black", - color="white", - padding="5pt", - font_size="12pt", - ) - ), - ( - tooltip_props["text"] - if "text" in tooltip_props - else 'Could not find prop "text" on tooltip_props' - ), - ), - ) - if event - else "" - ), - ) - ) - - -@component # NOTE: Breaks if @component decorator applied -def OlMap(props, *children): - load_js, set_load_js = hooks.use_state(False) - - def delay_load_script(event): - set_load_js(True) - - def handle_map_click(event): - pass - - return lib.html.div( - lib.html.div( - Props( - id="map", - class_name="map", - style=Props(width="100%", position="absolute", top=0, bottom=0), - on_click=handle_map_click, - ) - ), - lib.html.div( - Props(on_load=delay_load_script), - html.script(Props(src="https://cdn.jsdelivr.net/npm/ol@v9.2.4/dist/ol.js")), - html.link( - Props( - rel="stylesheet", - href="https://cdn.jsdelivr.net/npm/ol@v9.2.4/ol.css", - ) - ), - ( - html.script( - """ - const MAP = new ol.Map({ - target: 'map', - layers: [ - new ol.layer.Tile({ - source: new ol.source.OSM(), - }), - ], - view: new ol.View({ - center: [0, 0], - zoom: 2, - }), - }); - MAP.on('click', function (e) { - console.log(e); - }); - """ - ) - if load_js and set_load_js(False) == None - else "" - ), - ), - ) From d898438d60dc4a9bb5aaa55e145aadd6abc8ad2b Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Sat, 19 Oct 2024 10:12:55 -0600 Subject: [PATCH 27/31] Fixes pyproject.toml_tmpl for reacpty scaffold The authors field cannot be present if name and email are blank, so they are now conditional upon those values being filled --- .../app_templates/reactpy/pyproject.toml_tmpl | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/pyproject.toml_tmpl b/tethys_cli/scaffold_templates/app_templates/reactpy/pyproject.toml_tmpl index c83424af9..0db1fd38b 100644 --- a/tethys_cli/scaffold_templates/app_templates/reactpy/pyproject.toml_tmpl +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/pyproject.toml_tmpl @@ -6,16 +6,16 @@ build-backend = "setuptools.build_meta" name = "{{project_dir}}" description = "{{description|default('')}}" readme = "README.rst" -license = {text = "{{license_name|default('')}}"} +{% if license_name %}license = {text = "{{license_name|default('')}}"}{% endif %} keywords = [{{', '.join(tags.split(','))}}] -authors = [ +{% if author and author_email %}authors = [ {name = "{{author|default('')}}", email = "{{author_email|default('')}}"}, -] +]{% endif %} classifiers = [ "Environment :: Web Environment", "Framework :: Django", - "Intended Audience :: Developers", - "License :: OSI Approved :: {{license_name}}", + "Intended Audience :: Developers",{% if license_name %} + "License :: OSI Approved :: {{license_name}}",{% endif %} "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", From f0e00e982f37e9345b82cd2f33475d1ebe544b71 Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Tue, 22 Oct 2024 13:42:40 -0600 Subject: [PATCH 28/31] Update tethys_apps/base/url_map.py Co-authored-by: Nathan Swain --- tethys_apps/base/url_map.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tethys_apps/base/url_map.py b/tethys_apps/base/url_map.py index e6eec93e2..f5565cac8 100644 --- a/tethys_apps/base/url_map.py +++ b/tethys_apps/base/url_map.py @@ -41,8 +41,8 @@ def __init__( regex (str or iterable, optional): Custom regex pattern(s) for url variables. If a string is provided, it will be applied to all variables. If a list or tuple is provided, they will be applied in variable order. handler (str): Dot-notation path a handler function. A handler is associated to a specific controller and contains the main logic for creating and establishing a communication between the client and the server. handler_type (str): Tethys supported handler type. 'bokeh' is the only handler type currently supported. - title (str): The title to be used both in built-in Navigation components and in the browser tab - index (int): Used to determine the render order of nav items in built-in Navigation components. Defaults to the unpredictable processing order of the @page decorated functions. Set to -1 to remove from built-in Navigation components. + title (str): The title to be used both in navigation and in the browser tab. + index (int): Used to determine the render order of nav items in navigation. Defaults to the unpredictable processing order of decorated functions. Set to -1 to remove from navigation. """ # noqa: E501 # Validate if regex and ( From 01bdf94e85b193d2907a29c7cb1a070c24058de4 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 22 Oct 2024 15:48:22 -0600 Subject: [PATCH 29/31] Implements latest feedback from @swainn - pyproject.toml added to all scaffolds - reactpy_base.html refactored to extend app_base.html - minor cleanups and refactors --- .../test_base/test_app_base.py | 11 +- .../test_tethys_components/test_utils.py | 4 +- .../templates/tethys_apps/app_base.html | 6 +- .../templates/tethys_apps/reactpy_base.html | 149 ++++-------------- .../app_templates/default/pyproject.toml_tmpl | 44 ++++++ .../app_templates/default/setup.py_tmpl | 33 ---- .../app_templates/react/pyproject.toml_tmpl | 45 ++++++ .../app_templates/react/setup.py_tmpl | 31 ---- .../app_templates/reactpy/pyproject.toml_tmpl | 19 +++ tethys_components/utils.py | 4 +- tethys_sdk/components/utils.py | 2 +- 11 files changed, 154 insertions(+), 194 deletions(-) create mode 100644 tethys_cli/scaffold_templates/app_templates/default/pyproject.toml_tmpl delete mode 100644 tethys_cli/scaffold_templates/app_templates/default/setup.py_tmpl create mode 100644 tethys_cli/scaffold_templates/app_templates/react/pyproject.toml_tmpl delete mode 100644 tethys_cli/scaffold_templates/app_templates/react/setup.py_tmpl diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py index 72eae6050..0a301d830 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py @@ -6,7 +6,6 @@ from django.db.utils import ProgrammingError from django.test import RequestFactory, override_settings from django.core.exceptions import ValidationError, ObjectDoesNotExist -from argparse import Namespace from tethys_apps.exceptions import ( TethysAppSettingDoesNotExist, @@ -1558,11 +1557,11 @@ def test_navigation_links_auto_excluded_page(self): app.root_url = "test-app" app._registered_url_maps = [ - Namespace(name="exclude_page", title="Exclude Page", index=-1), - Namespace(name="last_page", title="Last Page", index=3), - Namespace(name="third_page", title="Third Page", index=2), - Namespace(name="second_page", title="Second Page", index=1), - Namespace(name="home", title="Home", index=0), + mock.MagicMock(name="exclude_page", title="Exclude Page", index=-1), + mock.MagicMock(name="last_page", title="Last Page", index=3), + mock.MagicMock(name="third_page", title="Third Page", index=2), + mock.MagicMock(name="second_page", title="Second Page", index=1), + mock.MagicMock(name="home", title="Home", index=0), ] links = app.navigation_links diff --git a/tests/unit_tests/test_tethys_components/test_utils.py b/tests/unit_tests/test_tethys_components/test_utils.py index 525d7fc12..a5a715c9a 100644 --- a/tests/unit_tests/test_tethys_components/test_utils.py +++ b/tests/unit_tests/test_tethys_components/test_utils.py @@ -75,8 +75,8 @@ def test_delayed_execute(self): def test_func(arg1): pass - utils.delayed_execute(10, test_func, ["Hello"]) - mock_import().Timer.assert_called_once_with(10, test_func, ["Hello"]) + utils.delayed_execute(test_func, 10, ["Hello"]) + mock_import().Timer.assert_called_once_with(test_func, 10, ["Hello"]) mock_import().Timer().start.assert_called_once() mock.patch.stopall() diff --git a/tethys_apps/templates/tethys_apps/app_base.html b/tethys_apps/templates/tethys_apps/app_base.html index 1f33bc4d6..ebb9b582d 100644 --- a/tethys_apps/templates/tethys_apps/app_base.html +++ b/tethys_apps/templates/tethys_apps/app_base.html @@ -85,8 +85,10 @@ {% endcomment %} {% block styles %} - {{ tethys.bootstrap.link_tag|safe }} - {{ tethys.bootstrap_icons.link_tag|safe }} + {% block bootstrap_styles %} + {{ tethys.bootstrap.link_tag|safe }} + {{ tethys.bootstrap_icons.link_tag|safe }} + {% endblock %} {% block app_base_styles %} {% endblock %} diff --git a/tethys_apps/templates/tethys_apps/reactpy_base.html b/tethys_apps/templates/tethys_apps/reactpy_base.html index d36823e12..b31e63200 100644 --- a/tethys_apps/templates/tethys_apps/reactpy_base.html +++ b/tethys_apps/templates/tethys_apps/reactpy_base.html @@ -1,119 +1,34 @@ +{% extends "tethys_apps/app_base.html" %} {% load static tethys reactpy %} - - - - - - - {% if has_analytical %} - {% include "analytical_head_top.html" %} - {% endif %} - - - - - - {{ title }} | {{ tethys_app.name }} - - {% if tethys_app.enable_feedback %} - - {% endif %} - - {% for css in custom_css %} - - {% endfor %} - - - - {% if has_session_security %} - {% include 'session_security/all.html' %} - - {% endif %} - - {% if has_analytical %} - {% include "analytical_head_bottom.html" %} - {% endif %} - - - - {% if has_analytical %} - {% include "analytical_body_top.html" %} - {% endif %} - - {% component "tethys_apps.base.page_handler.page_component_wrapper" app=app user=request.user layout=layout_func component=component_func extras=extras %} - - {% if has_terms %} - {% include "terms.html" %} - {% endif %} - - - - {% csrf_token %} - - {{ tethys.doc_cookies.script_tag|safe }} - - {% if tethys_app.enable_feedback %} - - {% endif %} - - {% for js in custom_js %} - - {% endfor %} - - {% if has_analytical %} - {% include "analytical_body_bottom.html" %} - {% endif %} - - \ No newline at end of file +{% block title %}{{ title }} | {{ tethys_app.name }}{% endblock %} + +{% block bootstrap_styles %}{% endblock %} +{% block app_base_styles %}{% endblock %} + +{% block app_styles %} + {% for css in custom_css %} + + {% endfor %} + {{ block.super }} +{% endblock %} + +{% block global_scripts %} +{{ tethys.jquery.script_tag|safe }} + +{% endblock %} + +{% block app_content_wrapper_override %} + {% component "tethys_apps.base.page_handler.page_component_wrapper" app=app user=request.user layout=layout_func component=component_func extras=extras %} +{% endblock %} + +{% block app_base_js %} + {% for js in custom_js %} + + {% endfor %} +{% endblock %} \ No newline at end of file diff --git a/tethys_cli/scaffold_templates/app_templates/default/pyproject.toml_tmpl b/tethys_cli/scaffold_templates/app_templates/default/pyproject.toml_tmpl new file mode 100644 index 000000000..12a57bff3 --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/default/pyproject.toml_tmpl @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "{{project_dir}}" +description = "{{description|default('')}}" +{% if license_name %}license = {text = "{{license_name|default('')}}"}{% endif %} +keywords = [{{', '.join(tags.split(','))}}] +{% if author and author_email %}authors = [ + {name = "{{author|default('')}}", email = "{{author_email|default('')}}"}, +]{% endif %} +classifiers = [ + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers",{% if license_name %} + "License :: OSI Approved :: {{license_name}}",{% endif %} + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", +] +dynamic = ["version"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["tethysapp*"] + +[tool.setuptools.package-data] +"*" = [ + "*.js", + "*.png", + "*.gif", + "*.jpg", + "*.html", + "*.css", + "*.gltf", + "*.json", + "*.svg", +] \ No newline at end of file diff --git a/tethys_cli/scaffold_templates/app_templates/default/setup.py_tmpl b/tethys_cli/scaffold_templates/app_templates/default/setup.py_tmpl deleted file mode 100644 index f7f8911f2..000000000 --- a/tethys_cli/scaffold_templates/app_templates/default/setup.py_tmpl +++ /dev/null @@ -1,33 +0,0 @@ -from setuptools import setup, find_namespace_packages -from tethys_apps.app_installation import find_all_resource_files -from tethys_apps.base.app_base import TethysAppBase - -# -- Apps Definition -- # -app_package = '{{project}}' -release_package = f'{TethysAppBase.package_namespace}-{app_package}' - -# -- Python Dependencies -- # -dependencies = [] - -# -- Get Resource File -- # -resource_files = find_all_resource_files( - app_package, TethysAppBase.package_namespace -) - -setup( - name=release_package, - version='0.0.1', - description='{{description|default('')}}', - long_description='', - keywords='', - author='{{author|default('')}}', - author_email='{{author_email|default('')}}', - url='', - license='{{license_name|default('')}}', - packages=find_namespace_packages(), - package_data={'': resource_files}, - include_package_data=True, - zip_safe=False, - install_requires=dependencies, -) - diff --git a/tethys_cli/scaffold_templates/app_templates/react/pyproject.toml_tmpl b/tethys_cli/scaffold_templates/app_templates/react/pyproject.toml_tmpl new file mode 100644 index 000000000..fbfd659ff --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/react/pyproject.toml_tmpl @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "{{project_dir}}" +description = "{{description|default('')}}" +readme = "README.md" +{% if license_name %}license = {text = "{{license_name|default('')}}"}{% endif %} +keywords = [{{', '.join(tags.split(','))}}] +{% if author and author_email %}authors = [ + {name = "{{author|default('')}}", email = "{{author_email|default('')}}"}, +]{% endif %} +classifiers = [ + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers",{% if license_name %} + "License :: OSI Approved :: {{license_name}}",{% endif %} + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", +] +dynamic = ["version"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["tethysapp*"] + +[tool.setuptools.package-data] +"*" = [ + "*.js", + "*.png", + "*.gif", + "*.jpg", + "*.html", + "*.css", + "*.gltf", + "*.json", + "*.svg", +] \ No newline at end of file diff --git a/tethys_cli/scaffold_templates/app_templates/react/setup.py_tmpl b/tethys_cli/scaffold_templates/app_templates/react/setup.py_tmpl deleted file mode 100644 index ef8ef99cb..000000000 --- a/tethys_cli/scaffold_templates/app_templates/react/setup.py_tmpl +++ /dev/null @@ -1,31 +0,0 @@ -from setuptools import setup, find_namespace_packages -from tethys_apps.app_installation import find_all_resource_files -from tethys_apps.base.app_base import TethysAppBase - -# -- Apps Definition -- # -app_package = '{{project}}' -release_package = f'{TethysAppBase.package_namespace}-{app_package}' - -# -- Python Dependencies -- # -dependencies = [] - -# -- Get Resource File -- # -resource_files = find_all_resource_files(app_package, TethysAppBase.package_namespace) - - -setup( - name=release_package, - version='0.0.1', - description='{{description|default('')}}', - long_description='', - keywords='', - author='{{author|default('')}}', - author_email='{{author_email|default('')}}', - url='', - license='{{license_name|default('')}}', - packages=find_namespace_packages(), - package_data={'': resource_files}, - include_package_data=True, - zip_safe=False, - install_requires=dependencies, -) diff --git a/tethys_cli/scaffold_templates/app_templates/reactpy/pyproject.toml_tmpl b/tethys_cli/scaffold_templates/app_templates/reactpy/pyproject.toml_tmpl index 0db1fd38b..5eb933301 100644 --- a/tethys_cli/scaffold_templates/app_templates/reactpy/pyproject.toml_tmpl +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/pyproject.toml_tmpl @@ -24,3 +24,22 @@ classifiers = [ "Topic :: Internet :: WWW/HTTP :: Dynamic Content", ] dynamic = ["version"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["tethysapp*"] + +[tool.setuptools.package-data] +"*" = [ + "*.js", + "*.png", + "*.gif", + "*.jpg", + "*.html", + "*.css", + "*.gltf", + "*.json", + "*.svg", +] \ No newline at end of file diff --git a/tethys_components/utils.py b/tethys_components/utils.py index 3a428c804..7f303ed65 100644 --- a/tethys_components/utils.py +++ b/tethys_components/utils.py @@ -38,10 +38,10 @@ def use_workspace(user=None): return workspace -def delayed_execute(seconds, callable, args=None): +def delayed_execute(callable, delay_seconds, args=None): from threading import Timer - t = Timer(seconds, callable, args or []) + t = Timer(delay_seconds, callable, args or []) t.start() diff --git a/tethys_sdk/components/utils.py b/tethys_sdk/components/utils.py index 53d187950..7b0497c06 100644 --- a/tethys_sdk/components/utils.py +++ b/tethys_sdk/components/utils.py @@ -1,2 +1,2 @@ -from reactpy import component, event as event_decorator # noqa: F401 +from reactpy import component, event # noqa: F401 from tethys_components.utils import Props, delayed_execute # noqa: F401 From 8a810af24d93dbe99ddbcfeb5cc68d71143abe11 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Wed, 23 Oct 2024 12:07:09 -0600 Subject: [PATCH 30/31] Additional tweaks per feedback/tests - Reverted last commit's swap of argparse.Namespace for mock.MagicMock since mock requires the "name" argument, which isn't allowed by MagicMock on initialization - Added reactpy to dependencies in environment.yml --- environment.yml | 1 + .../test_tethys_apps/test_base/test_app_base.py | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/environment.yml b/environment.yml index d7a851692..d7bde3f5e 100644 --- a/environment.yml +++ b/environment.yml @@ -107,4 +107,5 @@ dependencies: # reactpy dependencies - pip: + - reactpy - reactpy-django diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py index 0a301d830..72eae6050 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_app_base.py @@ -6,6 +6,7 @@ from django.db.utils import ProgrammingError from django.test import RequestFactory, override_settings from django.core.exceptions import ValidationError, ObjectDoesNotExist +from argparse import Namespace from tethys_apps.exceptions import ( TethysAppSettingDoesNotExist, @@ -1557,11 +1558,11 @@ def test_navigation_links_auto_excluded_page(self): app.root_url = "test-app" app._registered_url_maps = [ - mock.MagicMock(name="exclude_page", title="Exclude Page", index=-1), - mock.MagicMock(name="last_page", title="Last Page", index=3), - mock.MagicMock(name="third_page", title="Third Page", index=2), - mock.MagicMock(name="second_page", title="Second Page", index=1), - mock.MagicMock(name="home", title="Home", index=0), + Namespace(name="exclude_page", title="Exclude Page", index=-1), + Namespace(name="last_page", title="Last Page", index=3), + Namespace(name="third_page", title="Third Page", index=2), + Namespace(name="second_page", title="Second Page", index=1), + Namespace(name="home", title="Home", index=0), ] links = app.navigation_links From 8ab53d49167b47a0246f71bab91e8431f2eca9a4 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Wed, 23 Oct 2024 12:43:03 -0600 Subject: [PATCH 31/31] Fix broken test --- tests/unit_tests/test_tethys_components/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_tethys_components/test_utils.py b/tests/unit_tests/test_tethys_components/test_utils.py index a5a715c9a..5aeefa98c 100644 --- a/tests/unit_tests/test_tethys_components/test_utils.py +++ b/tests/unit_tests/test_tethys_components/test_utils.py @@ -76,7 +76,7 @@ def test_func(arg1): pass utils.delayed_execute(test_func, 10, ["Hello"]) - mock_import().Timer.assert_called_once_with(test_func, 10, ["Hello"]) + mock_import().Timer.assert_called_once_with(10, test_func, ["Hello"]) mock_import().Timer().start.assert_called_once() mock.patch.stopall()