cnotebook 1.0.1__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,491 @@
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
+ """
34
+ Value that can be deferred to the global CNotebook context
35
+ """
36
+ def __init__(self, name: str, value: T | _Deferred):
37
+ self.name = name
38
+ self._value = value
39
+ self._initial_value = value
40
+
41
+ def reset(self):
42
+ """
43
+ Reset this deferred value to the initial value (when the object was created)
44
+ """
45
+ self._value = self._initial_value
46
+
47
+ @property
48
+ def is_deferred(self) -> bool:
49
+ """
50
+ Check if the value is deferred to the global context
51
+ :return: True if the value is deferred to the global
52
+ """
53
+ return self._value is DEFERRED
54
+
55
+ def get(self) -> T:
56
+ """
57
+ If the value is DEFERRED then we defer to the local context
58
+ :return: Value
59
+ """
60
+ if self.is_deferred:
61
+ ctx = cnotebook_context.get()
62
+ if not hasattr(ctx, self.name):
63
+ raise AttributeError(f"Global context missing attribute '{self.name}'")
64
+ return getattr(ctx, self.name)
65
+ return self._value
66
+
67
+ def set(self, value: T | _Deferred) -> None:
68
+ """
69
+ Set a value (we never set the global context)
70
+ :param value: Value to set
71
+ """
72
+ self._value = value
73
+
74
+ def __str__(self):
75
+ return str(self.get())
76
+
77
+ def __repr__(self):
78
+ return repr(self.get())
79
+
80
+
81
+ class CNotebookContext:
82
+ """
83
+ Context in which to render OpenEye objects within IPython
84
+ """
85
+ # Supported image formats and their MIME types for rendering
86
+ supported_mime_types = {
87
+ 'png': 'image/png',
88
+ 'svg': 'image/svg+xml'
89
+ }
90
+
91
+ def __init__(
92
+ self,
93
+ *,
94
+ width: float | _Deferred = 0,
95
+ height: float | _Deferred = 0,
96
+ min_width: float | None | _Deferred = 200.0,
97
+ min_height: float | None | _Deferred = 200.0,
98
+ max_width: float | None | _Deferred = None,
99
+ max_height: float | None | _Deferred = None,
100
+ structure_scale: int | _Deferred = oedepict.OEScale_Default * 0.9,
101
+ title_font_scale: float | _Deferred = 1.0,
102
+ image_format: str | _Deferred = "png",
103
+ bond_width_scaling: bool | _Deferred = False,
104
+ callbacks: Iterable[Callable[[oedepict.OE2DMolDisplay], None]] | None | _Deferred = None,
105
+ scope: Literal["local", "global"] = "global",
106
+ title: bool = True
107
+ ):
108
+ """
109
+ Create the render context
110
+ :param width: Image width (default of None means it is determined by the structure scale)
111
+ :param height: Image height (default of None means it is determined by the structure scale)
112
+ :param min_width: Minimum image width (prevents tiny images)
113
+ :param min_height: Minimum image height (prevents tiny images)
114
+ :param structure_scale: Structure scale
115
+ :param title_font_scale: Font scaling (valid is 0.5 to 2.0)
116
+ :param image_format: Image format
117
+ :param bond_width_scaling: Bond width scaling
118
+ """
119
+ self._width = DeferredValue[float]("width", width)
120
+ self._height = DeferredValue[float]("height", height)
121
+ self._min_height = DeferredValue[float | None]("min_height", min_height)
122
+ self._min_width = DeferredValue[float | None]("min_width", min_width)
123
+ self._max_width = DeferredValue[float | None]("max_width", max_width)
124
+ self._max_height = DeferredValue[float | None]("max_height", max_height)
125
+ self._structure_scale = DeferredValue[int]("structure_scale", structure_scale)
126
+ self._title_font_scale = DeferredValue[float]("title_font_scale", title_font_scale)
127
+ self._image_format = DeferredValue[str]("image_format", image_format)
128
+ self._bond_width_scaling = DeferredValue[bool]("bond_width_scaling", bond_width_scaling)
129
+ self._title = DeferredValue[bool]("title", title)
130
+ self._scope = scope
131
+
132
+ # Set the callbacks (and do some type checking)
133
+ if callbacks is None:
134
+ self._callbacks = DeferredValue[list[Callable[[oedepict.OE2DMolDisplay], None]]](
135
+ "callbacks",
136
+ DEFERRED if scope == "local" else []
137
+ )
138
+ elif isinstance(callbacks, Iterable):
139
+ self._callbacks = DeferredValue[list[Callable[[oedepict.OE2DMolDisplay], None]]](
140
+ "callbacks",
141
+ list(callbacks)
142
+ )
143
+ elif callbacks is DEFERRED:
144
+ self._callbacks = DeferredValue[list[Callable[[oedepict.OE2DMolDisplay], None]]](
145
+ "callbacks",
146
+ DEFERRED
147
+ )
148
+ else:
149
+ raise TypeError(f'Invalid type for display callbacks: {type(callbacks).__name__}')
150
+
151
+ @property
152
+ def width(self) -> float:
153
+ return self._width.get()
154
+
155
+ @width.setter
156
+ def width(self, value: float) -> None:
157
+ if self.max_width is not None and value > self.max_width:
158
+ log.warning(f'Width exceeds max_width: {value} > {self.max_width}')
159
+
160
+ self._width.set(value)
161
+
162
+ @property
163
+ def height(self) -> float:
164
+ return self._height.get()
165
+
166
+ @height.setter
167
+ def height(self, value: float) -> None:
168
+ if self.max_height is not None and value > self.max_height:
169
+ log.warning(f'Height exceeds max_height: {value} > {self.max_height}')
170
+
171
+ self._height.set(value)
172
+
173
+ @property
174
+ def min_width(self) -> float | None:
175
+ return self._min_width.get()
176
+
177
+ @min_width.setter
178
+ def min_width(self, value: float | None) -> None:
179
+ self._min_width.set(value)
180
+
181
+ @property
182
+ def max_width(self) -> float | None:
183
+ return self._max_width.get()
184
+
185
+ @max_width.setter
186
+ def max_width(self, value: float | None):
187
+ if value is not None and self.width > value:
188
+ log.warning(f'Current width exceeds max_width: {self.width} > {value}')
189
+
190
+ self._max_width.set(value)
191
+
192
+ @property
193
+ def max_height(self) -> float | None:
194
+ return self._max_height.get()
195
+
196
+ @max_height.setter
197
+ def max_height(self, value: float | None):
198
+ if value is not None and self.height > value:
199
+ log.warning(f'Current height exceeds max_height: {self.height} > {value}')
200
+
201
+ self._max_height.set(value)
202
+
203
+ @property
204
+ def min_height(self) -> float | None:
205
+ return self._min_height.get()
206
+
207
+ @min_height.setter
208
+ def min_height(self, value: float | None) -> None:
209
+ self._min_height.set(value)
210
+
211
+ @property
212
+ def structure_scale(self) -> int:
213
+ return self._structure_scale.get()
214
+
215
+ @structure_scale.setter
216
+ def structure_scale(self, value: int) -> None:
217
+ self._structure_scale.set(value)
218
+
219
+ @property
220
+ def title_font_scale(self) -> float:
221
+ return self._title_font_scale.get()
222
+
223
+ @title_font_scale.setter
224
+ def title_font_scale(self, value: float) -> None:
225
+ self._title_font_scale.set(value)
226
+
227
+ @property
228
+ def bond_width_scaling(self) -> bool:
229
+ return self._bond_width_scaling.get()
230
+
231
+ @bond_width_scaling.setter
232
+ def bond_width_scaling(self, value: bool) -> None:
233
+ self._bond_width_scaling.set(value)
234
+
235
+ @property
236
+ def image_format(self) -> str:
237
+ return self._image_format.get()
238
+
239
+ @image_format.setter
240
+ def image_format(self, value: str) -> None:
241
+ self._image_format.set(value)
242
+
243
+ @property
244
+ def scope(self) -> Literal["global", "local"]:
245
+ return self._scope
246
+
247
+ @property
248
+ def callbacks(self) -> tuple[Callable[[oedepict.OE2DMolDisplay], None], ...]:
249
+ # noinspection PyTypeChecker
250
+ return tuple(self._callbacks.get())
251
+
252
+ def reset_callbacks(self) -> None:
253
+ self._callbacks.reset()
254
+
255
+ @property
256
+ def title(self) -> bool:
257
+ return self._title.get()
258
+
259
+ @title.setter
260
+ def title(self, value: bool) -> None:
261
+ self._title.set(value)
262
+
263
+ @property
264
+ def image_mime_type(self) -> str:
265
+ mime_type = self.supported_mime_types.get(self.image_format, None)
266
+ if mime_type is None:
267
+ raise KeyError(f'No MIME type registered for image format {self.image_format}')
268
+ return mime_type
269
+
270
+ @property
271
+ def display_options(self) -> oedepict.OE2DMolDisplayOptions:
272
+ opts = oedepict.OE2DMolDisplayOptions()
273
+ opts.SetHeight(self.height)
274
+ opts.SetWidth(self.width)
275
+ opts.SetScale(self.structure_scale)
276
+ opts.SetTitleFontScale(self.title_font_scale)
277
+ opts.SetBondWidthScaling(self.bond_width_scaling)
278
+
279
+ if not self.title:
280
+ opts.SetTitleLocation(oedepict.OETitleLocation_Hidden)
281
+
282
+ return opts
283
+
284
+ def add_callback(self, callback: Callable[[oedepict.OE2DMolDisplay], None]):
285
+ """
286
+ Add a callback that modifies an oedepict.OE2DMolDisplay to the current context
287
+ :param callback: Callback to add
288
+ """
289
+ if self._callbacks.is_deferred:
290
+ self._callbacks.set([])
291
+ self._callbacks.get().append(callback)
292
+
293
+ def create_molecule_display(
294
+ self,
295
+ mol: oechem.OEMolBase,
296
+ min_height: int | None = None,
297
+ min_width: int | None = None
298
+ ) -> oedepict.OE2DMolDisplay:
299
+ """
300
+ Create a molecule display that enforces minimum image height and width
301
+ :param mol: Molecule
302
+ :param min_height: Minimum image height
303
+ :param min_width: Minimum image width
304
+ :return: Molecule display
305
+ """
306
+ disp = oedepict.OE2DMolDisplay(mol, self.display_options)
307
+
308
+ # If the image was too small, and we're not enforcing a specific image size
309
+ if ((self.width == 0.0 and self.min_width is not None and disp.GetWidth() < self.min_width) or
310
+ (self.height == 0.0 and self.min_height is not None and disp.GetHeight() < self.min_height)):
311
+
312
+ min_height = min_height or self.min_height
313
+ min_width = min_width or self.min_width
314
+
315
+ # Create a new display context
316
+ new_ctx = self.copy()
317
+
318
+ # If width was not enforced already, then enforce the minimum width
319
+ if self.width == 0.0 and min_width is not None:
320
+ new_ctx.width = min_width if disp.GetWidth() < self.min_width else 0.0
321
+
322
+ # If height was not enforced already, then enforce the minimum height
323
+ if self.height == 0.0 and min_height is not None:
324
+ new_ctx.height = min_height if disp.GetHeight() < self.min_height else 0.0
325
+
326
+ # Create the display object
327
+ disp = oedepict.OE2DMolDisplay(mol, new_ctx.display_options)
328
+
329
+ # We need to scale down the image if it exceeds the max_width or max_height
330
+ if ((self.max_width is not None and disp.GetWidth() > self.max_width) or
331
+ (self.max_height is not None and disp.GetHeight() > self.max_height)):
332
+
333
+ # Create a new display context
334
+ new_ctx = self.copy()
335
+
336
+ # Set whatever parameter exceeded the maximum and let the other scale
337
+ if self.max_width is not None and disp.GetWidth() > self.max_width:
338
+ new_ctx.width = self.max_width
339
+ new_ctx.height = 0
340
+
341
+ elif self.max_height is not None and disp.GetHeight() > self.max_height:
342
+ new_ctx.width = 0
343
+ new_ctx.height = self.max_height
344
+
345
+ new_ctx.structure_scale = oedepict.OEScale_AutoScale
346
+
347
+ # Create the display object
348
+ disp = oedepict.OE2DMolDisplay(mol, new_ctx.display_options)
349
+
350
+ # TODO: Check the display again and see if we've exceeded max width or height again and potentially
351
+ # constrain both width and height
352
+
353
+ return disp
354
+
355
+ def reset(self) -> None:
356
+ """
357
+ Reset the rendering context to default values
358
+ """
359
+ self._width.reset()
360
+ self._height.reset()
361
+ self._min_width.reset()
362
+ self._min_height.reset()
363
+ self._max_width.reset()
364
+ self._max_height.reset()
365
+ self._structure_scale.reset()
366
+ self._title_font_scale.reset()
367
+ self._image_format.reset()
368
+ self._bond_width_scaling.reset()
369
+ self._title.reset()
370
+ self._callbacks.reset()
371
+
372
+ def copy(self) -> 'CNotebookContext':
373
+ """
374
+ Copy this object
375
+ :return: Copy of the object
376
+ """
377
+ return CNotebookContext(
378
+ width=self.width,
379
+ height=self.height,
380
+ min_width=self.min_width,
381
+ min_height=self.min_height,
382
+ max_width=self.max_width,
383
+ max_height=self.max_height,
384
+ structure_scale=self.structure_scale,
385
+ title_font_scale=self.title_font_scale,
386
+ title=self.title,
387
+ image_format=self.image_format,
388
+ bond_width_scaling=self.bond_width_scaling,
389
+ callbacks=self.callbacks,
390
+ )
391
+
392
+
393
+ ########################################################################################################################
394
+ # !!!!!!!!! Global render context !!!!!!!!!
395
+ ########################################################################################################################
396
+
397
+ # Create our global render context
398
+ cnotebook_context: ContextVar[CNotebookContext] = ContextVar("cnotebook_context", default=CNotebookContext())
399
+
400
+
401
+ ########################################################################################################################
402
+ # Decorator to automatically pass global rendering context
403
+ ########################################################################################################################
404
+
405
+ def pass_cnotebook_context(func):
406
+ """
407
+ Decorator that passes a copy of the current molecule render context
408
+ :param func: Function to decorate
409
+ :return: Decorated function
410
+ """
411
+ # TODO: Inspect func signature and check that it uses the ctx keyword
412
+ @wraps(func)
413
+ def call_with_render_context(*args, **kwargs):
414
+
415
+ # If we happened to be called with a custom molecule render context
416
+ if "ctx" in kwargs:
417
+ ctx = kwargs.pop("ctx")
418
+
419
+ if ctx is None:
420
+ ctx = cnotebook_context.get().copy()
421
+
422
+ # Other things are not OK
423
+ elif not isinstance(ctx, CNotebookContext):
424
+ raise TypeError("Received object of type type {} for OERenderContext (ctx) when calling {}".format(
425
+ type(ctx).__name__,
426
+ func.__name__
427
+ ))
428
+ else:
429
+ ctx = cnotebook_context.get().copy()
430
+
431
+ # Call the function
432
+ return func(*args, **kwargs, ctx=ctx)
433
+ return call_with_render_context
434
+
435
+
436
+ ########################################################################################################################
437
+ # Local rendering context
438
+ ########################################################################################################################
439
+
440
+ def create_local_context(
441
+ width: float = DEFERRED,
442
+ height: float = DEFERRED,
443
+ min_width: float = DEFERRED,
444
+ min_height: float = DEFERRED,
445
+ max_width: float = DEFERRED,
446
+ max_height: float = DEFERRED,
447
+ structure_scale: int = DEFERRED,
448
+ title_font_scale: float = DEFERRED,
449
+ image_format: str = DEFERRED,
450
+ bond_width_scaling: bool = DEFERRED,
451
+ callbacks: Iterable[Callable[[oedepict.OE2DMolDisplay], None]] | None = DEFERRED
452
+ ) -> CNotebookContext:
453
+ return CNotebookContext(
454
+ width=width,
455
+ height=height,
456
+ min_width=min_width,
457
+ min_height=min_height,
458
+ max_width=max_width,
459
+ max_height=max_height,
460
+ structure_scale=structure_scale,
461
+ title_font_scale=title_font_scale,
462
+ image_format=image_format,
463
+ bond_width_scaling=bond_width_scaling,
464
+ callbacks=callbacks,
465
+ scope="local"
466
+ )
467
+
468
+
469
+ def get_series_context(metadata: dict[Any, Any], save: bool = False) -> CNotebookContext:
470
+ """
471
+ Get the series context, else wrap the global context into a series context. This looks for the key "cnotebook" in
472
+ the metadta.
473
+ :param metadata: Series metadata
474
+ :param save: Whether to save any new metadata object that we create
475
+ :return: Series rendering context
476
+ """
477
+ ctx = metadata.get("cnotebook", create_local_context())
478
+
479
+ # Make sure context is a valid object
480
+ if not isinstance(ctx, CNotebookContext):
481
+ log.warning(
482
+ "Replacing unexpected object of type %s for metadata key 'cnotebook' with a CNotebookLocalContext",
483
+ type(ctx).__name__
484
+ )
485
+
486
+ ctx = create_local_context()
487
+
488
+ if save:
489
+ metadata["cnotebook"] = ctx
490
+
491
+ return ctx
cnotebook/helpers.py ADDED
@@ -0,0 +1,69 @@
1
+ import re
2
+ from typing import Callable
3
+ from openeye import oechem, oedepict
4
+
5
+
6
+ def escape_html(val):
7
+ """
8
+ Perform the same HTML escaping done by Pandas for displaying in Notebooks
9
+ :param val: Value to escape
10
+ :return: Escaped value (if val was a string)
11
+ """
12
+ if isinstance(val, str):
13
+ return val.replace("&", r"&amp;").replace("<", r"&lt;").replace(">", r"&gt;")
14
+ return val
15
+
16
+
17
+ def escape_brackets(val):
18
+ """
19
+ Escapes only HTML brackets
20
+ :param val: Value to escape
21
+ :return: Escaped value (if string)
22
+ """
23
+ if isinstance(val, str):
24
+ return val.replace("<", r"&lt;").replace(">", r"&gt;")
25
+ return val
26
+
27
+
28
+ # Remove conformer identifier from compound ID
29
+ CONFORMER_ID_REGEX = re.compile(r'(.*?)_\d+$')
30
+
31
+
32
+ def remove_omega_conformer_id(val):
33
+ """
34
+ Remove the conformer ID from a compound identifier
35
+ :param val: Value
36
+ :return: Processed value
37
+ """
38
+ if isinstance(val, str):
39
+ m = re.search(CONFORMER_ID_REGEX, val)
40
+ if m is not None:
41
+ return m.group(1)
42
+ return val
43
+
44
+
45
+ def create_structure_highlighter(
46
+ query: str | oechem.OESubSearch | oechem.OEMCSSearch | oechem.OEQMol,
47
+ color: oechem.OEColor = oechem.OEColor(oechem.OELightBlue),
48
+ style: int = oedepict.OEHighlightStyle_Stick
49
+ ) -> Callable[[oedepict.OE2DMolDisplay], None]:
50
+ """
51
+ Closure that creates a callback to highlight SMARTS patterns or MCSS results in a molecule
52
+ :param query: SMARTS pattern, oechem.OESubSearch, or oechem.OEMCSSearch object
53
+ :param color: Highlight color
54
+ :param style: Highlight style
55
+ :return: Function that highlights structures
56
+ """
57
+
58
+ if isinstance(query, (str, oechem.OEQMol)):
59
+ ss = oechem.OESubSearch(query)
60
+
61
+ elif not isinstance(query, (oechem.OESubSearch, oechem.OEMCSSearch)):
62
+ raise TypeError(f'Cannot create structure highlighter with object pattern of type {type(query).__name__}')
63
+
64
+ # Create the callback as a closure
65
+ def _structure_highlighter(disp: oedepict.OE2DMolDisplay):
66
+ for match in ss.Match(disp.GetMolecule(), True):
67
+ oedepict.OEAddHighlighting(disp, color, style, match)
68
+
69
+ return _structure_highlighter