Skip to content
Closed
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
36 changes: 35 additions & 1 deletion src/py/mat3ra/notebooks_utils/core/entity/material/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions src/py/mat3ra/notebooks_utils/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand All @@ -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"]
1 change: 1 addition & 0 deletions src/py/mat3ra/notebooks_utils/preamble/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Ready-to-execute namespace preambles for interactive Python environments."""
20 changes: 20 additions & 0 deletions src/py/mat3ra/notebooks_utils/preamble/material.py
Original file line number Diff line number Diff line change
@@ -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,
]
42 changes: 28 additions & 14 deletions src/py/mat3ra/notebooks_utils/pyodide/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)

Expand Down
48 changes: 48 additions & 0 deletions tests/py/unit/core/entity/test_material_io.py
Original file line number Diff line number Diff line change
@@ -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": []})
31 changes: 31 additions & 0 deletions tests/py/unit/test_pyodide_io.py
Original file line number Diff line number Diff line change
@@ -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
Loading