Skip to content

Various improvements for writing ESM components #7462

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 13 commits into from
Nov 8, 2024
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
8 changes: 6 additions & 2 deletions doc/how_to/custom_components/esm/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ panel compile confetti
```

:::{hint}
`panel compile` accepts file paths, e.g. `my_components/custom.py`, and dotted module name, e.g. `my_package.custom`. If you provide a module name it must be importable.
`panel compile` accepts file paths, e.g. `my_components/custom.py`, or dotted module names, e.g. `my_package.custom`. If you provide a module name it must be importable.
:::

This will automatically discover the `ConfettiButton` but you can also explicitly request a single component by adding the class name:
Expand Down Expand Up @@ -216,7 +216,11 @@ esbuild output:
⚡ Done in 9ms
```

The compiled JavaScript file will be automatically loaded if it remains alongside the component. If you rename the component or modify its code or `_importmap`, you must recompile the component. For ongoing development, consider using the `--dev` option to ignore the compiled file and automatically reload the development version when it changes.
If the supplied module or package contains multiple components they will all be bundled together by default. If instead you want to generate bundles for each file explicitly you must list them with the `:` syntax, e.g. `panel compile package.module:Component1,Component2`. You may also provide a glob pattern to request multiple components to be built individually without listing them all out, e.g. `panel compile "package.module:Component*"`.

During runtime the compiled bundles will be resolved automatically, where bundles compiled for a specific component (i.e. `<component-name>.bundle.js`) take highest precedence and we then search for module bundles up to the root package, e.g. for a component that lives in `package.module` we first search for `package.module.bundle.js` in the same directory as the component and then recursively search in parent directories until we reach the root of the package.

If you rename the component or modify its code or `_importmap`, you must recompile the component. For ongoing development, consider using the `--dev` option to ignore the compiled file and automatically reload the development version when it changes.

#### Compilation Steps

Expand Down
64 changes: 17 additions & 47 deletions panel/command/compile.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import os
import pathlib
import sys

from collections import defaultdict

from bokeh.command.subcommand import Argument, Subcommand

from ..io.compile import RED, compile_components, find_components
from ..io.compile import RED, compile_components, find_module_bundles


class Compile(Subcommand):
Expand Down Expand Up @@ -42,52 +36,28 @@ class Compile(Subcommand):
)

def invoke(self, args):
bundles = defaultdict(list)
for module_spec in args.modules:
if ':' in module_spec:
*parts, cls = module_spec.split(':')
module = ':'.join(parts)
else:
module = module_spec
cls = ''
classes = cls.split(',') if cls else None
module_name, ext = os.path.splitext(os.path.basename(module))
if ext not in ('', '.py'):
print( # noqa
f'{RED} Can only compile ESM components defined in Python '
'file or importable module.'
)
return 1
bundles = {}
for module in args.modules:
try:
components = find_components(module, classes)
except ValueError:
cls_error = f' and that class(es) {cls!r} are defined therein' if cls else ''
print( # noqa
f'{RED} Could not find any ESM components to compile, ensure '
f'you provided the right module{cls_error}.'
)
module_bundles = find_module_bundles(module)
except RuntimeError as e:
print(f'{RED} {e}') # noqa
return 1
if module in sys.modules:
module_path = sys.modules[module].__file__
else:
module_path = module
module_path = pathlib.Path(module_path).parent
for component in components:
if component._bundle:
bundle_path = component._bundle
if isinstance(bundle_path, str):
path = (module_path / bundle_path).absolute()
else:
path = bundle_path.absolute()
bundles[str(path)].append(component)
elif len(components) > 1 and not classes:
component_module = module_name if ext else component.__module__
bundles[module_path / f'{component_module}.bundle.js'].append(component)
if not module_bundles:
print ( # noqa
f'{RED} Could not find any ESM components to compile '
f'in {module}, ensure you provided the right module.'
)
for bundle, components in module_bundles.items():
if bundle in bundles:
bundles[bundle] = components
else:
bundles[module_path / f'{component.__name__}.bundle.js'].append(component)
bundles[bundle] += components

errors = 0
for bundle, components in bundles.items():
component_names = '\n- '.join(c.name for c in components)
print(f"Building {bundle} containing the following components:\n\n- {component_names}\n") # noqa
out = compile_components(
components,
build_dir=args.build_dir,
Expand Down
90 changes: 61 additions & 29 deletions panel/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import hashlib
import importlib
import inspect
import os
import pathlib
Expand Down Expand Up @@ -168,7 +169,7 @@ def __init__(mcs, name: str, bases: tuple[type, ...], dict_: Mapping[str, Any]):
model_name = f'{name}{ReactiveMetaBase._name_counter[name]}'
ignored = [p for p in Reactive.param if not issubclass(type(mcs.param[p].owner), ReactiveESMMetaclass)]
mcs._data_model = construct_data_model(
mcs, name=model_name, ignore=ignored
mcs, name=model_name, ignore=ignored, extras={'esm_constants': param.Dict}
)


Expand Down Expand Up @@ -216,6 +217,8 @@ class CounterButton(pn.custom.ReactiveESM):

_bundle: ClassVar[str | os.PathLike | None] = None

_constants: ClassVar[dict[str, Any]] = {}

_esm: ClassVar[str | os.PathLike] = ""

# Specifies exports to make available to JS in a bundled file
Expand All @@ -235,37 +238,62 @@ def __init__(self, **params):
self._msg__callbacks = []

@classproperty
def _bundle_path(cls) -> os.PathLike | None:
if config.autoreload and cls._esm:
return
def _module_path(cls):
if hasattr(cls, '__path__'):
return pathlib.Path(cls.__path__)
try:
mod_path = pathlib.Path(inspect.getfile(cls)).parent
return pathlib.Path(inspect.getfile(cls)).parent
except (OSError, TypeError, ValueError):
if not isinstance(cls._bundle, pathlib.PurePath):
return

@classproperty
def _bundle_path(cls) -> os.PathLike | None:
if config.autoreload and cls._esm:
return
mod_path = cls._module_path
if mod_path is None:
return
if cls._bundle:
for scls in cls.__mro__:
if issubclass(scls, ReactiveESM) and cls._bundle == scls._bundle:
cls = scls
mod_path = cls._module_path
bundle = cls._bundle
if isinstance(bundle, pathlib.PurePath):
return bundle
elif bundle.endswith('.js'):
bundle_path = mod_path / bundle
if bundle_path.is_file():
return bundle_path
return
else:
raise ValueError(
'Could not resolve {cls.__name__}._bundle. Ensure '
'you provide either a string with a relative or absolute '
'path or a Path object to a .js file extension.'
)
raise ValueError(
f'Could not resolve {cls.__name__}._bundle: {cls._bundle}. Ensure '
'you provide either a string with a relative or absolute '
'path or a Path object to a .js file extension.'
)

# Attempt resolving bundle for this component specifically
path = mod_path / f'{cls.__name__}.bundle.js'
if path.is_file():
return path

# Attempt to resolve bundle in current module and parent modules
module = cls.__module__
path = mod_path / f'{module}.bundle.js'
if path.is_file():
return path
elif module in sys.modules:
modules = module.split('.')
for i in reversed(range(len(modules))):
submodule = '.'.join(modules[:i+1])
try:
mod = importlib.import_module(submodule)
except (ModuleNotFoundError, ImportError):
continue
if not hasattr(mod, '__file__'):
continue
submodule_path = pathlib.Path(mod.__file__).parent
path = submodule_path / f'{submodule}.bundle.js'
if path.is_file():
return path

if module in sys.modules:
module = os.path.basename(sys.modules[module].__file__).replace('.py', '')
path = mod_path / f'{module}.bundle.js'
return path if path.is_file() else None
Expand Down Expand Up @@ -300,7 +328,7 @@ def _render_esm(cls, compiled: bool | Literal['compiling'] = True, server: bool
if esm_path:
if esm_path == cls._bundle_path and cls.__module__ in sys.modules and server:
base_cls = cls
for scls in cls.__mro__[1:][::-1]:
for scls in cls.__mro__[1:]:
if not issubclass(scls, ReactiveESM):
continue
if esm_path == scls._esm_path(compiled=compiled is True):
Expand Down Expand Up @@ -391,6 +419,7 @@ def _get_properties(self, doc: Document) -> dict[str, Any]:
else:
bundle_hash = None
data_props = self._process_param_change(data_params)
data_props['esm_constants'] = self._constants
props.update({
'bundle': bundle_hash,
'class_name': camel_to_kebab(cls.__name__),
Expand All @@ -406,24 +435,26 @@ def _get_properties(self, doc: Document) -> dict[str, Any]:
def _process_importmap(cls):
return cls._importmap

def _get_child_model(self, child, doc, root, parent, comm):
if child is None:
return None
ref = root.ref['id']
if isinstance(child, list):
return [
sv._models[ref][0] if ref in sv._models else sv._get_model(doc, root, parent, comm)
for sv in child
]
elif ref in child._models:
return child._models[ref][0]
return child._get_model(doc, root, parent, comm)

def _get_children(self, data_model, doc, root, parent, comm):
children = {}
ref = root.ref['id']
for k, v in self.param.values().items():
p = self.param[k]
if not is_viewable_param(p):
continue
if v is None:
children[k] = None
elif isinstance(v, list):
children[k] = [
sv._models[ref][0] if ref in sv._models else sv._get_model(doc, root, parent, comm)
for sv in v
]
elif ref in v._models:
children[k] = v._models[ref][0]
else:
children[k] = v._get_model(doc, root, parent, comm)
children[k] = self._get_child_model(v, doc, root, parent, comm)
return children

def _setup_autoreload(self):
Expand Down Expand Up @@ -673,6 +704,7 @@ def _process_importmap(cls):
"react-is": f"https://esm.sh/react-is@{v_react}&external=react",
"@emotion/cache": f"https://esm.sh/@emotion/cache?deps=react@{v_react},react-dom@{v_react}",
"@emotion/react": f"https://esm.sh/@emotion/react?deps=react@{v_react},react-dom@{v_react}&external=react,react-is",
"@emotion/styled": f"https://esm.sh/@emotion/styled?deps=react@{v_react},react-dom@{v_react}&external=react,react-is",
})
for k, v in imports.items():
if '?' not in v and 'esm.sh' in v:
Expand Down
66 changes: 64 additions & 2 deletions panel/io/compile.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import fnmatch
import importlib
import json
import os
Expand All @@ -10,6 +11,7 @@
import sys
import tempfile

from collections import defaultdict
from contextlib import contextmanager
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -71,6 +73,62 @@ def check_cli_tool(tool_name):
return False


def find_module_bundles(module_spec: str) -> dict[pathlib.PurePath, list[ReactiveESM]]:
"""
Takes module specifications and extracts a set of components to bundle.

Arguments
---------
module_spec: str
Module specification either as a dotted module or a path to a module.

Returns
-------
Dictionary containing the bundle paths and list of components to bundle.
"""
# Split module spec, while respecting Windows drive letters
if ':' in module_spec and (module_spec[1:3] != ':\\' or module_spec.count(':') > 1):
module, cls = module_spec.rsplit(':', 1)
else:
module = module_spec
cls = ''
classes = cls.split(',') if cls else None
if module.endswith('.py'):
module_name, _ = os.path.splitext(os.path.basename(module))
else:
module_name = module
try:
components = find_components(module, classes)
except ValueError:
cls_error = f' and that class(es) {cls!r} are defined therein' if cls else ''
raise RuntimeError( # noqa
f'Could not find any ESM components to compile, ensure '
f'you provided the right module{cls_error}.'
)
if module in sys.modules:
module_path = sys.modules[module].__file__
else:
module_path = module

bundles = defaultdict(list)
module_path = pathlib.Path(module_path).parent
for component in components:
if component._bundle:
bundle_path = component._bundle
if isinstance(bundle_path, str):
path = (module_path / bundle_path).absolute()
else:
path = bundle_path.absolute()
bundles[str(path)].append(component)
elif len(components) > 1 and not classes:
component_module = module_name or component.__module__
bundles[module_path / f'{component_module}.bundle.js'].append(component)
else:
bundles[component._module_path / f'{component.__name__}.bundle.js'].append(component)

return bundles


def find_components(module_or_file: str | os.PathLike, classes: list[str] | None = None) -> list[type[ReactiveESM]]:
"""
Creates a temporary module given a path-like object and finds all
Expand All @@ -94,6 +152,10 @@ def find_components(module_or_file: str | os.PathLike, classes: list[str] | None
runner = CodeRunner(source, module_or_file, [])
module = runner.new_module()
runner.run(module)
if runner.error:
raise RuntimeError(
f'Compilation failed because supplied module errored on import:\n\n{runner.error}'
)
else:
module = importlib.import_module(module_or_file)
classes = classes or []
Expand All @@ -103,12 +165,12 @@ def find_components(module_or_file: str | os.PathLike, classes: list[str] | None
isinstance(v, type) and
issubclass(v, ReactiveESM) and
not v.abstract and
(not classes or v.__name__ in classes)
(not classes or any(fnmatch.fnmatch(v.__name__, p) for p in classes))
):
if py_file:
v.__path__ = path_obj.parent.absolute()
components.append(v)
not_found = set(classes) - set(c.__name__ for c in components)
not_found = {cls for cls in classes if '*' not in cls} - set(c.__name__ for c in components)
if classes and not_found:
clss = ', '.join(map(repr, not_found))
raise ValueError(f'{clss} class(es) not found in {module_or_file!r}.')
Expand Down
Loading
Loading