Skip to content

Add support for Marimo notebooks - #73

Merged
cbouy merged 10 commits into
cbouy:masterfrom
N283T:feature/marimo_support
Dec 20, 2025
Merged

Add support for Marimo notebooks#73
cbouy merged 10 commits into
cbouy:masterfrom
N283T:feature/marimo_support

Conversation

@N283T

@N283T N283T commented Dec 11, 2025

Copy link
Copy Markdown
Contributor

Description

This PR adds support for running mols2grid within marimo notebooks.

Previously, the grid would not render correctly or support interactivity in marimo due to differences in how JavaScript, HTML, and widgets are handled compared to Jupyter. This implementation fixes the issue by:

  1. Detecting the marimo execution environment.
  2. Forcing the grid to render inside an iframe when in marimo. This isolates the grid's styles and scripts, ensuring it displays correctly.
  3. Returning a marimo.vstack containing both the anywidget instance and the marimo.Html grid. This ensures the anywidget backend is active, enabling bidirectional communication (e.g., for selection).
  4. Suppressing automatic widget display in __init__. In Marimo, we manually return the widget in display(), so the automatic display from __init__ (used in Jupyter) caused double rendering and warnings.

Changes

  • mols2grid/utils.py: Added is_running_within_marimo() utility function.
  • mols2grid/molgrid.py:
    • Updated MolGrid.display() to use marimo.Html in an iframe and return it wrapped in marimo.vstack with the widget.
    • Updated MolGrid.__init__ to skip display(widget) when running in marimo to prevent double rendering.
  • tests/test_marimo_integration.py: Added unit tests to verify marimo detection and display logic.

Verification

  • Verified that mols2grid.display(df) renders the interactive grid correctly in a local marimo notebook.
  • Verified that selections made in the grid are correctly reflected in mols2grid.get_selection().
  • Added unit tests pass with uv run pytest.

@cbouy

cbouy commented Dec 15, 2025

Copy link
Copy Markdown
Owner

Thanks @N283T for making a PR, this is greatly appreciated!

Coincidentally, I was planning to add direct marimo support in a refactoring branch #71, but until I manage to get more time to work on this I think we should go ahead with your PR.

I'll enable the CI pipelines, if you could address any issues highlighted there that would be great, and I've already added some minor comments (the main one being that we don't want marimo to be required when installing mols2grid). If you need any help with the above I'm happy to go into more details.

@codecov

codecov Bot commented Dec 16, 2025

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.86%. Comparing base (09701af) to head (ff3aeb0).
⚠️ Report is 2 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master      #73      +/-   ##
==========================================
+ Coverage   90.20%   90.86%   +0.65%     
==========================================
  Files           8        8              
  Lines         531      558      +27     
==========================================
+ Hits          479      507      +28     
+ Misses         52       51       -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cbouy cbouy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Sorry I just realised I never actually posted my PR comments, here they are

Comment thread mols2grid/molgrid.py Outdated
Comment on lines +1084 to +1097
if is_running_within_marimo():
import marimo as mo

if use_iframe:
# Render HTML in iframe.
iframe = env.get_template("html/iframe.html").render(
width=iframe_width,
height=iframe_height,
allow=iframe_allow,
sandbox=iframe_sandbox,
doc=escape(doc),
)
return mo.vstack([self.widget, mo.Html(iframe)])
return mo.vstack([self.widget, mo.Html(doc)])

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

this shouldn't repeat the use_iframe block below, instead do somethiing like this to keep it DRY:

requires_marimo = is_running_within_marimo()
if use_iframe or requires_marimo:
    iframe = env.get_template...
    if requires_marimo:
        import marimo as mo

        return mo.vstack...
    return HTML(iframe)
return HTML(doc)

Comment thread pyproject.toml Outdated
Comment thread pyproject.toml Outdated
Comment on lines +40 to +42
[project.optional-dependencies]
marimo = ["marimo>=0.18.4"]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

no need to declare it as an optional dependency either, move to tests under [dependency-groups] below

@cbouy

cbouy commented Dec 16, 2025

Copy link
Copy Markdown
Owner

you should be able to fix the ruff error with uv run poe style-fix.

@N283T

N283T commented Dec 17, 2025

Copy link
Copy Markdown
Contributor Author

Thanks for the review! I've addressed the comments:

  • Refactored MolGrid.display in mols2grid/molgrid.py to avoid code duplication across the marimo and iframe conditional blocks, following the suggested structure.
  • Moved marimo from project.optional-dependencies to the tests dependency group in pyproject.toml.
  • Ran uv run poe style-fix and fixed the corresponding linting errors in the tests.

Additionally, tests/test_interface.py::test_callbacks_3D is failing locally for me (missing <canvas> element), which seems to be a timing issue unrelated to these changes. Is it okay to proceed with this?

@cbouy

cbouy commented Dec 17, 2025

Copy link
Copy Markdown
Owner

Looks good, thanks @N283T ! Some additional small things and then I'll merge:

  • Could you create a new scripts directory and rename/move the reproduce_marimo.py file as scripts/marimo_example.py please?
  • Could you directly use the mols2grid.datafiles.SOLUBILITY_SDF input there instead of a stringIO?
  • Finally, to make this example maybe slightly more relevant for marimo, we could mimic what is done in the filtering tutorial notebook with sliders, something akin to:
solubility_range = mo.ui.range_slider(
    -10, 2, 0.5, debounce=True, show_value=True, full_width=True, label="Solubility"
)
grid = mols2grid.MolGrid.from_sdf(mols2grid.datafiles.SOLUBILITY_SDF, size=(120, 100))
view = grid.display(n_items_per_page=12)
results = grid.dataframe.loc[
    (grid.dataframe["SOL"] >= solubility_range.value[0]) & (grid.dataframe["SOL"] <= solubility_range.value[1])
]
grid.filter_by_index(results.index)
mo.vstack([solubility_range, view])

This will make it easier for me to test that all relevant functionalities are preserved when I make changes in the future

@N283T

N283T commented Dec 18, 2025

Copy link
Copy Markdown
Contributor Author

Thanks! I’ve updated the example following the suggested snippet:

  • Moved reproduce_marimo.py to scripts/marimo_example.py.
  • Switched to using mols2grid.datafiles.SOLUBILITY_SDF.
  • Reworked the example to use a range_slider for filtering, matching the filtering tutorial pattern.

Note that grid initialization needs to be kept in a separate cell: in marimo, slider updates trigger re-execution of dependent cells, and recreating the grid on each update would reset the widget state.

@N283T

N283T commented Dec 18, 2025

Copy link
Copy Markdown
Contributor Author

Unrelated follow-up (but still marimo-related):

mols2grid_get_state

I have a local implementation of a small MolGrid.get_selection_state() helper
for marimo, which exposes the current grid selection as a reactive state.
This makes it easy to drive components like mo.ui.table from the current
selection without adding custom observers in user code.

The helper looks roughly like this:

def get_selection_state(self):
    if not is_running_within_marimo():
        raise RuntimeError("This method is only available in a marimo notebook.")

    import marimo as mo

    get_state, set_state = mo.state([])

    def _on_change(change):
        try:
            sel = ast.literal_eval(change["new"])
            set_state(list(sel.keys()))
        except (ValueError, SyntaxError):
            pass

    if not getattr(self.widget, "_marimo_hooked", False):
        self.widget.observe(_on_change, names=["selection"])
        self.widget._marimo_hooked = True

    return get_state

With this in place, an example marimo workflow looks like:

...
# display grid
mo.vstack(
    [solubility_range, view]
)
# display table
selected = results[results["mols2grid-id"].isin(sel_ids())]
table = mo.ui.table(
    selected.reset_index(drop=True).drop(columns="img"),
    format_mapping={"mol": mol_to_svg},
    freeze_columns_left=["mols2grid-id", "mol"],
    freeze_columns_right=["SOL"],
    label="Selected Molecules"
)

table
 2025-12-18 16 51 02

This helper is not included in the current PR, but if you think it would be
useful, I already have it implemented locally and can either:

  • add it to this PR, or
  • open a separate follow-up PR

Just let me know which you prefer.

@N283T

N283T commented Dec 18, 2025

Copy link
Copy Markdown
Contributor Author

Sorry, I accidentally pushed a middle state earlier.
It's been fixed now, and the current commit accurately reflects the intended changes.
This push is unrelated to (MolGrid.get_selection_state()).

@cbouy

cbouy commented Dec 18, 2025

Copy link
Copy Markdown
Owner

That looks like a great addition, thanks for working on that!
I think it's easier if we do everything in this branch, can you add how to use that in the marimo_example.py as well?

…date the Marimo example to display selected molecules.
@N283T

N283T commented Dec 19, 2025

Copy link
Copy Markdown
Contributor Author

Description

This PR adds first-class support for reactive molecule selection in marimo by introducing a small helper on MolGrid, and updates the example accordingly.

Changes

  • Added MolGrid.get_selection_state()
    Introduces a helper method that exposes the current grid selection as a Marimo reactive state.
    This makes it straightforward to drive downstream components (e.g. mo.ui.table) from grid selections without manual observer wiring.

  • Updated scripts/marimo_example.py
    The example now demonstrates:

    • filtering with a range_slider
    • selecting molecules directly in the grid
    • reactively displaying the selected molecules in a Marimo table using get_selection_state()

    To keep the example focused and easy to follow, previously added filters such as MolWt, LogP, etc. were intentionally removed.

  • Tests
    Added coverage to ensure get_selection_state() behaves correctly inside and outside of a Marimo environment.

Notes

  • The grid creation is intentionally isolated in its own cell in the example.
    In marimo, cells are re-executed whenever their dependencies change, and keeping MolGrid.from_sdf(...) separate prevents the widget state from being reset on every slider update.

@cbouy cbouy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Really nice addition! Just some minor comments, don't forget to run uv run poe style-fix

Comment thread mols2grid/molgrid.py Outdated
else:
self._cached_selection = {}
register._init_grid(name)
if is_running_within_marimo():

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

it would be better to directly skip emitting the warning in _init_grid here instead of catching warnings:

if overwrite and not is_running_within_marimo():

Comment thread tests/test_marimo_integration.py Outdated
Comment on lines +70 to +71
df = pd.DataFrame({"SMILES": ["C"]})
mg = MolGrid(df, smiles_col="SMILES")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

might as well set this up as a fixture at the top of the file since it's used in almost all tests here

Comment thread tests/test_marimo_integration.py Outdated
mg.get_selection_state()

# Verify observe was called
assert mock_observe.called

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

nitpick: mock_observe.assert_called()

Comment thread mols2grid/molgrid.py
Comment on lines +828 to +833
def _on_change(change):
try:
sel = ast.literal_eval(change["new"])
set_state(list(sel.keys()))
except (ValueError, SyntaxError):
pass

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Just me thinking out loud, definitely NOT something to address in this PR, but in my refactoring branch,
I might move this to be directly integrated in the SelectionRegister.selection_updated in select.py with some mechanism to register custom callbacks that would be triggered by selection_updated, just so that we don't have multiple observers listening to the same event, e.g.

register.add_callback(lambda sel: set_state(list(sel.keys())))

Comment thread mols2grid/molgrid.py Outdated
columns=self._extra_columns
)

def get_selection_state(self):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

since this is specific to marimo, it should have a more descriptive name, something like marimo_selection_getter?

Comment thread scripts/marimo_example.py Outdated
Comment on lines +57 to +60
# Same as:
# results = grid.dataframe.loc[
# (grid.dataframe["SOL"] >= solubility_range.value[0]) & (grid.dataframe["SOL"] <= solubility_range.value[1])
# ]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

should be moved one line up as:

Suggested change
# Same as:
# results = grid.dataframe.loc[
# (grid.dataframe["SOL"] >= solubility_range.value[0]) & (grid.dataframe["SOL"] <= solubility_range.value[1])
# ]
# Same as:
# grid.dataframe["SOL"] >= solubility_range.value[0]) & (grid.dataframe["SOL"] <= solubility_range.value[1])

Comment thread scripts/marimo_example.py Outdated
view = grid.display(n_items_per_page=12)
return df, grid, view
grid = mols2grid.MolGrid.from_sdf(SOLUBILITY_SDF, size=(120, 100))
sel_ids = grid.get_selection_state()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

nitpick:

Suggested change
sel_ids = grid.get_selection_state()
get_selection_ids = grid.get_selection_state()

Comment thread pyproject.toml Outdated
".vscode",
"build",
"site-packages",
"scripts/marimo_example.py",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Please revert, I'd rather not ignore formatting/linting without a good reason 😅

@N283T

N283T commented Dec 20, 2025

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Sorry for the bundled reply — I addressed all the comments in one go:

  • Moved the warning gating into SelectionRegister._init_grid (only warn when overwrite and not running in marimo), so we no longer catch warnings in MolGrid.__init__.
  • Added a shared MolGrid fixture in tests/test_marimo_integration.py to avoid repeating setup.
  • Switched the nitpick to mock_observe.assert_called().
  • Renamed the marimo-specific helper to get_marimo_selection().
    I chose this name to stay consistent with the existing get_selection() API and the usual Python convention of using verbs for accessors.
    (marimo_selection_getter felt a bit more like a variable name to me.)
    Happy to rename it if you prefer marimo_selection_getter instead.
  • Adjusted the “Same as” comment placement in scripts/marimo_example.py per suggestion.
  • Reverted the Ruff exclusion for scripts/marimo_example.py.

Also ran uv run poe style-fix.

@cbouy

cbouy commented Dec 20, 2025

Copy link
Copy Markdown
Owner

All good from my side!

Could you just add something in the docs/contents.md file to mention the compatibility with Marimo since v2.2.0 (the next release) with a link to the example in scripts/, and also add your name and/or github handle in the Acknowledgement section?

@N283T

N283T commented Dec 20, 2025

Copy link
Copy Markdown
Contributor Author

Thanks! I've added a short note about Marimo compatibility in docs/contents.md with a link to the example, and added myself to the acknowledgments as well.

@cbouy cbouy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

All good, thanks a ton @N283T !
I'm going to make the release today and announce it on socials once the conda build is done (most likely tomorrow), should I add a link to your GitHub account, or is there a Linkedin/X/Bluesky/personal website link that you'd prefer?

@cbouy
cbouy merged commit 00b2b3b into cbouy:master Dec 20, 2025
8 checks passed
@N283T

N283T commented Dec 20, 2025

Copy link
Copy Markdown
Contributor Author

Thanks a lot!
Linking to my GitHub is totally fine, and if possible I’d be happy if you could also include my X account:
https://x.com/tbsng_ktkm0923

Once it’s released, I’ll share it in Japanese and help spread the word in the Japanese community as well !!

@N283T
N283T deleted the feature/marimo_support branch December 20, 2025 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants