diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/io.py b/src/py/mat3ra/notebooks_utils/core/entity/material/io.py index 5c60dd5f..cd18f9ed 100644 --- a/src/py/mat3ra/notebooks_utils/core/entity/material/io.py +++ b/src/py/mat3ra/notebooks_utils/core/entity/material/io.py @@ -7,7 +7,7 @@ from mat3ra.made.tools.build_components import MaterialWithBuildMetadata from mat3ra.utils.array import convert_to_array_if_not -from ....io import get_data, set_data +from ....io import get_data, send_data, set_data from ....primitive.enums import SeverityLevelEnum from ....primitive.logger import log from ....settings import UPLOADS_FOLDER @@ -64,6 +64,40 @@ def set_materials(materials: List[Any], folder_path: str = UPLOADS_FOLDER): ) +def sync_materials(globals_dict: dict, sync_scope: str = "python-repl") -> None: + """Send the complete set of public Material bindings owned by a REPL sync scope. + + Lists, tuples, and dictionary values are inspected one level deep. The host-provided input + bindings are deliberately excluded so merely running a cell does not echo all inputs back. + """ + reserved_names = {"materials_in", "material"} + entities = [] + + for name, value in globals_dict.items(): + if name.startswith("_") or name in reserved_names: + continue + + if isinstance(value, Material): + materials = [value] + elif isinstance(value, (list, tuple)): + materials = [item for item in value if isinstance(item, Material)] + elif isinstance(value, dict): + materials = [item for item in value.values() if isinstance(item, Material)] + else: + continue + + for material in materials: + entities.append( + { + "type": "material", + "name": name, + "config": json.loads(material.to_json()), + } + ) + + send_data({"syncScope": sync_scope, "entities": entities}) + + def load_materials_from_folder(folder_path: Optional[str] = None, verbose: bool = True) -> List[Any]: """ Load materials from the specified folder or from the UPLOADS_FOLDER by default. diff --git a/src/py/mat3ra/notebooks_utils/io.py b/src/py/mat3ra/notebooks_utils/io.py index 1e7edd47..6f8794ea 100644 --- a/src/py/mat3ra/notebooks_utils/io.py +++ b/src/py/mat3ra/notebooks_utils/io.py @@ -4,7 +4,7 @@ from .ipython.io import download_content_to_file from .primitive.enums import EnvironmentsEnum from .primitive.environment import ENVIRONMENT -from .pyodide.io import get_data_pyodide, read_from_url_pyodide, set_data_pyodide +from .pyodide.io import get_data_pyodide, read_from_url_pyodide, send_data_pyodide, set_data_pyodide from .settings import UPLOADS_FOLDER @@ -37,6 +37,12 @@ def set_data(key: str, value: Any, folder_path: str = UPLOADS_FOLDER): set_data_python(key, value, folder_path=folder_path) +def send_data(payload: Dict[str, Any]): + """Send a complete bridge payload. This operation is meaningful only in Pyodide.""" + if ENVIRONMENT == EnvironmentsEnum.PYODIDE: + send_data_pyodide(payload) + + async def read_from_url(url: str, as_bytes: bool = False) -> Union[str, bytes]: """ Read content from a URL, routing to the pyodide or Python implementation. @@ -53,4 +59,4 @@ async def read_from_url(url: str, as_bytes: bool = False) -> Union[str, bytes]: return read_from_url_python(url, as_bytes) -__all__ = ["download_content_to_file", "get_data", "read_from_url", "set_data"] +__all__ = ["download_content_to_file", "get_data", "read_from_url", "send_data", "set_data"] diff --git a/src/py/mat3ra/notebooks_utils/preamble/__init__.py b/src/py/mat3ra/notebooks_utils/preamble/__init__.py new file mode 100644 index 00000000..83c5b39e --- /dev/null +++ b/src/py/mat3ra/notebooks_utils/preamble/__init__.py @@ -0,0 +1 @@ +"""Ready-to-execute namespace preambles for interactive Python environments.""" diff --git a/src/py/mat3ra/notebooks_utils/preamble/material.py b/src/py/mat3ra/notebooks_utils/preamble/material.py new file mode 100644 index 00000000..935927fe --- /dev/null +++ b/src/py/mat3ra/notebooks_utils/preamble/material.py @@ -0,0 +1,20 @@ +"""Imports exposed by the Materials Designer Python REPL.""" + +from mat3ra.made.material import Material +from mat3ra.made.tools.build.defective_structures.zero_dimensional.point_defect.point_defect_type_enum import ( + PointDefectTypeEnum, +) +from mat3ra.made.tools.build.pristine_structures.zero_dimensional.nanoparticle.enums import NanoparticleShapesEnum +from mat3ra.made.tools.build_components.entities.reusable.zero_dimensional.coordinates_shape_enum import ( + CoordinatesShapeEnum, +) +from mat3ra.made.tools.helpers import * # noqa: F403 +from mat3ra.made.tools.helpers import __all__ as _HELPER_NAMES + +__all__ = [ + "CoordinatesShapeEnum", + "Material", + "NanoparticleShapesEnum", + "PointDefectTypeEnum", + *_HELPER_NAMES, +] diff --git a/src/py/mat3ra/notebooks_utils/pyodide/io.py b/src/py/mat3ra/notebooks_utils/pyodide/io.py index 9dbabfeb..e902d5fd 100644 --- a/src/py/mat3ra/notebooks_utils/pyodide/io.py +++ b/src/py/mat3ra/notebooks_utils/pyodide/io.py @@ -3,8 +3,6 @@ import os from typing import Any, Dict, Optional, Union -from IPython.display import Javascript, display - from ..core.io import set_data_python from ..primitive.logger import log @@ -30,6 +28,33 @@ async def read_from_url_pyodide(url: str, as_bytes: bool = False) -> Union[str, return await response.string() +def send_data_pyodide(payload: Dict[str, Any]): + """Send a complete bridge payload from either in-page Pyodide or JupyterLite.""" + serialized_data = json.dumps(payload) + + try: + from js import JSON, sendDataToHost # type: ignore + + sendDataToHost(JSON.parse(serialized_data)) + except (ImportError, AttributeError): + # JupyterLite kernels run in a worker where the host page is not directly accessible. Keep + # the display-based data_bridge extension path for that environment, and import IPython only + # when it is actually needed. + from IPython.display import Javascript, display + + js_code = f""" + (function() {{ + if (window.sendDataToHost) {{ + window.sendDataToHost({serialized_data}); + console.log('Data sent to host:', {serialized_data}); + }} else {{ + console.error('sendDataToHost function is not defined on the window object.'); + }} + }})(); + """ + display(Javascript(js_code)) + + def set_data_pyodide(key: str, value: Any): """ Take a Python object, serialize it to JSON, and send it to the host environment @@ -39,18 +64,7 @@ def set_data_pyodide(key: str, value: Any): key (str): The name under which data will be sent. value (Any): The value to send to the host environment. """ - serialized_data = json.dumps({key: value}) - js_code = f""" - (function() {{ - if (window.sendDataToHost) {{ - window.sendDataToHost({serialized_data}); - console.log('Data sent to host:', {serialized_data}); - }} else {{ - console.error('sendDataToHost function is not defined on the window object.'); - }} - }})(); - """ - display(Javascript(js_code)) + send_data_pyodide({key: value}) log(f"Data for {key} sent to host.") set_data_python(key, value) diff --git a/tests/py/unit/core/entity/test_material_io.py b/tests/py/unit/core/entity/test_material_io.py new file mode 100644 index 00000000..f824ad81 --- /dev/null +++ b/tests/py/unit/core/entity/test_material_io.py @@ -0,0 +1,48 @@ +from unittest.mock import patch + +from mat3ra.made.material import Material +from mat3ra.notebooks_utils.core.entity.material.io import sync_materials +from mat3ra.standata.materials import Materials + + +def material(name: str) -> Material: + return Material.create({**Materials.get_by_name_first_match("Silicon"), "name": name}) + + +def test_sync_materials_collects_public_bindings_and_one_container_level(): + direct = material("direct") + listed = material("listed") + mapped = material("mapped") + + namespace = { + "direct": direct, + "group": [listed, 3, [material("too-deep")]], + "mapping": {"value": mapped}, + "materials_in": [material("input")], + "material": material("selected"), + "_private": material("private"), + "number": 4, + } + + with patch("mat3ra.notebooks_utils.core.entity.material.io.send_data") as send: + sync_materials(namespace) + + payload = send.call_args.args[0] + assert payload["syncScope"] == "python-repl" + assert [(entity["type"], entity["name"]) for entity in payload["entities"]] == [ + ("material", "direct"), + ("material", "group"), + ("material", "mapping"), + ] + assert [entity["config"]["name"] for entity in payload["entities"]] == [ + "direct", + "listed", + "mapped", + ] + + +def test_sync_materials_sends_an_empty_batch_to_clear_the_scope(): + with patch("mat3ra.notebooks_utils.core.entity.material.io.send_data") as send: + sync_materials({"x": 1}, sync_scope="test-scope") + + send.assert_called_once_with({"syncScope": "test-scope", "entities": []}) diff --git a/tests/py/unit/test_pyodide_io.py b/tests/py/unit/test_pyodide_io.py new file mode 100644 index 00000000..32c49221 --- /dev/null +++ b/tests/py/unit/test_pyodide_io.py @@ -0,0 +1,31 @@ +import json +import sys +from types import SimpleNamespace +from unittest.mock import patch + +from mat3ra.notebooks_utils.pyodide.io import send_data_pyodide, set_data_pyodide + + +def test_send_data_pyodide_calls_same_page_bridge_directly(): + received = [] + javascript = SimpleNamespace( + JSON=SimpleNamespace(parse=json.loads), + sendDataToHost=received.append, + ) + + with patch.dict(sys.modules, {"js": javascript}): + send_data_pyodide({"syncScope": "python-repl", "entities": []}) + + assert received == [{"syncScope": "python-repl", "entities": []}] + + +def test_set_data_pyodide_keeps_the_jupyterlite_display_fallback(): + with patch.dict(sys.modules, {"js": None}): + with patch("IPython.display.Javascript", side_effect=lambda source: source): + with patch("IPython.display.display") as display: + with patch("mat3ra.notebooks_utils.pyodide.io.set_data_python"): + set_data_pyodide("materials", [{"name": "Si"}]) + + rendered_source = display.call_args.args[0] + assert "window.sendDataToHost" in rendered_source + assert '"materials": [{"name": "Si"}]' in rendered_source