diff --git a/docs/source/escape-hatches/_examples/super_simple_chart/app.py b/docs/source/escape-hatches/_examples/super_simple_chart/app.py index 9f0563a97..2f2e17556 100644 --- a/docs/source/escape-hatches/_examples/super_simple_chart/app.py +++ b/docs/source/escape-hatches/_examples/super_simple_chart/app.py @@ -4,13 +4,7 @@ file = Path(__file__).parent / "super-simple-chart.js" -ssc = web.module_from_file( - "super-simple-chart", - file, - fallback="⌛", - # normally this option is not required - replace_existing=True, -) +ssc = web.module_from_file("super-simple-chart", file, fallback="⌛") SuperSimpleChart = web.export(ssc, "SuperSimpleChart") diff --git a/src/idom/web/__init__.py b/src/idom/web/__init__.py index d3187366c..6fe239ed9 100644 --- a/src/idom/web/__init__.py +++ b/src/idom/web/__init__.py @@ -1,8 +1,15 @@ -from .module import export, module_from_file, module_from_template, module_from_url +from .module import ( + export, + module_from_file, + module_from_string, + module_from_template, + module_from_url, +) __all__ = [ "module_from_file", + "module_from_string", "module_from_template", "module_from_url", "export", diff --git a/src/idom/web/module.py b/src/idom/web/module.py index 67a4acec1..9002ded39 100644 --- a/src/idom/web/module.py +++ b/src/idom/web/module.py @@ -1,5 +1,7 @@ from __future__ import annotations +import filecmp +import logging import shutil from dataclasses import dataclass from functools import partial @@ -26,6 +28,8 @@ ) +logger = logging.getLogger(__name__) + SourceType = NewType("SourceType", str) NAME_SOURCE = SourceType("NAME") @@ -74,6 +78,9 @@ def module_from_url( ) +_FROM_TEMPLATE_DIR = "__from_template__" + + def module_from_template( template: str, package: str, @@ -140,22 +147,71 @@ def module_from_template( if not template_file.exists(): raise ValueError(f"No template for {template_file_name!r} exists") - target_file = _web_module_path(package_name, "from-template") + variables = {"PACKAGE": package, "CDN": cdn} + content = Template(template_file.read_text()).substitute(variables) + + return module_from_string( + _FROM_TEMPLATE_DIR + "/" + package_name + module_name_suffix(package_name), + content, + fallback, + resolve_exports, + resolve_exports_depth, + unmount_before_update=unmount_before_update, + ) + + +def module_from_file( + name: str, + file: Union[str, Path], + fallback: Optional[Any] = None, + resolve_exports: bool = IDOM_DEBUG_MODE.current, + resolve_exports_depth: int = 5, + unmount_before_update: bool = False, + symlink: bool = False, +) -> WebModule: + """Load a :class:`WebModule` from a given ``file`` + + Parameters: + name: + The name of the package + file: + The file from which the content of the web module will be created. + fallback: + What to temporarilly display while the module is being loaded. + resolve_imports: + Whether to try and find all the named exports of this module. + resolve_exports_depth: + How deeply to search for those exports. + unmount_before_update: + Cause the component to be unmounted before each update. This option should + only be used if the imported package failes to re-render when props change. + Using this option has negative performance consequences since all DOM + elements must be changed on each render. See :issue:`461` for more info. + symlink: + Whether the web module should be saved as a symlink to the given ``file``. + """ + source_file = Path(file).resolve() + target_file = _web_module_path(name) + if not source_file.exists(): + raise FileNotFoundError(f"Source file does not exist: {source_file}") + if not target_file.exists(): - target_file.parent.mkdir(parents=True, exist_ok=True) - target_file.write_text( - Template(template_file.read_text()).substitute( - {"PACKAGE": package, "CDN": cdn} - ) + _copy_file(target_file, source_file, symlink) + elif not _equal_files(source_file, target_file): + logger.info( + f"Existing web module {name!r} will " + f"be replaced with {target_file.resolve()}" ) + target_file.unlink() + _copy_file(target_file, source_file, symlink) return WebModule( - source="from-template/" + package_name + module_name_suffix(package_name), + source=name + module_name_suffix(name), source_type=NAME_SOURCE, default_fallback=fallback, file=target_file, export_names=( - resolve_module_exports_from_file(target_file, resolve_exports_depth) + resolve_module_exports_from_file(source_file, resolve_exports_depth) if resolve_exports else None ), @@ -163,26 +219,37 @@ def module_from_template( ) -def module_from_file( +def _equal_files(f1: Path, f2: Path) -> bool: + f1 = f1.resolve() + f2 = f2.resolve() + return ( + (f1.is_symlink() or f2.is_symlink()) and (f1.resolve() == f2.resolve()) + ) or filecmp.cmp(str(f1), str(f2), shallow=False) + + +def _copy_file(target: Path, source: Path, symlink: bool) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + if symlink: + target.symlink_to(source) + else: + shutil.copy(source, target) + + +def module_from_string( name: str, - file: Union[str, Path], + content: str, fallback: Optional[Any] = None, resolve_exports: bool = IDOM_DEBUG_MODE.current, resolve_exports_depth: int = 5, - symlink: bool = False, unmount_before_update: bool = False, - replace_existing: bool = False, ) -> WebModule: - """Load a :class:`WebModule` from a :data:`URL_SOURCE` using a known framework + """Load a :class:`WebModule` whose ``content`` comes from a string. Parameters: - template: - The name of the template to use with the given ``package`` - package: - The name of a package to load. May include a file extension (defaults to - ``.js`` if not given) - cdn: - Where the package should be loaded from. The CDN must distribute ESM modules + name: + The name of the package + content: + The contents of the web module fallback: What to temporarilly display while the module is being loaded. resolve_imports: @@ -194,25 +261,18 @@ def module_from_file( only be used if the imported package failes to re-render when props change. Using this option has negative performance consequences since all DOM elements must be changed on each render. See :issue:`461` for more info. - replace_existing: - Whether to replace the source for a module with the same name if it already - exists. Otherwise raise an error. """ - source_file = Path(file) target_file = _web_module_path(name) - if not source_file.exists(): - raise FileNotFoundError(f"Source file does not exist: {source_file}") - elif target_file.exists() or target_file.is_symlink(): - if not replace_existing: - raise FileExistsError(f"{name!r} already exists as {target_file.resolve()}") - else: - target_file.unlink() + + if target_file.exists() and target_file.read_text() != content: + logger.info( + f"Existing web module {name!r} will " + f"be replaced with {target_file.resolve()}" + ) + target_file.unlink() target_file.parent.mkdir(parents=True, exist_ok=True) - if symlink: - target_file.symlink_to(source_file) - else: - shutil.copy(source_file, target_file) + target_file.write_text(content) return WebModule( source=name + module_name_suffix(name), @@ -220,7 +280,7 @@ def module_from_file( default_fallback=fallback, file=target_file, export_names=( - resolve_module_exports_from_file(source_file, resolve_exports_depth) + resolve_module_exports_from_file(target_file, resolve_exports_depth) if resolve_exports else None ), @@ -326,10 +386,8 @@ def _make_export( ) -def _web_module_path(name: str, prefix: str = "") -> Path: +def _web_module_path(name: str) -> Path: name += module_name_suffix(name) directory = IDOM_WED_MODULES_DIR.current - if prefix: - directory /= prefix path = directory.joinpath(*name.split("/")) return path.with_suffix(path.suffix) diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 15067b896..1e31f5d0a 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -8,7 +8,7 @@ import idom from idom.server.sanic import PerClientStateServer -from idom.testing import ServerMountPoint +from idom.testing import ServerMountPoint, assert_idom_did_not_log, assert_idom_logged from idom.web.module import NAME_SOURCE, WebModule @@ -126,8 +126,14 @@ def test_module_from_file_source_conflict(tmp_path): second_file = tmp_path / "second.js" second_file.touch() - with pytest.raises(FileExistsError, match="already exists"): - idom.web.module_from_file("temp", second_file) + # ok, same content + idom.web.module_from_file("temp", second_file) + + third_file = tmp_path / "third.js" + third_file.write_text("something-different") + + with assert_idom_logged(r"Existing web module .* will be replaced with"): + idom.web.module_from_file("temp", third_file) def test_web_module_from_file_symlink(tmp_path): @@ -143,6 +149,22 @@ def test_web_module_from_file_symlink(tmp_path): assert module.file.resolve().read_text() == "hello world!" +def test_web_module_from_file_symlink_twice(tmp_path): + file_1 = tmp_path / "temp_1.js" + file_1.touch() + + idom.web.module_from_file("temp", file_1, symlink=True) + + with assert_idom_did_not_log(r"Existing web module .* will be replaced with"): + idom.web.module_from_file("temp", file_1, symlink=True) + + file_2 = tmp_path / "temp_2.js" + file_2.write_text("something") + + with assert_idom_logged(r"Existing web module .* will be replaced with"): + idom.web.module_from_file("temp", file_2, symlink=True) + + def test_web_module_from_file_replace_existing(tmp_path): file1 = tmp_path / "temp1.js" file1.touch() @@ -150,13 +172,11 @@ def test_web_module_from_file_replace_existing(tmp_path): idom.web.module_from_file("temp", file1) file2 = tmp_path / "temp2.js" - file2.touch() + file2.write_text("something") - with pytest.raises(FileExistsError, match="already exists"): + with assert_idom_logged(r"Existing web module .* will be replaced with"): idom.web.module_from_file("temp", file2) - idom.web.module_from_file("temp", file2, replace_existing=True) - def test_module_missing_exports(): module = WebModule("test", NAME_SOURCE, None, {"a", "b", "c"}, None, False) @@ -200,9 +220,15 @@ def test_imported_components_can_render_children(driver, display): ) parent = driver.find_element("id", "the-parent") - children = parent.find_elements_by_tag_name("li") + children = parent.find_elements("tag name", "li") assert len(children) == 3 for index, child in enumerate(children): assert child.get_attribute("id") == f"child-{index + 1}" + + +def test_module_from_string(): + idom.web.module_from_string("temp", "old") + with assert_idom_logged(r"Existing web module .* will be replaced with"): + idom.web.module_from_string("temp", "new")