From 67b2098045362c76ce226cc8087f67d8c5f22aca Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 13 May 2024 20:51:39 +0000 Subject: [PATCH 01/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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() From f4bc8b65bdf0281ae54a78af60b56703f2910c1a Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 25 Nov 2024 12:24:13 -0700 Subject: [PATCH 32/36] Removes reactpy[-django] from dependencies Since reactpy and reactpy-django are not yet available on conda-forge, they were added as pip dependencies. However, that was preventing the tethys coda package from building successfully. Thus, we're backing that out for now until they can be added to conda-forge --- environment.yml | 5 ----- tests/coverage.cfg | 3 ++- .../{test_custom.py => __test_custom.py} | 0 .../{test_layouts.py => __test_layouts.py} | 0 4 files changed, 2 insertions(+), 6 deletions(-) rename tests/unit_tests/test_tethys_components/{test_custom.py => __test_custom.py} (100%) rename tests/unit_tests/test_tethys_components/{test_layouts.py => __test_layouts.py} (100%) diff --git a/environment.yml b/environment.yml index d7bde3f5e..67d2fa703 100644 --- a/environment.yml +++ b/environment.yml @@ -104,8 +104,3 @@ dependencies: - factory_boy - flake8 - flake8-bugbear - - # reactpy dependencies - - pip: - - reactpy - - reactpy-django diff --git a/tests/coverage.cfg b/tests/coverage.cfg index 6c209d771..dca6481c0 100644 --- a/tests/coverage.cfg +++ b/tests/coverage.cfg @@ -3,7 +3,8 @@ [run] source = $TETHYS_TEST_DIR/../tethys_apps $TETHYS_TEST_DIR/../tethys_cli - $TETHYS_TEST_DIR/../tethys_components + $TETHYS_TEST_DIR/../tethys_components/library.py + $TETHYS_TEST_DIR/../tethys_components/utils.py $TETHYS_TEST_DIR/../tethys_compute $TETHYS_TEST_DIR/../tethys_config $TETHYS_TEST_DIR/../tethys_gizmos diff --git a/tests/unit_tests/test_tethys_components/test_custom.py b/tests/unit_tests/test_tethys_components/__test_custom.py similarity index 100% rename from tests/unit_tests/test_tethys_components/test_custom.py rename to tests/unit_tests/test_tethys_components/__test_custom.py diff --git a/tests/unit_tests/test_tethys_components/test_layouts.py b/tests/unit_tests/test_tethys_components/__test_layouts.py similarity index 100% rename from tests/unit_tests/test_tethys_components/test_layouts.py rename to tests/unit_tests/test_tethys_components/__test_layouts.py From f5af044f76b7550183a3b03f1044417776dd4bb0 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 25 Nov 2024 12:52:42 -0700 Subject: [PATCH 33/36] Replace Path.walk with os.walk Path.walk didn't exist until Python 3.12, so to support those versions of Python, I had to downgrade back to os.walk. --- .../test_tethys_cli/test_scaffold_commands.py | 22 +++++++++---------- tethys_apps/base/paths.py | 5 +++-- tethys_cli/scaffold_commands.py | 3 ++- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/tests/unit_tests/test_tethys_cli/test_scaffold_commands.py b/tests/unit_tests/test_tethys_cli/test_scaffold_commands.py index 3cb85da81..0735e0cd9 100644 --- a/tests/unit_tests/test_tethys_cli/test_scaffold_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_scaffold_commands.py @@ -122,7 +122,7 @@ def test_render_path(self): @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") @mock.patch("tethys_cli.scaffold_commands.shutil.rmtree") @mock.patch("tethys_cli.scaffold_commands.render_path") - @mock.patch("tethys_cli.scaffold_commands.Path.walk") + @mock.patch("tethys_cli.scaffold_commands.walk") @mock.patch("tethys_cli.scaffold_commands.Path.mkdir") @mock.patch("tethys_cli.scaffold_commands.Path.read_text") @mock.patch("tethys_cli.scaffold_commands.Path.write_text") @@ -267,7 +267,7 @@ def test_scaffold_command_with_not_valid_template( @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") @mock.patch("tethys_cli.scaffold_commands.shutil.rmtree") @mock.patch("tethys_cli.scaffold_commands.render_path") - @mock.patch("tethys_cli.scaffold_commands.Path.walk") + @mock.patch("tethys_cli.scaffold_commands.walk") @mock.patch("tethys_cli.scaffold_commands.Path.mkdir") @mock.patch("tethys_cli.scaffold_commands.Path.read_text") @mock.patch("tethys_cli.scaffold_commands.Path.write_text") @@ -369,7 +369,7 @@ def test_scaffold_command_with_no_extension( @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") @mock.patch("tethys_cli.scaffold_commands.shutil.rmtree") @mock.patch("tethys_cli.scaffold_commands.render_path") - @mock.patch("tethys_cli.scaffold_commands.Path.walk") + @mock.patch("tethys_cli.scaffold_commands.walk") @mock.patch("tethys_cli.scaffold_commands.Path.mkdir") @mock.patch("tethys_cli.scaffold_commands.Path.read_text") @mock.patch("tethys_cli.scaffold_commands.Path.write_text") @@ -519,7 +519,7 @@ def test_scaffold_command_with_wrong_project_name( @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") @mock.patch("tethys_cli.scaffold_commands.shutil.rmtree") @mock.patch("tethys_cli.scaffold_commands.render_path") - @mock.patch("tethys_cli.scaffold_commands.Path.walk") + @mock.patch("tethys_cli.scaffold_commands.walk") @mock.patch("tethys_cli.scaffold_commands.Path.mkdir") @mock.patch("tethys_cli.scaffold_commands.Path.read_text") @mock.patch("tethys_cli.scaffold_commands.Path.write_text") @@ -628,7 +628,7 @@ def test_scaffold_command_with_project_warning( @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") @mock.patch("tethys_cli.scaffold_commands.shutil.rmtree") @mock.patch("tethys_cli.scaffold_commands.render_path") - @mock.patch("tethys_cli.scaffold_commands.Path.walk") + @mock.patch("tethys_cli.scaffold_commands.walk") @mock.patch("tethys_cli.scaffold_commands.Path.mkdir") @mock.patch("tethys_cli.scaffold_commands.Path.read_text") @mock.patch("tethys_cli.scaffold_commands.Path.write_text") @@ -746,7 +746,7 @@ def test_scaffold_command_with_no_defaults( @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") @mock.patch("tethys_cli.scaffold_commands.shutil.rmtree") @mock.patch("tethys_cli.scaffold_commands.render_path") - @mock.patch("tethys_cli.scaffold_commands.Path.walk") + @mock.patch("tethys_cli.scaffold_commands.walk") @mock.patch("tethys_cli.scaffold_commands.Path.mkdir") @mock.patch("tethys_cli.scaffold_commands.Path.read_text") @mock.patch("tethys_cli.scaffold_commands.Template") @@ -841,7 +841,7 @@ def test_scaffold_command_with_no_defaults_input_exception( @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") @mock.patch("tethys_cli.scaffold_commands.shutil.rmtree") @mock.patch("tethys_cli.scaffold_commands.render_path") - @mock.patch("tethys_cli.scaffold_commands.Path.walk") + @mock.patch("tethys_cli.scaffold_commands.walk") @mock.patch("tethys_cli.scaffold_commands.Path.mkdir") @mock.patch("tethys_cli.scaffold_commands.Path.read_text") @mock.patch("tethys_cli.scaffold_commands.Path.write_text") @@ -968,7 +968,7 @@ def test_scaffold_command_with_no_defaults_invalid_response( @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") @mock.patch("tethys_cli.scaffold_commands.shutil.rmtree") @mock.patch("tethys_cli.scaffold_commands.render_path") - @mock.patch("tethys_cli.scaffold_commands.Path.walk") + @mock.patch("tethys_cli.scaffold_commands.walk") @mock.patch("tethys_cli.scaffold_commands.Path.mkdir") @mock.patch("tethys_cli.scaffold_commands.Path.read_text") @mock.patch("tethys_cli.scaffold_commands.Path.write_text") @@ -1081,7 +1081,7 @@ def test_scaffold_command_with_no_overwrite( @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") @mock.patch("tethys_cli.scaffold_commands.shutil.rmtree") @mock.patch("tethys_cli.scaffold_commands.render_path") - @mock.patch("tethys_cli.scaffold_commands.Path.walk") + @mock.patch("tethys_cli.scaffold_commands.walk") @mock.patch("tethys_cli.scaffold_commands.Path.mkdir") @mock.patch("tethys_cli.scaffold_commands.Path.read_text") @mock.patch("tethys_cli.scaffold_commands.Template") @@ -1175,7 +1175,7 @@ def test_scaffold_command_with_no_overwrite_keyboard_interrupt( @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") @mock.patch("tethys_cli.scaffold_commands.shutil.rmtree") @mock.patch("tethys_cli.scaffold_commands.render_path") - @mock.patch("tethys_cli.scaffold_commands.Path.walk") + @mock.patch("tethys_cli.scaffold_commands.walk") @mock.patch("tethys_cli.scaffold_commands.Path.mkdir") @mock.patch("tethys_cli.scaffold_commands.Path.read_text") @mock.patch("tethys_cli.scaffold_commands.Template") @@ -1269,7 +1269,7 @@ def test_scaffold_command_with_no_overwrite_cancel( @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") @mock.patch("tethys_cli.scaffold_commands.shutil.rmtree") @mock.patch("tethys_cli.scaffold_commands.render_path") - @mock.patch("tethys_cli.scaffold_commands.Path.walk") + @mock.patch("tethys_cli.scaffold_commands.walk") @mock.patch("tethys_cli.scaffold_commands.Path.mkdir") @mock.patch("tethys_cli.scaffold_commands.Path.read_text") @mock.patch("tethys_cli.scaffold_commands.Template") diff --git a/tethys_apps/base/paths.py b/tethys_apps/base/paths.py index 5658ac68e..d992043a8 100644 --- a/tethys_apps/base/paths.py +++ b/tethys_apps/base/paths.py @@ -11,6 +11,7 @@ import shutil import logging from pathlib import Path +from os import walk from django.conf import settings from django.utils.functional import wraps @@ -85,7 +86,7 @@ def files(self, names_only=False): tethys_path.files(names_only=True) """ - path, dirs, files = next(self.path.walk()) + path, dirs, files = next(walk(self.path)) if names_only: return files return [self.path / f for f in files] @@ -111,7 +112,7 @@ def directories(self, names_only=False): tethys_path.directories(names_only=True) """ - path, dirs, files = next(self.path.walk()) + path, dirs, files = next(walk(self.path)) if names_only: return dirs return [self.path / d for d in dirs] diff --git a/tethys_cli/scaffold_commands.py b/tethys_cli/scaffold_commands.py index a3efa5a99..1e08fd44d 100644 --- a/tethys_cli/scaffold_commands.py +++ b/tethys_cli/scaffold_commands.py @@ -3,6 +3,7 @@ import random import shutil from pathlib import Path +from os import walk from jinja2 import Template from tethys_cli.cli_colors import write_pretty_output, FG_RED, FG_YELLOW, FG_WHITE @@ -417,7 +418,7 @@ def scaffold_command(args): 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 template_root.walk(): + for curr_template_root, _, template_files in walk(template_root): curr_project_root = str(curr_template_root).replace( str(template_root), str(project_root) ) From 58bd925b898b8c4eaff13ab515cc8ee52de1fcdb Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 25 Nov 2024 13:24:12 -0700 Subject: [PATCH 34/36] Replaces odd Namespace usage with UrlMap --- .../test_tethys_apps/test_base/test_app_base.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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..53ab14664 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,13 +6,13 @@ 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, TethysAppSettingNotAssigned, ) import tethys_apps.base.app_base as tethys_app_base +from tethys_apps.base.url_map import UrlMapBase from tethys_apps.base.paths import TethysPath from tethys_apps.base.permissions import Permission, PermissionGroup from ... import UserFactory @@ -1558,11 +1558,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), + UrlMapBase(name="exclude_page", url="", controller=None, title="Exclude Page", index=-1), + UrlMapBase(name="last_page", url="", controller=None, title="Last Page", index=3), + UrlMapBase(name="third_page", url="", controller=None, title="Third Page", index=2), + UrlMapBase(name="second_page", url="", controller=None, title="Second Page", index=1), + UrlMapBase(name="home", url="", controller=None, title="Home", index=0), ] links = app.navigation_links From 7b8a45c49431749ef6789edce1b15b0ab12a8602 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 25 Nov 2024 13:26:57 -0700 Subject: [PATCH 35/36] Applies black formatting --- .../test_base/test_app_base.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) 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 53ab14664..29aa25ea0 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 @@ -1558,10 +1558,26 @@ def test_navigation_links_auto_excluded_page(self): app.root_url = "test-app" app._registered_url_maps = [ - UrlMapBase(name="exclude_page", url="", controller=None, title="Exclude Page", index=-1), - UrlMapBase(name="last_page", url="", controller=None, title="Last Page", index=3), - UrlMapBase(name="third_page", url="", controller=None, title="Third Page", index=2), - UrlMapBase(name="second_page", url="", controller=None, title="Second Page", index=1), + UrlMapBase( + name="exclude_page", + url="", + controller=None, + title="Exclude Page", + index=-1, + ), + UrlMapBase( + name="last_page", url="", controller=None, title="Last Page", index=3 + ), + UrlMapBase( + name="third_page", url="", controller=None, title="Third Page", index=2 + ), + UrlMapBase( + name="second_page", + url="", + controller=None, + title="Second Page", + index=1, + ), UrlMapBase(name="home", url="", controller=None, title="Home", index=0), ] From b3bb243490e7d38c490b88db703bfe164ef8f2c7 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 25 Nov 2024 13:37:38 -0700 Subject: [PATCH 36/36] Separates channels and daphne --- environment.yml | 3 ++- micro_environment.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/environment.yml b/environment.yml index 67d2fa703..418a43c20 100644 --- a/environment.yml +++ b/environment.yml @@ -21,7 +21,8 @@ dependencies: # core dependencies - django>=3.2,<6 - - channels["daphne"] + - channels + - daphne - setuptools_scm - pip - requests # required by lots of things diff --git a/micro_environment.yml b/micro_environment.yml index 1151e14b6..634a284da 100644 --- a/micro_environment.yml +++ b/micro_environment.yml @@ -20,7 +20,8 @@ dependencies: # core dependencies - django>=3.2,<6 - - channels["daphne"] + - channels + - daphne - setuptools_scm - pip - requests # required by lots of things