cnotebook 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cnotebook/helpers.py ADDED
@@ -0,0 +1,201 @@
1
+ import logging
2
+ import re
3
+ from typing import Callable, Literal, Sequence
4
+ from openeye import oechem, oedepict
5
+
6
+ log = logging.getLogger("cnotebook")
7
+
8
+
9
+ # Type alias for highlight style
10
+ HighlightStyle = int | Literal["overlay_default", "overlay_ball_and_stick"]
11
+
12
+ # Type alias for highlight colors
13
+ HighlightColors = oechem.OEColor | oechem.OEColorIter
14
+
15
+
16
+ def escape_html(val):
17
+ """
18
+ Perform the same HTML escaping done by Pandas for displaying in Notebooks
19
+ :param val: Value to escape
20
+ :return: Escaped value (if val was a string)
21
+ """
22
+ if isinstance(val, str):
23
+ return val.replace("&", r"&amp;").replace("<", r"&lt;").replace(">", r"&gt;")
24
+ return val
25
+
26
+
27
+ def escape_brackets(val):
28
+ """
29
+ Escapes only HTML brackets
30
+ :param val: Value to escape
31
+ :return: Escaped value (if string)
32
+ """
33
+ if isinstance(val, str):
34
+ return val.replace("<", r"&lt;").replace(">", r"&gt;")
35
+ return val
36
+
37
+
38
+ # Remove conformer identifier from compound ID
39
+ CONFORMER_ID_REGEX = re.compile(r'(.*?)_\d+$')
40
+
41
+
42
+ def remove_omega_conformer_id(val):
43
+ """
44
+ Remove the conformer ID from a compound identifier
45
+ :param val: Value
46
+ :return: Processed value
47
+ """
48
+ if isinstance(val, str):
49
+ m = re.search(CONFORMER_ID_REGEX, val)
50
+ if m is not None:
51
+ return m.group(1)
52
+ return val
53
+
54
+
55
+ def create_structure_highlighter(
56
+ query: str | oechem.OESubSearch | oechem.OEMCSSearch | oechem.OEQMol,
57
+ color: HighlightColors | None = None,
58
+ style: HighlightStyle = "overlay_default"
59
+ ) -> Callable[[oedepict.OE2DMolDisplay], None]:
60
+ """
61
+ Closure that creates a callback to highlight SMARTS patterns or MCSS results in a molecule.
62
+
63
+ :param query: SMARTS pattern, oechem.OESubSearch, or oechem.OEMCSSearch object.
64
+ :param color: Highlight color(s). Can be a single oechem.OEColor or an oechem.OEColorIter
65
+ (e.g., oechem.OEGetLightColors()). Defaults to oechem.OEGetLightColors().
66
+ :param style: Highlight style. Can be an int (OEHighlightStyle constant) or a string
67
+ ("overlay_default", "overlay_ball_and_stick"). Defaults to "overlay_default".
68
+ :returns: Callback function that highlights structures.
69
+ """
70
+ # Default color
71
+ if color is None:
72
+ color = oechem.OEGetLightColors()
73
+
74
+ # Create the substructure search object
75
+ if isinstance(query, (str, oechem.OEQMol)):
76
+ ss = oechem.OESubSearch(query)
77
+ elif isinstance(query, (oechem.OESubSearch, oechem.OEMCSSearch)):
78
+ ss = query
79
+ else:
80
+ raise TypeError(f'Cannot create structure highlighter with object pattern of type {type(query).__name__}')
81
+
82
+ # Determine highlighting approach based on style
83
+ use_overlay = isinstance(style, str) and style in ("overlay_default", "overlay_ball_and_stick")
84
+
85
+ # Check if color is compatible with overlay
86
+ if use_overlay and isinstance(color, oechem.OEColor):
87
+ log.warning(
88
+ "Overlay coloring is not compatible with a single oechem.OEColor. Falling back to standard highlighting")
89
+ use_overlay = False
90
+ style = oedepict.OEHighlightStyle_BallAndStick
91
+
92
+ if use_overlay:
93
+ # Overlay highlighting with color iterator
94
+ highlight = oedepict.OEHighlightOverlayByBallAndStick(color)
95
+
96
+ def _overlay_highlighter(disp: oedepict.OE2DMolDisplay):
97
+ oedepict.OEAddHighlightOverlay(disp, highlight, ss.Match(disp.GetMolecule(), True))
98
+
99
+ return _overlay_highlighter
100
+
101
+ else:
102
+ # Traditional highlighting with OEHighlightStyle int
103
+ # For traditional highlighting, we need a single color
104
+ if isinstance(color, oechem.OEColor):
105
+ highlight_color = color
106
+ else:
107
+ # Get first color from iterator for traditional highlighting
108
+ highlight_color = oechem.OELightBlue
109
+ for c in color:
110
+ highlight_color = c
111
+ break
112
+
113
+ def _structure_highlighter(disp: oedepict.OE2DMolDisplay):
114
+ for match in ss.Match(disp.GetMolecule(), True):
115
+ oedepict.OEAddHighlighting(disp, highlight_color, style, match)
116
+
117
+ return _structure_highlighter
118
+
119
+
120
+ def highlight_smarts(
121
+ mol: oechem.OEMolBase,
122
+ smarts: str | Sequence[str],
123
+ color: oechem.OEColor | Sequence[oechem.OEColor] = oechem.OEColor(oechem.OELightBlue),
124
+ style: int | Sequence[int] = oedepict.OEHighlightStyle_Stick,
125
+ opts: oedepict.OE2DMolDisplayOptions | None = None,
126
+ ) -> oedepict.OE2DMolDisplay:
127
+ """Highlight SMARTS patterns in a molecule and return a display object.
128
+
129
+ :param mol: OpenEye molecule to highlight.
130
+ :param smarts: SMARTS pattern or sequence of SMARTS patterns to highlight.
131
+ :param color: Highlight color or sequence of colors. If a single color, it is
132
+ applied to all patterns. If a sequence, must match length of smarts.
133
+ :param style: Highlight style or sequence of styles. If a single style, it is
134
+ applied to all patterns. If a sequence, must match length of smarts.
135
+ :param opts: Display options. If None, default options are used.
136
+ :returns: OE2DMolDisplay object with highlighted substructures.
137
+ :raises ValueError: If color/style sequence length doesn't match smarts length.
138
+
139
+ Example::
140
+
141
+ from openeye import oechem
142
+ import cnotebook
143
+
144
+ mol = oechem.OEGraphMol()
145
+ oechem.OESmilesToMol(mol, "c1ccc2c(c1)nc(n2)N")
146
+
147
+ # Single color/style for all patterns
148
+ disp = cnotebook.highlight_smarts(mol, ["ncn", "c1ccccc1"])
149
+
150
+ # Different colors for each pattern
151
+ disp = cnotebook.highlight_smarts(
152
+ mol,
153
+ ["ncn", "c1ccccc1"],
154
+ color=[oechem.OEColor(oechem.OELightBlue), oechem.OEColor(oechem.OEPink)]
155
+ )
156
+ """
157
+ # Prepare molecule for depiction
158
+ oedepict.OEPrepareDepiction(mol)
159
+
160
+ # Create display options if not provided
161
+ if opts is None:
162
+ opts = oedepict.OE2DMolDisplayOptions()
163
+
164
+ # Create display object
165
+ disp = oedepict.OE2DMolDisplay(mol, opts)
166
+
167
+ # Normalize smarts to a list
168
+ if isinstance(smarts, str):
169
+ smarts = [smarts]
170
+
171
+ n_patterns = len(smarts)
172
+
173
+ # Normalize colors to a list
174
+ if isinstance(color, oechem.OEColor):
175
+ colors = [color] * n_patterns
176
+ else:
177
+ colors = list(color)
178
+ if len(colors) != n_patterns:
179
+ raise ValueError(
180
+ f"Length of color sequence ({len(colors)}) must match "
181
+ f"length of smarts sequence ({n_patterns})"
182
+ )
183
+
184
+ # Normalize styles to a list
185
+ if isinstance(style, int):
186
+ styles = [style] * n_patterns
187
+ else:
188
+ styles = list(style)
189
+ if len(styles) != n_patterns:
190
+ raise ValueError(
191
+ f"Length of style sequence ({len(styles)}) must match "
192
+ f"length of smarts sequence ({n_patterns})"
193
+ )
194
+
195
+ # Highlight all patterns with corresponding color and style
196
+ for pattern, c, s in zip(smarts, colors, styles):
197
+ subs = oechem.OESubSearch(pattern)
198
+ for match in subs.Match(mol, True):
199
+ oedepict.OEAddHighlighting(disp, c, s, match)
200
+
201
+ return disp
@@ -0,0 +1,56 @@
1
+ import logging
2
+ from openeye import oechem, oedepict
3
+ from .render import (
4
+ oemol_to_html,
5
+ oedisp_to_html,
6
+ oeimage_to_html,
7
+ )
8
+
9
+ # Only register iPython formatters if that is present
10
+ try:
11
+ # noinspection PyProtectedMember,PyPackageRequirements
12
+ from IPython import get_ipython
13
+ ipython_present = True
14
+ except ModuleNotFoundError:
15
+ ipython_present = False
16
+
17
+ log = logging.getLogger("cnotebook")
18
+
19
+
20
+ ########################################################################################################################
21
+ # Register iPython formatters
22
+ ########################################################################################################################
23
+
24
+ if ipython_present:
25
+
26
+ def register_ipython_formatters():
27
+ """
28
+ Register formatters for OpenEye types here that can be rendered. Calls to this function are idempotent.
29
+ """
30
+ ipython_instance = get_ipython()
31
+
32
+ if ipython_instance is not None:
33
+ html_formatter = ipython_instance.display_formatter.formatters['text/html']
34
+
35
+ try:
36
+ _ = html_formatter.lookup(oechem.OEMolBase)
37
+ except KeyError:
38
+ html_formatter.for_type(oechem.OEMolBase, oemol_to_html)
39
+
40
+ try:
41
+ _ = html_formatter.lookup(oedepict.OE2DMolDisplay)
42
+ except KeyError:
43
+ html_formatter.for_type(oedepict.OE2DMolDisplay, oedisp_to_html)
44
+
45
+ try:
46
+ _ = html_formatter.lookup(oedepict.OEImage)
47
+ except KeyError:
48
+ html_formatter.for_type(oedepict.OEImage, oeimage_to_html)
49
+ else:
50
+ log.debug("[cnotebook] iPython installed but not in use - cannot register iPython extension")
51
+
52
+ else:
53
+
54
+ # iPython is not present, so we do not register formatters for OpenEye objects
55
+ def register_ipython_formatters():
56
+ pass
@@ -0,0 +1,272 @@
1
+ """
2
+ Marimo integration for CNotebook.
3
+
4
+ This module provides MIME handlers for OpenEye objects and patches Marimo's
5
+ internal table implementation to support molecule rendering with callbacks
6
+ (highlighting, alignment, etc.) in Marimo's built-in DataFrame table component.
7
+ """
8
+ import logging
9
+ import pandas as pd
10
+ from openeye import oechem, oedepict
11
+
12
+ # Import oepandas for dtype checking
13
+ try:
14
+ # noinspection PyUnusedImports
15
+ import oepandas as oepd
16
+ oepandas_available = True
17
+ except ImportError:
18
+ oepandas_available = False
19
+
20
+ # Import oepolars for dtype checking
21
+ try:
22
+ # noinspection PyUnusedImports
23
+ import polars as pl
24
+ # noinspection PyUnusedImports
25
+ import oepolars as oeplr
26
+ oepolars_available = True
27
+ except ImportError:
28
+ oepolars_available = False
29
+
30
+ from .context import cnotebook_context, get_series_context
31
+ from .render import (
32
+ oemol_to_html,
33
+ oedisp_to_html,
34
+ oeimage_to_html,
35
+ oemol_to_disp,
36
+ render_empty_molecule,
37
+ render_invalid_molecule
38
+ )
39
+ from .pandas_ext import render_dataframe
40
+
41
+ log = logging.getLogger("cnotebook")
42
+
43
+
44
+ ########################################################################################################################
45
+ # MIME handlers for individual OpenEye objects
46
+ ########################################################################################################################
47
+
48
+ def _display_mol(self: oechem.OEMolBase):
49
+ ctx = cnotebook_context.get().copy()
50
+ # Allow user's image_format preference (SVG or PNG)
51
+ return "text/html", oemol_to_html(self, ctx=ctx)
52
+
53
+ oechem.OEMolBase._mime_ = _display_mol
54
+
55
+
56
+ def _display_display(self: oedepict.OE2DMolDisplay):
57
+ ctx = cnotebook_context.get().copy()
58
+ # Allow user's image_format preference (SVG or PNG)
59
+ return "text/html", oedisp_to_html(self, ctx=ctx)
60
+
61
+ oedepict.OE2DMolDisplay._mime_ = _display_display
62
+
63
+
64
+ def _display_image(self: oedepict.OEImage):
65
+ ctx = cnotebook_context.get().copy()
66
+ # Allow user's image_format preference (SVG or PNG)
67
+ return "text/html", oeimage_to_html(self, ctx=ctx)
68
+
69
+ oedepict.OEImage._mime_ = _display_image
70
+
71
+
72
+ ########################################################################################################################
73
+ # Formatter factories for mo.ui.table format_mapping
74
+ ########################################################################################################################
75
+
76
+ def _create_molecule_formatter(ctx):
77
+ """
78
+ Create a formatter closure that renders molecules with callbacks applied.
79
+
80
+ :param ctx: CNotebookContext with callbacks (e.g., highlighting)
81
+ :return: Formatter function for use in mo.ui.table format_mapping
82
+ """
83
+ def formatter(mol):
84
+ if mol is None:
85
+ return ""
86
+
87
+ if not isinstance(mol, oechem.OEMolBase):
88
+ return str(mol)
89
+
90
+ # Handle invalid molecules
91
+ if not mol.IsValid():
92
+ return render_invalid_molecule(ctx=ctx)
93
+
94
+ # Handle empty molecules
95
+ if mol.NumAtoms() == 0:
96
+ return render_empty_molecule(ctx=ctx)
97
+
98
+ # Create display object
99
+ disp = oemol_to_disp(mol, ctx=ctx)
100
+
101
+ # Apply callbacks (highlighting, etc.)
102
+ if ctx.callbacks:
103
+ for callback in ctx.callbacks:
104
+ callback(disp)
105
+
106
+ # Return display object
107
+ return disp
108
+
109
+ return formatter
110
+
111
+
112
+ def _create_display_formatter(ctx):
113
+ """
114
+ Create a formatter closure that renders OE2DMolDisplay objects.
115
+
116
+ :param ctx: CNotebookContext for rendering options
117
+ :return: Formatter function for use in mo.ui.table format_mapping
118
+ """
119
+ def formatter(disp):
120
+ if disp is None:
121
+ return ""
122
+
123
+ if not isinstance(disp, oedepict.OE2DMolDisplay):
124
+ return str(disp)
125
+
126
+ if not disp.IsValid():
127
+ return str(disp)
128
+
129
+ # Copy the display to avoid modifying the original
130
+ disp_copy = oedepict.OE2DMolDisplay(disp)
131
+
132
+ # Apply callbacks if any
133
+ if ctx.callbacks:
134
+ for callback in ctx.callbacks:
135
+ callback(disp_copy)
136
+
137
+ return disp_copy
138
+
139
+ return formatter
140
+
141
+
142
+ ########################################################################################################################
143
+ # Marimo DataFrame formatter registration
144
+ #
145
+ # This registers a custom formatter with Marimo's OPINIONATED_FORMATTERS registry
146
+ # to handle DataFrames containing molecule columns. The formatter:
147
+ # - Detects MoleculeDtype and DisplayDtype columns
148
+ # - Creates format_mapping entries that apply callbacks (highlighting, alignment, etc.)
149
+ # - Returns OE2DMolDisplay objects which Marimo renders via their _mime_() method
150
+ ########################################################################################################################
151
+
152
+ try:
153
+ import marimo as mo
154
+ # noinspection PyProtectedMember,PyUnusedImports
155
+ from marimo._output.formatting import OPINIONATED_FORMATTERS
156
+ # noinspection PyProtectedMember,PyUnusedImports
157
+ from marimo._plugins.ui._impl.table import table
158
+
159
+
160
+ # 1. Define the custom formatting logic
161
+ def marimo_pandas_formatter(df: pd.DataFrame):
162
+ """
163
+ Monkey patch the Marimo DataFrame formatter
164
+ """
165
+ format_mapping = {}
166
+
167
+ # Check for MoleculeDtype / DisplayDtype (OEPandas specific)
168
+ if oepandas_available:
169
+ for col in df.columns:
170
+ dtype = df[col].dtype
171
+
172
+ if isinstance(dtype, oepd.MoleculeDtype):
173
+ arr = df[col].array
174
+ ctx = get_series_context(arr.metadata).copy()
175
+ format_mapping[col] = _create_molecule_formatter(ctx)
176
+
177
+ elif isinstance(dtype, oepd.DisplayDtype):
178
+ arr = df[col].array
179
+ ctx = get_series_context(arr.metadata).copy()
180
+ format_mapping[col] = _create_display_formatter(ctx)
181
+
182
+ # Return a Marimo table with our custom mapping
183
+ # noinspection PyProtectedMember,PyTypeChecker
184
+ return table(df, selection=None, format_mapping=format_mapping, pagination=True)._mime_()
185
+
186
+ # 2. Inject into the Registry
187
+ def install_marimo_pandas_formatter():
188
+ # Check if we've already installed it to avoid duplicates
189
+ for typ, func in OPINIONATED_FORMATTERS.formatters.items():
190
+ if typ is pd.DataFrame and func.__name__ == "marimo_pandas_formatter":
191
+ return # Already installed
192
+
193
+ OPINIONATED_FORMATTERS.formatters[pd.DataFrame] = marimo_pandas_formatter
194
+
195
+ # Do the installation
196
+ install_marimo_pandas_formatter()
197
+
198
+ def marimo_polars_formatter(df: pl.DataFrame):
199
+ """
200
+ Marimo DataFrame formatter for Polars DataFrames with molecule columns.
201
+ """
202
+ format_mapping = {}
203
+
204
+ # Check for MoleculeType / DisplayType (OEPolars specific)
205
+ if oepolars_available:
206
+ for col in df.columns:
207
+ dtype = df.schema[col]
208
+
209
+ if isinstance(dtype, oeplr.MoleculeType):
210
+ series = df.get_column(col)
211
+ metadata = series.chem.metadata if hasattr(series, 'chem') else {}
212
+ ctx = get_series_context(metadata).copy()
213
+ format_mapping[col] = _create_molecule_formatter(ctx)
214
+
215
+ elif isinstance(dtype, oeplr.DisplayType):
216
+ series = df.get_column(col)
217
+ metadata = series.chem.metadata if hasattr(series, 'chem') else {}
218
+ ctx = get_series_context(metadata).copy()
219
+ format_mapping[col] = _create_display_formatter(ctx)
220
+
221
+ # Return a Marimo table with our custom mapping
222
+ # noinspection PyProtectedMember,PyTypeChecker
223
+ return table(df, selection=None, format_mapping=format_mapping, pagination=True)._mime_()
224
+
225
+ def install_marimo_polars_formatter():
226
+ """Install the Polars DataFrame formatter if polars is available."""
227
+ if not oepolars_available:
228
+ return
229
+
230
+ # Check if we've already installed it to avoid duplicates
231
+ for typ, func in OPINIONATED_FORMATTERS.formatters.items():
232
+ if typ is pl.DataFrame and func.__name__ == "marimo_polars_formatter":
233
+ return # Already installed
234
+
235
+ OPINIONATED_FORMATTERS.formatters[pl.DataFrame] = marimo_polars_formatter
236
+
237
+ if oepolars_available:
238
+ install_marimo_polars_formatter()
239
+
240
+ except (ImportError, AttributeError) as ex:
241
+ # Marimo not installed or API changed - skip formatter registration
242
+ log.debug(f'Marimo formatter registration skipped: {ex}')
243
+
244
+
245
+ ########################################################################################################################
246
+ # Fallback DataFrame MIME handler for non-Marimo contexts
247
+ ########################################################################################################################
248
+
249
+ def _display_dataframe(self: pd.DataFrame):
250
+ """
251
+ Fallback MIME hook for Pandas DataFrames in non-Marimo contexts.
252
+
253
+ In Marimo, the internal table patch handles DataFrame display.
254
+ This is used for static exports or other tools that check _mime_.
255
+ """
256
+ return "text/html", render_dataframe(df=self, formatters=None, col_space=None)
257
+
258
+ pd.DataFrame._mime_ = _display_dataframe
259
+
260
+ if oepolars_available:
261
+ from .polars_ext import render_polars_dataframe
262
+
263
+ def _display_polars_dataframe(self: pl.DataFrame):
264
+ """
265
+ Fallback MIME hook for Polars DataFrames in non-Marimo contexts.
266
+
267
+ In Marimo, the internal table patch handles DataFrame display.
268
+ This is used for static exports or other tools that check _mime_.
269
+ """
270
+ return "text/html", render_polars_dataframe(df=self, formatters=None, col_space=None)
271
+
272
+ pl.DataFrame._mime_ = _display_polars_dataframe