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/context.py ADDED
@@ -0,0 +1,523 @@
1
+ import logging
2
+ from enum import Enum
3
+ from functools import wraps
4
+ from typing import Callable, Any, Literal, Generic, TypeVar
5
+ from collections.abc import Iterable
6
+ # noinspection PyPackageRequirements
7
+ from contextvars import ContextVar
8
+ from openeye import oechem, oedepict
9
+
10
+ log = logging.getLogger("cnotebook")
11
+
12
+
13
+ class _Deferred(Enum):
14
+ """
15
+ Sentinel to defer to global context
16
+ This uses the approach suggested by Guido van Rossum
17
+ https://github.com/python/typing/issues/236#issuecomment-227180301
18
+ """
19
+ value = 0
20
+
21
+
22
+ DEFERRED = _Deferred.value
23
+
24
+
25
+ ########################################################################################################################
26
+ # Global Rendering Context
27
+ ########################################################################################################################
28
+
29
+ T = TypeVar('T')
30
+
31
+
32
+ class DeferredValue(Generic[T]):
33
+ """A value that can be deferred to the global CNotebook context.
34
+
35
+ When a value is set to ``DEFERRED``, accessing it will look up the
36
+ corresponding attribute from the global context instead.
37
+ """
38
+
39
+ def __init__(self, name: str, value: T | _Deferred):
40
+ """Create a deferred value.
41
+
42
+ :param name: Attribute name to look up in global context when deferred.
43
+ :param value: Initial value, or ``DEFERRED`` to use global context.
44
+ """
45
+ self.name = name
46
+ self._value = value
47
+ self._initial_value = value
48
+
49
+ def reset(self):
50
+ """
51
+ Reset this deferred value to the initial value (when the object was created)
52
+ """
53
+ self._value = self._initial_value
54
+
55
+ @property
56
+ def is_deferred(self) -> bool:
57
+ """
58
+ Check if the value is deferred to the global context
59
+ :return: True if the value is deferred to the global
60
+ """
61
+ return self._value is DEFERRED
62
+
63
+ def get(self) -> T:
64
+ """
65
+ If the value is DEFERRED then we defer to the local context
66
+ :return: Value
67
+ """
68
+ if self.is_deferred:
69
+ ctx = cnotebook_context.get()
70
+ if not hasattr(ctx, self.name):
71
+ raise AttributeError(f"Global context missing attribute '{self.name}'")
72
+ return getattr(ctx, self.name)
73
+ return self._value
74
+
75
+ def set(self, value: T | _Deferred) -> None:
76
+ """
77
+ Set a value (we never set the global context)
78
+ :param value: Value to set
79
+ """
80
+ self._value = value
81
+
82
+ def __str__(self):
83
+ return str(self.get())
84
+
85
+ def __repr__(self):
86
+ return repr(self.get())
87
+
88
+
89
+ class CNotebookContext:
90
+ """Context for rendering OpenEye objects in IPython/Jupyter environments.
91
+
92
+ This context controls how molecules and other OpenEye objects are rendered
93
+ as images. It supports deferred values that fall back to a global context.
94
+
95
+ :cvar supported_mime_types: Mapping of image formats to MIME types.
96
+ """
97
+
98
+ # Supported image formats and their MIME types for rendering
99
+ supported_mime_types = {
100
+ 'png': 'image/png',
101
+ 'svg': 'image/svg+xml'
102
+ }
103
+
104
+ def __init__(
105
+ self,
106
+ *,
107
+ width: float | _Deferred = 0,
108
+ height: float | _Deferred = 0,
109
+ min_width: float | None | _Deferred = 200.0,
110
+ min_height: float | None | _Deferred = 200.0,
111
+ max_width: float | None | _Deferred = None,
112
+ max_height: float | None | _Deferred = None,
113
+ structure_scale: float | _Deferred = oedepict.OEScale_Default * 0.6,
114
+ atom_label_font_scale: float | _Deferred = 1.0,
115
+ title_font_scale: float | _Deferred = 1.0,
116
+ image_format: str | _Deferred = "png",
117
+ bond_width_scaling: bool | _Deferred = False,
118
+ callbacks: Iterable[Callable[[oedepict.OE2DMolDisplay], None]] | None | _Deferred = None,
119
+ scope: Literal["local", "global"] = "global",
120
+ title: bool = True
121
+ ):
122
+ """Create a rendering context.
123
+
124
+ :param width: Image width in pixels. If 0, determined by structure scale.
125
+ :param height: Image height in pixels. If 0, determined by structure scale.
126
+ :param min_width: Minimum image width in pixels (prevents tiny images).
127
+ :param min_height: Minimum image height in pixels (prevents tiny images).
128
+ :param max_width: Maximum image width in pixels, or None for no limit.
129
+ :param max_height: Maximum image height in pixels, or None for no limit.
130
+ :param structure_scale: Scale factor for structure rendering.
131
+ :param atom_label_font_scale: Scale factor for atom labels (0.5 to 2.0).
132
+ :param title_font_scale: Scale factor for title font (0.5 to 2.0).
133
+ :param image_format: Output image format ("png" or "svg").
134
+ :param bond_width_scaling: Whether to scale bond widths with structure scale.
135
+ :param callbacks: List of callables to invoke on OE2DMolDisplay before rendering.
136
+ Each callback receives the display object and can modify it.
137
+ :param scope: Context scope - "local" defers unset values to global context,
138
+ "global" uses defaults directly.
139
+ :param title: Whether to display molecule titles.
140
+ """
141
+ self._width = DeferredValue[float]("width", width)
142
+ self._height = DeferredValue[float]("height", height)
143
+ self._min_height = DeferredValue[float | None]("min_height", min_height)
144
+ self._min_width = DeferredValue[float | None]("min_width", min_width)
145
+ self._max_width = DeferredValue[float | None]("max_width", max_width)
146
+ self._max_height = DeferredValue[float | None]("max_height", max_height)
147
+ self._structure_scale = DeferredValue[float]("structure_scale", structure_scale)
148
+ self._atom_label_font_scale = DeferredValue[float | None]("atom_label_font_scale", atom_label_font_scale)
149
+ self._title_font_scale = DeferredValue[float]("title_font_scale", title_font_scale)
150
+ self._image_format = DeferredValue[str]("image_format", image_format)
151
+ self._bond_width_scaling = DeferredValue[bool]("bond_width_scaling", bond_width_scaling)
152
+ self._title = DeferredValue[bool]("title", title)
153
+ self._scope = scope
154
+
155
+ # Set the callbacks (and do some type checking)
156
+ if callbacks is None:
157
+ self._callbacks = DeferredValue[list[Callable[[oedepict.OE2DMolDisplay], None]]](
158
+ "callbacks",
159
+ DEFERRED if scope == "local" else []
160
+ )
161
+ elif isinstance(callbacks, Iterable):
162
+ self._callbacks = DeferredValue[list[Callable[[oedepict.OE2DMolDisplay], None]]](
163
+ "callbacks",
164
+ list(callbacks)
165
+ )
166
+ elif callbacks is DEFERRED:
167
+ self._callbacks = DeferredValue[list[Callable[[oedepict.OE2DMolDisplay], None]]](
168
+ "callbacks",
169
+ DEFERRED
170
+ )
171
+ else:
172
+ raise TypeError(f'Invalid type for display callbacks: {type(callbacks).__name__}')
173
+
174
+ @property
175
+ def width(self) -> float:
176
+ return self._width.get()
177
+
178
+ @width.setter
179
+ def width(self, value: float) -> None:
180
+ if self.max_width is not None and value > self.max_width:
181
+ log.warning(f'Width exceeds max_width: {value} > {self.max_width}')
182
+
183
+ self._width.set(value)
184
+
185
+ @property
186
+ def height(self) -> float:
187
+ return self._height.get()
188
+
189
+ @height.setter
190
+ def height(self, value: float) -> None:
191
+ if self.max_height is not None and value > self.max_height:
192
+ log.warning(f'Height exceeds max_height: {value} > {self.max_height}')
193
+
194
+ self._height.set(value)
195
+
196
+ @property
197
+ def min_width(self) -> float | None:
198
+ return self._min_width.get()
199
+
200
+ @min_width.setter
201
+ def min_width(self, value: float | None) -> None:
202
+ self._min_width.set(value)
203
+
204
+ @property
205
+ def max_width(self) -> float | None:
206
+ return self._max_width.get()
207
+
208
+ @max_width.setter
209
+ def max_width(self, value: float | None):
210
+ if value is not None and self.width > value:
211
+ log.warning(f'Current width exceeds max_width: {self.width} > {value}')
212
+
213
+ self._max_width.set(value)
214
+
215
+ @property
216
+ def max_height(self) -> float | None:
217
+ return self._max_height.get()
218
+
219
+ @max_height.setter
220
+ def max_height(self, value: float | None):
221
+ if value is not None and self.height > value:
222
+ log.warning(f'Current height exceeds max_height: {self.height} > {value}')
223
+
224
+ self._max_height.set(value)
225
+
226
+ @property
227
+ def min_height(self) -> float | None:
228
+ return self._min_height.get()
229
+
230
+ @min_height.setter
231
+ def min_height(self, value: float | None) -> None:
232
+ self._min_height.set(value)
233
+
234
+ @property
235
+ def structure_scale(self) -> float:
236
+ return self._structure_scale.get()
237
+
238
+ @structure_scale.setter
239
+ def structure_scale(self, value: float) -> None:
240
+ self._structure_scale.set(value)
241
+
242
+ @property
243
+ def atom_label_font_scale(self) -> float:
244
+ return self._atom_label_font_scale.get()
245
+
246
+ @atom_label_font_scale.setter
247
+ def atom_label_font_scale(self, value: float) -> None:
248
+ self._atom_label_font_scale.set(value)
249
+
250
+ @property
251
+ def title_font_scale(self) -> float:
252
+ return self._title_font_scale.get()
253
+
254
+ @title_font_scale.setter
255
+ def title_font_scale(self, value: float) -> None:
256
+ self._title_font_scale.set(value)
257
+
258
+ @property
259
+ def bond_width_scaling(self) -> bool:
260
+ return self._bond_width_scaling.get()
261
+
262
+ @bond_width_scaling.setter
263
+ def bond_width_scaling(self, value: bool) -> None:
264
+ self._bond_width_scaling.set(value)
265
+
266
+ @property
267
+ def image_format(self) -> str:
268
+ return self._image_format.get()
269
+
270
+ @image_format.setter
271
+ def image_format(self, value: str) -> None:
272
+ self._image_format.set(value)
273
+
274
+ @property
275
+ def scope(self) -> Literal["global", "local"]:
276
+ return self._scope
277
+
278
+ @property
279
+ def callbacks(self) -> tuple[Callable[[oedepict.OE2DMolDisplay], None], ...]:
280
+ # noinspection PyTypeChecker
281
+ return tuple(self._callbacks.get())
282
+
283
+ def reset_callbacks(self) -> None:
284
+ self._callbacks.reset()
285
+
286
+ @property
287
+ def title(self) -> bool:
288
+ return self._title.get()
289
+
290
+ @title.setter
291
+ def title(self, value: bool) -> None:
292
+ self._title.set(value)
293
+
294
+ @property
295
+ def image_mime_type(self) -> str:
296
+ mime_type = self.supported_mime_types.get(self.image_format, None)
297
+ if mime_type is None:
298
+ raise KeyError(f'No MIME type registered for image format {self.image_format}')
299
+ return mime_type
300
+
301
+ @property
302
+ def display_options(self) -> oedepict.OE2DMolDisplayOptions:
303
+ opts = oedepict.OE2DMolDisplayOptions()
304
+ opts.SetHeight(self.height)
305
+ opts.SetWidth(self.width)
306
+ opts.SetScale(self.structure_scale)
307
+ opts.SetTitleFontScale(self.title_font_scale)
308
+ opts.SetBondWidthScaling(self.bond_width_scaling)
309
+ opts.SetAtomLabelFontScale(self.atom_label_font_scale)
310
+
311
+ if not self.title:
312
+ opts.SetTitleLocation(oedepict.OETitleLocation_Hidden)
313
+
314
+ return opts
315
+
316
+ def add_callback(self, callback: Callable[[oedepict.OE2DMolDisplay], None]):
317
+ """
318
+ Add a callback that modifies an oedepict.OE2DMolDisplay to the current context
319
+ :param callback: Callback to add
320
+ """
321
+ if self._callbacks.is_deferred:
322
+ self._callbacks.set([])
323
+ self._callbacks.get().append(callback)
324
+
325
+ def create_molecule_display(
326
+ self,
327
+ mol: oechem.OEMolBase,
328
+ min_height: int | None = None,
329
+ min_width: int | None = None
330
+ ) -> oedepict.OE2DMolDisplay:
331
+ """
332
+ Create a molecule display that enforces minimum image height and width
333
+ :param mol: Molecule
334
+ :param min_height: Minimum image height
335
+ :param min_width: Minimum image width
336
+ :return: Molecule display
337
+ """
338
+ disp = oedepict.OE2DMolDisplay(mol, self.display_options)
339
+
340
+ # If the image was too small, and we're not enforcing a specific image size
341
+ if ((self.width == 0.0 and self.min_width is not None and disp.GetWidth() < self.min_width) or
342
+ (self.height == 0.0 and self.min_height is not None and disp.GetHeight() < self.min_height)):
343
+
344
+ min_height = min_height or self.min_height
345
+ min_width = min_width or self.min_width
346
+
347
+ # Create a new display context
348
+ new_ctx = self.copy()
349
+
350
+ # If width was not enforced already, then enforce the minimum width
351
+ if self.width == 0.0 and min_width is not None:
352
+ new_ctx.width = min_width if disp.GetWidth() < self.min_width else 0.0
353
+
354
+ # If height was not enforced already, then enforce the minimum height
355
+ if self.height == 0.0 and min_height is not None:
356
+ new_ctx.height = min_height if disp.GetHeight() < self.min_height else 0.0
357
+
358
+ # Create the display object
359
+ disp = oedepict.OE2DMolDisplay(mol, new_ctx.display_options)
360
+
361
+ # We need to scale down the image if it exceeds the max_width or max_height
362
+ if ((self.max_width is not None and disp.GetWidth() > self.max_width) or
363
+ (self.max_height is not None and disp.GetHeight() > self.max_height)):
364
+
365
+ # Create a new display context
366
+ new_ctx = self.copy()
367
+
368
+ # Set whatever parameter exceeded the maximum and let the other scale
369
+ if self.max_width is not None and disp.GetWidth() > self.max_width:
370
+ new_ctx.width = self.max_width
371
+ new_ctx.height = 0
372
+
373
+ elif self.max_height is not None and disp.GetHeight() > self.max_height:
374
+ new_ctx.width = 0
375
+ new_ctx.height = self.max_height
376
+
377
+ new_ctx.structure_scale = oedepict.OEScale_AutoScale
378
+
379
+ # Create the display object
380
+ disp = oedepict.OE2DMolDisplay(mol, new_ctx.display_options)
381
+
382
+ # TODO: Check the display again and see if we've exceeded max width or height again and potentially
383
+ # constrain both width and height
384
+
385
+ return disp
386
+
387
+ def reset(self) -> None:
388
+ """
389
+ Reset the rendering context to default values
390
+ """
391
+ self._width.reset()
392
+ self._height.reset()
393
+ self._min_width.reset()
394
+ self._min_height.reset()
395
+ self._max_width.reset()
396
+ self._max_height.reset()
397
+ self._structure_scale.reset()
398
+ self._title_font_scale.reset()
399
+ self._image_format.reset()
400
+ self._bond_width_scaling.reset()
401
+ self._title.reset()
402
+ self._callbacks.reset()
403
+
404
+ def copy(self) -> 'CNotebookContext':
405
+ """
406
+ Copy this object
407
+ :return: Copy of the object
408
+ """
409
+ return CNotebookContext(
410
+ width=self.width,
411
+ height=self.height,
412
+ min_width=self.min_width,
413
+ min_height=self.min_height,
414
+ max_width=self.max_width,
415
+ max_height=self.max_height,
416
+ structure_scale=self.structure_scale,
417
+ title_font_scale=self.title_font_scale,
418
+ title=self.title,
419
+ image_format=self.image_format,
420
+ bond_width_scaling=self.bond_width_scaling,
421
+ callbacks=self.callbacks,
422
+ )
423
+
424
+
425
+ ########################################################################################################################
426
+ # !!!!!!!!! Global render context !!!!!!!!!
427
+ ########################################################################################################################
428
+
429
+ # Create our global render context
430
+ cnotebook_context: ContextVar[CNotebookContext] = ContextVar("cnotebook_context", default=CNotebookContext())
431
+
432
+
433
+ ########################################################################################################################
434
+ # Decorator to automatically pass global rendering context
435
+ ########################################################################################################################
436
+
437
+ def pass_cnotebook_context(func):
438
+ """
439
+ Decorator that passes a copy of the current molecule render context
440
+ :param func: Function to decorate
441
+ :return: Decorated function
442
+ """
443
+ # TODO: Inspect func signature and check that it uses the ctx keyword
444
+ @wraps(func)
445
+ def call_with_render_context(*args, **kwargs):
446
+
447
+ # If we happened to be called with a custom molecule render context
448
+ if "ctx" in kwargs:
449
+ ctx = kwargs.pop("ctx")
450
+
451
+ if ctx is None:
452
+ ctx = cnotebook_context.get().copy()
453
+
454
+ # Other things are not OK
455
+ elif not isinstance(ctx, CNotebookContext):
456
+ raise TypeError("Received object of type type {} for OERenderContext (ctx) when calling {}".format(
457
+ type(ctx).__name__,
458
+ func.__name__
459
+ ))
460
+ else:
461
+ ctx = cnotebook_context.get().copy()
462
+
463
+ # Call the function
464
+ return func(*args, **kwargs, ctx=ctx)
465
+ return call_with_render_context
466
+
467
+
468
+ ########################################################################################################################
469
+ # Local rendering context
470
+ ########################################################################################################################
471
+
472
+ def create_local_context(
473
+ width: float = DEFERRED,
474
+ height: float = DEFERRED,
475
+ min_width: float = DEFERRED,
476
+ min_height: float = DEFERRED,
477
+ max_width: float = DEFERRED,
478
+ max_height: float = DEFERRED,
479
+ structure_scale: int = DEFERRED,
480
+ title_font_scale: float = DEFERRED,
481
+ image_format: str = DEFERRED,
482
+ bond_width_scaling: bool = DEFERRED,
483
+ callbacks: Iterable[Callable[[oedepict.OE2DMolDisplay], None]] | None = DEFERRED
484
+ ) -> CNotebookContext:
485
+ return CNotebookContext(
486
+ width=width,
487
+ height=height,
488
+ min_width=min_width,
489
+ min_height=min_height,
490
+ max_width=max_width,
491
+ max_height=max_height,
492
+ structure_scale=structure_scale,
493
+ title_font_scale=title_font_scale,
494
+ image_format=image_format,
495
+ bond_width_scaling=bond_width_scaling,
496
+ callbacks=callbacks,
497
+ scope="local"
498
+ )
499
+
500
+
501
+ def get_series_context(metadata: dict[Any, Any], save: bool = False) -> CNotebookContext:
502
+ """
503
+ Get the series context, else wrap the global context into a series context. This looks for the key "cnotebook" in
504
+ the metadta.
505
+ :param metadata: Series metadata
506
+ :param save: Whether to save any new metadata object that we create
507
+ :return: Series rendering context
508
+ """
509
+ ctx = metadata.get("cnotebook", create_local_context())
510
+
511
+ # Make sure context is a valid object
512
+ if not isinstance(ctx, CNotebookContext):
513
+ log.warning(
514
+ "Replacing unexpected object of type %s for metadata key 'cnotebook' with a CNotebookLocalContext",
515
+ type(ctx).__name__
516
+ )
517
+
518
+ ctx = create_local_context()
519
+
520
+ if save:
521
+ metadata["cnotebook"] = ctx
522
+
523
+ return ctx
@@ -0,0 +1,55 @@
1
+ """Interactive molecule grid for Jupyter and Marimo notebooks."""
2
+
3
+ from cnotebook.grid.grid import MolGrid
4
+ from typing import Iterable, List, Optional, Union
5
+
6
+
7
+ def molgrid(
8
+ mols: Iterable,
9
+ *,
10
+ title_field: Optional[str] = "Title",
11
+ tooltip_fields: Optional[List[str]] = None,
12
+ n_items_per_page: int = 24,
13
+ width: int = 200,
14
+ height: int = 200,
15
+ image_format: str = "svg",
16
+ select: bool = True,
17
+ information: bool = True,
18
+ data: Optional[Union[str, List[str]]] = None,
19
+ search_fields: Optional[List[str]] = None,
20
+ name: Optional[str] = None,
21
+ ) -> MolGrid:
22
+ """Create an interactive molecule grid.
23
+
24
+ :param mols: Iterable of OpenEye molecule objects.
25
+ :param title_field: Molecule field for title (None to hide).
26
+ :param tooltip_fields: List of fields for tooltip.
27
+ :param n_items_per_page: Molecules per page.
28
+ :param width: Image width in pixels (default 200).
29
+ :param height: Image height in pixels (default 200).
30
+ :param image_format: "svg" or "png" (default "svg").
31
+ :param select: Enable selection checkboxes.
32
+ :param information: Enable info button with hover tooltip.
33
+ :param data: Column(s) to display in info tooltip. If None, auto-detects
34
+ simple types (string, int, float) from DataFrame.
35
+ :param search_fields: Fields for text search.
36
+ :param name: Grid identifier.
37
+ :returns: MolGrid instance.
38
+ """
39
+ return MolGrid(
40
+ mols,
41
+ title_field=title_field,
42
+ tooltip_fields=tooltip_fields,
43
+ n_items_per_page=n_items_per_page,
44
+ width=width,
45
+ height=height,
46
+ image_format=image_format,
47
+ select=select,
48
+ information=information,
49
+ data=data,
50
+ search_fields=search_fields,
51
+ name=name,
52
+ )
53
+
54
+
55
+ __all__ = ["MolGrid", "molgrid"]