from __future__ import annotations
import filecmp
import logging
import shutil
from dataclasses import dataclass
from pathlib import Path
from string import Template
from typing import Any, NewType, overload
from urllib.parse import urlparse
from reactpy._warnings import warn
from reactpy.config import REACTPY_DEBUG_MODE, REACTPY_WEB_MODULES_DIR
from reactpy.core.types import ImportSourceDict, VdomDictConstructor
from reactpy.core.vdom import make_vdom_constructor
from reactpy.web.utils import (
module_name_suffix,
resolve_module_exports_from_file,
resolve_module_exports_from_url,
)
logger = logging.getLogger(__name__)
SourceType = NewType("SourceType", str)
NAME_SOURCE = SourceType("NAME")
"""A named source - usually a Javascript package name"""
URL_SOURCE = SourceType("URL")
"""A source loaded from a URL, usually a CDN"""
[docs]def module_from_url(
url: str,
fallback: Any | None = None,
resolve_exports: bool | None = None,
resolve_exports_depth: int = 5,
unmount_before_update: bool = False,
) -> WebModule:
"""Load a :class:`WebModule` from a :data:`URL_SOURCE`
Parameters:
url:
Where the javascript module will be loaded from which conforms to the
interface for :ref:`Custom Javascript Components`
fallback:
What to temporarily 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 fails 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.
"""
return WebModule(
source=url,
source_type=URL_SOURCE,
default_fallback=fallback,
file=None,
export_names=(
resolve_module_exports_from_url(url, resolve_exports_depth)
if (
resolve_exports
if resolve_exports is not None
else REACTPY_DEBUG_MODE.current
)
else None
),
unmount_before_update=unmount_before_update,
)
_FROM_TEMPLATE_DIR = "__from_template__"
[docs]def module_from_template(
template: str,
package: str,
cdn: str = "https://esm.sh",
fallback: Any | None = None,
resolve_exports: bool | None = None,
resolve_exports_depth: int = 5,
unmount_before_update: bool = False,
) -> WebModule:
"""Create a :class:`WebModule` from a framework template
This is useful for experimenting with component libraries that do not already
support ReactPy's :ref:`Custom Javascript Component` interface.
.. warning::
This approach is not recommended for use in a production setting because the
framework templates may use unpinned dependencies that could change without
warning. It's best to author a module adhering to the
:ref:`Custom Javascript Component` interface instead.
**Templates**
- ``react``: for modules exporting React components
Parameters:
template:
The name of the framework 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
fallback:
What to temporarily 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 fails 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.
"""
warn(
"module_from_template() is deprecated due to instability - use the Javascript "
"Components API instead. This function will be removed in a future release.",
DeprecationWarning,
)
template_name, _, template_version = template.partition("@")
template_version = "@" + template_version if template_version else ""
# We do this since the package may be any valid URL path. Thus we may need to strip
# object parameters or query information so we save the resulting template under the
# correct file name.
package_name = urlparse(package).path
# downstream code assumes no trailing slash
cdn = cdn.rstrip("/")
template_file_name = template_name + module_name_suffix(package_name)
template_file = Path(__file__).parent / "templates" / template_file_name
if not template_file.exists():
msg = f"No template for {template_file_name!r} exists"
raise ValueError(msg)
variables = {"PACKAGE": package, "CDN": cdn, "VERSION": template_version}
content = Template(template_file.read_text(encoding="utf-8")).substitute(variables)
return module_from_string(
_FROM_TEMPLATE_DIR + "/" + package_name,
content,
fallback,
resolve_exports,
resolve_exports_depth,
unmount_before_update=unmount_before_update,
)
[docs]def module_from_file(
name: str,
file: str | Path,
fallback: Any | None = None,
resolve_exports: bool | None = None,
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 temporarily 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 fails 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``.
"""
name += module_name_suffix(name)
source_file = Path(file).resolve()
target_file = _web_module_path(name)
if not source_file.exists():
msg = f"Source file does not exist: {source_file}"
raise FileNotFoundError(msg)
if not target_file.exists():
_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=name,
source_type=NAME_SOURCE,
default_fallback=fallback,
file=target_file,
export_names=(
resolve_module_exports_from_file(source_file, resolve_exports_depth)
if (
resolve_exports
if resolve_exports is not None
else REACTPY_DEBUG_MODE.current
)
else None
),
unmount_before_update=unmount_before_update,
)
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)
[docs]def module_from_string(
name: str,
content: str,
fallback: Any | None = None,
resolve_exports: bool | None = None,
resolve_exports_depth: int = 5,
unmount_before_update: bool = False,
) -> WebModule:
"""Load a :class:`WebModule` whose ``content`` comes from a string.
Parameters:
name:
The name of the package
content:
The contents of the web module
fallback:
What to temporarily 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 fails 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.
"""
name += module_name_suffix(name)
target_file = _web_module_path(name)
if target_file.exists() and target_file.read_text(encoding="utf-8") != 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)
target_file.write_text(content)
return WebModule(
source=name,
source_type=NAME_SOURCE,
default_fallback=fallback,
file=target_file,
export_names=(
resolve_module_exports_from_file(target_file, resolve_exports_depth)
if (
resolve_exports
if resolve_exports is not None
else REACTPY_DEBUG_MODE.current
)
else None
),
unmount_before_update=unmount_before_update,
)
[docs]@dataclass(frozen=True)
class WebModule:
source: str
source_type: SourceType
default_fallback: Any | None
export_names: set[str] | None
file: Path | None
unmount_before_update: bool
@overload
def export(
web_module: WebModule,
export_names: str,
fallback: Any | None = ...,
allow_children: bool = ...,
) -> VdomDictConstructor: ...
@overload
def export(
web_module: WebModule,
export_names: list[str] | tuple[str, ...],
fallback: Any | None = ...,
allow_children: bool = ...,
) -> list[VdomDictConstructor]: ...
[docs]def export(
web_module: WebModule,
export_names: str | list[str] | tuple[str, ...],
fallback: Any | None = None,
allow_children: bool = True,
) -> VdomDictConstructor | list[VdomDictConstructor]:
"""Return one or more VDOM constructors from a :class:`WebModule`
Parameters:
export_names:
One or more names to export. If given as a string, a single component
will be returned. If a list is given, then a list of components will be
returned.
fallback:
What to temporarily display while the module is being loaded.
allow_children:
Whether or not these components can have children.
"""
if isinstance(export_names, str):
if (
web_module.export_names is not None
and export_names not in web_module.export_names
):
msg = f"{web_module.source!r} does not export {export_names!r}"
raise ValueError(msg)
return _make_export(web_module, export_names, fallback, allow_children)
else:
if web_module.export_names is not None:
missing = sorted(set(export_names).difference(web_module.export_names))
if missing:
msg = f"{web_module.source!r} does not export {missing!r}"
raise ValueError(msg)
return [
_make_export(web_module, name, fallback, allow_children)
for name in export_names
]
def _make_export(
web_module: WebModule,
name: str,
fallback: Any | None,
allow_children: bool,
) -> VdomDictConstructor:
return make_vdom_constructor(
name,
allow_children=allow_children,
import_source=ImportSourceDict(
source=web_module.source,
sourceType=web_module.source_type,
fallback=(fallback or web_module.default_fallback),
unmountBeforeUpdate=web_module.unmount_before_update,
),
)
def _web_module_path(name: str) -> Path:
directory = REACTPY_WEB_MODULES_DIR.current
path = directory.joinpath(*name.split("/"))
return path.with_suffix(path.suffix)