diff --git a/.github/workflows/tethys.yml b/.github/workflows/tethys.yml index 27496d9a5..01dd844e4 100644 --- a/.github/workflows/tethys.yml +++ b/.github/workflows/tethys.yml @@ -220,7 +220,7 @@ jobs: run: | cd .. . ~/miniconda/etc/profile.d/conda.sh; - conda create -y -c conda-forge -n conda-build conda-build anaconda-client + conda create -y -c conda-forge -n conda-build conda-build anaconda-client conda activate conda-build conda config --set anaconda_upload no mkdir -p ~/conda-bld @@ -259,7 +259,7 @@ jobs: run: | cd .. . ~/miniconda/etc/profile.d/conda.sh; - conda create -y -c conda-forge -n conda-build conda-build anaconda-client + conda create -y -c conda-forge -n conda-build conda-build anaconda-client conda activate conda-build conda config --set anaconda_upload no mkdir -p ~/conda-bld diff --git a/docs/conf.py b/docs/conf.py index 6cc819a11..f51526c28 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,10 +11,10 @@ # # All configuration values have a default; values that are commented out # serve to show the default. -import os import sys import subprocess from dataclasses import asdict +from os import environ from pathlib import Path from unittest import mock @@ -106,7 +106,7 @@ def __getattr__(cls, name): # patcher.start() # Fixes django settings module problem -sys.path.insert(0, os.path.abspath("..")) +sys.path.insert(0, Path("..").absolute().resolve()) installed_apps = [ "django.contrib.admin", @@ -165,7 +165,7 @@ def __getattr__(cls, name): copyright = "2023, Tethys Platform" # on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org -on_rtd = os.environ.get("READTHEDOCS") == "True" +on_rtd = environ.get("READTHEDOCS") == "True" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -178,7 +178,7 @@ def __getattr__(cls, name): print(f'Using simplified version "{version}"') # Determine branch -git_directory = Path(__file__).parent.parent +git_directory = Path(__file__).parents[1] ret = subprocess.run( ["git", "-C", git_directory, "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, @@ -256,10 +256,10 @@ def __getattr__(cls, name): todo_emit_warnings = True # Define the canonical URL if you are using a custom domain on Read the Docs -html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "") +html_baseurl = environ.get("READTHEDOCS_CANONICAL_URL", "") # Tell Jinja2 templates the build is running on Read the Docs -if os.environ.get("READTHEDOCS", "") == "True": +if environ.get("READTHEDOCS", "") == "True": if "html_context" not in globals(): html_context = {} html_context["READTHEDOCS"] = True diff --git a/tests/apps/tethysapp-test_app/install-with-post.yml b/tests/apps/tethysapp-test_app/install-with-post.yml index b366a4cd5..20ab50fa1 100644 --- a/tests/apps/tethysapp-test_app/install-with-post.yml +++ b/tests/apps/tethysapp-test_app/install-with-post.yml @@ -11,4 +11,4 @@ requirements: - geojson - shapely post: - - ./test.sh \ No newline at end of file + - ./test.py \ No newline at end of file diff --git a/tests/apps/tethysapp-test_app/test.py b/tests/apps/tethysapp-test_app/test.py new file mode 100644 index 000000000..1ff8e07c7 --- /dev/null +++ b/tests/apps/tethysapp-test_app/test.py @@ -0,0 +1 @@ +print("test") diff --git a/tests/apps/tethysapp-test_app/test.sh b/tests/apps/tethysapp-test_app/test.sh deleted file mode 100755 index 739dc1183..000000000 --- a/tests/apps/tethysapp-test_app/test.sh +++ /dev/null @@ -1 +0,0 @@ -echo "test" \ No newline at end of file diff --git a/tests/coverage.cfg b/tests/coverage.cfg index 6604f1acd..dca6481c0 100644 --- a/tests/coverage.cfg +++ b/tests/coverage.cfg @@ -3,6 +3,8 @@ [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_compute $TETHYS_TEST_DIR/../tethys_config $TETHYS_TEST_DIR/../tethys_gizmos diff --git a/tests/unit_tests/test_tethys_apps/test_admin.py b/tests/unit_tests/test_tethys_apps/test_admin.py index db2d06a39..fabd8e063 100644 --- a/tests/unit_tests/test_tethys_apps/test_admin.py +++ b/tests/unit_tests/test_tethys_apps/test_admin.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path import unittest from unittest import mock from django.db.utils import ProgrammingError @@ -47,8 +47,8 @@ class TestTethysAppAdmin(unittest.TestCase): def setUp(self): from tethys_apps.models import TethysApp - self.src_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - self.root_app_path = os.path.join(self.src_dir, "apps", "tethysapp-test_app") + self.src_dir = Path(__file__).parents[1] + self.root_app_path = self.src_dir / "apps" / "tethysapp-test_app" self.app_model = TethysApp(name="admin_test_app", package="admin_test_app") self.app_model.save() self.proxy_app_model = ProxyApp( 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 9f3c6f08e..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 @@ -12,6 +12,7 @@ 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 @@ -1543,3 +1544,52 @@ def test_remove_from_db_2(self, mock_ta, mock_log): # Check tethys log error mock_log.error.assert_called() + + def test_navigation_links_not_auto(self): + app = tethys_app_base.TethysAppBase() + app.nav_links = ["test", "1", "2", "3"] + links = app.navigation_links + self.assertListEqual(links, app.nav_links) + + def test_navigation_links_auto_excluded_page(self): + app = tethys_app_base.TethysAppBase() + app.nav_links = "auto" + app.index = "home" + 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="home", url="", controller=None, title="Home", index=0), + ] + + links = app.navigation_links + + self.assertListEqual( + links, + [ + {"title": "Home", "href": "/apps/test-app/"}, + {"title": "Second Page", "href": "/apps/test-app/second-page/"}, + {"title": "Third Page", "href": "/apps/test-app/third-page/"}, + {"title": "Last Page", "href": "/apps/test-app/last-page/"}, + ], + ) + self.assertEqual(links, app.nav_links) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_consumer.py b/tests/unit_tests/test_tethys_apps/test_base/test_consumer.py index a7589dcdc..6a83e4129 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_consumer.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_consumer.py @@ -1,6 +1,6 @@ -import os import json import asyncio +from os import environ from tethys_sdk.testing import TethysTestCase from channels.testing import WebsocketCommunicator @@ -15,7 +15,7 @@ def test_consumer(self): asyncio.set_event_loop(event_loop) async def run_test(): - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") + environ.setdefault("DJANGO_SETTINGS_MODULE", "tethys_portal.settings") settings.CHANNEL_LAYERS = {} communicator = WebsocketCommunicator( 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..35d841df4 --- /dev/null +++ b/tests/unit_tests/test_tethys_apps/test_base/test_page_handler.py @@ -0,0 +1,307 @@ +import sys +from unittest import TestCase, mock +from importlib import reload + +from tethys_apps.base import page_handler +import tethys_apps.base.controller as tethys_controller + + +class TestPageHandler(TestCase): + @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_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_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", + "extras", + ], + ) + 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) + + +class TestPageComponentWrapper(TestCase): + + @classmethod + 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_reactpy = mock.MagicMock() + sys.modules["reactpy"] = mock_reactpy + mock_reactpy.component = lambda x: x + reload(page_handler) + + @classmethod + def tearDownClass(cls): + mock.patch.stopall() + del sys.modules["reactpy"] + reload(page_handler) + + 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_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.registered_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, + ) + + 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): + @mock.patch("tethys_apps.base.controller._process_url_kwargs") + @mock.patch("tethys_apps.base.controller.global_page_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"] + my_function = lambda x: x # noqa E731 + + 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, + )(my_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=my_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_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, + ): + my_function = lambda x: x # noqa: E731 + return_value = tethys_controller.page()(my_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=my_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=my_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_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 # noqa: E731 + 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/tests/unit_tests/test_tethys_apps/test_base/test_url_map.py b/tests/unit_tests/test_tethys_apps/test_base/test_url_map.py index e6ab12726..d1308fc07 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_url_map.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_url_map.py @@ -93,7 +93,7 @@ def test_UrlMap_repr(self): "[0-9A-Za-z-_.]+)/$," " controller=foo_app.controllers.foo, protocol=http," - " handler=None, handler_type=None>" + " handler=None, handler_type=None, title=None, index=None>" ) result = self.bound_UrlMap( name=self.name, url=url, controller=self.controller 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 681321f61..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 @@ -1,7 +1,7 @@ import unittest import tethys_apps.base.workspace as base_workspace -import os import shutil +from pathlib import Path from unittest import mock from ... import UserFactory from django.http import HttpRequest @@ -42,72 +42,72 @@ class TethysAppChild(tethys_app_base.TethysAppBase): class TestUrlMap(unittest.TestCase): def setUp(self): - self.root = os.path.abspath(os.path.dirname(__file__)) - self.test_root = os.path.join(self.root, "test_workspace") - self.test_root_a = os.path.join(self.test_root, "test_workspace_a") - self.test_root2 = os.path.join(self.root, "test_workspace2") + self.root = Path(__file__).parent.absolute() + self.test_root = self.root / "test_workspace" + self.test_root_a = self.test_root / "test_workspace_a" + self.test_root2 = self.root / "test_workspace2" self.app = tethys_app_base.TethysAppBase() self.user = UserFactory() def tearDown(self): - if os.path.isdir(self.test_root): + if self.test_root.is_dir(): shutil.rmtree(self.test_root) - if os.path.isdir(self.test_root2): + if self.test_root2.is_dir(): shutil.rmtree(self.test_root2) def test_TethysWorkspace(self): # Test Create new workspace folder test_workspace - result = base_workspace.TethysWorkspace(path=self.test_root) + result = base_workspace.TethysWorkspace(path=str(self.test_root)) workspace = ''.format(self.test_root) # Create new folder inside test_workspace - base_workspace.TethysWorkspace(path=self.test_root_a) + base_workspace.TethysWorkspace(path=str(self.test_root_a)) # Create new folder test_workspace2 - base_workspace.TethysWorkspace(path=self.test_root2) + base_workspace.TethysWorkspace(path=str(self.test_root2)) self.assertEqual(result.__repr__(), workspace) - self.assertEqual(result.path, self.test_root) + self.assertEqual(result.path, str(self.test_root)) # Create Files file_list = ["test1.txt", "test2.txt"] for file_name in file_list: # Create file - open(os.path.join(self.test_root, file_name), "a").close() + (self.test_root / file_name).touch() # Test files with full path - result = base_workspace.TethysWorkspace(path=self.test_root).files( + result = base_workspace.TethysWorkspace(path=str(self.test_root)).files( full_path=True ) for file_name in file_list: - self.assertIn(os.path.join(self.test_root, file_name), result) + self.assertIn(str(self.test_root / file_name), result) # Test files without full path - result = base_workspace.TethysWorkspace(path=self.test_root).files() + result = base_workspace.TethysWorkspace(path=str(self.test_root)).files() for file_name in file_list: self.assertIn(file_name, result) # Test Directories with full path - result = base_workspace.TethysWorkspace(path=self.root).directories( + result = base_workspace.TethysWorkspace(path=str(self.root)).directories( full_path=True ) - self.assertIn(self.test_root, result) - self.assertIn(self.test_root2, result) + self.assertIn(str(self.test_root), result) + self.assertIn(str(self.test_root2), result) # Test Directories without full path - result = base_workspace.TethysWorkspace(path=self.root).directories() + result = base_workspace.TethysWorkspace(path=str(self.root)).directories() self.assertIn("test_workspace", result) self.assertIn("test_workspace2", result) self.assertNotIn(self.test_root, result) self.assertNotIn(self.test_root2, result) # Write to file - f = open(os.path.join(self.test_root, "test2.txt"), "w") - f.write("Hello World") - f.close() + (self.test_root / "test2.txt").write_text("Hello World") # Test size greater than zero - workspace_size = base_workspace.TethysWorkspace(path=self.test_root).get_size() + workspace_size = base_workspace.TethysWorkspace( + path=str(self.test_root) + ).get_size() self.assertTrue(workspace_size > 0) # Test get size unit conversion @@ -117,64 +117,62 @@ def test_TethysWorkspace(self): self.assertEqual(workspace_size / 1024, workspace_size_kb) # Test Remove file - base_workspace.TethysWorkspace(path=self.test_root).remove("test2.txt") + base_workspace.TethysWorkspace(path=str(self.test_root)).remove("test2.txt") # Verify that the file has been remove - self.assertFalse(os.path.isfile(os.path.join(self.test_root, "test2.txt"))) + self.assertFalse((self.test_root / "test2.txt").is_file()) # Test Remove Directory - base_workspace.TethysWorkspace(path=self.root).remove(self.test_root2) + base_workspace.TethysWorkspace(path=str(self.root)).remove(str(self.test_root2)) # Verify that the Directory has been remove - self.assertFalse(os.path.isdir(self.test_root2)) + self.assertFalse(self.test_root2.is_dir()) # Test Clear - base_workspace.TethysWorkspace(path=self.test_root).clear() + base_workspace.TethysWorkspace(path=str(self.test_root)).clear() # Test size equal to zero - workspace_size = base_workspace.TethysWorkspace(path=self.test_root).get_size() + workspace_size = base_workspace.TethysWorkspace( + path=str(self.test_root) + ).get_size() self.assertTrue(workspace_size == 0) # Verify that the Directory has been remove - self.assertFalse(os.path.isdir(self.test_root_a)) + self.assertFalse(self.test_root_a.is_dir()) # Verify that the File has been remove - self.assertFalse(os.path.isfile(os.path.join(self.test_root, "test1.txt"))) + self.assertFalse((self.test_root / "test1.txt").is_file()) # Test don't allow overwriting the path property - workspace = base_workspace.TethysWorkspace(path=self.test_root) + workspace = base_workspace.TethysWorkspace(path=str(self.test_root)) workspace.path = "foo" - self.assertEqual(self.test_root, workspace.path) + self.assertEqual(str(self.test_root), workspace.path) @mock.patch("tethys_apps.base.workspace.TethysWorkspace") def test__get_user_workspace_user(self, mock_tws): ret = _get_user_workspace(self.app, self.user) - expected_path = os.path.join( - "workspaces", "user_workspaces", self.user.username - ) + expected_path = Path("workspaces") / "user_workspaces" / self.user.username rts_call_args = mock_tws.call_args_list self.assertEqual(ret, mock_tws()) - self.assertIn(expected_path, rts_call_args[0][0][0]) + self.assertIn(str(expected_path), rts_call_args[0][0][0]) @mock.patch("tethys_apps.base.workspace.TethysWorkspace") def test__get_user_workspace_http(self, mock_tws): request = HttpRequest() request.user = self.user ret = _get_user_workspace(self.app, request) - expected_path = os.path.join( - "workspaces", "user_workspaces", self.user.username - ) + expected_path = Path("workspaces") / "user_workspaces" / self.user.username rts_call_args = mock_tws.call_args_list self.assertEqual(ret, mock_tws()) - self.assertIn(expected_path, rts_call_args[0][0][0]) + self.assertIn(str(expected_path), rts_call_args[0][0][0]) @mock.patch("tethys_apps.base.workspace.TethysWorkspace") def test__get_user_workspace_none(self, mock_tws): ret = _get_user_workspace(self.app, None) - expected_path = os.path.join("workspaces", "user_workspaces", "anonymous_user") + expected_path = Path("workspaces") / "user_workspaces" / "anonymous_user" rts_call_args = mock_tws.call_args_list self.assertEqual(ret, mock_tws()) - self.assertIn(expected_path, rts_call_args[0][0][0]) + self.assertIn(str(expected_path), rts_call_args[0][0][0]) def test__get_user_workspace_error(self): with self.assertRaises(ValueError) as context: @@ -285,9 +283,9 @@ def test_user_workspace_decorator_HttpRequest_not_given(self): def test__get_app_workspace(self, mock_tws): ret = _get_app_workspace(self.app) self.assertEqual(ret, mock_tws()) - expected_path = os.path.join("workspaces", "app_workspace") + expected_path = Path("workspaces") / "app_workspace" rts_call_args = mock_tws.call_args_list - self.assertIn(expected_path, rts_call_args[0][0][0]) + self.assertIn(str(expected_path), rts_call_args[0][0][0]) @mock.patch("tethys_apps.base.workspace.passes_quota", return_value=True) @mock.patch("tethys_apps.utilities.get_active_app") diff --git a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_collectworkspaces.py b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_collectworkspaces.py index 7042ecbb9..933bc5b28 100644 --- a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_collectworkspaces.py +++ b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_collectworkspaces.py @@ -1,5 +1,6 @@ import unittest from unittest import mock +from pathlib import Path from tethys_apps.management.commands import collectworkspaces @@ -46,8 +47,8 @@ def test_collectworkspaces_handle_no_atts( @mock.patch("tethys_apps.management.commands.collectworkspaces.print") @mock.patch("tethys_apps.management.commands.pre_collectstatic.shutil.move") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.makedirs") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.isdir") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.mkdir") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_dir") @mock.patch( "tethys_apps.management.commands.collectworkspaces.get_installed_tethys_items" ) @@ -56,28 +57,29 @@ def test_collectworkspaces_handle_no_force_not_dir( self, mock_settings, mock_get_apps, - mock_os_path_isdir, - mock_os_makedirs, + mock_is_dir, + mock_mkdir, mock_shutil_move, mock_print, ): - mock_settings.TETHYS_WORKSPACES_ROOT = "/foo/workspace" - mock_get_apps.return_value = {"foo_app": "/foo/testing/tests/foo_app"} - mock_os_path_isdir.return_value = False + mock_settings.TETHYS_WORKSPACES_ROOT = Path.home() / "foo" / "workspace" + mock_get_apps.return_value = { + "foo_app": Path.home() / "foo" / "testing" / "tests" / "foo_app" + } + mock_is_dir.return_value = False cmd = collectworkspaces.Command() cmd.handle(force=False) mock_get_apps.assert_called_once() - mock_os_path_isdir.assert_called() - mock_os_makedirs.assert_called_once_with( - "/foo/testing/tests/foo_app/workspaces", exist_ok=True - ) + mock_is_dir.assert_called() + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) mock_shutil_move.assert_called_once_with( - "/foo/testing/tests/foo_app/workspaces", "/foo/workspace/foo_app" + Path.home() / "foo" / "testing" / "tests" / "foo_app" / "workspaces", + Path.home() / "foo" / "workspace" / "foo_app", ) - msg_info = 'INFO: Moving workspace directories of apps to "/foo/workspace" and linking back.' + msg_info = f'INFO: Moving workspace directories of apps to "{Path.home() / "foo" / "workspace"}" and linking back.' msg_warning = 'WARNING: The workspace_path for app "foo_app" is not a directory. Making workspace directory...' @@ -88,8 +90,8 @@ def test_collectworkspaces_handle_no_force_not_dir( self.assertEqual(msg_warning, print_call_args[1][0][0]) @mock.patch("tethys_apps.management.commands.collectworkspaces.print") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.islink") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.isdir") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_symlink") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_dir") @mock.patch( "tethys_apps.management.commands.collectworkspaces.get_installed_tethys_items" ) @@ -98,34 +100,32 @@ def test_collectworkspaces_handle_no_force_is_link( self, mock_settings, mock_get_apps, - mock_os_path_isdir, - mock_os_path_islink, + mock_is_dir, + mock_is_symlink, mock_print, ): - mock_settings.TETHYS_WORKSPACES_ROOT = "/foo/workspace" - mock_get_apps.return_value = {"foo_app": "/foo/testing/tests/foo_app"} - mock_os_path_isdir.return_value = True - mock_os_path_islink.return_value = True + mock_settings.TETHYS_WORKSPACES_ROOT = Path.home() / "foo" / "workspace" + mock_get_apps.return_value = { + "foo_app": Path.home() / "foo" / "testing" / "tests" / "foo_app" + } + mock_is_dir.return_value = True + mock_is_symlink.return_value = True cmd = collectworkspaces.Command() cmd.handle(force=False) mock_get_apps.assert_called_once() - mock_os_path_isdir.assert_called_once_with( - "/foo/testing/tests/foo_app/workspaces" - ) - mock_os_path_islink.assert_called_once_with( - "/foo/testing/tests/foo_app/workspaces" - ) - msg_in = 'INFO: Moving workspace directories of apps to "/foo/workspace" and linking back.' + mock_is_dir.assert_called_once() + mock_is_symlink.assert_called_once() + msg_in = f'INFO: Moving workspace directories of apps to "{Path.home() / "foo" / "workspace"}" and linking back.' mock_print.assert_called_with(msg_in) @mock.patch("tethys_apps.management.commands.collectworkspaces.print") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.symlink") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.symlink_to") @mock.patch("tethys_apps.management.commands.pre_collectstatic.shutil.move") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.exists") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.islink") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.isdir") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.exists") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_symlink") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_dir") @mock.patch( "tethys_apps.management.commands.collectworkspaces.get_installed_tethys_items" ) @@ -134,35 +134,35 @@ def test_collectworkspaces_handle_not_exists( self, mock_settings, mock_get_apps, - mock_os_path_isdir, - mock_os_path_islink, - mock_os_path_exists, + mock_is_dir, + mock_is_symlink, + mock_path_exists, mock_shutil_move, - mock_os_symlink, + mock_symlink_to, mock_print, ): - mock_settings.TETHYS_WORKSPACES_ROOT = "/foo/workspace" - mock_get_apps.return_value = {"foo_app": "/foo/testing/tests/foo_app"} - mock_os_path_isdir.side_effect = [True, True] - mock_os_path_islink.return_value = False - mock_os_path_exists.return_value = False + mock_settings.TETHYS_WORKSPACES_ROOT = Path.home() / "foo" / "workspace" + mock_get_apps.return_value = { + "foo_app": Path.home() / "foo" / "testing" / "tests" / "foo_app" + } + mock_is_dir.side_effect = [True, True] + mock_is_symlink.return_value = False + mock_path_exists.return_value = False mock_shutil_move.return_value = True - mock_os_symlink.return_value = True + mock_symlink_to.return_value = True cmd = collectworkspaces.Command() cmd.handle(force=True) mock_get_apps.assert_called_once() - mock_os_path_isdir.assert_any_call("/foo/testing/tests/foo_app/workspaces") - mock_os_path_isdir.assert_called_with("/foo/workspace/foo_app") - mock_os_path_islink.assert_called_once_with( - "/foo/testing/tests/foo_app/workspaces" - ) - mock_os_path_exists.assert_called_once_with("/foo/workspace/foo_app") + self.assertEqual(mock_is_dir.call_count, 2) + mock_is_symlink.assert_called_once() + mock_path_exists.assert_called_once() mock_shutil_move.assert_called_once_with( - "/foo/testing/tests/foo_app/workspaces", "/foo/workspace/foo_app" + Path.home() / "foo" / "testing" / "tests" / "foo_app" / "workspaces", + Path.home() / "foo" / "workspace" / "foo_app", ) - msg_first_info = 'INFO: Moving workspace directories of apps to "/foo/workspace" and linking back.' + msg_first_info = f'INFO: Moving workspace directories of apps to "{Path.home() / "foo" / "workspace"}" and linking back.' msg_second_info = ( 'INFO: Successfully linked "workspaces" directory to ' 'TETHYS_WORKSPACES_ROOT for app "foo_app".' @@ -176,11 +176,11 @@ def test_collectworkspaces_handle_not_exists( @mock.patch("tethys_apps.management.commands.collectworkspaces.print") @mock.patch("tethys_apps.management.commands.pre_collectstatic.shutil.rmtree") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.symlink") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.symlink_to") @mock.patch("tethys_apps.management.commands.pre_collectstatic.shutil.move") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.exists") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.islink") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.isdir") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.exists") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_symlink") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_dir") @mock.patch( "tethys_apps.management.commands.collectworkspaces.get_installed_tethys_items" ) @@ -189,40 +189,38 @@ def test_collectworkspaces_handle_exists_no_force( self, mock_settings, mock_get_apps, - mock_os_path_isdir, - mock_os_path_islink, - mock_os_path_exists, + mock_is_dir, + mock_is_symlink, + mock_path_exists, mock_shutil_move, - mock_os_symlink, + mock_symlink_to, mock_shutil_rmtree, mock_print, ): app_name = "foo_app" - app_path = f"/foo/testing/tests/{app_name}" - app_ws_path = f"{app_path}/workspaces" - tethys_workspaces_root = "/foo/workspace" - app_workspaces_root = f"{tethys_workspaces_root}/{app_name}" + app_path = Path.home() / "foo" / "testing" / "tests" / app_name + app_ws_path = app_path / "workspaces" + tethys_workspaces_root = Path.home() / "foo" / "workspace" mock_settings.TETHYS_WORKSPACES_ROOT = tethys_workspaces_root mock_get_apps.return_value = {app_name: app_path} - mock_os_path_isdir.side_effect = [True, True] - mock_os_path_islink.return_value = False - mock_os_path_exists.return_value = True + mock_is_dir.side_effect = [True, True] + mock_is_symlink.return_value = False + mock_path_exists.return_value = True mock_shutil_move.return_value = True - mock_os_symlink.return_value = True + mock_symlink_to.return_value = True mock_shutil_rmtree.return_value = True cmd = collectworkspaces.Command() cmd.handle(force=False) mock_get_apps.assert_called_once() - mock_os_path_isdir.assert_any_call(app_ws_path) - mock_os_path_isdir.assert_called_with(app_workspaces_root) - mock_os_path_islink.assert_called_once_with(app_ws_path) - mock_os_path_exists.assert_called_once_with(app_workspaces_root) + self.assertEqual(mock_is_dir.call_count, 2) + mock_is_symlink.assert_called_once() + mock_path_exists.assert_called_once() mock_shutil_move.assert_not_called() mock_shutil_rmtree.assert_called_once_with(app_ws_path, ignore_errors=True) - msg_first_info = 'INFO: Moving workspace directories of apps to "/foo/workspace" and linking back.' + msg_first_info = f'INFO: Moving workspace directories of apps to "{Path.home() / "foo" / "workspace"}" and linking back.' msg_warning = ( 'WARNING: Workspace directory for app "foo_app" already exists in the ' @@ -243,13 +241,13 @@ def test_collectworkspaces_handle_exists_no_force( self.assertEqual(msg_second_info, print_call_args[2][0][0]) @mock.patch("tethys_apps.management.commands.collectworkspaces.print") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.remove") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.unlink") @mock.patch("tethys_apps.management.commands.pre_collectstatic.shutil.rmtree") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.symlink") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.symlink_to") @mock.patch("tethys_apps.management.commands.pre_collectstatic.shutil.move") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.exists") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.islink") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.isdir") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.exists") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_symlink") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_dir") @mock.patch( "tethys_apps.management.commands.collectworkspaces.get_installed_tethys_items" ) @@ -258,45 +256,44 @@ def test_collectworkspaces_handle_exists_force_exception( self, mock_settings, mock_get_apps, - mock_os_path_isdir, - mock_os_path_islink, - mock_os_path_exists, + mock_is_dir, + mock_is_symlink, + mock_path_exists, mock_shutil_move, - mock_os_symlink, + mock_symlink_to, mock_shutil_rmtree, - mock_os_remove, + mock_unlink, mock_print, ): app_name = "foo_app" - app_path = f"/foo/testing/tests/{app_name}" - app_ws_path = f"{app_path}/workspaces" - tethys_workspaces_root = "/foo/workspace" - app_workspaces_root = f"{tethys_workspaces_root}/{app_name}" + app_path = Path.home() / "foo" / "testing" / "tests" / app_name + app_ws_path = app_path / "workspaces" + tethys_workspaces_root = Path.home() / "foo" / "workspace" + app_workspaces_root = tethys_workspaces_root / app_name mock_settings.TETHYS_WORKSPACES_ROOT = tethys_workspaces_root mock_get_apps.return_value = {app_name: app_path} - mock_os_path_isdir.side_effect = [True, True] - mock_os_path_islink.return_value = False - mock_os_path_exists.return_value = True + mock_is_dir.side_effect = [True, True] + mock_is_symlink.return_value = False + mock_path_exists.return_value = True mock_shutil_move.return_value = True - mock_os_symlink.return_value = True + mock_symlink_to.return_value = True mock_shutil_rmtree.return_value = True - mock_os_remove.side_effect = OSError + mock_unlink.side_effect = OSError cmd = collectworkspaces.Command() cmd.handle(force=True) mock_get_apps.assert_called_once() - mock_os_path_isdir.assert_any_call(app_ws_path) - mock_os_path_isdir.assert_called_with(app_workspaces_root) - mock_os_path_islink.assert_called_once_with(app_ws_path) - mock_os_path_exists.assert_called_once_with(app_workspaces_root) + self.assertEqual(mock_is_dir.call_count, 2) + mock_is_symlink.assert_called_once() + mock_path_exists.assert_called_once() mock_shutil_move.assert_called_once_with(app_ws_path, app_workspaces_root) mock_shutil_rmtree.assert_called_once_with( app_workspaces_root, ignore_errors=True ) - mock_os_remove.assert_called_once_with(app_workspaces_root) + mock_unlink.assert_called_once() - msg_first_info = 'INFO: Moving workspace directories of apps to "/foo/workspace" and linking back.' + msg_first_info = f'INFO: Moving workspace directories of apps to "{Path.home() / "foo" / "workspace"}" and linking back.' msg_second_info = ( 'INFO: Successfully linked "workspaces" directory to ' diff --git a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_pre_collectstatic.py b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_pre_collectstatic.py index fc2a2ec9c..7e91dbc4a 100644 --- a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_pre_collectstatic.py +++ b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_pre_collectstatic.py @@ -1,5 +1,6 @@ import unittest from unittest import mock +from pathlib import Path from tethys_apps.management.commands import pre_collectstatic @@ -46,8 +47,8 @@ def test_handle_no_static_root(self, mock_settings, mock_exit, mock_print): @mock.patch("tethys_apps.management.commands.pre_collectstatic.print") @mock.patch("tethys_apps.management.commands.pre_collectstatic.shutil.copytree") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.isdir") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.remove") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_dir") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.unlink") @mock.patch( "tethys_apps.management.commands.pre_collectstatic.get_installed_tethys_items" ) @@ -56,29 +57,25 @@ def test_handle__not_named_static_or_public( self, mock_settings, mock_get_items, - mock_os_remove, - mock_os_path_isdir, + mock_unlink, + mock_is_dir, mock_shutil_copytree, mock_print, ): options = {"link": False} # Don't create symbolic link (copy instead) - static_root_dir = "/foo/static/root" - app_source_dir = "/foo/sources/foo_app" - ext_source_dir = "/foo/sources/foo_ext" - app_public_dir = app_source_dir + "/public" - ext_public_dir = ext_source_dir + "/public" - app_static_dir = app_source_dir + "/static" - ext_static_dir = ext_source_dir + "/static" + static_root_dir = Path("/") / "foo" / "static" / "root" + app_source_dir = Path("/") / "foo" / "sources" / "foo_app" + ext_source_dir = Path("/") / "foo" / "sources" / "foo_ext" mock_settings.STATIC_ROOT = static_root_dir mock_get_items.return_value = { "foo_app": app_source_dir, "foo_ext": ext_source_dir, } - mock_os_remove.return_value = ( - True # Successfully remove old link or dir with os.remove + mock_unlink.return_value = ( + True # Successfully remove old link or dir with.Path.unlink ) - mock_os_path_isdir.side_effect = ( + mock_is_dir.side_effect = ( False, False, False, @@ -92,13 +89,10 @@ def test_handle__not_named_static_or_public( mock_get_items.assert_called_with(apps=True, extensions=True) # Verify check for public dir was performed for app and extension - mock_os_path_isdir.assert_any_call(app_public_dir) - mock_os_path_isdir.assert_any_call(ext_public_dir) - mock_os_path_isdir.assert_any_call(app_static_dir) - mock_os_path_isdir.assert_any_call(ext_static_dir) + self.assertEqual(mock_is_dir.call_count, 4) # Verify attempt to remove old dirs/links - mock_os_remove.assert_not_called() + mock_unlink.assert_not_called() # Verify attempt to copy public dir to static root location mock_shutil_copytree.assert_not_called() @@ -135,9 +129,9 @@ def test_handle__not_named_static_or_public( @mock.patch("tethys_apps.management.commands.pre_collectstatic.print") @mock.patch("tethys_apps.management.commands.pre_collectstatic.shutil.copytree") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.isdir") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_dir") @mock.patch("tethys_apps.management.commands.pre_collectstatic.shutil.rmtree") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.remove") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.unlink") @mock.patch( "tethys_apps.management.commands.pre_collectstatic.get_installed_tethys_items" ) @@ -146,29 +140,29 @@ def test_handle__public__remove_fail__rmtree_fail( self, mock_settings, mock_get_items, - mock_os_remove, + mock_unlink, mock_shutil_rmtree, - mock_os_path_isdir, + mock_is_dir, mock_shutil_copytree, mock_print, ): options = {"link": False} # Don't create symbolic link (copy instead) - static_root_dir = "/foo/static/root" - app_source_dir = "/foo/sources/foo_app" - ext_source_dir = "/foo/sources/foo_ext" - app_public_dir = app_source_dir + "/public" - ext_public_dir = ext_source_dir + "/public" - app_static_root_dir = static_root_dir + "/foo_app" - ext_static_root_dir = static_root_dir + "/foo_ext" + static_root_dir = Path("/") / "foo" / "static" / "root" + app_source_dir = Path("/") / "foo" / "sources" / "foo_app" + ext_source_dir = Path("/") / "foo" / "sources" / "foo_ext" + app_public_dir = Path(app_source_dir) / "public" + ext_public_dir = Path(ext_source_dir) / "public" + app_static_root_dir = Path(static_root_dir) / "foo_app" + ext_static_root_dir = Path(static_root_dir) / "foo_ext" mock_settings.STATIC_ROOT = static_root_dir mock_get_items.return_value = { "foo_app": app_source_dir, "foo_ext": ext_source_dir, } - mock_os_remove.side_effect = OSError # remove fails + mock_unlink.side_effect = OSError # remove fails mock_shutil_rmtree.side_effect = OSError # rmtree fails - mock_os_path_isdir.side_effect = (True, True) # "public" dir found + mock_is_dir.side_effect = (True, True) # "public" dir found cmd = pre_collectstatic.Command() cmd.handle(**options) @@ -177,12 +171,10 @@ def test_handle__public__remove_fail__rmtree_fail( mock_get_items.assert_called_with(apps=True, extensions=True) # Verify check for public dir was performed for app and extension - mock_os_path_isdir.assert_any_call(app_public_dir) - mock_os_path_isdir.assert_any_call(ext_public_dir) + self.assertEqual(mock_is_dir.call_count, 2) # Verify attempt to remove old dirs/links - mock_os_remove.assert_any_call(app_static_root_dir) - mock_os_remove.assert_any_call(ext_static_root_dir) + self.assertEqual(mock_unlink.call_count, 2) mock_shutil_rmtree.assert_any_call(app_static_root_dir) mock_shutil_rmtree.assert_any_call(ext_static_root_dir) @@ -222,8 +214,8 @@ def test_handle__public__remove_fail__rmtree_fail( @mock.patch("tethys_apps.management.commands.pre_collectstatic.print") @mock.patch("tethys_apps.management.commands.pre_collectstatic.shutil.copytree") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.isdir") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.remove") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_dir") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.unlink") @mock.patch( "tethys_apps.management.commands.pre_collectstatic.get_installed_tethys_items" ) @@ -232,29 +224,29 @@ def test_handle__named_public__copy( self, mock_settings, mock_get_items, - mock_os_remove, - mock_os_path_isdir, + mock_unlink, + mock_is_dir, mock_shutil_copytree, mock_print, ): options = {"link": False} # Don't create symbolic link (copy instead) - static_root_dir = "/foo/static/root" - app_source_dir = "/foo/sources/foo_app" - app_public_dir = app_source_dir + "/public" - ext_source_dir = "/foo/sources/foo_ext" - ext_public_dir = ext_source_dir + "/public" - app_static_root_dir = static_root_dir + "/foo_app" - ext_static_root_dir = static_root_dir + "/foo_ext" + static_root_dir = Path("/") / "foo" / "static" / "root" + app_source_dir = Path("/") / "foo" / "sources" / "foo_app" + app_public_dir = Path(app_source_dir) / "public" + ext_source_dir = Path("/") / "foo" / "sources" / "foo_ext" + ext_public_dir = Path(ext_source_dir) / "public" + app_static_root_dir = Path(static_root_dir) / "foo_app" + ext_static_root_dir = Path(static_root_dir) / "foo_ext" mock_settings.STATIC_ROOT = static_root_dir mock_get_items.return_value = { "foo_app": app_source_dir, "foo_ext": ext_source_dir, } - mock_os_remove.return_value = ( - True # Successfully remove old link or dir with os.remove + mock_unlink.return_value = ( + True # Successfully remove old link or dir with.Path.unlink ) - mock_os_path_isdir.side_effect = (True, True) # "public" test path exists + mock_is_dir.side_effect = (True, True) # "public" test path exists cmd = pre_collectstatic.Command() cmd.handle(**options) @@ -263,12 +255,10 @@ def test_handle__named_public__copy( mock_get_items.assert_called_with(apps=True, extensions=True) # Verify check for public dir was performed for app and extension - mock_os_path_isdir.assert_any_call(app_public_dir) - mock_os_path_isdir.assert_any_call(ext_public_dir) + self.assertEqual(mock_is_dir.call_count, 2) # Verify attempt to remove old dirs/links - mock_os_remove.assert_any_call(app_static_root_dir) - mock_os_remove.assert_any_call(ext_static_root_dir) + self.assertEqual(mock_unlink.call_count, 2) # Verify attempt to copy public dir to static root location mock_shutil_copytree.assert_any_call(app_public_dir, app_static_root_dir) @@ -305,9 +295,9 @@ def test_handle__named_public__copy( self.assertNotEqual(info_not_in_second, print_args[i][0][0]) @mock.patch("tethys_apps.management.commands.pre_collectstatic.print") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.symlink") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.path.isdir") - @mock.patch("tethys_apps.management.commands.pre_collectstatic.os.remove") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.symlink_to") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.is_dir") + @mock.patch("tethys_apps.management.commands.pre_collectstatic.Path.unlink") @mock.patch( "tethys_apps.management.commands.pre_collectstatic.get_installed_tethys_items" ) @@ -316,31 +306,27 @@ def test_handle__named_static__link( self, mock_settings, mock_get_items, - mock_os_remove, - mock_os_path_isdir, - mock_os_symlink, + mock_unlink, + mock_is_dir, + mock_symlink_to, mock_print, ): options = {"link": True} # Create symbolic link (instead of copy) - static_root_dir = "/foo/static/root" - app_source_dir = "/foo/sources/foo_app" - ext_source_dir = "/foo/sources/foo_ext" - app_static_root_dir = static_root_dir + "/foo_app" - ext_static_root_dir = static_root_dir + "/foo_ext" - app_public_dir = app_source_dir + "/public" - ext_public_dir = ext_source_dir + "/public" - app_static_dir = app_source_dir + "/static" - ext_static_dir = ext_source_dir + "/static" + static_root_dir = Path("/") / "foo" / "static" / "root" + app_source_dir = Path("/") / "foo" / "sources" / "foo_app" + ext_source_dir = Path("/") / "foo" / "sources" / "foo_ext" + app_static_root_dir = Path(static_root_dir) / "foo_app" + ext_static_root_dir = Path(static_root_dir) / "foo_ext" mock_settings.STATIC_ROOT = static_root_dir mock_get_items.return_value = { "foo_app": app_source_dir, "foo_ext": ext_source_dir, } - mock_os_remove.return_value = ( - True # Successfully remove old link or dir with os.remove + mock_unlink.return_value = ( + True # Successfully remove old link or dir with.Path.unlink ) - mock_os_path_isdir.side_effect = ( + mock_is_dir.side_effect = ( False, True, False, @@ -354,18 +340,15 @@ def test_handle__named_static__link( mock_get_items.assert_called_with(apps=True, extensions=True) # Verify check for public dir was performed for app and extension - mock_os_path_isdir.assert_any_call(app_public_dir) - mock_os_path_isdir.assert_any_call(ext_public_dir) - mock_os_path_isdir.assert_any_call(app_static_dir) - mock_os_path_isdir.assert_any_call(ext_static_dir) + self.assertEqual(mock_is_dir.call_count, 4) # Verify attempt to remove old dirs/links - mock_os_remove.assert_any_call(app_static_root_dir) - mock_os_remove.assert_any_call(ext_static_root_dir) + self.assertEqual(mock_unlink.call_count, 2) # Verify attempt to copy public dir to static root location - mock_os_symlink.assert_any_call(app_static_dir, app_static_root_dir) - mock_os_symlink.assert_any_call(ext_static_dir, ext_static_root_dir) + self.assertEqual(mock_symlink_to.call_count, 2) + mock_symlink_to.assert_any_call(app_static_root_dir) + mock_symlink_to.assert_any_call(ext_static_root_dir) # Verify messages print_args = mock_print.call_args_list diff --git a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_tethys_app_uninstall.py b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_tethys_app_uninstall.py index 606e9a9ce..dddbe898a 100644 --- a/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_tethys_app_uninstall.py +++ b/tests/unit_tests/test_tethys_apps/test_management/test_commands/test_tethys_app_uninstall.py @@ -57,8 +57,7 @@ def test_tethys_app_uninstall_handle_apps_cancel( @mock.patch( "tethys_apps.management.commands.tethys_app_uninstall.Permission.objects" ) - @mock.patch("tethys_apps.management.commands.tethys_app_uninstall.os.path.join") - @mock.patch("tethys_apps.management.commands.tethys_app_uninstall.os.remove") + @mock.patch("tethys_apps.management.commands.tethys_app_uninstall.Path.unlink") @mock.patch("tethys_apps.management.commands.tethys_app_uninstall.subprocess.Popen") @mock.patch("warnings.warn") @mock.patch("sys.stdout", new_callable=StringIO) @@ -77,8 +76,7 @@ def test_tethys_app_uninstall_handle_apps_delete_rmtree_Popen_remove_exceptions( mock_stdout, mock_warnings, mock_popen, - mock_os_remove, - mock_join, + mock_unlink, mock_permissions, mock_groups, _, @@ -91,8 +89,7 @@ def test_tethys_app_uninstall_handle_apps_delete_rmtree_Popen_remove_exceptions( mock_installed_items.return_value = {"foo_app": "/foo/foo_app"} mock_input.side_effect = ["yes"] mock_popen.side_effect = KeyboardInterrupt - mock_os_remove.side_effect = [True, Exception] - mock_join.return_value = "/foo/tethysapp-foo-app-nspkg.pth" + mock_unlink.side_effect = [True, Exception] mock_permission = mock.MagicMock(delete=mock.MagicMock()) mock_permissions.filter().filter().all.return_value = [mock_permission] mock_group = mock.MagicMock(delete=mock.MagicMock()) @@ -115,7 +112,6 @@ def test_tethys_app_uninstall_handle_apps_delete_rmtree_Popen_remove_exceptions( mock_popen.assert_called_once_with( ["pip", "uninstall", "-y", "tethysapp-foo_app"], stderr=-2, stdout=-1 ) - mock_join.assert_called() @mock.patch("warnings.warn") @mock.patch("tethys_apps.management.commands.tethys_app_uninstall.exit") diff --git a/tests/unit_tests/test_tethys_apps/test_models/test_CustomSetting.py b/tests/unit_tests/test_tethys_apps/test_models/test_CustomSetting.py index 2476dbd02..69812c3a7 100644 --- a/tests/unit_tests/test_tethys_apps/test_models/test_CustomSetting.py +++ b/tests/unit_tests/test_tethys_apps/test_models/test_CustomSetting.py @@ -153,10 +153,9 @@ def test_clean_secret_validation_error(self): self.assertEqual(json.dumps(dict_example), ret.value) @mock.patch("tethys_apps.utilities.yaml.safe_load") - @mock.patch("tethys_apps.utilities.os.path.exists") + @mock.patch("tethys_apps.utilities.Path.exists") @mock.patch( - "tethys_apps.utilities.open", - new_callable=lambda: mock.mock_open(read_data='{"secrets": "{}"}'), + "tethys_apps.utilities.Path.read_text", return_value='{"secrets": "{}"}' ) def test_clean_secret_validation_with_complete_secrets_yml( self, mock_open_file, mock_path_exists, mock_yaml_safe_load @@ -187,10 +186,9 @@ def test_clean_secret_validation_with_complete_secrets_yml( self.assertEqual(custom_secret_setting.get_value(), "SECRE:TXX1Y") @mock.patch("tethys_apps.utilities.yaml.safe_load") - @mock.patch("tethys_apps.utilities.os.path.exists") + @mock.patch("tethys_apps.utilities.Path.exists") @mock.patch( - "tethys_apps.utilities.open", - new_callable=lambda: mock.mock_open(read_data='{"secrets": "{}"}'), + "tethys_apps.utilities.Path.read_text", return_value='{"secrets": "{}"}' ) def test_clean_secret_validation_with_incomplete_secrets_yml( self, mock_open_file, mock_path_exists, mock_yaml_safe_load @@ -358,7 +356,8 @@ def test_get_value_json_custom_setting(self): ret_string = json.dumps(ret) self.assertEqual('{"Test": "JSON test String"}', ret_string) - def test_get_value_secret_custom_setting_without_setttings_file(self): + @mock.patch("tethys_apps.utilities.yaml.safe_load") + def test_get_value_secret_custom_setting_without_setttings_file(self, _): custom_setting = self.test_app.settings_set.select_subclasses().get( name="Secret_Test2_without_required" ) @@ -374,10 +373,9 @@ def test_get_value_secret_custom_setting_without_setttings_file(self): self.assertEqual("Mysecrertxxxx23526236sddgsdgsgsuiLSD", ret) @mock.patch("tethys_apps.utilities.yaml.safe_load") - @mock.patch("tethys_apps.utilities.os.path.exists") + @mock.patch("tethys_apps.utilities.Path.exists") @mock.patch( - "tethys_apps.utilities.open", - new_callable=lambda: mock.mock_open(read_data='{"secrets": "{}"}'), + "tethys_apps.utilities.Path.read_text", return_value='{"secrets": "{}"}' ) def test_clean_secret_validation_with_complete_secrets_yml_and_error( self, mock_open_file, mock_path_exists, mock_yaml_safe_load 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 4c4546dc9..0c5f7f293 100644 --- a/tests/unit_tests/test_tethys_apps/test_static_finders.py +++ b/tests/unit_tests/test_tethys_apps/test_static_finders.py @@ -1,21 +1,19 @@ -import os +from pathlib import Path import unittest from tethys_apps.static_finders import TethysStaticFinder class TestTethysStaticFinder(unittest.TestCase): def setUp(self): - self.src_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - ) - self.root = os.path.join( - self.src_dir, - "tests", - "apps", - "tethysapp-test_app", - "tethysapp", - "test_app", - "public", + self.src_dir = Path(__file__).parents[3] + self.root = ( + self.src_dir + / "tests" + / "apps" + / "tethysapp-test_app" + / "tethysapp" + / "test_app" + / "public" ) def tearDown(self): @@ -26,42 +24,49 @@ def test_init(self): def test_find(self): tethys_static_finder = TethysStaticFinder() - path = "test_app/css/main.css" - ret = tethys_static_finder.find(path) - self.assertEqual(os.path.join(self.root, "css/main.css"), ret) + path = Path("test_app") / "css" / "main.css" + 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 = "test_app/css/main.css" - ret = tethys_static_finder.find(path, all=True) - self.assertIn(os.path.join(self.root, "css/main.css"), ret) + path = Path("test_app") / "css" / "main.css" + 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 = "css/main.css" + path = Path("css") / "main.css" tethys_static_finder = TethysStaticFinder() - ret = tethys_static_finder.find_location(self.root, path, prefix) - - self.assertEqual(os.path.join(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 = "css/main.css" + path = Path("css") / "main.css" tethys_static_finder = TethysStaticFinder() - ret = tethys_static_finder.find_location(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 = "tethys_app/css/main.css" + path = Path("tethys_app") / "css" / "main.css" tethys_static_finder = TethysStaticFinder() - ret = tethys_static_finder.find_location(self.root, path, prefix) - - self.assertEqual(os.path.join(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() @@ -71,6 +76,6 @@ def test_list(self): if "test_app" in storage.location: expected_app_paths.append(path) - self.assertIn("js/main.js", expected_app_paths) - self.assertIn("images/icon.gif", expected_app_paths) - self.assertIn("css/main.css", expected_app_paths) + self.assertIn(str(Path("js") / "main.js"), expected_app_paths) + self.assertIn(str(Path("images") / "icon.gif"), expected_app_paths) + self.assertIn(str(Path("css") / "main.css"), expected_app_paths) 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 85b1a199c..e24922b14 100644 --- a/tests/unit_tests/test_tethys_apps/test_template_loaders.py +++ b/tests/unit_tests/test_tethys_apps/test_template_loaders.py @@ -1,3 +1,4 @@ +from pathlib import Path import unittest from unittest import mock import errno @@ -21,7 +22,7 @@ def test_get_contents(self, _, mock_file): mock_file.return_value, ) mock_file.side_effect = handlers - origin = mock.MagicMock(name="test_app/css/main.css") + origin = mock.MagicMock(name=str(Path("test_app") / "css" / "main.css")) tethys_template_loader = TethysTemplateLoader(self.mock_engine) @@ -33,7 +34,7 @@ def test_get_contents(self, _, mock_file): @mock.patch("tethys_apps.template_loaders.BaseLoader") def test_get_contents_io_error(self, _, mock_file): mock_file.side_effect = IOError - origin = mock.MagicMock(name="test_app/css/main.css") + origin = mock.MagicMock(name=str(Path("test_app") / "css" / "main.css")) tethys_template_loader = TethysTemplateLoader(self.mock_engine) @@ -45,7 +46,7 @@ def test_get_contents_io_error(self, _, mock_file): ) @mock.patch("tethys_apps.template_loaders.BaseLoader") def test_get_contents_template_does_not_exist(self, _, mock_file): - origin = mock.MagicMock(name="test_app/css/main.css") + origin = mock.MagicMock(name=str(Path("test_app") / "css" / "main.css")) tethys_template_loader = TethysTemplateLoader(self.mock_engine) @@ -58,13 +59,16 @@ def test_get_contents_template_does_not_exist(self, _, mock_file): @mock.patch("tethys_apps.template_loaders.get_directories_in_tethys") def test_get_template_sources(self, mock_gdt, _): tethys_template_loader = TethysTemplateLoader(self.mock_engine) - mock_gdt.return_value = ["/foo/template1"] + mock_gdt.return_value = [str(Path.home() / "foo" / "template1")] expected_template_name = "foo" for origin in tethys_template_loader.get_template_sources( expected_template_name ): - self.assertEqual("/foo/template1/foo", origin.name) + self.assertEqual( + str(Path.home() / "foo" / "template1" / "foo"), + origin.name, + ) self.assertEqual("foo", origin.template_name) self.assertTrue(isinstance(origin.loader, TethysTemplateLoader)) @@ -75,13 +79,21 @@ def test_get_template_sources_exception(self, mock_gdt, _, mock_safe_join): from django.core.exceptions import SuspiciousFileOperation tethys_template_loader = TethysTemplateLoader(self.mock_engine) - mock_gdt.return_value = ["/foo/template1", "/foo/template2"] - mock_safe_join.side_effect = [SuspiciousFileOperation, "/foo/template2/foo"] + mock_gdt.return_value = [ + str(Path("foo") / "template1"), + str(Path("foo") / "template2"), + ] + mock_safe_join.side_effect = [ + SuspiciousFileOperation, + str(Path.home() / "foo" / "template2" / "foo"), + ] expected_template_name = "foo" for origin in tethys_template_loader.get_template_sources( expected_template_name ): - self.assertEqual("/foo/template2/foo", origin.name) + self.assertEqual( + str(Path.home() / "foo" / "template2" / "foo"), origin.name + ) self.assertEqual("foo", origin.template_name) self.assertTrue(isinstance(origin.loader, TethysTemplateLoader)) diff --git a/tests/unit_tests/test_tethys_apps/test_templatetags/test_site_settings.py b/tests/unit_tests/test_tethys_apps/test_templatetags/test_site_settings.py index 55197ba53..fb7042e1f 100644 --- a/tests/unit_tests/test_tethys_apps/test_templatetags/test_site_settings.py +++ b/tests/unit_tests/test_tethys_apps/test_templatetags/test_site_settings.py @@ -11,26 +11,32 @@ def tearDown(self): pass @mock.patch("tethys_apps.templatetags.site_settings.settings") - @mock.patch("tethys_apps.templatetags.site_settings.os.path.isfile") + @mock.patch("tethys_apps.templatetags.site_settings.Path.is_file") def test_get_css_in_static_root(self, mock_isfile, mock_settings): mock_isfile.return_value = True mock_settings.STATIC_ROOT = "test_base_path" ret = ss.load_custom_css("/test.css") # test slash stripping - self.assertEqual(ret, '') + self.assertEqual( + ret, + '', + ) @mock.patch("tethys_apps.templatetags.site_settings.settings") - @mock.patch("tethys_apps.templatetags.site_settings.os.path.isfile") + @mock.patch("tethys_apps.templatetags.site_settings.Path.is_file") def test_get_css_in_staticfiles_dirs(self, mock_isfile, mock_settings): mock_isfile.side_effect = [False, True] mock_settings.STATIC_ROOT = "test_base_path1" mock_settings.STATICFILES_DIRS = ["test_base_path2"] ret = ss.load_custom_css("test.css") - self.assertEqual(ret, '') + self.assertEqual( + ret, + '', + ) @mock.patch("tethys_apps.templatetags.site_settings.settings") - @mock.patch("tethys_apps.templatetags.site_settings.os.path.isfile") + @mock.patch("tethys_apps.templatetags.site_settings.Path.is_file") def test_get_css_is_code(self, mock_isfile, mock_settings): mock_isfile.return_value = False mock_settings.STATIC_ROOT = "test_base_path1" diff --git a/tests/unit_tests/test_tethys_apps/test_urls.py b/tests/unit_tests/test_tethys_apps/test_urls.py index 86ddc9ff1..5e56a1528 100644 --- a/tests/unit_tests/test_tethys_apps/test_urls.py +++ b/tests/unit_tests/test_tethys_apps/test_urls.py @@ -67,6 +67,16 @@ def test_urls(self): "tethysext.test_extension.controllers.home", resolver._func_path ) + @mock.patch("django.urls.include") + @mock.patch("tethys_portal.optional_dependencies.has_module") + def test_reactpy_urls(self, mock_has_module, mock_include): + mock_has_module.return_value = True + from tethys_portal import urls + from importlib import reload + + reload(urls) + mock_include.assert_any_call("reactpy_django.http.urls") + # probably need to test for extensions manually @override_settings(MULTIPLE_APP_MODE=True) diff --git a/tests/unit_tests/test_tethys_apps/test_utilities.py b/tests/unit_tests/test_tethys_apps/test_utilities.py index 2e91a30b4..3341d2135 100644 --- a/tests/unit_tests/test_tethys_apps/test_utilities.py +++ b/tests/unit_tests/test_tethys_apps/test_utilities.py @@ -1,4 +1,5 @@ import unittest +from pathlib import Path from unittest import mock from guardian.shortcuts import assign_perm from tethys_sdk.testing import TethysTestCase @@ -24,9 +25,18 @@ def test_get_directories_in_tethys_templates(self): test_ext = False for r in result: - if "/tethysapp/test_app/templates" in r: + if str(Path("/") / "tethysapp" / "test_app" / "templates") in r: test_app = True - if "/tethysext-test_extension/tethysext/test_extension/templates" in r: + if ( + str( + Path("/") + / "tethysext-test_extension" + / "tethysext" + / "test_extension" + / "templates" + ) + in r + ): test_ext = True self.assertTrue(test_app) @@ -44,11 +54,20 @@ def test_get_directories_in_tethys_templates_with_app_name(self): test_ext = False for r in result: - if "test_app" in r and "/tethysapp/test_app/templates" in r[1]: + if ( + "test_app" in r + and str(Path("/") / "tethysapp" / "test_app" / "templates") in r[1] + ): test_app = True if ( "test_extension" in r - and "/tethysext-test_extension/tethysext/test_extension/templates" + and str( + Path("/") + / "tethysext-test_extension" + / "tethysext" + / "test_extension" + / "templates" + ) in r[1] ): test_ext = True @@ -73,9 +92,18 @@ def test_get_directories_in_tethys_templates_extension_import_error( test_ext = False for r in result: - if "/tethysapp/test_app/templates" in r: + if str(Path("/") / "tethysapp" / "test_app" / "templates") in r: test_app = True - if "/tethysext-test_extension/tethysext/test_extension/templates" in r: + if ( + str( + Path("/") + / "tethysext-test_extension" + / "tethysext" + / "test_extension" + / "templates" + ) + in r + ): test_ext = True self.assertTrue(test_app) @@ -115,9 +143,18 @@ def test_get_directories_in_tethys_foo_public(self): test_ext = False for r in result: - if "/tethysapp/test_app/public" in r: + if str(Path("/") / "tethysapp" / "test_app" / "public") in r: test_app = True - if "/tethysext-test_extension/tethysext/test_extension/public" in r: + if ( + str( + Path("/") + / "tethysext-test_extension" + / "tethysext" + / "test_extension" + / "public" + ) + in r + ): test_ext = True self.assertTrue(test_app) @@ -677,108 +714,97 @@ def test_link_service_to_app_setting_spatial_link_does_not_exist( ) self.assertIn("does not exist.", po_call_args[0][0][0]) - @mock.patch("tethys_apps.utilities.os") + @mock.patch("tethys_apps.utilities.environ") + @mock.patch("tethys_apps.utilities.Path.home") def test_get_tethys_home_dir__default_env_name__tethys_home_not_defined( - self, mock_os + self, mock_home, mock_environ ): env_tethys_home = None conda_default_env = "tethys" # Default Tethys environment name expand_user_path = "/home/tethys" - mock_os.environ.get.side_effect = [ + mock_environ.get.side_effect = [ env_tethys_home, conda_default_env, ] # [TETHYS_HOME, CONDA_DEFAULT_ENV] - mock_os.path.expanduser.return_value = expand_user_path - mock_os.path.join.return_value = f"{expand_user_path}/.tethys" + mock_home.return_value = Path(expand_user_path) ret = utilities.get_tethys_home_dir() - mock_os.environ.get.assert_any_call("TETHYS_HOME") - mock_os.environ.get.assert_any_call("CONDA_DEFAULT_ENV") - mock_os.path.join.assert_called_with(expand_user_path, ".tethys") + self.assertEqual(mock_environ.get.call_count, 2) + mock_environ.get.assert_any_call("TETHYS_HOME") + mock_environ.get.assert_any_call("CONDA_DEFAULT_ENV") # Returns default tethys home environment - self.assertEqual(f"{expand_user_path}/.tethys", ret) + self.assertEqual(str(Path(expand_user_path) / ".tethys"), ret) - @mock.patch("tethys_apps.utilities.os") + @mock.patch("tethys_apps.utilities.environ") + @mock.patch("tethys_apps.utilities.Path.home") def test_get_tethys_home_dir__non_default_env_name__tethys_home_not_defined( - self, mock_os + self, mock_home, mock_environ ): env_tethys_home = None conda_default_env = "foo" # Non-default Tethys environment name expand_user_path = "/home/tethys" - default_tethys_home = f"{expand_user_path}/.tethys" - mock_os.environ.get.side_effect = [ + mock_environ.get.side_effect = [ env_tethys_home, conda_default_env, ] # [TETHYS_HOME, CONDA_DEFAULT_ENV] - mock_os.path.expanduser.return_value = expand_user_path - mock_os.path.join.side_effect = [ - default_tethys_home, - f"{default_tethys_home}/{conda_default_env}", - ] + mock_home.return_value = Path(expand_user_path) ret = utilities.get_tethys_home_dir() - mock_os.environ.get.assert_any_call("TETHYS_HOME") - mock_os.environ.get.assert_any_call("CONDA_DEFAULT_ENV") - os_join_call_args = mock_os.path.join.call_args_list - self.assertEqual([os_join_call_args[0][0]], [(expand_user_path, ".tethys")]) - self.assertEqual( - [os_join_call_args[1][0]], [(default_tethys_home, conda_default_env)] - ) - mock_os.path.join.assert_called_with(default_tethys_home, conda_default_env) + mock_environ.get.assert_any_call("TETHYS_HOME") + mock_environ.get.assert_any_call("CONDA_DEFAULT_ENV") # Returns joined path of default tethys home path and environment name - self.assertEqual(f"{default_tethys_home}/{conda_default_env}", ret) + self.assertEqual( + str(Path(expand_user_path) / ".tethys" / conda_default_env), ret + ) - @mock.patch("tethys_apps.utilities.os") - def test_get_tethys_home_dir__tethys_home_defined(self, mock_os): + @mock.patch("tethys_apps.utilities.environ") + @mock.patch("tethys_apps.utilities.Path.home") + def test_get_tethys_home_dir__tethys_home_defined(self, mock_home, mock_environ): env_tethys_home = "/foo/.bar" conda_default_env = "foo" - mock_os.environ.get.side_effect = [ + mock_environ.get.side_effect = [ env_tethys_home, conda_default_env, ] # [TETHYS_HOME, CONDA_DEFAULT_ENV] ret = utilities.get_tethys_home_dir() - mock_os.environ.get.assert_called_once_with("TETHYS_HOME") - mock_os.path.expanduser.assert_not_called() + mock_environ.get.assert_called_once_with("TETHYS_HOME") + mock_home.assert_not_called() # Returns path defined by TETHYS_HOME environment variable self.assertEqual(env_tethys_home, ret) @mock.patch("tethys_apps.utilities.tethys_log") - @mock.patch("tethys_apps.utilities.os") - def test_get_tethys_home_dir__exception(self, mock_os, mock_tethys_log): + @mock.patch("tethys_apps.utilities.environ") + @mock.patch("tethys_apps.utilities.Path.home") + def test_get_tethys_home_dir__exception( + self, mock_home, mock_environ, mock_tethys_log + ): env_tethys_home = None - conda_default_env = "foo" # Non-default Tethys environment name expand_user_path = "/home/tethys" - default_tethys_home = f"{expand_user_path}/.tethys" - mock_os.environ.get.side_effect = [ + mock_environ.get.side_effect = [ env_tethys_home, - conda_default_env, + Exception, ] # [TETHYS_HOME, CONDA_DEFAULT_ENV] - mock_os.path.expanduser.return_value = expand_user_path - mock_os.path.join.side_effect = [default_tethys_home, Exception] + mock_home.return_value = Path(expand_user_path) ret = utilities.get_tethys_home_dir() - mock_os.environ.get.assert_any_call("TETHYS_HOME") - mock_os.environ.get.assert_any_call("CONDA_DEFAULT_ENV") - os_join_call_args = mock_os.path.join.call_args_list - self.assertEqual([os_join_call_args[0][0]], [(expand_user_path, ".tethys")]) - self.assertEqual( - [os_join_call_args[1][0]], [(default_tethys_home, conda_default_env)] - ) + self.assertEqual(mock_environ.get.call_count, 2) + mock_environ.get.assert_any_call("TETHYS_HOME") + mock_environ.get.assert_any_call("CONDA_DEFAULT_ENV") mock_tethys_log.warning.assert_called() # Returns default tethys home environment path - self.assertEqual(default_tethys_home, ret) + self.assertEqual(str(Path(expand_user_path) / ".tethys"), ret) @mock.patch("tethys_apps.utilities.SingletonHarvester") def test_get_app_class(self, mock_harvester): @@ -938,16 +964,14 @@ def test_get_all_submodules(self, mock_iter_modules, mock_import): @mock.patch("tethys_apps.utilities.yaml.dump") @mock.patch("tethys_apps.utilities.yaml.safe_load") - @mock.patch( - "tethys_apps.utilities.Path.open", - new_callable=lambda: mock.mock_open(read_data='{"secrets": "{}"}'), + @mock.patch.object( + Path, "open", new_callable=lambda: mock.mock_open(read_data='{"secrets": "{}"}') ) - @mock.patch("tethys_apps.utilities.Path.exists") + @mock.patch("tethys_apps.utilities.Path.exists", return_value=True) def test_delete_secrets( - self, mock_path_exists, mock_open_file, mock_yaml_safe_load, mock_yaml_dumps + self, mock_exists, mock_open_file, mock_yaml_safe_load, mock_yaml_dumps ): app_target_name = "test_app" - mock_path_exists.return_value = True before_content = { "secrets": { app_target_name: { @@ -975,16 +999,14 @@ def test_delete_secrets( @mock.patch("tethys_apps.utilities.yaml.dump") @mock.patch("tethys_apps.utilities.yaml.safe_load") - @mock.patch( - "tethys_apps.utilities.Path.open", - new_callable=lambda: mock.mock_open(read_data='{"secrets": "{}"}'), + @mock.patch.object( + Path, "open", new_callable=lambda: mock.mock_open(read_data='{"secrets": "{}"}') ) - @mock.patch("tethys_apps.utilities.Path.exists") + @mock.patch("tethys_apps.utilities.Path.exists", return_value=True) def test_delete_secrets_without_app_in_secrets_yml( - self, mock_path_exists, mock_open_file, mock_yaml_safe_load, mock_yaml_dumps + self, mock_path, mock_open_file, mock_yaml_safe_load, mock_yaml_dumps ): app_target_name = "test_app" - mock_path_exists.return_value = True before_content = {"secrets": {"version": "1.0"}} after_content = {"secrets": {app_target_name: {}, "version": "1.0"}} @@ -1008,14 +1030,13 @@ def test_get_secret_custom_settings_without_app(self): return_val = utilities.get_secret_custom_settings(app_target_name) self.assertEqual(return_val, None) - @mock.patch("tethys_apps.utilities.os.path.exists") - @mock.patch("tethys_apps.utilities.yaml.safe_load") @mock.patch( - "tethys_apps.utilities.open", - new_callable=lambda: mock.mock_open(read_data='{"secrets": "{}"}'), + "tethys_apps.utilities.Path.read_text", return_value='{"secrets": "{}"}' ) + @mock.patch("tethys_apps.utilities.Path.exists") + @mock.patch("tethys_apps.utilities.yaml.safe_load") def test_secrets_signed_unsigned_value_with_secrets( - self, mock_open_file, mock_yaml_safe_load, mock_file_exists + self, mock_yaml_safe_load, mock_path_exists, _ ): app_target_name = "test_app" @@ -1031,7 +1052,7 @@ def test_secrets_signed_unsigned_value_with_secrets( } mock_yaml_safe_load.return_value = before_content - mock_file_exists.side_effect = [True, True, False, False] + mock_path_exists.side_effect = [True, True, False, False] signer = Signer(salt="my_first_fake_string") mock_val = "SECRETXX1Y" diff --git a/tests/unit_tests/test_tethys_cli/test__init__.py b/tests/unit_tests/test_tethys_cli/test__init__.py index 07b7227d5..4d7d935b2 100644 --- a/tests/unit_tests/test_tethys_cli/test__init__.py +++ b/tests/unit_tests/test_tethys_cli/test__init__.py @@ -2,7 +2,7 @@ import unittest from unittest import mock from io import StringIO -import os +from pathlib import Path from tethys_cli import tethys_command @@ -74,7 +74,7 @@ def test_scaffold_subcommand(self, mock_scaffold_command): mock_scaffold_command.assert_called() call_args = mock_scaffold_command.call_args_list self.assertEqual("foo", call_args[0][0][0].name) - self.assertEqual(os.getcwd(), call_args[0][0][0].prefix) + self.assertEqual(str(Path.cwd()), call_args[0][0][0].prefix) self.assertEqual("default", call_args[0][0][0].template) self.assertFalse(call_args[0][0][0].overwrite) self.assertFalse(call_args[0][0][0].extension) @@ -98,7 +98,7 @@ def test_scaffold_subcommand_with_prefix(self, mock_scaffold_command): @mock.patch("tethys_cli.scaffold_commands.scaffold_command") def test_scaffold_subcommand_with_options(self, mock_scaffold_command): - testargs = ["tethys", "scaffold", "foo", "-e", "-t", "my_template", "-o", "-d"] + testargs = ["tethys", "scaffold", "foo", "-e", "-t", "default", "-o", "-d"] with mock.patch.object(sys, "argv", testargs): tethys_command() @@ -106,7 +106,7 @@ def test_scaffold_subcommand_with_options(self, mock_scaffold_command): mock_scaffold_command.assert_called() call_args = mock_scaffold_command.call_args_list self.assertEqual("foo", call_args[0][0][0].name) - self.assertEqual("my_template", call_args[0][0][0].template) + self.assertEqual("default", call_args[0][0][0].template) self.assertTrue(call_args[0][0][0].overwrite) self.assertTrue(call_args[0][0][0].extension) self.assertTrue(call_args[0][0][0].use_defaults) @@ -119,7 +119,7 @@ def test_scaffold_subcommand_with_verbose_options(self, mock_scaffold_command): "foo", "--extension", "--template", - "my_template", + "default", "--overwrite", "--defaults", ] @@ -130,7 +130,7 @@ def test_scaffold_subcommand_with_verbose_options(self, mock_scaffold_command): mock_scaffold_command.assert_called() call_args = mock_scaffold_command.call_args_list self.assertEqual("foo", call_args[0][0][0].name) - self.assertEqual("my_template", call_args[0][0][0].template) + self.assertEqual("default", call_args[0][0][0].template) self.assertTrue(call_args[0][0][0].overwrite) self.assertTrue(call_args[0][0][0].extension) self.assertTrue(call_args[0][0][0].use_defaults) diff --git a/tests/unit_tests/test_tethys_cli/test_docker_commands.py b/tests/unit_tests/test_tethys_cli/test_docker_commands.py index 2c841f9fb..c3f4d3d42 100644 --- a/tests/unit_tests/test_tethys_cli/test_docker_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_docker_commands.py @@ -1,6 +1,8 @@ import unittest from unittest import mock import tethys_cli.docker_commands as cli_docker_commands +from pathlib import Path +from tempfile import gettempdir class TestDockerCommands(unittest.TestCase): @@ -361,7 +363,9 @@ def test_cm_get_container_options_geoserver_numprocessors_bind( "c", # Would you like to specify number of Processors (c) OR set limits (e) "y", # Bind the GeoServer data directory to the host? ] - mock_dir_input.side_effect = ["/tmp"] # Specify location to bind data directory + mock_dir_input.side_effect = [ + gettempdir() + ] # Specify location to bind data directory expected_options = dict( environment=dict( @@ -631,7 +635,9 @@ def test_cm_get_container_options_thredds( "y", # Bind the THREDDS data directory to the host? ] - mock_dir_input.side_effect = ["/tmp"] # Specify location to bind data directory + mock_dir_input.side_effect = [ + gettempdir() + ] # Specify location to bind data directory expected_options = dict( environment=dict( @@ -1197,55 +1203,61 @@ def test_uih_get_valid_choice_input_invalid(self): "Please provide a valid option\nprompt [A/b]: ", input_call_args[3][0][0] ) - @mock.patch("tethys_cli.docker_commands.os.path.isdir") - def test_uih_get_valid_directory_input_default(self, mock_os_path_isdir): - mock_os_path_isdir.return_value = True + @mock.patch("tethys_cli.docker_commands.Path.is_dir") + def test_uih_get_valid_directory_input_default(self, mock_is_dir): + mock_is_dir.return_value = True self.mock_input.side_effect = [""] c = cli_docker_commands.UserInputHelper.get_valid_directory_input( - prompt="prompt", default="/tmp" - ) - self.assertEqual(c, "/tmp") - self.mock_input.assert_called_with("prompt [/tmp]: ") - - @mock.patch("tethys_cli.docker_commands.os.makedirs") - @mock.patch("tethys_cli.docker_commands.os.path.isdir") - def test_uih_get_valid_directory_input_makedirs( - self, mock_os_path_isdir, mock_os_makedirs - ): - value = "/non/existing/path" - self.mock_input.side_effect = [value[1:]] - mock_os_path_isdir.return_value = False - + prompt="prompt", default=gettempdir() + ) + self.assertEqual(c, gettempdir()) + self.mock_input.assert_called_with(f"prompt [{gettempdir()}]: ") + + @mock.patch("tethys_cli.docker_commands.Path.mkdir") + @mock.patch("tethys_cli.docker_commands.Path.is_dir") + def test_uih_get_valid_directory_input_makedirs(self, mock_is_dir, mock_mkdir): + value = str(Path("/").absolute() / "non" / "existing" / "path") + self.mock_input.side_effect = [str(Path("non") / "existing" / "path")] + mock_is_dir.return_value = False + + absolute_return_value = Path("/").absolute() / "non" / "existing" / "path" + mock_absolute_path = mock.patch( + "tethys_cli.docker_commands.Path.absolute" + ).start() + mock_absolute_path.return_value = absolute_return_value c = cli_docker_commands.UserInputHelper.get_valid_directory_input( - prompt="prompt", default="/tmp" + prompt="prompt", default=gettempdir() ) + mock.patch.stopall() self.assertEqual(c, value) - self.mock_input.assert_called_with("prompt [/tmp]: ") - mock_os_path_isdir.assert_called_with(value) - mock_os_makedirs.assert_called_with(value) + self.mock_input.assert_called_with(f"prompt [{gettempdir()}]: ") + mock_is_dir.assert_called_once() + mock_mkdir.assert_called_once() @mock.patch("tethys_cli.docker_commands.write_pretty_output") - @mock.patch("tethys_cli.docker_commands.os.makedirs") - @mock.patch("tethys_cli.docker_commands.os.path.isdir") + @mock.patch("tethys_cli.docker_commands.Path.mkdir") + @mock.patch("tethys_cli.docker_commands.Path.is_dir") def test_uih_get_valid_directory_input_oserror( - self, mock_os_path_isdir, mock_os_makedirs, mock_pretty_output + self, mock_is_dir, mock_mkdir, mock_pretty_output ): - mock_os_path_isdir.side_effect = [False, True] - mock_os_makedirs.side_effect = OSError - self.mock_input.side_effect = ["/invalid/path", "/foo/tmp"] + invalid_path = str(Path("/").absolute() / "invalid" / "path") + valid_path = str(Path("/").absolute() / "foo" / "tmp") + mock_is_dir.side_effect = [False, True] + mock_mkdir.side_effect = OSError + self.mock_input.side_effect = [invalid_path, valid_path] c = cli_docker_commands.UserInputHelper.get_valid_directory_input( - prompt="prompt", default="/tmp" + prompt="prompt", default=gettempdir() ) - self.assertEqual(c, "/foo/tmp") + self.assertEqual(c, valid_path) - mock_pretty_output.assert_called_with("OSError(): /invalid/path") + mock_pretty_output.assert_called_with(f"OSError(): {invalid_path}") input_call_args = self.mock_input.call_args_list self.assertEqual(2, len(input_call_args)) - self.assertEqual("prompt [/tmp]: ", input_call_args[0][0][0]) + self.assertEqual(f"prompt [{gettempdir()}]: ", input_call_args[0][0][0]) self.assertEqual( - "Please provide a valid directory\nprompt [/tmp]: ", + f"Please provide a valid directory\nprompt [{gettempdir()}]: ", input_call_args[1][0][0], ) diff --git a/tests/unit_tests/test_tethys_cli/test_gen_commands.py b/tests/unit_tests/test_tethys_cli/test_gen_commands.py index 0d3ca6ef9..26b7c207f 100644 --- a/tests/unit_tests/test_tethys_cli/test_gen_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_gen_commands.py @@ -64,20 +64,20 @@ def test_get_settings_value_bad(self): @mock.patch("tethys_cli.gen_commands.write_info") @mock.patch("tethys_cli.gen_commands.get_settings_value") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") def test_generate_command_apache_option( - self, mock_os_path_isfile, mock_file, mock_settings, mock_write_info + self, mock_is_file, mock_file, mock_settings, mock_write_info ): mock_args = mock.MagicMock() mock_args.type = GEN_APACHE_OPTION mock_args.directory = None - mock_os_path_isfile.return_value = False + mock_is_file.return_value = False mock_settings.side_effect = ["/foo/workspace", "/foo/static", "/foo/media"] generate_command(args=mock_args) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() mock_settings.assert_any_call("MEDIA_ROOT") mock_settings.assert_any_call("STATIC_ROOT") @@ -86,20 +86,20 @@ def test_generate_command_apache_option( @mock.patch("tethys_cli.gen_commands.write_info") @mock.patch("tethys_cli.gen_commands.get_settings_value") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") def test_generate_command_nginx_option( - self, mock_os_path_isfile, mock_file, mock_settings, mock_write_info + self, mock_is_file, mock_file, mock_settings, mock_write_info ): mock_args = mock.MagicMock() mock_args.type = GEN_NGINX_OPTION mock_args.directory = None - mock_os_path_isfile.return_value = False + mock_is_file.return_value = False mock_settings.side_effect = ["/foo/workspace", "/foo/static", "/foo/media"] generate_command(args=mock_args) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() mock_settings.assert_any_call("TETHYS_WORKSPACES_ROOT") mock_settings.assert_called_with("MEDIA_ROOT") @@ -107,53 +107,53 @@ def test_generate_command_nginx_option( mock_write_info.assert_called_once() @mock.patch("tethys_cli.gen_commands.write_info") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") def test_generate_command_nginx_service( - self, mock_os_path_isfile, mock_file, mock_write_info + self, mock_is_file, mock_file, mock_write_info ): mock_args = mock.MagicMock() mock_args.type = GEN_NGINX_SERVICE_OPTION mock_args.directory = None - mock_os_path_isfile.return_value = False + mock_is_file.return_value = False generate_command(args=mock_args) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() mock_write_info.assert_called_once() @mock.patch("tethys_cli.gen_commands.write_info") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") def test_generate_command_apache_service( - self, mock_os_path_isfile, mock_file, mock_write_info + self, mock_is_file, mock_file, mock_write_info ): mock_args = mock.MagicMock() mock_args.type = GEN_APACHE_SERVICE_OPTION mock_args.directory = None - mock_os_path_isfile.return_value = False + mock_is_file.return_value = False generate_command(args=mock_args) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() mock_write_info.assert_called_once() - @mock.patch("tethys_cli.gen_commands.os.path.isdir") + @mock.patch("tethys_cli.gen_commands.Path.is_dir") @mock.patch("tethys_cli.gen_commands.write_info") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") - @mock.patch("tethys_cli.gen_commands.os.makedirs") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") + @mock.patch("tethys_cli.gen_commands.Path.mkdir") def test_generate_command_portal_yaml__tethys_home_not_exists( - self, mock_makedirs, mock_os_path_isfile, mock_file, mock_write_info, mock_isdir + self, mock_mkdir, mock_is_file, mock_file, mock_write_info, mock_isdir ): mock_args = mock.MagicMock( type=GEN_PORTAL_OPTION, directory=None, spec=["overwrite", "server_port"] ) - mock_os_path_isfile.return_value = False + mock_is_file.return_value = False mock_isdir.side_effect = [ False, True, @@ -161,43 +161,43 @@ def test_generate_command_portal_yaml__tethys_home_not_exists( generate_command(args=mock_args) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() # Verify it makes the Tethys Home directory - mock_makedirs.assert_called() + mock_mkdir.assert_called() rts_call_args = mock_write_info.call_args_list[0] self.assertIn("A Tethys Portal configuration file", rts_call_args.args[0]) @mock.patch("tethys_cli.gen_commands.write_info") @mock.patch("tethys_cli.gen_commands.render_template") - @mock.patch("tethys_cli.gen_commands.os.path.exists") + @mock.patch("tethys_cli.gen_commands.Path.exists") @mock.patch("tethys_cli.gen_commands.get_environment_value") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") def test_generate_command_asgi_service_option_nginx_conf( self, - mock_os_path_isfile, + mock_is_file, mock_file, mock_env, - mock_os_path_exists, + mock_path_exists, mock_render_template, mock_write_info, ): mock_args = mock.MagicMock(conda_prefix=False) mock_args.type = GEN_ASGI_SERVICE_OPTION mock_args.directory = None - mock_os_path_isfile.return_value = False + mock_is_file.return_value = False mock_env.side_effect = ["/foo/conda", "conda_env"] - mock_os_path_exists.return_value = True + mock_path_exists.return_value = True mock_file.return_value = mock.mock_open(read_data="user foo_user").return_value generate_command(args=mock_args) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() mock_env.assert_called_with("CONDA_PREFIX") - mock_os_path_exists.assert_any_call("/etc/nginx/nginx.conf") + mock_path_exists.assert_called_once() context = mock_render_template.call_args.args[1] self.assertEqual("foo_user", context["nginx_user"]) @@ -205,20 +205,20 @@ def test_generate_command_asgi_service_option_nginx_conf( @mock.patch("tethys_cli.gen_commands.write_info") @mock.patch("tethys_cli.gen_commands.get_environment_value") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") def test_generate_command_asgi_service_option( - self, mock_os_path_isfile, mock_file, mock_env, mock_write_info + self, mock_is_file, mock_file, mock_env, mock_write_info ): mock_args = mock.MagicMock(conda_prefix=False) mock_args.type = GEN_ASGI_SERVICE_OPTION mock_args.directory = None - mock_os_path_isfile.return_value = False + mock_is_file.return_value = False mock_env.side_effect = ["/foo/conda", "conda_env"] generate_command(args=mock_args) - mock_os_path_isfile.assert_called() + mock_is_file.assert_called() mock_file.assert_called() mock_env.assert_called_with("CONDA_PREFIX") @@ -226,11 +226,11 @@ def test_generate_command_asgi_service_option( @mock.patch("tethys_cli.gen_commands.write_info") @mock.patch("tethys_cli.gen_commands.get_environment_value") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") def test_generate_command_asgi_service_option_distro( self, - mock_os_path_isfile, + mock_is_file, mock_file, mock_env, mock_write_info, @@ -238,68 +238,68 @@ def test_generate_command_asgi_service_option_distro( mock_args = mock.MagicMock(conda_prefix=False) mock_args.type = GEN_ASGI_SERVICE_OPTION mock_args.directory = None - mock_os_path_isfile.return_value = False + mock_is_file.return_value = False mock_env.side_effect = ["/foo/conda", "conda_env"] generate_command(args=mock_args) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() mock_env.assert_called_with("CONDA_PREFIX") mock_write_info.assert_called() @mock.patch("tethys_cli.gen_commands.write_info") - @mock.patch("tethys_cli.gen_commands.os.path.isdir") + @mock.patch("tethys_cli.gen_commands.Path.is_dir") @mock.patch("tethys_cli.gen_commands.get_environment_value") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") def test_generate_command_asgi_settings_option_directory( self, - mock_os_path_isfile, + mock_is_file, mock_file, mock_env, - mock_os_path_isdir, + mock_is_dir, mock_write_info, ): mock_args = mock.MagicMock(conda_prefix=False) mock_args.type = GEN_ASGI_SERVICE_OPTION - mock_args.directory = "/foo/temp" - mock_os_path_isfile.return_value = False + mock_args.directory = str(Path("/").absolute() / "foo" / "temp") + mock_is_file.return_value = False mock_env.side_effect = ["/foo/conda", "conda_env"] - mock_os_path_isdir.side_effect = [ + mock_is_dir.side_effect = [ True, True, ] # TETHYS_HOME exists, computed directory exists generate_command(args=mock_args) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() - mock_os_path_isdir.assert_called_with(mock_args.directory) + self.assertEqual(mock_is_dir.call_count, 2) mock_env.assert_called_with("CONDA_PREFIX") mock_write_info.assert_called() @mock.patch("tethys_cli.gen_commands.write_error") @mock.patch("tethys_cli.gen_commands.exit") - @mock.patch("tethys_cli.gen_commands.os.path.isdir") + @mock.patch("tethys_cli.gen_commands.Path.is_dir") @mock.patch("tethys_cli.gen_commands.get_environment_value") - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.is_file") def test_generate_command_asgi_settings_option_bad_directory( self, - mock_os_path_isfile, + mock_is_file, mock_env, - mock_os_path_isdir, + mock_is_dir, mock_exit, mock_write_error, ): mock_args = mock.MagicMock(conda_prefix=False) mock_args.type = GEN_ASGI_SERVICE_OPTION - mock_args.directory = "/foo/temp" - mock_os_path_isfile.return_value = False + mock_args.directory = str(Path("/").absolute() / "foo" / "temp") + mock_is_file.return_value = False mock_env.side_effect = ["/foo/conda", "conda_env"] - mock_os_path_isdir.side_effect = [ + mock_is_dir.side_effect = [ True, False, ] # TETHYS_HOME exists, computed directory exists @@ -309,8 +309,8 @@ def test_generate_command_asgi_settings_option_bad_directory( self.assertRaises(SystemExit, generate_command, args=mock_args) - mock_os_path_isfile.assert_not_called() - mock_os_path_isdir.assert_any_call(mock_args.directory) + mock_is_file.assert_not_called() + self.assertEqual(mock_is_dir.call_count, 2) # Check if print is called correctly rts_call_args = mock_write_error.call_args @@ -324,10 +324,10 @@ def test_generate_command_asgi_settings_option_bad_directory( @mock.patch("tethys_cli.gen_commands.exit") @mock.patch("tethys_cli.gen_commands.input") @mock.patch("tethys_cli.gen_commands.get_environment_value") - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.is_file") def test_generate_command_asgi_settings_pre_existing_input_exit( self, - mock_os_path_isfile, + mock_is_file, mock_env, mock_input, mock_exit, @@ -338,7 +338,7 @@ def test_generate_command_asgi_settings_pre_existing_input_exit( mock_args.type = GEN_ASGI_SERVICE_OPTION mock_args.directory = None mock_args.overwrite = False - mock_os_path_isfile.return_value = True + mock_is_file.return_value = True mock_env.side_effect = ["/foo/conda", "conda_env"] mock_input.side_effect = ["foo", "no"] # NOTE: to prevent our tests from exiting prematurely, we change the behavior of exit to raise an exception @@ -347,7 +347,7 @@ def test_generate_command_asgi_settings_pre_existing_input_exit( self.assertRaises(SystemExit, generate_command, args=mock_args) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() # Check if print is called correctly rts_call_args = mock_write_warning.call_args @@ -358,107 +358,104 @@ def test_generate_command_asgi_settings_pre_existing_input_exit( @mock.patch("tethys_cli.gen_commands.write_info") @mock.patch("tethys_cli.gen_commands.get_environment_value") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") def test_generate_command_asgi_settings_pre_existing_overwrite( - self, mock_os_path_isfile, mock_file, mock_env, mock_write_info + self, mock_is_file, mock_file, mock_env, mock_write_info ): mock_args = mock.MagicMock(conda_prefix=False) mock_args.type = GEN_ASGI_SERVICE_OPTION mock_args.directory = None mock_args.overwrite = True - mock_os_path_isfile.return_value = True + mock_is_file.return_value = True mock_env.side_effect = ["/foo/conda", "conda_env"] generate_command(args=mock_args) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() mock_env.assert_called_with("CONDA_PREFIX") mock_write_info.assert_called() @mock.patch("tethys_cli.gen_commands.write_info") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") def test_generate_command_services_option( - self, mock_os_path_isfile, mock_file, mock_write_info + self, mock_is_file, mock_file, mock_write_info ): mock_args = mock.MagicMock() mock_args.type = GEN_SERVICES_OPTION mock_args.directory = None - mock_os_path_isfile.return_value = False + mock_is_file.return_value = False generate_command(args=mock_args) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") @mock.patch("tethys_cli.gen_commands.write_info") def test_generate_command_install_option( - self, mock_write_info, mock_os_path_isfile, mock_file + self, mock_write_info, mock_is_file, mock_file ): mock_args = mock.MagicMock() mock_args.type = GEN_INSTALL_OPTION mock_args.directory = None - mock_os_path_isfile.return_value = False + mock_is_file.return_value = False generate_command(args=mock_args) rts_call_args = mock_write_info.call_args_list[0] self.assertIn("Please review the generated install.yml", rts_call_args.args[0]) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() @mock.patch("tethys_cli.gen_commands.run") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") @mock.patch("tethys_cli.gen_commands.write_warning") @mock.patch("tethys_cli.gen_commands.write_info") def test_generate_requirements_option( - self, mock_write_info, mock_write_warn, mock_os_path_isfile, mock_file, mock_run + self, mock_write_info, mock_write_warn, mock_is_file, mock_file, mock_run ): mock_args = mock.MagicMock() mock_args.type = GEN_REQUIREMENTS_OPTION mock_args.directory = None - mock_os_path_isfile.return_value = False + mock_is_file.return_value = False generate_command(args=mock_args) mock_write_warn.assert_called_once() mock_write_info.assert_called_once() - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() mock_run.assert_called_once() - @mock.patch("tethys_cli.gen_commands.os.path.join") @mock.patch("tethys_cli.gen_commands.write_info") @mock.patch("tethys_cli.gen_commands.Template") @mock.patch("tethys_cli.gen_commands.yaml.safe_load") @mock.patch("tethys_cli.gen_commands.run_command") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") @mock.patch("tethys_cli.gen_commands.print") def test_generate_command_metayaml( self, mock_print, - mock_os_path_isfile, + mock_is_file, mock_file, mock_run_command, mock_load, mock_Template, _, - mock_os_path_join, ): mock_args = mock.MagicMock(micro=False) mock_args.type = GEN_META_YAML_OPTION mock_args.directory = None mock_args.pin_level = "minor" - mock_os_path_isfile.return_value = False - mock_os_path_join.return_value = f"{TETHYS_SRC}/conda.recipe" + mock_is_file.return_value = False stdout = ( "# packages in environment at /home/nswain/miniconda/envs/tethys:\n" "#\n" @@ -485,12 +482,11 @@ def test_generate_command_metayaml( } self.assertDictEqual(expected_context, render_context) mock_file.assert_called() - mock_os_path_join.assert_called() @mock.patch("tethys_cli.gen_commands.write_info") @mock.patch("tethys_cli.gen_commands.derive_version_from_conda_environment") @mock.patch("tethys_cli.gen_commands.yaml.safe_load") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) def test_gen_meta_yaml_overriding_dependencies( self, _, mock_load, mock_dvfce, mock_write_info ): @@ -795,7 +791,7 @@ def test_download_vendor_static_files_no_npm_no_conda( mock_error.assert_called_once() @mock.patch("tethys_cli.gen_commands.check_for_existing_file") - @mock.patch("tethys_cli.gen_commands.os.path.isdir", return_value=True) + @mock.patch("tethys_cli.gen_commands.Path.is_dir", return_value=True) def test_get_destination_path_vendor(self, mock_isdir, mock_check_file): mock_args = mock.MagicMock( type=GEN_PACKAGE_JSON_OPTION, @@ -804,7 +800,9 @@ def test_get_destination_path_vendor(self, mock_isdir, mock_check_file): result = get_destination_path(mock_args) mock_isdir.assert_called() mock_check_file.assert_called_once() - self.assertEqual(result, f"{TETHYS_SRC}/tethys_portal/static/package.json") + self.assertEqual( + result, str(Path(TETHYS_SRC) / "tethys_portal" / "static" / "package.json") + ) @mock.patch("tethys_cli.gen_commands.GEN_COMMANDS") @mock.patch("tethys_cli.gen_commands.write_path_to_console") @@ -829,18 +827,18 @@ def test_templates_exist(self): template_path = template_dir / file_name self.assertTrue(template_path.exists()) - @mock.patch("tethys_cli.gen_commands.os.path.isdir") + @mock.patch("tethys_cli.gen_commands.Path.is_dir") @mock.patch("tethys_cli.gen_commands.write_info") - @mock.patch("tethys_cli.gen_commands.open", new_callable=mock.mock_open) - @mock.patch("tethys_cli.gen_commands.os.path.isfile") - @mock.patch("tethys_cli.gen_commands.os.makedirs") + @mock.patch("tethys_cli.gen_commands.Path.open", new_callable=mock.mock_open) + @mock.patch("tethys_cli.gen_commands.Path.is_file") + @mock.patch("tethys_cli.gen_commands.Path.mkdir") def test_generate_command_secrets_yaml_tethys_home_not_exists( - self, mock_makedirs, mock_os_path_isfile, mock_file, mock_write_info, mock_isdir + self, mock_mkdir, mock_is_file, mock_file, mock_write_info, mock_isdir ): mock_args = mock.MagicMock( type=GEN_SECRETS_OPTION, directory=None, spec=["overwrite"] ) - mock_os_path_isfile.return_value = False + mock_is_file.return_value = False mock_isdir.side_effect = [ False, True, @@ -848,10 +846,10 @@ def test_generate_command_secrets_yaml_tethys_home_not_exists( generate_command(args=mock_args) - mock_os_path_isfile.assert_called_once() + mock_is_file.assert_called_once() mock_file.assert_called() # Verify it makes the Tethys Home directory - mock_makedirs.assert_called() + mock_mkdir.assert_called() rts_call_args = mock_write_info.call_args_list[0] self.assertIn("A Tethys Secrets file", rts_call_args.args[0]) diff --git a/tests/unit_tests/test_tethys_cli/test_install_commands.py b/tests/unit_tests/test_tethys_cli/test_install_commands.py index b3f6ec605..90a7d853f 100644 --- a/tests/unit_tests/test_tethys_cli/test_install_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_install_commands.py @@ -1,6 +1,5 @@ -import os +from os import devnull, chdir from pathlib import Path - from django.db import transaction from django.test import TestCase from django.core.exceptions import ObjectDoesNotExist, ValidationError @@ -9,7 +8,7 @@ from tethys_cli import install_commands -FNULL = open(os.devnull, "w") +FNULL = open(devnull, "w") class TestServiceInstallHelpers(TestCase): @@ -388,21 +387,14 @@ def setUp(self): @mock.patch("tethys_cli.install_commands.find_and_link") @mock.patch("tethys_cli.cli_colors.pretty_output") @mock.patch("tethys_cli.install_commands.json.loads") - @mock.patch("tethys_cli.install_commands.json.load") - @mock.patch( - "tethys_cli.install_commands.open", - new_callable=lambda: mock.mock_open(read_data='{"fake_json": "{}"}'), - ) - @mock.patch("tethys_cli.install_commands.os.path.isfile") + @mock.patch("tethys_cli.install_commands.Path") @mock.patch("tethys_apps.models.CustomSettingBase") @mock.patch("tethys_apps.models.TethysApp") def test_configure_services_from_file( self, mock_TethysApp, mock_CustomSetting, - mock_isfile, - mock_open, - mock_json_load, + mock_path, mock_json_loads, mock_pretty_output, mock_find_and_link, @@ -555,17 +547,14 @@ def test_configure_services_from_file( mock_json_custom_setting_invalid_value, mock_secret_custom_setting, ] - - # mock_open.side_effect = (mock_open.return_value, FileNotFoundError, TypeError) - mock_open.side_effect = mock_open.return_value - - mock_isfile.side_effect = (True, False, False) + mock_path().is_file.side_effect = [True, False, False] + mock_path().read_text.return_value = '{"fake_json": "{}"}' mock_json_loads.side_effect = [ + ['{"fake_json": "{}"}'], json_custom_setting_wrong_path_value, ValueError, ] - mock_json_load.side_effect = ['{"fake_json": "{}"}'] # This persistent setting exists and is listed in the file mock_persistent_database_setting = mock.MagicMock() @@ -771,18 +760,18 @@ def setUp(self): self.root_app_path = self.src_dir / "apps" / "tethysapp-test_app" self.app_model = TethysApp(name="test_app", package="test_app") self.app_model.save() - self.cwd = os.getcwd() - os.chdir(self.root_app_path) + self.cwd = str(Path.cwd()) + chdir(self.root_app_path) def tearDown(self): self.app_model.delete() - os.chdir(self.cwd) + chdir(self.cwd) @mock.patch("tethys_cli.cli_colors.pretty_output") @mock.patch("builtins.input", side_effect=["x", "n"]) @mock.patch("tethys_cli.install_commands.call") def test_install_file_not_generate(self, mock_call, _, mock_pretty_output): - os.chdir("..") # move to a different directory that doesn't have an install.yml + chdir("..") # move to a different directory that doesn't have an install.yml args = mock.MagicMock( file=None, quiet=False, @@ -807,7 +796,7 @@ def test_install_file_not_generate(self, mock_call, _, mock_pretty_output): @mock.patch("tethys_cli.install_commands.call") @mock.patch("tethys_cli.install_commands.exit") def test_install_file_generate(self, mock_exit, mock_call, _, __): - os.chdir("..") # move to a different directory that doesn't have an install.yml + chdir("..") # move to a different directory that doesn't have an install.yml args = mock.MagicMock( file=None, quiet=False, @@ -879,7 +868,7 @@ def test_input_file_with_post(self, mock_pretty_output, _, __): self.assertIn("Services Configuration Completed.", po_call_args[4][0][0]) self.assertIn("Skipping syncstores.", po_call_args[5][0][0]) self.assertIn("Running post installation tasks...", po_call_args[6][0][0]) - self.assertIn("Post Script Result: b'test\\n'", po_call_args[7][0][0]) + self.assertIn("Post Script Result: b'test", po_call_args[7][0][0]) self.assertIn("Successfully installed test_app.", po_call_args[8][0][0]) @mock.patch("tethys_cli.install_commands.run_services") @@ -1122,7 +1111,7 @@ def test_without_dependencies( def test_conda_and_pip_package_install_only_dependencies( self, mock_pretty_output, mock_conda_run, mock_call, _ ): - os.chdir("..") + chdir("..") file_path = self.root_app_path / "install-dep.yml" args = mock.MagicMock( file=file_path, @@ -1526,7 +1515,7 @@ def test_interactive_custom_setting_set_secret_with_salt_previous_empty_secret_f @mock.patch("tethys_cli.install_commands.json.loads") @mock.patch("tethys_cli.cli_colors.pretty_output") @mock.patch( - "tethys_cli.install_commands.open", + "tethys_cli.install_commands.Path.open", new_callable=lambda: mock.mock_open(read_data='{"fake_json": "{}"}'), ) def test_interactive_custom_setting_set_json_with_path( @@ -1556,11 +1545,10 @@ def test_interactive_custom_setting_set_json_with_path( @mock.patch("tethys_cli.install_commands.json.loads") @mock.patch("tethys_cli.cli_colors.pretty_output") @mock.patch( - "tethys_cli.install_commands.open", - new_callable=lambda: mock.mock_open(read_data='{"fake_json": "{}"}'), + "tethys_cli.install_commands.Path.read_text", return_value='{"fake_json": "{}"}' ) def test_interactive_custom_setting_set_json_with_not_found_path( - self, mock_path_open, mock_pretty_output, mock_json_loads, mock_gas, _ + self, mock_read_text, mock_pretty_output, mock_json_loads, mock_gas, _ ): mock_cs = mock.MagicMock() mock_cs.name = "mock_cs" @@ -1584,12 +1572,8 @@ def test_interactive_custom_setting_set_json_with_not_found_path( ) @mock.patch("tethys_cli.install_commands.get_app_settings") @mock.patch("tethys_cli.cli_colors.pretty_output") - @mock.patch( - "tethys_cli.install_commands.open", - new_callable=lambda: mock.mock_open(read_data='{"fake_json": "{}"}'), - ) def test_interactive_custom_setting_set_json_without_path( - self, mock_path_open, mock_pretty_output, mock_gas, _ + self, mock_pretty_output, mock_gas, _ ): mock_cs = mock.MagicMock() mock_cs.name = "mock_cs" @@ -1617,12 +1601,8 @@ def test_interactive_custom_setting_set_json_without_path( ) @mock.patch("tethys_cli.install_commands.get_app_settings") @mock.patch("tethys_cli.cli_colors.pretty_output") - @mock.patch( - "tethys_cli.install_commands.open", - new_callable=lambda: mock.mock_open(read_data='{"fake_json": "{}"}'), - ) def test_interactive_custom_setting_set_json_without_path_error( - self, mock_path_open, mock_pretty_output, mock_gas, _ + self, mock_pretty_output, mock_gas, _ ): mock_cs = mock.MagicMock() mock_cs.name = "mock_cs" @@ -1835,7 +1815,8 @@ def test_npm_install( self.assertEqual(1, len(mock_download.mock_calls)) self.assertEqual( - {"cwd": "tethysapp/test_app/public"}, mock_download.mock_calls[0][2] + {"cwd": str(Path("tethysapp/test_app/public"))}, + mock_download.mock_calls[0][2], ) mock_exists.assert_called() 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 365c42b99..0735e0cd9 100644 --- a/tests/unit_tests/test_tethys_cli/test_scaffold_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_scaffold_commands.py @@ -1,6 +1,6 @@ import unittest from unittest import mock -import os +from pathlib import Path from tethys_cli.scaffold_commands import ( proper_name_validator, @@ -19,15 +19,13 @@ def setUp(self): self.extension_template_dir = "extension_templates" self.app_template_dir = "app_templates" self.template_suffix = "_tmpl" - self.app_path = os.path.join( - os.path.dirname(__file__), - self.scaffold_templates_dir, - self.app_template_dir, + self.app_path = str( + Path(__file__).parent / self.scaffold_templates_dir / self.app_template_dir ) - self.extension_path = os.path.join( - os.path.dirname(__file__), - self.scaffold_templates_dir, - self.extension_template_dir, + self.extension_path = str( + Path(__file__).parent + / self.scaffold_templates_dir + / self.extension_template_dir ) def tearDown(self): @@ -121,22 +119,24 @@ def test_render_path(self): @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.get_random_color") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @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.os.walk") - @mock.patch("tethys_cli.scaffold_commands.os.makedirs") - @mock.patch("tethys_cli.scaffold_commands.open", new_callable=mock.mock_open) + @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") @mock.patch("tethys_cli.scaffold_commands.Template") def test_scaffold_command( self, _, __, - mock_makedirs, - mock_os_walk, + ___, + mock_mkdir, + mock_path_walk, mock_render_path, mock_rmt, - mock_os_path_isdir, + mock_is_dir, mock_pretty_output, mock_random_color, mock_logger, @@ -159,16 +159,16 @@ def test_scaffold_command( mock_logger.return_value = mock_log # mocking the validate template call return value - mock_os_path_isdir.return_value = [True, True] + mock_is_dir.return_value = [True, True] mock_render_path.return_value = "" - mock_os_walk.return_value = [ - ("/foo", ("bar",), ("baz",)), - ("/foo/bar", (), ("spam", "eggs_tmpl")), + mock_path_walk.return_value = [ + (Path("/").absolute() / "foo", ("bar",), ("baz",)), + (Path("/").absolute() / "foo" / "bar", (), ("spam", "eggs_tmpl")), ] - mock_makedirs.return_value = True + mock_mkdir.return_value = True scaffold_command(args=mock_args) @@ -181,12 +181,12 @@ def test_scaffold_command( mock_render_path.assert_called() - mock_makedirs.assert_called_with(mock_render_path.return_value) + self.assertEqual(mock_mkdir.call_count, 2) po_call_args = mock_pretty_output.call_args_list self.assertEqual( - f'Creating new Tethys project at "cwd{os.sep}tethysext-project_name".', + f'Creating new Tethys project at "cwd{Path("/") / "tethysext-project_name"}".', po_call_args[0][0][0], ) self.assertIn("Created:", po_call_args[1][0][0]) @@ -205,20 +205,25 @@ def test_scaffold_command( self.assertIn("Template root directory", mock_log_call_args[3][0][0]) self.assertIn("Template context", mock_log_call_args[4][0][0]) self.assertIn("Project root path", mock_log_call_args[5][0][0]) - self.assertEqual('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) self.assertEqual( - 'Loading template: "/foo/bar/spam"', mock_log_call_args[7][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "baz")}"', + mock_log_call_args[6][0][0], ) self.assertEqual( - 'Loading template: "/foo/bar/eggs_tmpl"', mock_log_call_args[8][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "spam")}"', + mock_log_call_args[7][0][0], + ) + self.assertEqual( + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "eggs_tmpl")}"', + mock_log_call_args[8][0][0], ) @mock.patch("tethys_cli.scaffold_commands.exit") @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") def test_scaffold_command_with_not_valid_template( - self, mock_os_path_isdir, mock_pretty_output, mock_logger, mock_exit + self, mock_is_dir, mock_pretty_output, mock_logger, mock_exit ): # mock the input args mock_args = mock.MagicMock() @@ -235,7 +240,7 @@ def test_scaffold_command_with_not_valid_template( # mock the getlogger from logging mock_logger.return_value = mock_log - mock_os_path_isdir.return_value = False + mock_is_dir.return_value = False mock_exit.side_effect = SystemExit @@ -259,22 +264,24 @@ def test_scaffold_command_with_not_valid_template( @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.get_random_color") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @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.os.walk") - @mock.patch("tethys_cli.scaffold_commands.os.makedirs") - @mock.patch("tethys_cli.scaffold_commands.open", new_callable=mock.mock_open) + @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") @mock.patch("tethys_cli.scaffold_commands.Template") def test_scaffold_command_with_no_extension( self, _, __, - mock_makedirs, - mock_os_walk, + ___, + mock_mkdir, + mock_path_walk, mock_render_path, mock_rmt, - mock_os_path_isdir, + mock_is_dir, mock_pretty_output, mock_random_color, mock_logger, @@ -296,16 +303,16 @@ def test_scaffold_command_with_no_extension( # mock the getlogger from logging mock_logger.return_value = mock_log - mock_os_path_isdir.return_value = [True, True] + mock_is_dir.return_value = [True, True] mock_render_path.return_value = "" - mock_os_walk.return_value = [ - ("/foo", ("bar",), ("baz",)), - ("/foo/bar", (), ("spam", "eggs_tmpl")), + mock_path_walk.return_value = [ + (Path("/").absolute() / "foo", ("bar",), ("baz",)), + (Path("/").absolute() / "foo" / "bar", (), ("spam", "eggs_tmpl")), ] - mock_makedirs.return_value = True + mock_mkdir.return_value = True scaffold_command(args=mock_args) @@ -318,12 +325,12 @@ def test_scaffold_command_with_no_extension( mock_render_path.assert_called() - mock_makedirs.assert_called_with(mock_render_path.return_value) + self.assertEqual(mock_mkdir.call_count, 2) po_call_args = mock_pretty_output.call_args_list self.assertEqual( - f'Creating new Tethys project at "cwd{os.sep}tethysapp-project_name".', + f'Creating new Tethys project at "cwd{Path("/") / "tethysapp-project_name"}".', po_call_args[0][0][0], ) self.assertIn("Created:", po_call_args[1][0][0]) @@ -342,34 +349,41 @@ def test_scaffold_command_with_no_extension( self.assertIn("Template root directory", mock_log_call_args[3][0][0]) self.assertIn("Template context", mock_log_call_args[4][0][0]) self.assertIn("Project root path", mock_log_call_args[5][0][0]) - self.assertEqual('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) self.assertEqual( - 'Loading template: "/foo/bar/spam"', mock_log_call_args[7][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "baz")}"', + mock_log_call_args[6][0][0], + ) + self.assertEqual( + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "spam")}"', + mock_log_call_args[7][0][0], ) self.assertEqual( - 'Loading template: "/foo/bar/eggs_tmpl"', mock_log_call_args[8][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "eggs_tmpl")}"', + mock_log_call_args[8][0][0], ) @mock.patch("tethys_cli.scaffold_commands.shutil.copy") @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.get_random_color") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @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.os.walk") - @mock.patch("tethys_cli.scaffold_commands.os.makedirs") - @mock.patch("tethys_cli.scaffold_commands.open", new_callable=mock.mock_open) + @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") @mock.patch("tethys_cli.scaffold_commands.Template") def test_scaffold_command_with_uppercase_project_name( self, _, __, - mock_makedirs, - mock_os_walk, + ___, + mock_mkdir, + mock_path_walk, mock_render_path, mock_rmt, - mock_os_path_isdir, + mock_is_dir, mock_pretty_output, mock_random_color, mock_logger, @@ -392,16 +406,16 @@ def test_scaffold_command_with_uppercase_project_name( mock_logger.return_value = mock_log # mocking the validate template call return value - mock_os_path_isdir.return_value = [True, True] + mock_is_dir.return_value = [True, True] mock_render_path.return_value = "" - mock_os_walk.return_value = [ - ("/foo", ("bar",), ("baz",)), - ("/foo/bar", (), ("spam", "eggs_tmpl")), + mock_path_walk.return_value = [ + (Path("/").absolute() / "foo", ("bar",), ("baz",)), + (Path("/").absolute() / "foo" / "bar", (), ("spam", "eggs_tmpl")), ] - mock_makedirs.return_value = True + mock_mkdir.return_value = True scaffold_command(args=mock_args) @@ -414,7 +428,7 @@ def test_scaffold_command_with_uppercase_project_name( mock_render_path.assert_called() - mock_makedirs.assert_called_with(mock_render_path.return_value) + self.assertEqual(mock_mkdir.call_count, 2) po_call_args = mock_pretty_output.call_args_list @@ -424,7 +438,7 @@ def test_scaffold_command_with_uppercase_project_name( po_call_args[0][0][0], ) self.assertEqual( - f'Creating new Tethys project at "cwd{os.sep}tethysext-project_name".', + f'Creating new Tethys project at "cwd{Path("/") / "tethysext-project_name"}".', po_call_args[1][0][0], ) self.assertIn("Created:", po_call_args[2][0][0]) @@ -443,20 +457,25 @@ def test_scaffold_command_with_uppercase_project_name( self.assertIn("Template root directory", mock_log_call_args[3][0][0]) self.assertIn("Template context", mock_log_call_args[4][0][0]) self.assertIn("Project root path", mock_log_call_args[5][0][0]) - self.assertEqual('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) self.assertEqual( - 'Loading template: "/foo/bar/spam"', mock_log_call_args[7][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "baz")}"', + mock_log_call_args[6][0][0], ) self.assertEqual( - 'Loading template: "/foo/bar/eggs_tmpl"', mock_log_call_args[8][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "spam")}"', + mock_log_call_args[7][0][0], + ) + self.assertEqual( + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "eggs_tmpl")}"', + mock_log_call_args[8][0][0], ) @mock.patch("tethys_cli.scaffold_commands.exit") @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @mock.patch("tethys_cli.scaffold_commands.Path.is_dir") def test_scaffold_command_with_wrong_project_name( - self, mock_os_path_isdir, mock_pretty_output, mock_logger, mock_exit + self, mock_is_dir, mock_pretty_output, mock_logger, mock_exit ): # mock the input args mock_args = mock.MagicMock() @@ -473,7 +492,7 @@ def test_scaffold_command_with_wrong_project_name( # mock the getlogger from logging mock_logger.return_value = mock_log - mock_os_path_isdir.return_value = True + mock_is_dir.return_value = True mock_exit.side_effect = SystemExit @@ -497,22 +516,24 @@ def test_scaffold_command_with_wrong_project_name( @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.get_random_color") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @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.os.walk") - @mock.patch("tethys_cli.scaffold_commands.os.makedirs") - @mock.patch("tethys_cli.scaffold_commands.open", new_callable=mock.mock_open) + @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") @mock.patch("tethys_cli.scaffold_commands.Template") def test_scaffold_command_with_project_warning( self, _, __, - mock_makedirs, - mock_os_walk, + ___, + mock_mkdir, + mock_path_walk, mock_render_path, mock_rmt, - mock_os_path_isdir, + mock_is_dir, mock_pretty_output, mock_random_color, mock_logger, @@ -534,16 +555,16 @@ def test_scaffold_command_with_project_warning( # mock the getlogger from logging mock_logger.return_value = mock_log - mock_os_path_isdir.return_value = [True, True] + mock_is_dir.return_value = [True, True] mock_render_path.return_value = "" - mock_os_walk.return_value = [ - ("/foo", ("bar",), ("baz",)), - ("/foo/bar", (), ("spam", "eggs_tmpl")), + mock_path_walk.return_value = [ + (Path("/").absolute() / "foo", ("bar",), ("baz",)), + (Path("/").absolute() / "foo" / "bar", (), ("spam", "eggs_tmpl")), ] - mock_makedirs.return_value = True + mock_mkdir.return_value = True scaffold_command(args=mock_args) @@ -556,7 +577,7 @@ def test_scaffold_command_with_project_warning( mock_render_path.assert_called() - mock_makedirs.assert_called_with(mock_render_path.return_value) + self.assertEqual(mock_mkdir.call_count, 2) po_call_args = mock_pretty_output.call_args_list @@ -566,7 +587,7 @@ def test_scaffold_command_with_project_warning( po_call_args[0][0][0], ) self.assertEqual( - f'Creating new Tethys project at "cwd{os.sep}tethysext-project_name".', + f'Creating new Tethys project at "cwd{Path("/") / "tethysext-project_name"}".', po_call_args[1][0][0], ) self.assertIn("Created:", po_call_args[2][0][0]) @@ -585,12 +606,17 @@ def test_scaffold_command_with_project_warning( self.assertIn("Template root directory", mock_log_call_args[3][0][0]) self.assertIn("Template context", mock_log_call_args[4][0][0]) self.assertIn("Project root path", mock_log_call_args[5][0][0]) - self.assertEqual('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) self.assertEqual( - 'Loading template: "/foo/bar/spam"', mock_log_call_args[7][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "baz")}"', + mock_log_call_args[6][0][0], + ) + self.assertEqual( + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "spam")}"', + mock_log_call_args[7][0][0], ) self.assertEqual( - 'Loading template: "/foo/bar/eggs_tmpl"', mock_log_call_args[8][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "eggs_tmpl")}"', + mock_log_call_args[8][0][0], ) @mock.patch("tethys_cli.scaffold_commands.shutil.copy") @@ -599,22 +625,24 @@ def test_scaffold_command_with_project_warning( @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.get_random_color") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @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.os.walk") - @mock.patch("tethys_cli.scaffold_commands.os.makedirs") - @mock.patch("tethys_cli.scaffold_commands.open", new_callable=mock.mock_open) + @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") @mock.patch("tethys_cli.scaffold_commands.Template") def test_scaffold_command_with_no_defaults( self, _, __, - mock_makedirs, - mock_os_walk, + ___, + mock_mkdir, + mock_path_walk, mock_render_path, mock_rmt, - mock_os_path_isdir, + mock_is_dir, mock_pretty_output, mock_random_color, mock_logger, @@ -638,16 +666,16 @@ def test_scaffold_command_with_no_defaults( # mock the getlogger from logging mock_logger.return_value = mock_log - mock_os_path_isdir.return_value = [True, False] + mock_is_dir.return_value = [True, False] mock_render_path.return_value = "" - mock_os_walk.return_value = [ - ("/foo", ("bar",), ("baz",)), - ("/foo/bar", (), ("spam", "eggs_tmpl")), + mock_path_walk.return_value = [ + (Path("/").absolute() / "foo", ("bar",), ("baz",)), + (Path("/").absolute() / "foo" / "bar", (), ("spam", "eggs_tmpl")), ] - mock_makedirs.return_value = True + mock_mkdir.return_value = True mock_input.side_effect = ["test1", "test2", "test3", "test4", "test5"] mock_proper_name_validator.return_value = True, "foo" @@ -663,8 +691,7 @@ def test_scaffold_command_with_no_defaults( mock_render_path.assert_called() - # mock the create root directory - mock_makedirs.assert_called_with(mock_render_path.return_value) + self.assertEqual(mock_mkdir.call_count, 2) po_call_args = mock_pretty_output.call_args_list @@ -674,7 +701,7 @@ def test_scaffold_command_with_no_defaults( po_call_args[0][0][0], ) self.assertEqual( - f'Creating new Tethys project at "cwd{os.sep}tethysext-project_name".', + f'Creating new Tethys project at "cwd{Path("/") / "tethysext-project_name"}".', po_call_args[1][0][0], ) self.assertIn("Created:", po_call_args[2][0][0]) @@ -698,12 +725,17 @@ def test_scaffold_command_with_no_defaults( self.assertIn("test4", mock_log_call_args[4][0][0]) self.assertIn("test5", mock_log_call_args[4][0][0]) self.assertIn("Project root path", mock_log_call_args[5][0][0]) - self.assertEqual('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) self.assertEqual( - 'Loading template: "/foo/bar/spam"', mock_log_call_args[7][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "baz")}"', + mock_log_call_args[6][0][0], + ) + self.assertEqual( + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "spam")}"', + mock_log_call_args[7][0][0], ) self.assertEqual( - 'Loading template: "/foo/bar/eggs_tmpl"', mock_log_call_args[8][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "eggs_tmpl")}"', + mock_log_call_args[8][0][0], ) @mock.patch("tethys_cli.scaffold_commands.proper_name_validator") @@ -711,12 +743,12 @@ def test_scaffold_command_with_no_defaults( @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.get_random_color") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @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.os.walk") - @mock.patch("tethys_cli.scaffold_commands.os.makedirs") - @mock.patch("tethys_cli.scaffold_commands.open", new_callable=mock.mock_open) + @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") @mock.patch("tethys_cli.scaffold_commands.exit") def test_scaffold_command_with_no_defaults_input_exception( @@ -724,11 +756,11 @@ def test_scaffold_command_with_no_defaults_input_exception( mock_exit, _, __, - mock_makedirs, - mock_os_walk, + mock_mkdir, + mock_path_walk, mock_render_path, mock_rmt, - mock_os_path_isdir, + mock_is_dir, mock_pretty_output, mock_random_color, mock_logger, @@ -752,16 +784,16 @@ def test_scaffold_command_with_no_defaults_input_exception( mock_logger.return_value = mock_log # mocking the validate template call return value - mock_os_path_isdir.return_value = [True, False] + mock_is_dir.return_value = [True, False] mock_render_path.return_value = "" - mock_os_walk.return_value = [ - ("/foo", ("bar",), ("baz",)), - ("/foo/bar", (), ("spam", "eggs")), + mock_path_walk.return_value = [ + (Path("/").absolute() / "foo", ("bar",), ("baz",)), + (Path("/").absolute() / "foo" / "bar", (), ("spam", "eggs")), ] - mock_makedirs.return_value = True + mock_mkdir.return_value = True mock_exit.side_effect = SystemExit @@ -779,7 +811,7 @@ def test_scaffold_command_with_no_defaults_input_exception( mock_render_path.assert_not_called() # mock the create root directory - mock_makedirs.assert_not_called() + mock_mkdir.assert_not_called() po_call_args = mock_pretty_output.call_args_list @@ -789,7 +821,7 @@ def test_scaffold_command_with_no_defaults_input_exception( po_call_args[0][0][0], ) self.assertEqual( - f'Creating new Tethys project at "cwd{os.sep}tethysext-project_name".', + f'Creating new Tethys project at "cwd{Path("/") / "tethysext-project_name"}".', po_call_args[1][0][0], ) self.assertIn("Scaffolding cancelled.", po_call_args[2][0][0]) @@ -806,22 +838,24 @@ def test_scaffold_command_with_no_defaults_input_exception( @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.get_random_color") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @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.os.walk") - @mock.patch("tethys_cli.scaffold_commands.os.makedirs") - @mock.patch("tethys_cli.scaffold_commands.open", new_callable=mock.mock_open) + @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") @mock.patch("tethys_cli.scaffold_commands.Template") def test_scaffold_command_with_no_defaults_invalid_response( self, _, __, - mock_makedirs, - mock_os_walk, + ___, + mock_mkdir, + mock_path_walk, mock_render_path, mock_rmt, - mock_os_path_isdir, + mock_is_dir, mock_pretty_output, mock_random_color, mock_logger, @@ -846,16 +880,16 @@ def test_scaffold_command_with_no_defaults_invalid_response( mock_logger.return_value = mock_log # mocking the validate template call return value - mock_os_path_isdir.return_value = [True, False] + mock_is_dir.return_value = [True, False] mock_render_path.return_value = "" - mock_os_walk.return_value = [ - ("/foo", ("bar",), ("baz",)), - ("/foo/bar", (), ("spam", "eggs_tmpl")), + mock_path_walk.return_value = [ + (Path("/").absolute() / "foo", ("bar",), ("baz",)), + (Path("/").absolute() / "foo" / "bar", (), ("spam", "eggs_tmpl")), ] - mock_makedirs.return_value = True + mock_mkdir.return_value = True mock_input.side_effect = [ "test1", @@ -878,8 +912,7 @@ def test_scaffold_command_with_no_defaults_invalid_response( mock_render_path.assert_called() - # mock the create root directory - mock_makedirs.assert_called_with(mock_render_path.return_value) + self.assertEqual(mock_mkdir.call_count, 2) po_call_args = mock_pretty_output.call_args_list @@ -889,7 +922,7 @@ def test_scaffold_command_with_no_defaults_invalid_response( po_call_args[0][0][0], ) self.assertEqual( - f'Creating new Tethys project at "cwd{os.sep}tethysext-project_name".', + f'Creating new Tethys project at "cwd{Path("/") / "tethysext-project_name"}".', po_call_args[1][0][0], ) self.assertIn("Invalid response: foo", po_call_args[2][0][0]) @@ -914,12 +947,17 @@ def test_scaffold_command_with_no_defaults_invalid_response( self.assertIn("test4", mock_log_call_args[4][0][0]) self.assertIn("test5", mock_log_call_args[4][0][0]) self.assertIn("Project root path", mock_log_call_args[5][0][0]) - self.assertEqual('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) self.assertEqual( - 'Loading template: "/foo/bar/spam"', mock_log_call_args[7][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "baz")}"', + mock_log_call_args[6][0][0], ) self.assertEqual( - 'Loading template: "/foo/bar/eggs_tmpl"', mock_log_call_args[8][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "spam")}"', + mock_log_call_args[7][0][0], + ) + self.assertEqual( + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "eggs_tmpl")}"', + mock_log_call_args[8][0][0], ) @mock.patch("tethys_cli.scaffold_commands.shutil.copy") @@ -927,22 +965,24 @@ def test_scaffold_command_with_no_defaults_invalid_response( @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.get_random_color") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @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.os.walk") - @mock.patch("tethys_cli.scaffold_commands.os.makedirs") - @mock.patch("tethys_cli.scaffold_commands.open", new_callable=mock.mock_open) + @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") @mock.patch("tethys_cli.scaffold_commands.Template") def test_scaffold_command_with_no_overwrite( self, _, __, - mock_makedirs, - mock_os_walk, + ___, + mock_mkdir, + mock_path_walk, mock_render_path, mock_rmt, - mock_os_path_isdir, + mock_is_dir, mock_pretty_output, mock_random_color, mock_logger, @@ -966,16 +1006,16 @@ def test_scaffold_command_with_no_overwrite( mock_logger.return_value = mock_log # mocking the validate template call return value - mock_os_path_isdir.return_value = [True, True] + mock_is_dir.return_value = [True, True] mock_render_path.return_value = "" - mock_os_walk.return_value = [ - ("/foo", ("bar",), ("baz",)), - ("/foo/bar", (), ("spam", "eggs_tmpl")), + mock_path_walk.return_value = [ + (Path("/").absolute() / "foo", ("bar",), ("baz",)), + (Path("/").absolute() / "foo" / "bar", (), ("spam", "eggs_tmpl")), ] - mock_makedirs.return_value = True + mock_mkdir.return_value = True mock_input.side_effect = ["y"] @@ -991,7 +1031,7 @@ def test_scaffold_command_with_no_overwrite( mock_render_path.assert_called() # mock the create root directory - mock_makedirs.assert_called_with(mock_render_path.return_value) + self.assertEqual(mock_mkdir.call_count, 2) po_call_args = mock_pretty_output.call_args_list @@ -1001,7 +1041,7 @@ def test_scaffold_command_with_no_overwrite( po_call_args[0][0][0], ) self.assertEqual( - f'Creating new Tethys project at "cwd{os.sep}tethysext-project_name".', + f'Creating new Tethys project at "cwd{Path("/") / "tethysext-project_name"}".', po_call_args[1][0][0], ) self.assertIn("Created:", po_call_args[2][0][0]) @@ -1020,12 +1060,17 @@ def test_scaffold_command_with_no_overwrite( self.assertIn("Template root directory", mock_log_call_args[3][0][0]) self.assertIn("Template context", mock_log_call_args[4][0][0]) self.assertIn("Project root path", mock_log_call_args[5][0][0]) - self.assertEqual('Loading template: "/foo/baz"', mock_log_call_args[6][0][0]) self.assertEqual( - 'Loading template: "/foo/bar/spam"', mock_log_call_args[7][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "baz")}"', + mock_log_call_args[6][0][0], + ) + self.assertEqual( + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "spam")}"', + mock_log_call_args[7][0][0], ) self.assertEqual( - 'Loading template: "/foo/bar/eggs_tmpl"', mock_log_call_args[8][0][0] + f'Loading template: "{str(Path("/").absolute() / "foo" / "bar" / "eggs_tmpl")}"', + mock_log_call_args[8][0][0], ) @mock.patch("tethys_cli.scaffold_commands.exit") @@ -1033,22 +1078,22 @@ def test_scaffold_command_with_no_overwrite( @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.get_random_color") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @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.os.walk") - @mock.patch("tethys_cli.scaffold_commands.os.makedirs") - @mock.patch("tethys_cli.scaffold_commands.open", new_callable=mock.mock_open) + @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") def test_scaffold_command_with_no_overwrite_keyboard_interrupt( self, _, __, - mock_makedirs, - mock_os_walk, + mock_mkdir, + mock_path_walk, mock_render_path, mock_rmt, - mock_os_path_isdir, + mock_is_dir, mock_pretty_output, mock_random_color, mock_logger, @@ -1072,16 +1117,16 @@ def test_scaffold_command_with_no_overwrite_keyboard_interrupt( mock_logger.return_value = mock_log # mocking the validate template call return value - mock_os_path_isdir.return_value = [True, True] + mock_is_dir.return_value = [True, True] mock_render_path.return_value = "" - mock_os_walk.return_value = [ - ("/foo", ("bar",), ("baz",)), - ("/foo/bar", (), ("spam", "eggs")), + mock_path_walk.return_value = [ + (Path("/").absolute() / "foo", ("bar",), ("baz",)), + (Path("/").absolute() / "foo" / "bar", (), ("spam", "eggs")), ] - mock_makedirs.return_value = True + mock_mkdir.return_value = True mock_exit.side_effect = SystemExit @@ -1099,7 +1144,7 @@ def test_scaffold_command_with_no_overwrite_keyboard_interrupt( mock_render_path.assert_not_called() # mock the create root directory - mock_makedirs.assert_not_called() + mock_mkdir.assert_not_called() po_call_args = mock_pretty_output.call_args_list @@ -1109,7 +1154,7 @@ def test_scaffold_command_with_no_overwrite_keyboard_interrupt( po_call_args[0][0][0], ) self.assertEqual( - f'Creating new Tethys project at "cwd{os.sep}tethysext-project_name".', + f'Creating new Tethys project at "cwd{Path("/") / "tethysext-project_name"}".', po_call_args[1][0][0], ) self.assertIn("Scaffolding cancelled.", po_call_args[2][0][0]) @@ -1127,22 +1172,22 @@ def test_scaffold_command_with_no_overwrite_keyboard_interrupt( @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.get_random_color") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @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.os.walk") - @mock.patch("tethys_cli.scaffold_commands.os.makedirs") - @mock.patch("tethys_cli.scaffold_commands.open", new_callable=mock.mock_open) + @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") def test_scaffold_command_with_no_overwrite_cancel( self, _, __, - mock_makedirs, - mock_os_walk, + mock_mkdir, + mock_path_walk, mock_render_path, mock_rmt, - mock_os_path_isdir, + mock_is_dir, mock_pretty_output, mock_random_color, mock_logger, @@ -1166,16 +1211,16 @@ def test_scaffold_command_with_no_overwrite_cancel( mock_logger.return_value = mock_log # mocking the validate template call return value - mock_os_path_isdir.return_value = [True, True] + mock_is_dir.return_value = [True, True] mock_render_path.return_value = "" - mock_os_walk.return_value = [ - ("/foo", ("bar",), ("baz",)), - ("/foo/bar", (), ("spam", "eggs")), + mock_path_walk.return_value = [ + (Path("/").absolute() / "foo", ("bar",), ("baz",)), + (Path("/").absolute() / "foo" / "bar", (), ("spam", "eggs")), ] - mock_makedirs.return_value = True + mock_mkdir.return_value = True mock_exit.side_effect = SystemExit @@ -1193,7 +1238,7 @@ def test_scaffold_command_with_no_overwrite_cancel( mock_render_path.assert_not_called() # mock the create root directory - mock_makedirs.assert_not_called() + mock_mkdir.assert_not_called() po_call_args = mock_pretty_output.call_args_list @@ -1203,7 +1248,7 @@ def test_scaffold_command_with_no_overwrite_cancel( po_call_args[0][0][0], ) self.assertEqual( - f'Creating new Tethys project at "cwd{os.sep}tethysext-project_name".', + f'Creating new Tethys project at "cwd{Path("/") / "tethysext-project_name"}".', po_call_args[1][0][0], ) self.assertIn("Scaffolding cancelled.", po_call_args[2][0][0]) @@ -1221,22 +1266,22 @@ def test_scaffold_command_with_no_overwrite_cancel( @mock.patch("tethys_cli.scaffold_commands.logging.getLogger") @mock.patch("tethys_cli.scaffold_commands.get_random_color") @mock.patch("tethys_cli.scaffold_commands.write_pretty_output") - @mock.patch("tethys_cli.scaffold_commands.os.path.isdir") + @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.os.walk") - @mock.patch("tethys_cli.scaffold_commands.os.makedirs") - @mock.patch("tethys_cli.scaffold_commands.open", new_callable=mock.mock_open) + @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") def test_scaffold_command_with_no_overwrite_os_error( self, _, __, - mock_makedirs, - mock_os_walk, + mock_mkdir, + mock_path_walk, mock_render_path, mock_rmt, - mock_os_path_isdir, + mock_is_dir, mock_pretty_output, mock_random_color, mock_logger, @@ -1260,16 +1305,16 @@ def test_scaffold_command_with_no_overwrite_os_error( mock_logger.return_value = mock_log # mocking the validate template call return value - mock_os_path_isdir.return_value = [True, True] + mock_is_dir.return_value = [True, True] mock_render_path.return_value = "" - mock_os_walk.return_value = [ - ("/foo", ("bar",), ("baz",)), - ("/foo/bar", (), ("spam", "eggs")), + mock_path_walk.return_value = [ + (Path("/").absolute() / "foo", ("bar",), ("baz",)), + (Path("/").absolute() / "foo" / "bar", (), ("spam", "eggs")), ] - mock_makedirs.return_value = True + mock_mkdir.return_value = True mock_exit.side_effect = SystemExit @@ -1289,7 +1334,7 @@ def test_scaffold_command_with_no_overwrite_os_error( mock_render_path.assert_not_called() # mock the create root directory - mock_makedirs.assert_not_called() + mock_mkdir.assert_not_called() po_call_args = mock_pretty_output.call_args_list @@ -1299,7 +1344,7 @@ def test_scaffold_command_with_no_overwrite_os_error( po_call_args[0][0][0], ) self.assertEqual( - f'Creating new Tethys project at "cwd{os.sep}tethysext-project_name".', + f'Creating new Tethys project at "cwd{Path("/") / "tethysext-project_name"}".', po_call_args[1][0][0], ) self.assertIn("Error: Unable to overwrite", po_call_args[2][0][0]) diff --git a/tests/unit_tests/test_tethys_cli/test_site_commands.py b/tests/unit_tests/test_tethys_cli/test_site_commands.py index 71ba0c541..885f79de7 100644 --- a/tests/unit_tests/test_tethys_cli/test_site_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_site_commands.py @@ -1,5 +1,5 @@ import unittest -import os +from pathlib import Path from unittest import mock from tethys_cli.site_commands import ( @@ -11,8 +11,7 @@ class CLISiteCommandsTest(unittest.TestCase): def setUp(self): - self.src_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - self.root_app_path = os.path.join(self.src_dir, "apps", "tethysapp-test_app") + self.root_app_path = Path(__file__).parents[2] / "apps" / "tethysapp-test_app" def tearDown(self): pass @@ -65,11 +64,11 @@ def test_gen_site_content_restore_defaults( def test_gen_site_content_with_yaml( self, mock_setup_django, mock_path, mock_update_settings ): - file_path = os.path.join(self.root_app_path, "test-portal_config.yml") + file_path = self.root_app_path / "test-portal_config.yml" mock_file_path = mock.MagicMock() mock_path.return_value = mock_file_path mock_file_path.__truediv__().exists.return_value = True - mock_file_path.__truediv__().open.return_value = open(file_path) + mock_file_path.__truediv__().read_text.return_value = file_path.read_text() mock_args = mock.MagicMock(from_file=True, restore_defaults=False) diff --git a/tests/unit_tests/test_tethys_cli/test_start_commands.py b/tests/unit_tests/test_tethys_cli/test_start_commands.py index e2b99d5c1..7657e584c 100644 --- a/tests/unit_tests/test_tethys_cli/test_start_commands.py +++ b/tests/unit_tests/test_tethys_cli/test_start_commands.py @@ -38,14 +38,16 @@ def test_start_command_with_port(self, mock_run_process, mock_get_manage_path): @mock.patch("tethys_cli.start_commands.configure_tethys_db") @mock.patch("tethys_cli.start_commands.process_args") @mock.patch("tethys_cli.start_commands.generate_command") - @mock.patch("tethys_cli.start_commands.os") + @mock.patch("tethys_cli.start_commands.Path.exists") + @mock.patch("tethys_cli.start_commands.chdir") @mock.patch("tethys_cli.start_commands.get_destination_path") @mock.patch("tethys_cli.start_commands.Namespace") def test_quickstart_command_completely_fresh( self, mock_namespace, mock_get_destination_path, - mock_os, + mock_chdir, + mock_path_exists, mock_generate_command, mock_process_args, mock_configure_tethys_db, @@ -58,7 +60,7 @@ def test_quickstart_command_completely_fresh( mock_start_command, ): mock_get_destination_path.return_value = "path/to/portal_config.yml" - mock_os.path.exists.side_effect = [False, False] + mock_path_exists.side_effect = [False, False] mock_namespace.side_effect = [ "portal_config_args", "db_config_args", @@ -81,7 +83,7 @@ def test_quickstart_command_completely_fresh( mock_setup_django.assert_called_once() mock_get_installed_tethys_items.assert_called_once_with(apps=True) mock_scaffold_command.assert_called_once_with("app_scaffold_args") - mock_os.chdir.assert_called_once_with("tethysapp-hello_world") + mock_chdir.assert_called_once_with("tethysapp-hello_world") mock_install_command.assert_called_once_with("app_install_args") mock_settings_command.assert_called_once_with("update_settings_args") mock_webbrowser.open.assert_called_once_with("http://127.0.0.1:8000/") @@ -89,19 +91,19 @@ def test_quickstart_command_completely_fresh( @mock.patch("tethys_cli.start_commands.write_warning") @mock.patch("tethys_cli.start_commands.exit") - @mock.patch("tethys_cli.start_commands.os") + @mock.patch("tethys_cli.start_commands.Path.exists") @mock.patch("tethys_cli.start_commands.get_destination_path") @mock.patch("tethys_cli.start_commands.Namespace") def test_quickstart_command_portal_config_exists( self, mock_namespace, mock_get_destination_path, - mock_os, + mock_path_exists, mock_exit, mock_write_warning, ): mock_get_destination_path.return_value = "path/to/portal_config.yml" - mock_os.path.exists.return_value = True + mock_path_exists.return_value = True mock_namespace.side_effect = ["portal_config_args"] mock_exit.side_effect = SystemExit args = Namespace() diff --git a/tests/unit_tests/test_tethys_cli/test_test_command.py b/tests/unit_tests/test_tethys_cli/test_test_command.py index 22ddad06f..8b2f4ea67 100644 --- a/tests/unit_tests/test_tethys_cli/test_test_command.py +++ b/tests/unit_tests/test_tethys_cli/test_test_command.py @@ -1,28 +1,30 @@ import unittest -import os +from pathlib import Path +from os import devnull from unittest import mock from tethys_apps.utilities import get_tethys_src_dir from tethys_cli.test_command import test_command, check_and_install_prereqs -FNULL = open(os.devnull, "w") +FNULL = open(devnull, "w") TETHYS_SRC_DIRECTORY = get_tethys_src_dir() class TestCommandTests(unittest.TestCase): def setUp(self): - pass + mock.patch( + "tethys_cli.test_command.subprocess.call", side_effect=Exception + ).start() def tearDown(self): - pass + mock.patch.stopall() - @mock.patch("tethys_cli.test_command.os.path.isfile", return_value=True) + @mock.patch("tethys_cli.test_command.Path.is_file", return_value=True) @mock.patch("tethys_cli.test_command.run_process") - @mock.patch("tethys_cli.test_command.os.path.join") @mock.patch("tethys_cli.test_command.get_manage_path") def test_test_command_no_coverage_file_path( - self, mock_get_manage_path, mock_join, mock_run_process, _ + self, mock_get_manage_path, mock_run_process, _ ): mock_args = mock.MagicMock() mock_args.coverage = False @@ -32,22 +34,19 @@ def test_test_command_no_coverage_file_path( mock_args.gui = False mock_args.verbosity = None mock_get_manage_path.return_value = "/foo/manage.py" - mock_join.return_value = "/foo" mock_run_process.return_value = 0 self.assertRaises(SystemExit, test_command, mock_args) mock_get_manage_path.assert_called() - mock_join.assert_called() mock_run_process.assert_called_once() mock_run_process.assert_called_with( ["python", "/foo/manage.py", "test", "foo", "--pattern", "bar_file"] ) @mock.patch("tethys_cli.test_command.run_process") - @mock.patch("tethys_cli.test_command.os.path.join") @mock.patch("tethys_cli.test_command.get_manage_path") def test_test_command_no_coverage_file_dot_notation( - self, mock_get_manage_path, mock_join, mock_run_process + self, mock_get_manage_path, mock_run_process ): mock_args = mock.MagicMock() mock_args.coverage = False @@ -57,23 +56,19 @@ def test_test_command_no_coverage_file_dot_notation( mock_args.gui = False mock_args.verbosity = None mock_get_manage_path.return_value = "/foo/manage.py" - mock_join.return_value = "/foo" mock_run_process.return_value = 0 self.assertRaises(SystemExit, test_command, mock_args) mock_get_manage_path.assert_called() - mock_join.assert_called() mock_run_process.assert_called_once() mock_run_process.assert_called_with( ["python", "/foo/manage.py", "test", "foo_file"] ) + @mock.patch("tethys_cli.test_command.TETHYS_SRC_DIRECTORY", "/foo") @mock.patch("tethys_cli.test_command.run_process") - @mock.patch("tethys_cli.test_command.os.path.join") @mock.patch("tethys_cli.test_command.get_manage_path") - def test_test_command_coverage_unit( - self, mock_get_manage_path, mock_join, mock_run_process - ): + def test_test_command_coverage_unit(self, mock_get_manage_path, mock_run_process): mock_args = mock.MagicMock() mock_args.coverage = True mock_args.coverage_html = False @@ -82,23 +77,29 @@ def test_test_command_coverage_unit( mock_args.gui = False mock_args.verbosity = None mock_get_manage_path.return_value = "/foo/manage.py" - mock_join.return_value = "/foo" mock_run_process.return_value = 0 self.assertRaises(SystemExit, test_command, mock_args) mock_get_manage_path.assert_called() - mock_join.assert_called() mock_run_process.assert_called() mock_run_process.assert_any_call( - ["coverage", "run", "--rcfile=/foo", "/foo/manage.py", "test", "/foo"] + [ + "coverage", + "run", + f"--rcfile={Path('/foo/tests/coverage.cfg')}", + "/foo/manage.py", + "test", + str(Path("/foo/tests/unit_tests")), + ] + ) + mock_run_process.assert_called_with( + ["coverage", "report", f"--rcfile={Path('/foo/tests/coverage.cfg')}"] ) - mock_run_process.assert_called_with(["coverage", "report", "--rcfile=/foo"]) @mock.patch("tethys_cli.test_command.run_process") - @mock.patch("tethys_cli.test_command.os.path.join") @mock.patch("tethys_cli.test_command.get_manage_path") def test_test_command_coverage_unit_file_app_package( - self, mock_get_manage_path, mock_join, mock_run_process + self, mock_get_manage_path, mock_run_process ): mock_args = mock.MagicMock() mock_args.coverage = True @@ -108,12 +109,10 @@ def test_test_command_coverage_unit_file_app_package( mock_args.gui = False mock_args.verbosity = None mock_get_manage_path.return_value = "/foo/manage.py" - mock_join.return_value = "/foo" mock_run_process.return_value = 0 self.assertRaises(SystemExit, test_command, mock_args) mock_get_manage_path.assert_called() - mock_join.assert_called() mock_run_process.assert_called() mock_run_process.assert_any_call( [ @@ -127,11 +126,11 @@ def test_test_command_coverage_unit_file_app_package( ) mock_run_process.assert_called_with(["coverage", "report"]) + @mock.patch("tethys_cli.test_command.TETHYS_SRC_DIRECTORY", "/foo") @mock.patch("tethys_cli.test_command.run_process") - @mock.patch("tethys_cli.test_command.os.path.join") @mock.patch("tethys_cli.test_command.get_manage_path") def test_test_command_coverage_html_unit_file_app_package( - self, mock_get_manage_path, mock_join, mock_run_process + self, mock_get_manage_path, mock_run_process ): mock_args = mock.MagicMock() mock_args.coverage = False @@ -141,12 +140,10 @@ def test_test_command_coverage_html_unit_file_app_package( mock_args.gui = False mock_args.verbosity = None mock_get_manage_path.return_value = "/foo/manage.py" - mock_join.return_value = "/foo" mock_run_process.return_value = 0 self.assertRaises(SystemExit, test_command, mock_args) mock_get_manage_path.assert_called() - mock_join.assert_called() mock_run_process.assert_called() mock_run_process.assert_any_call( [ @@ -158,14 +155,21 @@ def test_test_command_coverage_html_unit_file_app_package( "/foo/tethys_apps.tethysapp.foo", ] ) - mock_run_process.assert_any_call(["coverage", "html", "--directory=/foo"]) - mock_run_process.assert_called_with(["open", "/foo"]) + mock_run_process.assert_any_call( + [ + "coverage", + "html", + f"--directory={Path('/foo/tests/coverage_html_report')}", + ] + ) + mock_run_process.assert_called_with( + ["open", str(Path("/foo/tests/coverage_html_report/index.html"))] + ) @mock.patch("tethys_cli.test_command.run_process") - @mock.patch("tethys_cli.test_command.os.path.join") @mock.patch("tethys_cli.test_command.get_manage_path") def test_test_command_coverage_unit_file_extension_package( - self, mock_get_manage_path, mock_join, mock_run_process + self, mock_get_manage_path, mock_run_process ): mock_args = mock.MagicMock() mock_args.coverage = True @@ -175,12 +179,10 @@ def test_test_command_coverage_unit_file_extension_package( mock_args.gui = False mock_args.verbosity = None mock_get_manage_path.return_value = "/foo/manage.py" - mock_join.return_value = "/foo" mock_run_process.return_value = 0 self.assertRaises(SystemExit, test_command, mock_args) mock_get_manage_path.assert_called() - mock_join.assert_called() mock_run_process.assert_called() mock_run_process.assert_any_call( [ @@ -194,11 +196,11 @@ def test_test_command_coverage_unit_file_extension_package( ) mock_run_process.assert_called_with(["coverage", "report"]) + @mock.patch("tethys_cli.test_command.TETHYS_SRC_DIRECTORY", "/foo/bar") @mock.patch("tethys_cli.test_command.run_process") - @mock.patch("tethys_cli.test_command.os.path.join") @mock.patch("tethys_cli.test_command.get_manage_path") def test_test_command_coverage_html_gui_file( - self, mock_get_manage_path, mock_join, mock_run_process + self, mock_get_manage_path, mock_run_process ): mock_args = mock.MagicMock() mock_args.coverage = False @@ -208,32 +210,34 @@ def test_test_command_coverage_html_gui_file( mock_args.gui = True mock_args.verbosity = None mock_get_manage_path.return_value = "/foo/manage.py" - mock_join.return_value = "/foo/bar" mock_run_process.return_value = 0 self.assertRaises(SystemExit, test_command, mock_args) mock_get_manage_path.assert_called() - mock_join.assert_called() mock_run_process.assert_called() mock_run_process.assert_any_call( [ "coverage", "run", - "--rcfile=/foo/bar", + f"--rcfile={Path('/foo/bar/tests/coverage.cfg')}", "/foo/manage.py", "test", "foo_file", ] ) - mock_run_process.assert_any_call(["coverage", "html", "--rcfile=/foo/bar"]) - mock_run_process.assert_called_with(["open", "/foo/bar"]) + mock_run_process.assert_any_call( + ["coverage", "html", f"--rcfile={Path('/foo/bar/tests/coverage.cfg')}"] + ) + mock_run_process.assert_called_with( + ["open", str(Path("/foo/bar/tests/coverage_html_report/index.html"))] + ) + @mock.patch("tethys_cli.test_command.TETHYS_SRC_DIRECTORY", "/foo") @mock.patch("tethys_cli.test_command.webbrowser.open_new_tab") @mock.patch("tethys_cli.test_command.run_process") - @mock.patch("tethys_cli.test_command.os.path.join") @mock.patch("tethys_cli.test_command.get_manage_path") def test_test_command_coverage_html_gui_file_exception( - self, mock_get_manage_path, mock_join, mock_run_process, mock_open_new_tab + self, mock_get_manage_path, mock_run_process, mock_open_new_tab ): mock_args = mock.MagicMock() mock_args.coverage = False @@ -243,42 +247,37 @@ def test_test_command_coverage_html_gui_file_exception( mock_args.gui = True mock_args.verbosity = None mock_get_manage_path.return_value = "/foo/manage.py" - mock_join.side_effect = [ - "/foo/bar", - "/foo/bar2", - "/foo/bar3", - "/foo/bar4", - "/foo/bar5", - "/foo/bar6", - ] mock_run_process.side_effect = [0, 0, 1] mock_open_new_tab.return_value = 1 self.assertRaises(SystemExit, test_command, mock_args) mock_get_manage_path.assert_called() - mock_join.assert_called() mock_run_process.assert_called() mock_run_process.assert_any_call( [ "coverage", "run", - "--rcfile=/foo/bar2", + f"--rcfile={Path('/foo/tests/coverage.cfg')}", "/foo/manage.py", "test", "foo_file", ] ) - mock_run_process.assert_any_call(["coverage", "html", "--rcfile=/foo/bar2"]) - mock_run_process.assert_called_with(["open", "/foo/bar3"]) + mock_run_process.assert_any_call( + ["coverage", "html", f"--rcfile={Path('/foo/tests/coverage.cfg')}"] + ) + mock_run_process.assert_called_with( + ["open", str(Path("/foo/tests/coverage_html_report/index.html"))] + ) mock_open_new_tab.assert_called_once() - mock_open_new_tab.assert_called_with("/foo/bar4") + mock_open_new_tab.assert_called_with( + str(Path("/foo/tests/coverage_html_report/index.html")) + ) + @mock.patch("tethys_cli.test_command.TETHYS_SRC_DIRECTORY", "/foo") @mock.patch("tethys_cli.test_command.run_process") - @mock.patch("tethys_cli.test_command.os.path.join") @mock.patch("tethys_cli.test_command.get_manage_path") - def test_test_command_unit_no_file( - self, mock_get_manage_path, mock_join, mock_run_process - ): + def test_test_command_unit_no_file(self, mock_get_manage_path, mock_run_process): mock_args = mock.MagicMock() mock_args.coverage = False mock_args.coverage_html = False @@ -287,24 +286,20 @@ def test_test_command_unit_no_file( mock_args.gui = False mock_args.verbosity = None mock_get_manage_path.return_value = "/foo/manage.py" - mock_join.return_value = "/foo" mock_run_process.return_value = 0 self.assertRaises(SystemExit, test_command, mock_args) mock_get_manage_path.assert_called() - mock_join.assert_called() mock_run_process.assert_called_once() mock_run_process.assert_called_with( - ["python", "/foo/manage.py", "test", "/foo"] + ["python", "/foo/manage.py", "test", str(Path("/foo/tests/unit_tests"))] ) + @mock.patch("tethys_cli.test_command.TETHYS_SRC_DIRECTORY", "/foo") @mock.patch("tethys_cli.test_command.run_process") - @mock.patch("tethys_cli.test_command.os.path.join") @mock.patch("tethys_cli.test_command.get_manage_path") - def test_test_command_gui_no_file( - self, mock_get_manage_path, mock_join, mock_run_process - ): + def test_test_command_gui_no_file(self, mock_get_manage_path, mock_run_process): mock_args = mock.MagicMock() mock_args.coverage = False mock_args.coverage_html = False @@ -313,15 +308,13 @@ def test_test_command_gui_no_file( mock_args.gui = True mock_args.verbosity = None mock_get_manage_path.return_value = "/foo/manage.py" - mock_join.return_value = "/foo" mock_run_process.return_value = 0 self.assertRaises(SystemExit, test_command, mock_args) mock_get_manage_path.assert_called() - mock_join.assert_called() mock_run_process.assert_called_once() mock_run_process.assert_called_with( - ["python", "/foo/manage.py", "test", "/foo"] + ["python", "/foo/manage.py", "test", str(Path("/foo/tests/gui_tests"))] ) @mock.patch("tethys_cli.test_command.write_warning") @@ -329,35 +322,30 @@ def test_test_command_gui_no_file( @mock.patch("tethysapp.test_app", new=None) @mock.patch("tethysext.test_extension", new=None) def test_check_and_install_prereqs(self, mock_run_process, mock_write_warning): - tests_path = os.path.join(TETHYS_SRC_DIRECTORY, "tests") + tests_path = Path(TETHYS_SRC_DIRECTORY) / "tests" check_and_install_prereqs(tests_path) - setup_path = os.path.join(tests_path, "apps", "tethysapp-test_app") - extension_setup_path = os.path.join( - tests_path, "extensions", "tethysext-test_extension" - ) + setup_path = tests_path / "apps" / "tethysapp-test_app" + extension_setup_path = tests_path / "extensions" / "tethysext-test_extension" mock_run_process.assert_any_call( ["pip", "install", "-e", "."], stdout=mock.ANY, stderr=mock.ANY, - cwd=setup_path, + cwd=str(setup_path), ) mock_run_process.assert_any_call( ["pip", "install", "-e", "."], stdout=mock.ANY, stderr=mock.ANY, - cwd=extension_setup_path, + cwd=str(extension_setup_path), ) mock_write_warning.assert_called() @mock.patch("tethys_cli.test_command.run_process") - @mock.patch("tethys_cli.test_command.os.path.join") @mock.patch("tethys_cli.test_command.get_manage_path") - def test_test_command_verbosity( - self, mock_get_manage_path, mock_join, mock_run_process - ): + def test_test_command_verbosity(self, mock_get_manage_path, mock_run_process): mock_args = mock.MagicMock() mock_args.coverage = False mock_args.coverage_html = False @@ -366,24 +354,20 @@ def test_test_command_verbosity( mock_args.gui = False mock_args.verbosity = "2" mock_get_manage_path.return_value = "/foo/manage.py" - mock_join.return_value = "/foo" mock_run_process.return_value = 0 self.assertRaises(SystemExit, test_command, mock_args) mock_get_manage_path.assert_called() - mock_join.assert_called() mock_run_process.assert_called_with( ["python", "/foo/manage.py", "test", "-v", "2"] ) @mock.patch("tethys_cli.test_command.write_error") @mock.patch("tethys_cli.test_command.check_and_install_prereqs") - @mock.patch("tethys_cli.test_command.os.path.join") @mock.patch("tethys_cli.test_command.get_manage_path") def test_test_command_not_installed( self, mock_get_manage_path, - mock_join, mock_check_and_install_prereqs, mock_write_error, ): @@ -395,7 +379,6 @@ def test_test_command_not_installed( mock_args.gui = False mock_args.verbosity = None mock_get_manage_path.return_value = "/foo/manage.py" - mock_join.return_value = "/foo" mock_check_and_install_prereqs.side_effect = FileNotFoundError self.assertRaises(SystemExit, test_command, mock_args) diff --git a/tests/unit_tests/test_tethys_components/__init__.py b/tests/unit_tests/test_tethys_components/__init__.py new file mode 100644 index 000000000..e69de29bb 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..fce12e0c5 --- /dev/null +++ b/tests/unit_tests/test_tethys_components/__test_custom.py @@ -0,0 +1,85 @@ +from tethys_components import custom +from tethys_components.library import Library as lib +from unittest import mock, IsolatedAsyncioTestCase +from importlib import reload + + +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..3b3ea78d2 --- /dev/null +++ b/tests/unit_tests/test_tethys_components/__test_layouts.py @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..b94bcd5fb --- /dev/null +++ b/tests/unit_tests/test_tethys_components/test_library.py @@ -0,0 +1,124 @@ +from unittest import TestCase, mock +from pathlib import Path + +THIS_DIR = Path(__file__).parent +RESOURCES_DIR = THIS_DIR / "test_resources" + + +class TestComponentLibrary(TestCase): + def test_standard_library_workflow(self): + from tethys_components.library import Library as lib, ComponentLibrary + + mock_import = mock.patch("builtins.__import__").start() + + # TEST VALID ACCESSORS + lib.tethys + self.assertEqual(mock_import.call_args_list[-1][0][0], "tethys_components") + self.assertEqual(mock_import.call_args_list[-1][0][3][0], "custom") + lib.html + self.assertEqual(mock_import.call_args_list[-1][0][0], "reactpy") + self.assertEqual(mock_import.call_args_list[-1][0][3][0], "html") + lib.hooks + self.assertEqual(mock_import.call_args_list[-1][0][0], "tethys_components") + self.assertEqual(mock_import.call_args_list[-1][0][3][0], "hooks") + self.assertEqual(len(lib.styles), 0) + self.assertIsNone(lib.parent_package) + external_lib = lib.bs + self.assertNotEqual(lib.bs, lib) + self.assertIsInstance(external_lib, ComponentLibrary) + self.assertIsNone(lib.bs.package) + self.assertEqual(lib.bs.parent_package, "bs") + self.assertEqual(len(lib.styles), 1) + self.assertEqual(lib.styles[0], lib.STYLE_DEPS["bs"][0]) + self.assertDictEqual(lib.components_by_package, {}) + orig_func = ComponentLibrary.get_reactjs_module_wrapper_js + mock_func = mock.MagicMock() + ComponentLibrary.get_reactjs_module_wrapper_js = mock_func + button_component = lib.bs.Button + lib.bs.Button + mock_func.assert_called_once() + self.assertEqual(button_component, mock_import().web.export()) + mock.patch.stopall() + + # CREATE JAVASCRIPT WRAPPER FOR LIBRARY + ComponentLibrary.get_reactjs_module_wrapper_js = orig_func + content = lib.get_reactjs_module_wrapper_js() + # (RESOURCES_DIR / 'expected_1.js').open('w+').write(content) # Uncomment to write newly expected js + self.assertEqual(content, (RESOURCES_DIR / "expected_1.js").open("r").read()) + + # LOAD A NEW PAGE + ComponentLibrary.refresh("new_page") + self.assertDictEqual(lib.components_by_package, {}) + self.assertDictEqual(lib.package_handles, {}) + self.assertListEqual(lib.styles, []) + self.assertListEqual(lib.defaults, []) + self.assertEqual(lib.EXPORT_NAME, "new_page") + + # TRY TO ACCESS INVALID PACKAGE + self.assertRaises(AttributeError, lambda: lib.does_not_exist) + + # REGISTER PACKAGE + lib.register( + "my-react-package@0.0.0", + "does_not_exist", + styles=["my_style.css"], + use_default=True, + ) + self.assertIn("does_not_exist", lib.PACKAGE_BY_ACCESSOR) + self.assertEqual( + lib.PACKAGE_BY_ACCESSOR["does_not_exist"], "my-react-package@0.0.0" + ) + self.assertIn("does_not_exist", lib.STYLE_DEPS) + self.assertListEqual(lib.STYLE_DEPS["does_not_exist"], ["my_style.css"]) + self.assertListEqual(lib.DEFAULTS, ["rp", "mapgl", "does_not_exist"]) + + # REGISTER AGAIN EXACTLY + lib.register( + "my-react-package@0.0.0", + "does_not_exist", + styles=["my_style.css"], + use_default=True, + ) + + # REGISTER NEW PACKAGE TO SAME ACCESSOR (NAUGHTY) + self.assertRaises( + ValueError, lib.register, "different-react-package@1.1.1", "does_not_exist" + ) + + # PREVIOUSLY INVALID PACKAGE NOW WORKS + lib.does_not_exist # Does not raise AttributeError it did before + + # LOAD COMPONENTS FROM SOURCE CODE + mock_import = mock.patch("builtins.__import__").start() + ComponentLibrary.get_reactjs_module_wrapper_js = mock_func + test_source_code = """ + @compoenent + def my_component(): + return lib.html.div( + lib.pm.Map(), + lib.bs.Button(Props(), "My Button"), + lib.does_not_exist.Test() + ) + """ + lib.load_dependencies_from_source_code(test_source_code) + + self.assertDictEqual( + lib.components_by_package, + { + "pigeon-maps@0.21.6": ["Map"], + "react-bootstrap@2.10.2": ["Button"], + "my-react-package@0.0.0": ["Test"], + }, + ) + self.assertIn("pm", lib.package_handles) + self.assertIn("bs", lib.package_handles) + self.assertIn("does_not_exist", lib.package_handles) + self.assertIn("my_style.css", lib.styles) + self.assertEqual(lib.defaults, ["Test"]) + ComponentLibrary.get_reactjs_module_wrapper_js = orig_func + mock.patch.stopall() + + # EXPORT TO JS ONCE MORE + content = lib.get_reactjs_module_wrapper_js() + # (RESOURCES_DIR / 'expected_2.js').open('w+').write(content) # Uncomment to write newly expected js + self.assertEqual(content, (RESOURCES_DIR / "expected_2.js").open("r").read()) diff --git a/tests/unit_tests/test_tethys_components/test_resources/expected_1.js b/tests/unit_tests/test_tethys_components/test_resources/expected_1.js new file mode 100644 index 000000000..0a8f04ce8 --- /dev/null +++ b/tests/unit_tests/test_tethys_components/test_resources/expected_1.js @@ -0,0 +1,86 @@ + +import {Button} from "https://esm.sh/react-bootstrap@2.10.2?deps=react@18.2.0,react-dom@18.2.0,react-is@18.2.0,@restart/ui@1.6.8&exports=Button&bundle_deps"; +export {Button}; +loadCSS("https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"); + +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/tests/unit_tests/test_tethys_components/test_resources/expected_2.js b/tests/unit_tests/test_tethys_components/test_resources/expected_2.js new file mode 100644 index 000000000..5155a029a --- /dev/null +++ b/tests/unit_tests/test_tethys_components/test_resources/expected_2.js @@ -0,0 +1,91 @@ + +import {Map} from "https://esm.sh/pigeon-maps@0.21.6?deps=react@18.2.0,react-dom@18.2.0,react-is@18.2.0,@restart/ui@1.6.8&exports=Map&bundle_deps"; +export {Map}; +import {Button} from "https://esm.sh/react-bootstrap@2.10.2?deps=react@18.2.0,react-dom@18.2.0,react-is@18.2.0,@restart/ui@1.6.8&exports=Button&bundle_deps"; +export {Button}; +import Test from "https://esm.sh/my-react-package@0.0.0?deps=react@18.2.0,react-dom@18.2.0,react-is@18.2.0,@restart/ui@1.6.8&bundle_deps"; +export {Test}; +loadCSS("my_style.css"); +loadCSS("https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"); + +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/tests/unit_tests/test_tethys_components/test_utils.py b/tests/unit_tests/test_tethys_components/test_utils.py new file mode 100644 index 000000000..5aeefa98c --- /dev/null +++ b/tests/unit_tests/test_tethys_components/test_utils.py @@ -0,0 +1,121 @@ +import asyncio +from unittest import TestCase, mock +from tethys_components import utils +from tethys_apps.base import TethysWorkspace +from pathlib import Path +from django.contrib.auth.models import User + +THIS_DIR = Path(__file__).parent +TEST_APP_DIR = ( + THIS_DIR.parents[1] / "apps" / "tethysapp-test_app" / "tethysapp" / "test_app" +) + + +class TestComponentUtils(TestCase): + @classmethod + def setUpClass(cls): + cls.loop = asyncio.new_event_loop() + asyncio.set_event_loop(cls.loop) + cls.user = User.objects.create_user( + username="john", email="john@gmail.com", password="pass" + ) + cls.app = mock.MagicMock() + + @classmethod + def tearDownClass(cls): + cls.loop.close() + cls.user.delete() + + def run_coroutine(self, f, *args, **kwargs): + result = self.loop.run_until_complete(f(*args, **kwargs)) + return result + + def test_get_workspace_for_app(self): + workspace = self.run_coroutine(utils.get_workspace, "test_app", user=None) + self.assertIsInstance(workspace, TethysWorkspace) + self.assertEqual( + workspace.path.lower(), + str(TEST_APP_DIR / "workspaces" / "app_workspace").lower(), + ) + + def test_get_workspace_for_user(self): + workspace = self.run_coroutine(utils.get_workspace, "test_app", user=self.user) + self.assertIsInstance(workspace, TethysWorkspace) + self.assertEqual( + workspace.path.lower(), + str(TEST_APP_DIR / "workspaces" / "user_workspaces" / "john").lower(), + ) + + @mock.patch("tethys_components.utils.inspect") + def test_use_workspace(self, mock_inspect): + mock_import = mock.patch("builtins.__import__").start() + try: + 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_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() + + def test_delayed_execute(self): + mock_import = mock.patch("builtins.__import__").start() + + def test_func(arg1): + pass + + utils.delayed_execute(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() + + def test_props_all_cases_combined(self): + expected = {"foo": "bar", "on_click": "test", "this-prop": "none"} + value = utils.Props(foo_="bar", on_click="test", this_prop=None) + + self.assertEqual(value, expected) + + def test_get_layout_component_layout_callable(self): + def test_layout_func(): + pass + + self.assertEqual( + utils.get_layout_component(self.app, test_layout_func), test_layout_func + ) + + def test_get_layout_component_default_layout_callable(self): + def test_layout_func(): + pass + + self.app.default_layout = test_layout_func + self.assertEqual( + utils.get_layout_component(self.app, "default"), self.app.default_layout + ) + + def test_get_layout_component_default_layout_not_callable(self): + self.app.default_layout = "TestLayout" + mock_import = mock.patch("builtins.__import__").start() + self.assertEqual( + utils.get_layout_component(self.app, "default"), + mock_import().layouts.TestLayout, + ) + mock.patch.stopall() + + def test_get_layout_component_not_default_not_callable(self): + mock_import = mock.patch("builtins.__import__").start() + self.assertEqual( + utils.get_layout_component(self.app, "TestLayout"), + mock_import().layouts.TestLayout, + ) + mock.patch.stopall() diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_CondorBase.py b/tests/unit_tests/test_tethys_compute/test_models/test_CondorBase.py index 050c7c0b0..fe30b216e 100644 --- a/tests/unit_tests/test_tethys_compute/test_models/test_CondorBase.py +++ b/tests/unit_tests/test_tethys_compute/test_models/test_CondorBase.py @@ -286,9 +286,9 @@ def test_get_lazy_log_content_local(self, mock_path): self.assertEqual(expected, logs) @mock.patch( - "tethys_compute.models.condor.condor_base.os.path.exists", return_value=True + "tethys_compute.models.condor.condor_base.Path.exists", return_value=True ) - def test_check_local_logs_exist(self, mock_path_exists): + def test_check_local_logs_exist(self, _): mock_partial = mock.MagicMock(args=("file_path",)) logs_file_contents = { "workspace": mock_partial, 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 fcfc88084..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 @@ -6,9 +6,8 @@ from tethys_compute.models.condor.condor_py_job import CondorPyJob from django.contrib.auth.models import User from unittest import mock -import os +from pathlib import Path import shutil -import os.path class CondorJobTest(TethysTestCase): @@ -23,8 +22,7 @@ def set_up(self): ) self.scheduler.save() - path = os.path.dirname(__file__) - self.workspace_dir = os.path.join(path, "workspace") + self.workspace_dir = Path(__file__).parent / "workspace" self.condorjob = CondorJob( name="test condorbase", @@ -33,7 +31,7 @@ def set_up(self): label="test_label", cluster_id="1", remote_id="test_machine", - workspace=self.workspace_dir, + workspace=str(self.workspace_dir), scheduler=self.scheduler, condorpyjob_id="99", _attributes={"foo": "bar"}, @@ -48,8 +46,8 @@ def tear_down(self): if self.condorjob.condorbase_ptr_id is not None: self.condorjob.delete() - if os.path.exists(self.workspace_dir): - shutil.rmtree(self.workspace_dir) + if self.workspace_dir.exists(): + shutil.rmtree(str(self.workspace_dir)) def test_type(self): ret = self.condorjob.type @@ -101,10 +99,10 @@ def test_condor_job_pre_save(self): @mock.patch("tethys_compute.models.condor.condor_job.CondorBase.condor_object") def test_condor_job_pre_delete(self, mock_co): - if not os.path.exists(self.workspace_dir): - os.makedirs(self.workspace_dir) - file_path = os.path.join(self.workspace_dir, "test_file.txt") - open(file_path, "a").close() + if not self.workspace_dir.exists(): + self.workspace_dir.mkdir(parents=True) + file_path = self.workspace_dir / "test_file.txt" + file_path.touch() self.condorjob.delete() @@ -112,7 +110,7 @@ def test_condor_job_pre_delete(self, mock_co): mock_co.close_remote.assert_called() # Check if file has been removed - self.assertFalse(os.path.isfile(file_path)) + self.assertFalse(file_path.is_file()) @mock.patch("tethys_compute.models.condor.condor_job.log") @mock.patch("tethys_compute.models.condor.condor_job.CondorBase.condor_object") diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyJob.py b/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyJob.py index 14e14b63b..0586cc59c 100644 --- a/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyJob.py +++ b/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyJob.py @@ -6,6 +6,7 @@ from django.contrib.auth.models import User from condorpy import Job from unittest import mock +from pathlib import Path class CondorPyJobTest(TethysTestCase): @@ -128,7 +129,7 @@ def test_initial_dir(self): ret = self.condorjob.initial_dir # Check result - self.assertEqual("test_workspace/.", ret) + self.assertEqual(str(Path("test_workspace") / "."), ret) def test_set_and_get_attribute(self): self.condorjob.set_attribute("test", "value") diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyWorkflow.py b/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyWorkflow.py index 514f7e50a..e4b8de471 100644 --- a/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyWorkflow.py +++ b/tests/unit_tests/test_tethys_compute/test_models/test_CondorPyWorkflow.py @@ -5,17 +5,14 @@ from tethys_compute.models.condor.condor_workflow import CondorWorkflow from django.contrib.auth.models import User from unittest import mock -import os -import os.path +from pathlib import Path class CondorPyWorkflowTest(TethysTestCase): def set_up(self): - test_models_dir = os.path.dirname(__file__) - self.workspace_dir = os.path.join(test_models_dir, "workspace") - - files_dir = os.path.join(os.path.dirname(test_models_dir), "files") - self.private_key = os.path.join(files_dir, "keys", "testkey") + test_models_dir = Path(__file__).parent + self.workspace_dir = test_models_dir / "workspace" + self.private_key = test_models_dir.parent / "files" / "keys" / "testkey" self.private_key_pass = "password" self.user = User.objects.create_user("tethys_super", "user@example.com", "pass") @@ -25,7 +22,7 @@ def set_up(self): host="localhost", username="tethys_super", password="pass", - private_key_path=self.private_key, + private_key_path=str(self.private_key), private_key_pass=self.private_key_pass, ) self.scheduler.save() @@ -34,7 +31,7 @@ def set_up(self): _max_jobs={"foo": 10}, _config="test_config", name="test name", - workspace=self.workspace_dir, + workspace=str(self.workspace_dir), user=self.user, scheduler=self.scheduler, ) @@ -84,7 +81,7 @@ def test_condorpy_workflow_prop(self): # Check Result self.assertEqual("", repr(ret)) - self.assertEqual(self.workspace_dir, ret._cwd) + self.assertEqual(str(self.workspace_dir), ret._cwd) self.assertEqual("test_config", ret.config) @mock.patch("tethys_compute.models.condor.condor_py_workflow.Workflow") 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 3c6e6e278..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 @@ -6,9 +6,8 @@ from django.contrib.auth.models import User from django.utils import timezone as tz from unittest import mock -import os import shutil -import os.path +from pathlib import Path class CondorWorkflowTest(TethysTestCase): @@ -19,12 +18,10 @@ class CondorWorkflowTest(TethysTestCase): mock_condor_workflow._execute.return_value = "out", "err" def set_up(self): - test_models_dir = os.path.dirname(__file__) - self.workspace_dir = os.path.join(test_models_dir, "workspace") + test_models_dir = Path(__file__).parent + self.workspace_dir = test_models_dir / "workspace" self.user = User.objects.create_user("tethys_super", "user@example.com", "pass") - - files_dir = os.path.join(os.path.dirname(test_models_dir), "files") - self.private_key = os.path.join(files_dir, "keys", "testkey") + self.private_key = test_models_dir.parent / "files" / "keys" / "testkey" self.private_key_pass = "password" self.scheduler = CondorScheduler( @@ -32,7 +29,7 @@ def set_up(self): host="localhost", username="tethys_super", password="pass", - private_key_path=self.private_key, + private_key_path=str(self.private_key), private_key_pass=self.private_key_pass, ) self.scheduler.save() @@ -41,7 +38,7 @@ def set_up(self): _max_jobs={"foo": 10}, _config="test_config", name="test name", - workspace=self.workspace_dir, + workspace=str(self.workspace_dir), user=self.user, scheduler=self.scheduler, ) @@ -88,8 +85,8 @@ def tear_down(self): if self.condorworkflow.condorbase_ptr_id == self.condorbase_id: self.condorworkflow.delete() - if os.path.exists(self.workspace_dir): - shutil.rmtree(self.workspace_dir) + if self.workspace_dir.exists(): + shutil.rmtree(str(self.workspace_dir)) def test_type(self): ret = self.condorworkflow.type @@ -177,9 +174,9 @@ def test_log_files(self, _): "error": "test_name.dag.lib.err", }, "test_job1": { - "log": "test_job1/logs/*.log", - "error": "test_job1/logs/*.err", - "output": "test_job1/logs/*.out", + "log": str(Path("test_job1/logs/*.log")), + "error": str(Path("test_job1/logs/*.err")), + "output": str(Path("test_job1/logs/*.out")), }, } # Execute @@ -201,10 +198,10 @@ def test_condor_workflow_presave(self, mock_update): "tethys_compute.models.condor.condor_workflow.CondorWorkflow.condor_object" ) def test_condor_job_pre_delete(self, mock_co): - if not os.path.exists(self.workspace_dir): - os.makedirs(self.workspace_dir) - file_path = os.path.join(self.workspace_dir, "test_file.txt") - open(file_path, "a").close() + if not self.workspace_dir.exists(): + self.workspace_dir.mkdir(parents=True) + file_path = self.workspace_dir / "test_file.txt" + file_path.touch() self.condorworkflow.delete() @@ -212,7 +209,7 @@ def test_condor_job_pre_delete(self, mock_co): mock_co.close_remote.assert_called() # Check if file has been removed - self.assertFalse(os.path.isfile(file_path)) + self.assertFalse(file_path.is_file()) @mock.patch("tethys_compute.models.condor.condor_workflow.log") @mock.patch( diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflowJobNode.py b/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflowJobNode.py index f93a06098..8101f5367 100644 --- a/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflowJobNode.py +++ b/tests/unit_tests/test_tethys_compute/test_models/test_CondorWorkflowJobNode.py @@ -5,14 +5,12 @@ from tethys_compute.models.condor.condor_workflow import CondorWorkflow from django.contrib.auth.models import User from unittest import mock -import os -import os.path +from pathlib import Path class CondorPyWorkflowJobNodeTest(TethysTestCase): def set_up(self): - path = os.path.dirname(__file__) - self.workspace_dir = os.path.join(path, "workspace") + self.workspace_dir = Path(__file__).parent / "workspace" self.user = User.objects.create_user("tethys_super", "user@example.com", "pass") @@ -20,7 +18,7 @@ def set_up(self): _max_jobs={"foo": 10}, _config="test_config", name="foo{id}", - workspace=self.workspace_dir, + workspace=str(self.workspace_dir), user=self.user, ) self.condorworkflow.save() diff --git a/tests/unit_tests/test_tethys_compute/test_models/test_WorkflowNode.py b/tests/unit_tests/test_tethys_compute/test_models/test_WorkflowNode.py index 6e521499a..4c62d9fda 100644 --- a/tests/unit_tests/test_tethys_compute/test_models/test_WorkflowNode.py +++ b/tests/unit_tests/test_tethys_compute/test_models/test_WorkflowNode.py @@ -7,17 +7,14 @@ from django.contrib.auth.models import User from condorpy import Job from unittest import mock -import os -import os.path +from pathlib import Path class CondorWorkflowNodeTest(TethysTestCase): def set_up(self): - test_models_dir = os.path.dirname(__file__) - self.workspace_dir = os.path.join(test_models_dir, "workspace") - - files_dir = os.path.join(os.path.dirname(test_models_dir), "files") - self.private_key = os.path.join(files_dir, "keys", "testkey") + test_models_dir = Path(__file__).parent + self.workspace_dir = test_models_dir / "workspace" + self.private_key = test_models_dir.parent / "files" / "keys" / "testkey" self.private_key_pass = "password" self.user = User.objects.create_user("tethys_super", "user@example.com", "pass") @@ -27,7 +24,7 @@ def set_up(self): host="localhost", username="tethys_super", password="pass", - private_key_path=self.private_key, + private_key_path=str(self.private_key), private_key_pass=self.private_key_pass, ) self.scheduler.save() @@ -36,7 +33,7 @@ def set_up(self): _max_jobs={"foo": 10}, _config="test_config", name="test name", - workspace=self.workspace_dir, + workspace=str(self.workspace_dir), user=self.user, scheduler=self.scheduler, ) @@ -101,7 +98,7 @@ def test_condorpy_node(self, mock_job): attributes={"foo": "bar"}, num_jobs=1, remote_input_files=["test_file.txt"], - working_directory=self.workspace_dir, + working_directory=str(self.workspace_dir), ) mock_job.return_value = mock_job_return diff --git a/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py b/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py index 07e5efd85..830c3875c 100644 --- a/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py +++ b/tests/unit_tests/test_tethys_gizmos/test_templatetags/test_tethys_gizmos.py @@ -7,6 +7,7 @@ from django.template import TemplateSyntaxError from django.template import Context from importlib import reload +from pathlib import Path class TestGizmo(TethysGizmoOptions): @@ -25,7 +26,7 @@ def get_vendor_js(): @staticmethod def get_gizmo_js(): - return ("tethys_gizmos/js/tethys_map_view.js",) + return (str(Path("tethys_gizmos/js/tethys_map_view.js")),) @staticmethod def get_vendor_css(): @@ -35,7 +36,7 @@ def get_vendor_css(): @staticmethod def get_gizmo_css(): - return ("tethys_gizmos/css/tethys_map_view.min.css",) + return (str(Path("tethys_gizmos/css/tethys_map_view.min.css")),) @staticmethod def get_gizmo_modals(): @@ -242,7 +243,9 @@ def test_render_in_extension_path(self, mock_gt): result.render(context) # Check Result - mock_gt.assert_called_with("tethys_gizmos/templates/gizmos/test_gizmo.html") + mock_gt.assert_called_with( + str(Path("tethys_gizmos/templates/gizmos/test_gizmo.html")) + ) # We need to delete this extension path map to avoid template not exist error on the # previous test 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..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 @@ -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..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 @@ -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..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 @@ -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/app_base.py b/tethys_apps/base/app_base.py index 3843a32ba..aaf4dc500 100644 --- a/tethys_apps/base/app_base.py +++ b/tethys_apps/base/app_base.py @@ -43,7 +43,7 @@ tethys_log = logging.getLogger("tethys.app_base") -DEFAULT_CONTROLLER_MODULES = ["controllers", "consumers", "handlers"] +DEFAULT_CONTROLLER_MODULES = ["controllers", "consumers", "handlers", "pages"] class TethysBase(TethysBaseMixin): @@ -586,6 +586,8 @@ class TethysAppBase(TethysBase): feedback_emails = [] enabled = True show_in_apps_library = True + default_layout = None + nav_links = [] def __str__(self): """ @@ -609,6 +611,29 @@ def db_model(cls): return TethysApp + @property + def navigation_links(self): + nav_links = self.nav_links + if 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, + ): + href = f"/apps/{self.root_url}/" + if url_map.name != self.index: + href += url_map.name.replace("_", "-") + "/" + if url_map.index == -1: + continue # Do not render + nav_links.append( + { + "title": url_map.title, + "href": href, + } + ) + self.nav_links = nav_links # Caches results of "auto" + return nav_links + def custom_settings(self): """ Override this method to define custom settings for use in your app. diff --git a/tethys_apps/base/controller.py b/tethys_apps/base/controller.py index b46e7d470..c1f9d6a9e 100644 --- a/tethys_apps/base/controller.py +++ b/tethys_apps/base/controller.py @@ -15,6 +15,7 @@ from django.http import HttpRequest from django.contrib.auth import REDIRECT_FIELD_NAME +from tethys_apps.base.page_handler import global_page_controller from tethys_cli.cli_colors import write_warning from tethys_quotas.decorators import enforce_quota from tethys_services.utilities import ensure_oauth2 @@ -36,7 +37,6 @@ from typing import Union, Any from collections.abc import Callable - app_controllers_list = list() @@ -443,6 +443,113 @@ def wrapped(function_or_class): return wrapped if function_or_class is None else wrapped(function_or_class) +def page( + component_function: Callable = None, + /, + *, + # UrlMap Overrides + name: str = None, + url: Union[str, list, tuple, dict, None] = None, + regex: Union[str, list, tuple] = None, + handler: Union[str, Callable] = None, + # login_required kwargs + login_required: bool = True, + redirect_field_name: str = REDIRECT_FIELD_NAME, + login_url: str = None, + # 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=None, + custom_js=None, +) -> Callable: + """ + 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. + 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. + 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. + """ # noqa: E501 + permissions_required = _listify(permissions_required) + enforce_quota_codenames = _listify(enforce_quotas) + + def wrapped(component_function): + component_source_code = inspect.getsource(component_function) + url_map_kwargs_list = _get_url_map_kwargs_list( + function_or_class=component_function, + name=name, + url=url, + protocol="http", + regex=regex, + title=title, + index=index, + ) + + def controller_wrapper(request, **kwargs): + controller = handler or global_page_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, + 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, + **kwargs, + ) + + _process_url_kwargs(controller_wrapper, url_map_kwargs_list) + return component_function + + return wrapped if component_function is None else wrapped(component_function) + + controller_decorator = controller @@ -651,6 +758,8 @@ def _get_url_map_kwargs_list( app_media=False, app_public=False, app_resources=False, + title=None, + index=None, ): final_urls = [] if url is not None: @@ -712,6 +821,9 @@ def _get_url_map_kwargs_list( for i, final_url in enumerate(final_urls) } + if not title: + title = url_name.replace("_", " ").title() + return [ dict( name=url_name, @@ -721,6 +833,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() ] diff --git a/tethys_apps/base/page_handler.py b/tethys_apps/base/page_handler.py new file mode 100644 index 000000000..395f5113d --- /dev/null +++ b/tethys_apps/base/page_handler.py @@ -0,0 +1,60 @@ +from django.shortcuts import render +from tethys_components.library import Library as ComponentLibrary +from tethys_apps.utilities import get_active_app +from tethys_components.utils import get_layout_component +from tethys_portal.optional_dependencies import has_module + + +def global_page_controller( + request, + layout, + component_func, + component_source_code, + 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) + 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 or [], + "custom_js": custom_js or [], + "extras": kwargs, + } + + return render(request, "tethys_apps/reactpy_base.html", context) + + +if has_module("reactpy"): + from reactpy import component + + @component + def page_component_wrapper(app, user, layout, component, extras=None): + """ + 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(**extras) if extras else component(), + ) + else: + return component(**extras) if extras else component() diff --git a/tethys_apps/base/paths.py b/tethys_apps/base/paths.py index 109ec5712..d992043a8 100644 --- a/tethys_apps/base/paths.py +++ b/tethys_apps/base/paths.py @@ -8,10 +8,10 @@ ******************************************************************************** """ -import os import shutil import logging from pathlib import Path +from os import walk from django.conf import settings from django.utils.functional import wraps @@ -86,7 +86,7 @@ def files(self, names_only=False): tethys_path.files(names_only=True) """ - path, dirs, files = next(os.walk(self.path)) + path, dirs, files = next(walk(self.path)) if names_only: return files return [self.path / f for f in files] @@ -112,7 +112,7 @@ def directories(self, names_only=False): tethys_path.directories(names_only=True) """ - path, dirs, files = next(os.walk(self.path)) + path, dirs, files = next(walk(self.path)) if names_only: return dirs return [self.path / d for d in dirs] @@ -208,7 +208,7 @@ def get_size(self, units="b"): """ total_size = 0 for file in self.files(): - total_size += os.path.getsize(file) + total_size += file.stat().st_size if units.lower() == "b": conversion_factor = 1 @@ -381,7 +381,6 @@ def get_user_workspace( .. code-block:: python - import os from tethys_sdk.workspaces import get_user_workspace from .app import App diff --git a/tethys_apps/base/url_map.py b/tethys_apps/base/url_map.py index fd89546fe..f5565cac8 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 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 ( @@ -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/base/workspace.py b/tethys_apps/base/workspace.py index 22574bc89..1c2bd9cd7 100644 --- a/tethys_apps/base/workspace.py +++ b/tethys_apps/base/workspace.py @@ -8,10 +8,10 @@ ******************************************************************************** """ -import os import sys import shutil import logging +from pathlib import Path from django.utils.functional import wraps from django.http import HttpRequest from django.utils.functional import SimpleLazyObject @@ -33,12 +33,13 @@ def __init__(self, path): """ Constructor """ + _path = Path(path) # Create the path if it doesn't already exist - if not os.path.exists(path): - os.makedirs(path) + if not _path.exists(): + _path.mkdir(parents=True) # Validate that the path is a directory - self._path = path + self._path = _path def __repr__(self): """ @@ -48,7 +49,7 @@ def __repr__(self): @property def path(self): - return self._path + return str(self._path) @path.setter def path(self, value): @@ -79,17 +80,9 @@ def files(self, full_path=False): """ if full_path: - files = [ - os.path.join(self._path, f) - for f in os.listdir(self._path) - if os.path.isfile(os.path.join(self._path, f)) - ] + files = [str(p) for p in self._path.iterdir() if p.is_file()] else: - files = [ - f - for f in os.listdir(self._path) - if os.path.isfile(os.path.join(self._path, f)) - ] + files = [str(p.name) for p in self._path.iterdir() if p.is_file()] return files def directories(self, full_path=False): @@ -114,17 +107,9 @@ def directories(self, full_path=False): """ if full_path: - directories = [ - os.path.join(self._path, d) - for d in os.listdir(self._path) - if os.path.isdir(os.path.join(self._path, d)) - ] + directories = [str(p) for p in self._path.iterdir() if p.is_dir()] else: - directories = [ - d - for d in os.listdir(self._path) - if os.path.isdir(os.path.join(self._path, d)) - ] + directories = [str(p.name) for p in self._path.iterdir() if p.is_dir()] return directories def clear(self, exclude=None, exclude_files=False, exclude_directories=False): @@ -156,26 +141,15 @@ def clear(self, exclude=None, exclude_files=False, exclude_directories=False): if exclude is None: exclude = list() - files = [ - f - for f in os.listdir(self._path) - if os.path.isfile(os.path.join(self._path, f)) - ] - directories = [ - d - for d in os.listdir(self._path) - if os.path.isdir(os.path.join(self._path, d)) - ] - if not exclude_files: - for file in files: - fullpath = os.path.join(self._path, file) + for file in self.files(): + fullpath = self._path / file if file not in exclude and fullpath not in exclude: - os.remove(fullpath) + fullpath.unlink() if not exclude_directories: - for directory in directories: - fullpath = os.path.join(self._path, directory) + for directory in self.directories(): + fullpath = self._path / directory if directory not in exclude and fullpath not in exclude: shutil.rmtree(fullpath) @@ -210,18 +184,20 @@ def remove(self, item): .replace("~\\", "") ) - if self._path not in full_path: - full_path = os.path.join(self._path, full_path) + if self.path not in full_path: + full_path = self._path / full_path + else: + full_path = Path(full_path) - if os.path.isdir(full_path): + if full_path.is_dir(): shutil.rmtree(full_path) - elif os.path.isfile(full_path): - os.remove(full_path) + elif full_path.is_file(): + full_path.unlink() def get_size(self, units="b"): total_size = 0 for file in self.files(True): - total_size += os.path.getsize(file) + total_size += Path(file).stat().st_size if units.lower() == "b": conversion_factor = 1 @@ -262,11 +238,11 @@ def _get_user_workspace(app_class, user_or_request): "Invalid type for argument 'user': must be either an User or HttpRequest object." ) - project_directory = os.path.dirname(sys.modules[app_class.__module__].__file__) - workspace_directory = os.path.join( - project_directory, "workspaces", "user_workspaces", username + project_directory = Path(sys.modules[app_class.__module__].__file__).parent + workspace_directory = ( + project_directory / "workspaces" / "user_workspaces" / username ) - return TethysWorkspace(workspace_directory) + return TethysWorkspace(str(workspace_directory)) def get_user_workspace_old(app_class_or_request, user_or_request) -> TethysWorkspace: @@ -286,7 +262,6 @@ def get_user_workspace_old(app_class_or_request, user_or_request) -> TethysWorks :: - import os from tethys_sdk.workspaces import get_user_workspace from .app import App @@ -391,9 +366,9 @@ def _get_app_workspace(app_class): Returns: tethys_apps.base.TethysWorkspace: An object representing the workspace. """ - project_directory = os.path.dirname(sys.modules[app_class.__module__].__file__) - workspace_directory = os.path.join(project_directory, "workspaces", "app_workspace") - return TethysWorkspace(workspace_directory) + project_directory = Path(sys.modules[app_class.__module__].__file__).parent + workspace_directory = project_directory / "workspaces" / "app_workspace" + return TethysWorkspace(str(workspace_directory)) def get_app_workspace_old(app_or_request) -> TethysWorkspace: @@ -414,7 +389,6 @@ def get_app_workspace_old(app_or_request) -> TethysWorkspace: :: - import os from tethys_sdk.workspaces import get_app_workspace from .app import App diff --git a/tethys_apps/management/commands/collectworkspaces.py b/tethys_apps/management/commands/collectworkspaces.py index d38881a4b..9d61049eb 100644 --- a/tethys_apps/management/commands/collectworkspaces.py +++ b/tethys_apps/management/commands/collectworkspaces.py @@ -8,7 +8,7 @@ ******************************************************************************** """ -import os +from pathlib import Path import shutil from django.core.management.base import BaseCommand @@ -62,18 +62,18 @@ def handle(self, *args, **options): for app, path in installed_apps.items(): # Check for both variants of the static directory (public and static) - app_ws_path = os.path.join(path, "workspaces") - tethys_ws_root_path = os.path.join(workspaces_root, app) + app_ws_path = Path(path) / "workspaces" + tethys_ws_root_path = Path(workspaces_root) / app # Only perform if workspaces_path is a directory - if not os.path.isdir(app_ws_path): + if not app_ws_path.is_dir(): print( f'WARNING: The workspace_path for app "{app}" is not a directory. Making workspace directory...' ) - os.makedirs(app_ws_path, exist_ok=True) + app_ws_path.mkdir(parents=True, exist_ok=True) - if not os.path.islink(app_ws_path): - if not os.path.exists(tethys_ws_root_path): + if not app_ws_path.is_symlink(): + if not tethys_ws_root_path.exists(): # Move the directory to workspace root path shutil.move(app_ws_path, tethys_ws_root_path) else: @@ -81,7 +81,7 @@ def handle(self, *args, **options): # Clear out old symbolic links/directories in workspace root if necessary try: # Remove link - os.remove(tethys_ws_root_path) + tethys_ws_root_path.unlink() except OSError: shutil.rmtree(tethys_ws_root_path, ignore_errors=True) @@ -96,8 +96,8 @@ def handle(self, *args, **options): shutil.rmtree(app_ws_path, ignore_errors=True) # Create appropriate symbolic link - if os.path.isdir(tethys_ws_root_path): - os.symlink(tethys_ws_root_path, app_ws_path) + if tethys_ws_root_path.is_dir(): + tethys_ws_root_path.symlink_to(app_ws_path) print( 'INFO: Successfully linked "workspaces" directory to TETHYS_WORKSPACES_ROOT for app ' '"{0}".'.format(app) diff --git a/tethys_apps/management/commands/pre_collectstatic.py b/tethys_apps/management/commands/pre_collectstatic.py index e276a015e..c88f27022 100644 --- a/tethys_apps/management/commands/pre_collectstatic.py +++ b/tethys_apps/management/commands/pre_collectstatic.py @@ -8,7 +8,7 @@ ******************************************************************************** """ -import os +from pathlib import Path import shutil from django.core.management.base import BaseCommand @@ -64,12 +64,12 @@ def handle(self, *args, **kwargs): for item, path in installed_apps_and_extensions.items(): # Check for both variants of the static directory (named either public or static) - public_path = os.path.join(path, "public") - static_path = os.path.join(path, "static") + public_path = Path(path) / "public" + static_path = Path(path) / "static" - if os.path.isdir(public_path): + if public_path.is_dir(): item_static_source_dir = public_path - elif os.path.isdir(static_path): + elif static_path.is_dir(): item_static_source_dir = static_path else: print( @@ -78,12 +78,12 @@ def handle(self, *args, **kwargs): continue # Path for app in the STATIC_ROOT directory - item_static_root_dir = os.path.join(static_root, item) + item_static_root_dir = Path(static_root) / item # Clear out old symbolic links/directories if necessary try: # Remove link - os.remove(item_static_root_dir) + item_static_root_dir.unlink() except OSError: try: # Remove directory @@ -94,7 +94,7 @@ def handle(self, *args, **kwargs): # Create appropriate symbolic link if link_opt: - os.symlink(item_static_source_dir, item_static_root_dir) + item_static_source_dir.symlink_to(item_static_root_dir) print( 'INFO: Successfully linked public directory to STATIC_ROOT for app "{0}".'.format( item diff --git a/tethys_apps/management/commands/tethys_app_uninstall.py b/tethys_apps/management/commands/tethys_app_uninstall.py index 0783ee99b..29da34c61 100644 --- a/tethys_apps/management/commands/tethys_app_uninstall.py +++ b/tethys_apps/management/commands/tethys_app_uninstall.py @@ -8,10 +8,10 @@ ******************************************************************************** """ -import os import site import subprocess import warnings +from pathlib import Path from django.core.management.base import BaseCommand from django.contrib.contenttypes.models import ContentType @@ -76,7 +76,7 @@ def handle(self, *args, **options): if not module_found and not db_found: warnings.warn( f'WARNING: {verbose_name} with name "{item_name}" cannot be uninstalled, ' - f"because it is not installed or not an {verbose_name}.", + f"because it is not installed or not a {verbose_name}.", stacklevel=2, ) exit(0) @@ -144,12 +144,9 @@ def handle(self, *args, **options): # Remove the namespace package file if applicable. for site_package in site.getsitepackages(): try: - os.remove( - os.path.join( - site_package, - f'{PREFIX}-{item_name.replace("_", "-")}-nspkg.pth', - ) - ) + Path( + f'{site_package}/{PREFIX}-{item_name.replace("_", "-")}-nspkg.pth' + ).unlink() except Exception: continue delete_secrets(item_name) diff --git a/tethys_apps/static_finders.py b/tethys_apps/static_finders.py index 4ea35bae1..bdd621373 100644 --- a/tethys_apps/static_finders.py +++ b/tethys_apps/static_finders.py @@ -8,7 +8,7 @@ ******************************************************************************** """ -import os +from pathlib import Path from collections import OrderedDict as SortedDict from django.contrib.staticfiles import utils from django.contrib.staticfiles.finders import BaseFinder @@ -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 = "%s%s" % (prefix, os.sep) - if not path.startswith(prefix): + prefix = Path(f"{prefix}/") + if not path.is_relative_to(prefix): return None - path = path[len(prefix) :] - path = safe_join(root, path) - if os.path.exists(path): + path = path.relative_to(prefix) + path = Path(safe_join(str(root), str(path))) + if path.exists(): return path def list(self, ignore_patterns): 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 new file mode 100644 index 000000000..b31e63200 --- /dev/null +++ b/tethys_apps/templates/tethys_apps/reactpy_base.html @@ -0,0 +1,34 @@ +{% extends "tethys_apps/app_base.html" %} +{% load static tethys reactpy %} + +{% 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_apps/templatetags/site_settings.py b/tethys_apps/templatetags/site_settings.py index 42ce6646a..c8b7120f4 100644 --- a/tethys_apps/templatetags/site_settings.py +++ b/tethys_apps/templatetags/site_settings.py @@ -2,7 +2,7 @@ from django.template.defaultfilters import stringfilter from django.conf import settings -import os +from pathlib import Path from ..static_finders import TethysStaticFinder @@ -17,21 +17,12 @@ def load_custom_css(var): if var.startswith("/"): var = var.lstrip("/") - is_file = os.path.isfile( - os.path.join(settings.STATIC_ROOT, var) - ) or static_finder.find(var) - - if is_file: - return '' + if (Path(settings.STATIC_ROOT) / var).is_file() or static_finder.find(var): + return f'' else: for path in settings.STATICFILES_DIRS: - is_file = os.path.isfile(os.path.join(path, var)) - if is_file: - return ( - '' - ) + if (Path(path) / var).is_file(): + return f'' return "" diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 4bdd60469..cc01ee330 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 @@ -701,31 +701,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/cli_helpers.py b/tethys_cli/cli_helpers.py index 7e4431e5a..ec73eba1f 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 = Path(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 b564f2b10..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("/"): - if len(value) > 0 and value[0] != "/": - value = "/" + 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 30e5e1311..ea0677f1a 100644 --- a/tethys_cli/install_commands.py +++ b/tethys_cli/install_commands.py @@ -1,11 +1,12 @@ 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 from collections.abc import Mapping +import sys from django.core.exceptions import ObjectDoesNotExist, ValidationError @@ -30,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): @@ -391,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: @@ -696,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(): @@ -857,6 +857,8 @@ def install_command(args): for post in install_options["post"]: path_to_post = file_path.resolve().parent / post # Attempting to run processes. + 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)) @@ -875,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 f621709e3..1e08fd44d 100644 --- a/tethys_cli/scaffold_commands.py +++ b/tethys_cli/scaffold_commands.py @@ -1,8 +1,9 @@ -import os import re import logging 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 @@ -15,11 +16,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,11 +35,15 @@ 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( - "-t", "--template", dest="template", help="Name of template to use." + "-t", + "--template", + dest="template", + help="Name of template to use.", + choices=[p.name for p in APP_PATH.iterdir()], ) scaffold_parser.add_argument( "-e", "--extension", dest="extension", action="store_true" @@ -181,15 +184,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 ) @@ -247,11 +250,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: @@ -375,12 +376,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", ""] @@ -392,10 +393,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 ) @@ -411,44 +409,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 walk(template_root): + 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/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/.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/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 new file mode 100644 index 000000000..470de9d9b --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/install.yml_tmpl @@ -0,0 +1,16 @@ +# 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: + - reactpy-django \ No newline at end of file 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..5eb933301 --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/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.rst" +{% 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/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..ab88fb86a --- /dev/null +++ b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/app.py_tmpl @@ -0,0 +1,21 @@ +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" + nav_links = "auto" 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 000000000..aa1fa8732 Binary files /dev/null and b/tethys_cli/scaffold_templates/app_templates/reactpy/tethysapp/+project+/public/images/icon.png differ 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/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 7f692a5fa..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,11 +94,12 @@ def quickstart_command(args): name="hello_world", extension=False, template="default", + 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/__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..74dc66bc9 --- /dev/null +++ b/tethys_components/custom.py @@ -0,0 +1,195 @@ +from reactpy import component +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), + lib.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", "Navigation") + + 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 = lib.hooks.use_query(get_db_object, {"app": app}) + app_id = app_db_query.data.id if app_db_query.data else 999 + location = lib.hooks.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..68d9e2021 --- /dev/null +++ b/tethys_components/hooks.py @@ -0,0 +1,21 @@ +from tethys_components.utils import use_workspace # noqa: F401 +from reactpy_django.hooks import ( # noqa: F401 + 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..1290c6755 --- /dev/null +++ b/tethys_components/layouts.py @@ -0,0 +1,16 @@ +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..a91b23d8b --- /dev/null +++ b/tethys_components/library.py @@ -0,0 +1,265 @@ +""" +******************************************************************************** +* Name: library.py +* Author: Shawn Crawley +* Created On: 2024 +* Copyright: +* License: BSD 2-Clause +******************************************************************************** +""" + +from pathlib import Path +from jinja2 import Template +from re import findall +import logging + +logging.getLogger("reactpy.web.module").setLevel(logging.WARN) + +TETHYS_COMPONENTS_ROOT_DPATH = Path(__file__).parent + + +class ComponentLibrary: + """ + Class for providing access to registered ReactPy/ReactJS components + """ + + 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.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", + "html": None, # Managed internally + "tethys": None, # Managed internally, + "hooks": None, # Managed internally + } + DEFAULTS = ["rp", "mapgl"] + STYLE_DEPS = { + "ag": [ + "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 + ] + 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): + """ + All instance attributes except package and parent_package are created on the fly the first time. + This enables dynamic access to ReactJS components via Python (i.e. the ReactPy web module). + The only downside is that we can't get code suggestions nor auto-completions. The user is + instructed and expected to refer to the official documentation for the ReactJS library. + """ + 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) + from reactpy import web + + module = web.module_from_string( + name=self.EXPORT_NAME, + content=self.get_reactjs_module_wrapper_js(), + resolve_exports=False, + ) + 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): + """ + Refreshes the library as if no components were ever loaded. + This ensures a clean slate for each page, meaning that the javascript file + produced and grabbed by the browser (via the ReactPy Django Client) only + contains the JavaScript that is relevant for the page being rendered. + This is necessary since the Library is treated as a Singleton - which is + necessary to be able to import the lib from anywhere and use it, but not + have an ever-growing javascript file (i.e. ever-growing list of Javascript + dependencies). + + This function, though a public method of the class, is only called in one single + place: tethys_apps/base/page_handler.py in the global_page_controller + + Args: + new_identifier(str): The name that will be used for the JavaScript file + that will be used on the new page load + """ + 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): + """ + Creates the JavaScript file that imports all of the ReactJS components. + + The content of this JavaScript file was adapted from the pattern established by ReactPy, + as documented here: + https://reactpy.dev/docs/guides/escape-hatches/javascript-components.html#custom-javascript-components + """ + 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=None, use_default=False): + """ + Registers a new package to be used by the ComponentLibrary + + Args: + package(str): The name of the package to register. The version is optional, and if included + should be of the format "@X.Y.Z". The package must be found at https://esm.sh, and can + be verified there by checking https://esm.sh/ + (i.e. https://esm.sh/reactive-button@1.3.15) + accessor(str): The name that will be used to access the package on the ComponentLibrary + (i.e. the "X" in lib.X.ComponentName) + styles(list): The full URL path to styles that are required for this new component + library to render correctly + use_default(bool): Whether or not the component library is accessed via its default export. + + 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 doing so 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. + + Significant shortcoming: If someone has a custom component that itself has newly referenced + library packages nested in conditional logic, these will not be picked up with this method. + To solve this, we'd need to write a function crawler of sorts that is able to traverse the + entire + + Like the "refresh" function above, this is only called in on single place: + tethys_apps/base/page_handler.py in the global_page_controller + + Args: + source_code(str): The string representation of the python code to be analyzed for + calls to the component library (i.e. "lib.X.Y") + """ + 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..7f303ed65 --- /dev/null +++ b/tethys_components/utils.py @@ -0,0 +1,78 @@ +import asyncio +import inspect +from pathlib import Path +from channels.db import database_sync_to_async + + +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): + 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(callable, delay_seconds, args=None): + from threading import Timer + + t = Timer(delay_seconds, callable, args or []) + t.start() + + +class Props(dict): + def __init__(self, **kwargs): + new_kwargs = {} + for k, v in kwargs.items(): + v = "none" if v is None else v + 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] = v + setattr(self, k, v) + super(Props, self).__init__(**new_kwargs) + + +def get_layout_component(app, layout): + if callable(layout) or layout is None: + layout_func = layout + elif layout == "default": + if callable(app.default_layout): + layout_func = app.default_layout + else: + from tethys_components import layouts + + layout_func = getattr(layouts, app.default_layout) + else: + from tethys_components import layouts + + layout_func = getattr(layouts, app.default_layout) + + return layout_func 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 334a2cb8e..a2d9d192b 100644 --- a/tethys_portal/asgi.py +++ b/tethys_portal/asgi.py @@ -3,16 +3,24 @@ 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 from django.urls import re_path +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.page_handler.page_component_wrapper") + app_websocket_urls.append(REACTPY_WEBSOCKET_ROUTE) + application = ProtocolTypeRouter( { "http": AuthMiddlewareStack( @@ -29,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/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/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 98b825ab5..2170da01e 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 @@ -202,7 +202,7 @@ "django", { "handlers": ["console_simple"], - "level": os.getenv("DJANGO_LOG_LEVEL", "WARNING"), + "level": getenv("DJANGO_LOG_LEVEL", "WARNING"), }, ) LOGGERS.setdefault( @@ -222,6 +222,7 @@ default_installed_apps = [ "channels", + "daphne", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -255,6 +256,7 @@ "django_recaptcha", "social_django", "termsandconditions", + "reactpy_django", ]: if has_module(module): default_installed_apps.append(module) @@ -330,6 +332,8 @@ "SUPPRESS_QUOTA_WARNINGS", ["user_workspace_quota", "app_workspace_quota"] ) +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 057c2e93b..06f201c04 100644 --- a/tethys_portal/urls.py +++ b/tethys_portal/urls.py @@ -318,3 +318,6 @@ name="login_prefix", ) ) + +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..7b0497c06 --- /dev/null +++ b/tethys_sdk/components/utils.py @@ -0,0 +1,2 @@ +from reactpy import component, event # noqa: F401 +from tethys_components.utils import Props, delayed_execute # noqa: F401 diff --git a/tethys_sdk/routing.py b/tethys_sdk/routing.py index 3f120ca63..88d39be32 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,