Skip to content
Open
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
26 changes: 26 additions & 0 deletions config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,35 @@ default:
packages_pyodide:
- mat3ra-notebooks-utils
notebooks:
# Materials Designer REPL: `made` minus ipywidgets/plotly/nbformat, which it never renders (~7s).
# Not named `made-repl` — names are regexes matched inside the request, so that would also match
# `made` and merge both lists. Profiles can only add packages, never subtract.
- name: repl
packages_pyodide:
- lzma
- sqlite3
- ssl
- annotated_types>=0.6.0
- networkx==3.2.1
- monty==2023.11.3
- scipy==1.11.2
- tabulate==0.9.0
- sympy==1.12
- uncertainties==3.1.6
- ase==3.25.0
- emfs:/drive/packages/pymatgen-2024.4.13-py3-none-any.whl
- emfs:/drive/packages/spglib-2.0.2-py3-none-any.whl
- emfs:/drive/packages/ruamel.yaml-0.17.32-py3-none-any.whl
- emfs:/drive/packages/pydantic_core-2.18.2-py3-none-any.whl
- emfs:/drive/packages/pydantic-2.7.1-py3-none-any.whl
- pymatgen-analysis-defects<=2024.4.23
- mat3ra-periodic-table
- mat3ra-made
- name: made
packages_pyodide:
- lzma
- sqlite3
- ssl
- annotated_types>=0.6.0
- networkx==3.2.1
- monty==2023.11.3
Expand Down
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"]
9 changes: 8 additions & 1 deletion src/py/mat3ra/notebooks_utils/ipython/io.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import json

from IPython.display import Javascript, display
try:
from IPython.display import Javascript, display
except ImportError:
Javascript = None
display = None


def download_content_to_file(content: dict, filename: str):
Expand All @@ -11,6 +15,9 @@ def download_content_to_file(content: dict, filename: str):
content (dict): The content to download.
filename (str): The name of the file to download.
"""
if Javascript is None or display is None:
raise RuntimeError("IPython is required to download content from a notebook")

if isinstance(content, dict):
content_str = json.dumps(content, indent=4)
else:
Expand Down
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,
]
Comment on lines +14 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

ruff --version
for config in pyproject.toml ruff.toml .ruff.toml; do
  if [ -f "$config" ]; then
    rg -n 'PLE0604|ruff|select|ignore' "$config" || true
  fi
done
ruff check src/py/mat3ra/notebooks_utils/preamble/material.py

Repository: mat3ra/api-examples

Length of output: 1863


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== pyproject ruff section ==='
sed -n '120,190p' pyproject.toml || true

echo '=== material.py relevant lines ==='
sed -n '1,40p' src/py/mat3ra/notebooks_utils/preamble/material.py

echo '=== ruff check with only PLE0604 enabled ==='
ruff check src/py/mat3ra/notebooks_utils/preamble/material.py --select PLE0604 || true

Repository: mat3ra/api-examples

Length of output: 2149


Use a known string list for __all__.

Current Ruff setup flags __all__ on line 14 with PLE0604 because the starred expansion comes from dynamic helper imports. Either define __all__ with explicit string names, or reassign it after module load; keep the noqa: F403 here since the wildcard import is used.

🧰 Tools
🪛 Ruff (0.16.1)

[error] 14-14: Invalid object in __all__, must contain only strings

(PLE0604)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/py/mat3ra/notebooks_utils/preamble/material.py` around lines 14 - 20,
Update the module-level __all__ definition to contain an explicit list of known
string names instead of expanding _HELPER_NAMES dynamically, resolving Ruff
PLE0604. Preserve the existing noqa: F403 on the wildcard import.

Source: Linters/SAST tools

51 changes: 34 additions & 17 deletions src/py/mat3ra/notebooks_utils/pyodide/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,26 @@
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

try:
from IPython.display import Javascript, display
except ImportError:
Javascript = None
display = None

try:
from js import JSON, sendDataToHost # type: ignore
except ImportError:
JSON = None
sendDataToHost = None

try:
from pyodide.http import pyfetch # type: ignore
except ImportError:
pyfetch = None


async def read_from_url_pyodide(url: str, as_bytes: bool = False) -> Union[str, bytes]:
"""
Expand All @@ -20,16 +35,29 @@ async def read_from_url_pyodide(url: str, as_bytes: bool = False) -> Union[str,
Returns:
str or bytes: The content.
"""
# `http` is a Pyodide module that will be installed in the Pyodide environment by default.
from pyodide.http import pyfetch # type: ignore
if pyfetch is None:
raise RuntimeError("pyfetch is available only in Pyodide")

# Per https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
response = await pyfetch(url)
if as_bytes:
return await response.bytes()
return await response.string()


def send_data_pyodide(payload: Dict[str, Any]):
"""Send a bridge payload to the host application."""
serialized_data = json.dumps(payload)

if JSON is not None and sendDataToHost is not None:
sendDataToHost(JSON.parse(serialized_data))
Comment on lines +49 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

serialized="$(python - <<'PY'
import json
print(json.dumps({"value": float("nan")}))
PY
)"

printf 'Serialized payload: %s\n' "$serialized"

if ! command -v node >/dev/null 2>&1; then
  echo "Node.js is required for this verification." >&2
  exit 2
fi

if node -e 'JSON.parse(process.argv[1])' "$serialized"; then
  echo "Unexpectedly accepted non-standard JSON." >&2
  exit 1
else
  echo "Confirmed: JavaScript JSON.parse rejects the payload."
fi

Repository: mat3ra/api-examples

Length of output: 795


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf 'Target file exists:\n'
ls -l src/py/mat3ra/notebooks_utils/pyodide/io.py

printf '\nRelevant source:\n'
sed -n '1,120p' src/py/mat3ra/notebooks_utils/pyodide/io.py

printf '\nSearch for sendDataToHost and JSON usage:\n'
rg -n "sendDataToHost|JSON\.parse|json\.dumps|allow_nan|nan|NaN|Infinity|Infinity|-Infinity" src/py/mat3ra/notebooks_utils/pyodide -S

Repository: mat3ra/api-examples

Length of output: 4693


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf 'Find sendDataToHost definitions/usages in repo:\n'
rg -n "sendDataToHost|window\.sendDataToHost|JSON\.parse|json\.dumps\(.*,\s*allow_nan\s*=\s*False|allow_nan" . -S

printf '\nProbe Python/JS behavior for edge non-finite payloads if Node is available:\n'
python3 - <<'PY'
import json

for value, label in [
    (float("nan"), "nan"),
    (float("inf"), "inf"),
    (float("-inf"), "-inf"),
    (complex(1, 2), "complex"),
]:
    try:
        s = json.dumps({"value": value})
    except Exception as exc:
        print(label, "python_raises", type(exc).__name__, str(exc))
        continue
    print(label, "python", s)

PY

node - <<'JS'
for (const s of ['{"value": NaN}', '{"value": Infinity}', '{"value": -Infinity}', '{"value": 1+2j}']) {
  try {
    JSON.parse(s);
    console.log("node accepted", s);
  } catch (e) {
    console.log("node rejected", s, e.name + ": " + e.message);
  }
}
JS

Repository: mat3ra/api-examples

Length of output: 1794


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Find sendDataToHost definitions/usages in repo:\n'
rg -n "sendDataToHost|window\.sendDataToHost|JSON\.parse|json\.dumps\(.*,\s*allow_nan\s*=\s*False|allow_nan" . -S

printf '\nProbe Python/JS behavior for edge non-finite payloads if Node is available:\n'
python3 - <<'PY'
import json

for value, label in [
    (float("nan"), "nan"),
    (float("inf"), "inf"),
    (float("-inf"), "-inf"),
    (complex(1, 2), "complex"),
]:
    try:
        s = json.dumps({"value": value})
    except Exception as exc:
        print(label, "python_raises", type(exc).__name__, str(exc))
        continue
    print(label, "python", s)

PY

node - <<'JS'
for (const s of ['{"value": NaN}', '{"value": Infinity}', '{"value": -Infinity}', '{"value": 1+2j}']) {
  try {
    JSON.parse(s);
    console.log("node accepted", s);
  } catch (e) {
    console.log("node rejected", s, e.name + ": " + e.message);
  }
}
JS

Repository: mat3ra/api-examples

Length of output: 1794


Reject non-finite JSON before both transports.

json.dumps emits NaN and Infinity, but JSON.parse rejects them, so the direct Pyodide bridge can fail. The fallback path may evaluate some of these values successfully, which creates divergent transport behavior. Use json.dumps(payload, allow_nan=False) before transport, and add a regression test for NaN/infinity payloads.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/py/mat3ra/notebooks_utils/pyodide/io.py` around lines 49 - 52, Update the
serialization in the io transport flow to call json.dumps with allow_nan=False,
ensuring NaN and infinity payloads are rejected before either the direct
JSON.parse/sendDataToHost bridge or fallback transport runs. Add a regression
test covering non-finite payload values and verify both transports receive no
divergent serialized result.

return

if Javascript is None or display is None:
raise RuntimeError("IPython is required to send data from JupyterLite")

display(Javascript(f"window.sendDataToHost({serialized_data});"))


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 +67,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
13 changes: 9 additions & 4 deletions src/py/mat3ra/notebooks_utils/pyodide/packages/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,12 @@ def package_has_version_specifier(pkg: str) -> bool:
return any(op in spec for op in VERSION_SPECIFIERS)


def should_reinstall_package(pkg: str, profile_changed: bool) -> bool:
return profile_changed and package_has_version_specifier(pkg) and not is_url_package(remove_nodeps_prefix(pkg))
def should_reinstall_package(pkg: str, previous_packages: List[str]) -> bool:
package_name = get_package_name(pkg)
if not package_name or not package_has_version_specifier(pkg) or is_url_package(remove_nodeps_prefix(pkg)):
return False
previous_spec = next((item for item in previous_packages if get_package_name(item) == package_name), None)
return previous_spec is not None and previous_spec != pkg
Comment on lines +117 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate target file =="
git ls-files | rg 'install\.py$|config\.yml|pyodide|notebooks_utils'

echo "== target outlines =="
ast-grep outline src/py/mat3ra/notebooks_utils/pyodide/packages/install.py --view expanded || true

echo "== relevant source lines =="
cat -n src/py/mat3ra/notebooks_utils/pyodide/packages/install.py | sed -n '1,180p'

echo "== search for should_reinstall_package/install_package_pyodide/usages =="
rg -n "should_reinstall_package|install_package_pyodide|package_has_version_specifier|is_url_package|remove_nodeps_prefix|emfs:/drive/packages|micropip\.install" src/py/mat3ra/notebooks_utils/pyodide/packages/install.py . || true

Repository: mat3ra/api-examples

Length of output: 20999


🌐 Web query:

Micropip Pyodide install URL wheel reinstall=False compatible incompatible installed package documentation

💡 Result:

In Pyodide's micropip library, the install function includes a reinstall parameter that controls how the system handles packages that are already present in the environment [1][2]. The behavior is as follows: reinstall=False (Default): If a requested package is already installed but is incompatible with the version being requested, micropip will raise an error [1][3][4]. reinstall=True: If a requested package is already installed but is incompatible, micropip will uninstall the existing incompatible package and then install the new version [1][2]. Important considerations when using the reinstall parameter: Imported Modules: Micropip does not automatically reload modules that have already been imported into the Python namespace [1][2]. After a successful reinstallation, you must manually reload any modules you have already imported, for example, by using importlib.reload(module) [1][3]. Package Compatibility: Micropip can only install pure Python wheels or wheels specifically built for the wasm32/emscripten platform (Pyodide) [1][5]. If a package is not found in the Pyodide repository, it will attempt to load it from PyPI, provided it meets these wheel requirements [1][2]. Uninstalling: The separate micropip.uninstall function is also available and behaves similarly regarding imported modules—it removes the package from the environment but not from the active namespace [1][2]. It only supports uninstalling packages that were installed using a wheel file (i.e., those with distribution metadata) [1][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== install_packages_pyodide implementation =="
cat -n src/py/mat3ra/notebooks_utils/pyodide/packages/install.py | sed -n '180,260p'

echo "== package install tests =="
cat -n tests/py/unit/test_pyodide_packages_install.py | sed -n '1,140p'

echo "== deterministic guard behavior for URL wheels =="
python3 - <<'PY'
import re
from typing import List, Union

NODEPS_PREFIX = "nodeps:"
URL_PREFIXES = ("http://", "https://", "emfs:/")
VERSION_SPECIFIERS = ("==", ">=", "<=", "!=", "~=", ">", "<")

def get_package_name(pkg: str) -> Union[str, None]:
    spec = pkg.replace(NODEPS_PREFIX, "", 1) if pkg.startswith(NODEPS_PREFIX) else pkg
    match = re.match(r"^[A-Za-z0-9_.-]+", spec)
    return match.group(0) if match else None

def is_url_package(pkg: str) -> bool:
    return pkg.startswith(URL_PREFIXES)

def package_has_version_specifier(pkg: str) -> bool:
    spec = pkg.replace(NODEPS_PREFIX, "", 1) if pkg.startswith(NODEPS_PREFIX) else pkg
    return any(op in spec for op in VERSION_SPECIFIERS)

def should_reinstall_package(pkg: str, previous_packages: List[str]) -> bool:
    package_name = get_package_name(pkg)
    if not package_name or not package_has_version_specifier(pkg) or is_url_package(pkg):
        return False
    previous_spec = next((item for item in previous_packages if get_package_name(item) == package_name), None)
    return previous_spec is not None and previous_spec != pkg

cases = [
    ["emfs:/drive/packages/pymatgen-A.whl", ["emfs:/drive/packages/pymatgen-B.whl"]],
    ["https://example.com/specific.whl", ["https://example.com/changed.whl"]],
    ["emfs:/drive/packages/pymatgen-A.whl", []],
]
for pkg, previous_packages in cases:
    print(pkg, previous_packages, "=>", should_reinstall_package(pkg, previous_packages))
PY

Repository: mat3ra/api-examples

Length of output: 2647


Reinstall changed URL wheels.

should_reinstall_package() returns False for every URL request, including emfs:/drive/packages/*.whl entries. When a changed wheel is requested after the same distribution was installed, install_package_pyodide() calls micropip.install() with reinstall=False, and Micropip reports an incompatible-installed-package error instead of replacing the wheel. Compare URL requirements by distribution name and full URL, and reinstall when the URL changes. Add a regression test for the same distribution requested from two different emfs: wheel URLs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/py/mat3ra/notebooks_utils/pyodide/packages/install.py` around lines 114 -
119, The URL-package exclusion in should_reinstall_package currently prevents
changed wheel URLs from triggering reinstalls. Update this function to compare
URL requirements by distribution name and full URL, returning true when the
requested URL differs from the previously installed URL while preserving
existing version-specifier behavior. Add a regression test covering the same
distribution requested from two different emfs: wheel URLs.



def get_package_name(pkg: str) -> Union[str, None]:
Expand Down Expand Up @@ -187,17 +191,18 @@ async def install_packages_pyodide(notebook_name_pattern: str, verbose: bool = T
packages = await get_package_list_from_config(get_config_yml_file_path(""), notebook_name_pattern)
requirements_hash = str(hash(json.dumps(packages)))
previous_hash = os.environ.get("requirements_hash")
profile_changed = previous_hash is not None and previous_hash != requirements_hash
previous_packages = json.loads(os.environ.get("requirements_packages", "[]"))
if should_install_packages(previous_hash, requirements_hash):
for pkg in packages:
await install_package_pyodide(
pkg,
verbose,
reinstall=should_reinstall_package(pkg, profile_changed),
reinstall=should_reinstall_package(pkg, previous_packages),
)
if verbose:
log("Packages installed successfully.", force_verbose=verbose)
os.environ["requirements_hash"] = requirements_hash
os.environ["requirements_packages"] = json.dumps(packages)
Comment on lines +194 to +205

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg 'src/py/mat3ra/notebooks_utils/pyodide/packages/install.py|requirements_packages|requirements_hash|should_reinstall_package|should_install_packages' || true

echo
echo "install.py outline:"
ast-grep outline src/py/mat3ra/noteboards_utils/pyodide/packages/install.py --view expanded 2>/dev/null || ast-grep outline src/py/mat3ra/notebooks_utils/pyodide/packages/install.py --view expanded || true

echo
echo "install.py relevant lines:"
cat -n src/py/mat3ra/notebooks_utils/pyodide/packages/install.py | sed -n '1,260p'

echo
echo "Search package-state helpers/usages:"
rg -n 'requirements_packages|requirements_hash|should_reinstall_package|should_install_packages|previous_packages' src/py/mat3ra/notebooks_utils/pyodide/packages/install.py src -S

Repository: mat3ra/api-examples

Length of output: 12320


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json

def should_install_packages(previous_hash, requirements_hash):
    return previous_hash != requirements_hash

def get_package_name(pkg):
    spec = pkg.removeprefix("nodeps:")
    import re
    match = re.match(r"^[A-Za-z0-9_.-]+", spec)
    return match.group(0) if match else None

def package_has_version_specifier(pkg):
    spec = pkg.removeprefix("nodeps:")
    return any(op in spec for op in ["==", ">=", "<=", "!=", "~=", ">", "<"])

def is_url_package(pkg):
    return pkg.startswith(("http://", "https://", "emfs:/"))

def should_reinstall_package(pkg, previous_packages):
    package_name = get_package_name(pkg)
    if not package_name or not package_has_version_specifier(pkg) or is_url_package(pkg.removeprefix("nodeps:")):
        return False
    previous_spec = next((item for item in previous_packages if get_package_name(item) == package_name), None)
    return previous_spec is not None and previous_spec != pkg

# Original hash-change behavior
current_hash = "current-hash"
previous_packages_json_missing = os.environ.get("requirements_packages", "[]") if False else "[]"
previous_packages_expected_false = json.loads(previous_packages_json_missing)
print("current_should_install_packages_missing_hash=True:", should_install_packages(None, current_hash))
print("current_should_install_packages_matching_empty_packages:", should_install_packages(current_hash, current_hash))
print("should_reinstall_package_with_empty_previous_packages:", should_reinstall_package("numpy==1.0", json.loads("[]"))

def main():
    pass
PY

Repository: mat3ra/api-examples

Length of output: 233


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json
import os
import re
from typing import List, Union

def should_install_packages(previous_hash: Union[str, None], requirements_hash: str) -> bool:
    return previous_hash != requirements_hash

def remove_nodeps_prefix(pkg: str) -> str:
    return pkg.replace("nodeps:", "", 1) if pkg.startswith("nodeps:") else pkg

VERSION_SPECIFIERS = ("==", ">=", "<=", "!=", "~=", ">", "<")

def package_has_version_specifier(pkg: str) -> bool:
    spec = remove_nodeps_prefix(pkg)
    return any(op in spec for op in VERSION_SPECIFIERS)

def is_url_package(pkg: str) -> bool:
    return pkg.startswith(("http://", "https://", "emfs:/"))

def get_package_name(pkg: str) -> Union[str, None]:
    spec = remove_nodeps_prefix(pkg)
    match = re.match(r"^[A-Za-z0-9_.-]+", spec)
    return match.group(0) if match else None

def should_reinstall_package(pkg: str, previous_packages: List[str]) -> bool:
    package_name = get_package_name(pkg)
    if not package_name or not package_has_version_specifier(pkg) or is_url_package(remove_nodeps_prefix(pkg)):
        return False
    previous_spec = next((item for item in previous_packages if get_package_name(item) == package_name), None)
    return previous_spec is not None and previous_spec != pkg

os.environ["requirements_hash"] = "current-hash"
os.environ.pop("requirements_packages", None)
packages = ["numpy==1.0", "scipy==2.0", pandas"]
requirements_hash = os.environ["requirements_hash"]
previous_package_state_missing = json.loads(os.environ.get("requirements_packages", "[]"))

print("previous_packages_missing_state:", previous_package_state_missing)
print("match_hash_skips_reinstall:", should_install_packages(requirements_hash, requirements_hash))
for pkg in packages:
    print("should_reinstall_package", pkg, "=>", should_reinstall_package(pkg, previous_package_state_missing))

current_packages = ["numpy==2.0"]
missing_hash_should_install = should_install_packages(None, "current-hash")
print("new_hash_installs_all_with_initial_state:", missing_hash_should_install)
print("reinstall_flags_with_initial_state:", [(pkg, should_reinstall_package(pkg, json.loads("[]"))) for pkg in current_packages])
PY

Repository: mat3ra/api-examples

Length of output: 337


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json
import os
import re
from typing import List, Union

def remove_nodeps_prefix(pkg: str) -> str:
    return pkg.replace("nodeps:", "", 1) if pkg.startswith("nodeps:") else pkg

VERSION_SPECIFIERS = ("==", ">=", "<=", "!=", "~=", ">", "<")

def packet_should_reinstall_package(pkg: str, previous_packages: List[str]) -> bool:
    def get_package_name(p: str) -> Union[str, None]:
        spec = remove_nodeps_prefix(p)
        match = re.match(r"^[A-Za-z0-9_.-]+", spec)
        return match.group(0) if match else None

    def package_has_version_specifier(p: str) -> bool:
        spec = remove_nodeps_prefix(p)
        return any(op in spec for op in VERSION_SPECIFIERS)

    for is_url_package in [lambda p: p.startswith(("http://", "https://", "emfs:/"))]
    package_name = get_package_name(pkg)
    if not package_name or not package_has_version_specifier(pkg) or is_url_package(remove_nodeps_prefix(pkg)):
        return False
    previous_spec = next((item for item in previous_packages if get_package_name(item) == package_name), None)
    return previous_spec is not None and previous_spec != pkg

os.environ["requirements_hash"] = "current-hash"
os.environ.pop("requirements_packages", None)
packages = ["numpy==1.0", "scipy==2.0"]
requirements_hash = os.environ["requirements_hash"]
previous_package_state_missing = json.loads(os.environ.get("requirements_packages", "[]"))

print("previous_packages_missing_state:", previous_package_state_missing)
print("match_hash_skips_reinstall:", packet_should_reinstall_package(requirements_hash, requirements_hash))
for pkg in packages:
    print("should_reinstall_package", pkg, "=>", packet_should_reinstall_package(pkg, previous_package_state_missing))
PY

Repository: mat3ra/api-examples

Length of output: 384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json
import os
import re
from typing import List, Union

def remove_nodeps_prefix(pkg: str) -> str:
    return pkg.replace("nodeps:", "", 1) if pkg.startswith("nodeps:") else pkg

VERSION_SPECIFIERS = ("==", ">=", "<=", "!=", "~=", ">", "<")

def get_package_name(pkg: str) -> Union[str, None]:
    spec = remove_nodeps_prefix(pkg)
    match = re.match(r"^[A-Za-z0-9_.-]+", spec)
    return match.group(0) if match else None

def package_has_version_specifier(pkg: str) -> bool:
    spec = remove_nodeps_prefix(pkg)
    return any(op in spec for op in VERSION_SPECIFIERS)

def is_url_package(pkg: str) -> bool:
    return pkg.startswith(("http://", "https://", "emfs:/"))

def should_reinstall_package(pkg: str, previous_packages: List[str]) -> bool:
    package_name = get_package_name(pkg)
    if not package_name or not package_has_version_specifier(pkg) or is_url_package(remove_nodeps_prefix(pkg)):
        return False
    previous_spec = next((item for item in previous_packages if get_package_name(item) == package_name), None)
    return previous_spec is not None and previous_spec != pkg

os.environ["requirements_hash"] = "current-hash"
os.environ.pop("requirements_packages", None)
requirements_hash = os.environ["requirements_hash"]
previous_package_state_missing = json.loads(os.environ.get("requirements_packages", "[]"))

print("previous_packages_missing_state:", previous_package_state_missing)
print("same_hash_skips_block:", previous_package_state_missing == [])
for pkg in ["numpy==1.0", "scipy==2.0"]:
    print("should_reinstall_package", pkg, "=", should_reinstall_package(pkg, previous_package_state_missing))

current_packages = ["numpy==2.0"]
print("new_hash_installs_all_without_initial_state:", should_reinstall_package(current_packages[0], json.loads("[]")))
PY

Repository: mat3ra/api-examples

Length of output: 360


Treat missing requirements_packages as an unstable package state.

If requirements_hash exists but requirements_packages is unset, the fallback [] becomes a cache key. Matching the hash skips installation, then on the first requirements change every versioned package looks absent from previous_packages, so should_reinstall_package returns False. Use separate state presence check before trusting requirements_hash, and add coverage for existing requirements_hash without requirements_packages.

🧰 Tools
🪛 ast-grep (0.45.0)

[info] 201-201: use jsonify instead of json.dumps for JSON output
Context: json.dumps(packages)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/py/mat3ra/notebooks_utils/pyodide/packages/install.py` around lines 191 -
202, Update the package-state logic around should_install_packages so an
existing requirements_hash is not trusted when requirements_packages is unset;
treat the missing package-state variable as unstable and force the installation
path. Preserve parsed package data only when the environment variable is
present, and add coverage for the existing requirements_hash/missing
requirements_packages case.

else:
if verbose:
log("Packages are already installed.", force_verbose=verbose)
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": []})
32 changes: 32 additions & 0 deletions tests/py/unit/test_pyodide_io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import json
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 = []
with patch("mat3ra.notebooks_utils.pyodide.io.JSON", SimpleNamespace(parse=json.loads)):
with patch("mat3ra.notebooks_utils.pyodide.io.sendDataToHost", received.append):
send_data_pyodide({"syncScope": "python-repl", "entities": []})

assert received == [{"syncScope": "python-repl", "entities": []}]


def test_send_data_pyodide_uses_display_for_jupyterlite():
with patch("mat3ra.notebooks_utils.pyodide.io.JSON", None):
with patch("mat3ra.notebooks_utils.pyodide.io.Javascript", side_effect=lambda source: source):
with patch("mat3ra.notebooks_utils.pyodide.io.display") as display:
send_data_pyodide({"syncScope": "python-repl", "entities": []})

assert display.call_args.args[0] == 'window.sendDataToHost({"syncScope": "python-repl", "entities": []});'


def test_set_data_pyodide_updates_python_data():
with patch("mat3ra.notebooks_utils.pyodide.io.JSON", SimpleNamespace(parse=json.loads)):
with patch("mat3ra.notebooks_utils.pyodide.io.sendDataToHost"):
with patch("mat3ra.notebooks_utils.pyodide.io.set_data_python") as set_data_python:
set_data_pyodide("materials", [{"name": "Si"}])

set_data_python.assert_called_once_with("materials", [{"name": "Si"}])
16 changes: 16 additions & 0 deletions tests/py/unit/test_pyodide_packages_install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from mat3ra.notebooks_utils.pyodide.packages.install import should_reinstall_package


def test_reinstalls_only_when_the_same_package_version_changes():
previous = ["networkx==3.2.1", "scipy==1.11.2"]

assert should_reinstall_package("networkx==3.2.2", previous)
assert not should_reinstall_package("networkx==3.2.1", previous)
assert not should_reinstall_package("tabulate==0.9.0", previous)


def test_does_not_reinstall_url_or_emfs_requirements():
assert not should_reinstall_package(
"emfs:/drive/packages/example.whl",
["emfs:/drive/packages/old.whl"],
)
Loading