Skip to content

Log on web module replacement instead of error #586

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jan 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
9 changes: 8 additions & 1 deletion src/idom/web/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
136 changes: 97 additions & 39 deletions src/idom/web/module.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import filecmp
import logging
import shutil
from dataclasses import dataclass
from functools import partial
Expand All @@ -26,6 +28,8 @@
)


logger = logging.getLogger(__name__)

SourceType = NewType("SourceType", str)

NAME_SOURCE = SourceType("NAME")
Expand Down Expand Up @@ -74,6 +78,9 @@ def module_from_url(
)


_FROM_TEMPLATE_DIR = "__from_template__"


def module_from_template(
template: str,
package: str,
Expand Down Expand Up @@ -140,49 +147,109 @@ 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
),
unmount_before_update=unmount_before_update,
)


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:
Expand All @@ -194,33 +261,26 @@ 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),
source_type=NAME_SOURCE,
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
),
Expand Down Expand Up @@ -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)
42 changes: 34 additions & 8 deletions tests/test_web/test_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand All @@ -143,20 +149,34 @@ 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()

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)
Expand Down Expand Up @@ -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")