cnotebook 1.2.0__py3-none-any.whl → 2.1.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/__init__.py CHANGED
@@ -1,102 +1,400 @@
1
+ """
2
+ CNotebook - Ergonomic chemistry visualization in notebooks.
3
+
4
+ Auto-detects available backends (Pandas/Polars) and environments (Jupyter/Marimo).
5
+ Only requires openeye-toolkits; all other dependencies are optional.
6
+ """
1
7
  import logging
2
- from .pandas_ext import render_dataframe
3
- from .context import cnotebook_context
4
- from .ipython_ext import register_ipython_formatters as _register_ipython_formatters
5
- from .render import render_molecule_grid
6
8
 
7
- # Only import formatter registration from the Pandas module, otherwise have users import functionality from there
8
- # to avoid confusion
9
- from cnotebook.pandas_ext import register_pandas_formatters as _register_pandas_formatters
9
+ # Required imports (openeye-toolkits)
10
+ from openeye import oechem, oedepict
10
11
 
11
- __version__ = '1.2.0'
12
+ # Core functionality that doesn't depend on backends
13
+ from .context import cnotebook_context, CNotebookContext
14
+ from .helpers import highlight_smarts
12
15
 
13
- ###########################################
16
+ __version__ = '2.1.1'
14
17
 
15
- def is_jupyter_notebook() -> bool:
16
- # noinspection PyBroadException
17
- try:
18
- from IPython import get_ipython
19
- shell = get_ipython().__class__.__name__
18
+ # Configure logging first
19
+ log = logging.getLogger("cnotebook")
20
20
 
21
- if shell == 'ZMQInteractiveShell':
22
- return True # Jupyter notebook or qtconsole
23
21
 
24
- elif shell == 'TerminalInteractiveShell':
25
- return False # Terminal running IPython
22
+ class LevelSpecificFormatter(logging.Formatter):
23
+ """A logging formatter that uses level-specific formats.
24
+
25
+ Uses a simple format for INFO and above, and includes the level name
26
+ for DEBUG messages to help distinguish debug output.
26
27
 
28
+ :cvar NORMAL_FORMAT: Format string for INFO and above.
29
+ :cvar DEBUG_FORMAT: Format string for DEBUG level.
30
+ """
31
+
32
+ NORMAL_FORMAT = "%(message)s"
33
+ DEBUG_FORMAT = "%(levelname)s: %(message)s"
34
+
35
+ def __init__(self):
36
+ """Create the formatter with the normal format as default."""
37
+ super().__init__(fmt=self.NORMAL_FORMAT, datefmt=None, style='%')
38
+
39
+ def format(self, record: logging.LogRecord) -> str:
40
+ if record.levelno == logging.DEBUG:
41
+ self._style._fmt = self.DEBUG_FORMAT
27
42
  else:
28
- return False # Other type (?)
43
+ self._style._fmt = self.NORMAL_FORMAT
44
+ return logging.Formatter.format(self, record)
45
+
46
+
47
+ # Configure handler
48
+ _handler = logging.StreamHandler()
49
+ _handler.setLevel(logging.DEBUG)
50
+ _handler.setFormatter(LevelSpecificFormatter())
51
+ log.addHandler(_handler)
52
+ log.setLevel(logging.INFO)
53
+
54
+
55
+ def enable_debugging():
56
+ """Convenience function for enabling the debug log."""
57
+ log.setLevel(logging.DEBUG)
29
58
 
30
- except Exception:
31
- return False # Probably standard Python interpreter
32
59
 
60
+ ########################################################################################################################
61
+ # Environment Information
62
+ ########################################################################################################################
63
+
64
+ class CNotebookEnvInfo:
65
+ """Environment information for CNotebook.
66
+
67
+ This class provides read-only access to detected backend and environment
68
+ availability. A singleton instance is created at module load time and
69
+ can be retrieved via :func:`get_env`.
70
+
71
+ All properties are read-only to ensure consistency throughout the
72
+ application lifecycle. Availability is determined by checking if the
73
+ version string is non-empty.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ pandas_version: str,
79
+ polars_version: str,
80
+ ipython_version: str,
81
+ marimo_version: str,
82
+ molgrid_available: bool,
83
+ is_jupyter_notebook: bool,
84
+ is_marimo_notebook: bool,
85
+ ):
86
+ """Create environment info (typically called once at module load).
87
+
88
+ :param pandas_version: Detected Pandas version string, or empty if unavailable.
89
+ :param polars_version: Detected Polars version string, or empty if unavailable.
90
+ :param ipython_version: Detected IPython version string, or empty if unavailable.
91
+ :param marimo_version: Detected Marimo version string, or empty if unavailable.
92
+ :param molgrid_available: Whether MolGrid widget dependencies are available.
93
+ :param is_jupyter_notebook: Whether running in a Jupyter notebook environment.
94
+ :param is_marimo_notebook: Whether running in a Marimo notebook environment.
95
+ """
96
+ self._pandas_version = pandas_version
97
+ self._polars_version = polars_version
98
+ self._ipython_version = ipython_version
99
+ self._marimo_version = marimo_version
100
+ self._molgrid_available = molgrid_available
101
+ self._is_jupyter_notebook = is_jupyter_notebook
102
+ self._is_marimo_notebook = is_marimo_notebook
103
+
104
+ @property
105
+ def pandas_available(self) -> bool:
106
+ """Whether Pandas and OEPandas are available."""
107
+ return bool(self._pandas_version)
108
+
109
+ @property
110
+ def pandas_version(self) -> str:
111
+ """Pandas version string, or empty string if not available."""
112
+ return self._pandas_version
113
+
114
+ @property
115
+ def polars_available(self) -> bool:
116
+ """Whether Polars and OEPolars are available."""
117
+ return bool(self._polars_version)
118
+
119
+ @property
120
+ def polars_version(self) -> str:
121
+ """Polars version string, or empty string if not available."""
122
+ return self._polars_version
123
+
124
+ @property
125
+ def ipython_available(self) -> bool:
126
+ """Whether IPython is available and active."""
127
+ return bool(self._ipython_version)
128
+
129
+ @property
130
+ def ipython_version(self) -> str:
131
+ """IPython version string, or empty string if not available."""
132
+ return self._ipython_version
133
+
134
+ @property
135
+ def marimo_available(self) -> bool:
136
+ """Whether Marimo is available and running in notebook mode."""
137
+ return bool(self._marimo_version)
138
+
139
+ @property
140
+ def marimo_version(self) -> str:
141
+ """Marimo version string, or empty string if not available."""
142
+ return self._marimo_version
143
+
144
+ @property
145
+ def molgrid_available(self) -> bool:
146
+ """Whether MolGrid is available (requires anywidget)."""
147
+ return self._molgrid_available
148
+
149
+ @property
150
+ def is_jupyter_notebook(self) -> bool:
151
+ """Whether running in a Jupyter notebook environment."""
152
+ return self._is_jupyter_notebook
153
+
154
+ @property
155
+ def is_marimo_notebook(self) -> bool:
156
+ """Whether running in a Marimo notebook environment."""
157
+ return self._is_marimo_notebook
158
+
159
+ def __repr__(self) -> str:
160
+ return (
161
+ f"CNotebookEnvInfo("
162
+ f"pandas={self.pandas_available} ({self._pandas_version}), "
163
+ f"polars={self.polars_available} ({self._polars_version}), "
164
+ f"ipython={self.ipython_available} ({self._ipython_version}), "
165
+ f"marimo={self.marimo_available} ({self._marimo_version}), "
166
+ f"molgrid={self._molgrid_available}, "
167
+ f"jupyter={self._is_jupyter_notebook}, "
168
+ f"marimo_nb={self._is_marimo_notebook})"
169
+ )
170
+
171
+
172
+ def _detect_environment() -> CNotebookEnvInfo:
173
+ """Detect available backends and environments.
174
+
175
+ :returns: CNotebookEnvInfo instance with detection results.
176
+ """
177
+ pandas_version = ""
178
+ polars_version = ""
179
+ ipython_version = ""
180
+ marimo_version = ""
181
+ molgrid_available = False
182
+ is_jupyter = False
183
+ is_marimo = False
184
+
185
+ # Detect molgrid (requires anywidget)
186
+ try:
187
+ from cnotebook.grid import molgrid, MolGrid
188
+ molgrid_available = True
189
+ except ImportError:
190
+ pass
33
191
 
34
- def is_marimo_notebook() -> bool:
35
- # noinspection PyBroadException
192
+ # Detect pandas/oepandas
193
+ try:
194
+ import pandas as pd
195
+ import oepandas as oepd
196
+ pandas_version = pd.__version__
197
+ except ImportError:
198
+ pass
199
+
200
+ # Detect polars/oepolars
201
+ try:
202
+ import polars as pl
203
+ import oepolars as oeplr
204
+ polars_version = pl.__version__
205
+ except ImportError:
206
+ pass
207
+
208
+ # Detect iPython
209
+ try:
210
+ import IPython
211
+ # noinspection PyProtectedMember
212
+ from IPython import get_ipython
213
+ ipy = get_ipython()
214
+ if ipy is not None:
215
+ ipython_version = IPython.__version__
216
+ # Check if running in Jupyter notebook
217
+ is_jupyter = ipy.__class__.__name__ == 'ZMQInteractiveShell'
218
+ except (ImportError, Exception):
219
+ pass
220
+
221
+ # Detect Marimo
36
222
  try:
37
- # noinspection PyUnresolvedReferences
38
223
  import marimo as mo
39
- return mo.running_in_notebook()
224
+ if mo.running_in_notebook():
225
+ marimo_version = mo.__version__
226
+ is_marimo = True
227
+ except (ImportError, Exception):
228
+ # Marimo raises exception if not running in notebook context
229
+ pass
40
230
 
41
- except Exception:
42
- return False
231
+ return CNotebookEnvInfo(
232
+ pandas_version=pandas_version,
233
+ polars_version=polars_version,
234
+ ipython_version=ipython_version,
235
+ marimo_version=marimo_version,
236
+ molgrid_available=molgrid_available,
237
+ is_jupyter_notebook=is_jupyter,
238
+ is_marimo_notebook=is_marimo,
239
+ )
43
240
 
44
241
 
45
- # Register the formatters
46
- # Note: All registration function calls are idempotent
47
- if is_jupyter_notebook():
48
- _register_ipython_formatters()
49
- _register_pandas_formatters()
242
+ # Initialize environment detection at module load (singleton instance)
243
+ _env_info: CNotebookEnvInfo = _detect_environment()
50
244
 
51
- elif is_marimo_notebook():
52
- from . import marimo_ext
53
245
 
54
- # Configure logging
55
- log = logging.getLogger("cnotebook")
246
+ def get_env() -> CNotebookEnvInfo:
247
+ """Get environment information for CNotebook.
56
248
 
249
+ Returns a singleton instance containing information about available
250
+ backends and environments. The environment is detected once at module
251
+ load time and the same object is returned on subsequent calls.
57
252
 
58
- class LevelSpecificFormatter(logging.Formatter):
59
- """
60
- A logging formatter
253
+ :returns: CNotebookEnvInfo instance with read-only properties.
254
+
255
+ Example::
256
+
257
+ env = cnotebook.get_env()
258
+ if env.pandas_available:
259
+ print(f"Pandas {env.pandas_version} is available")
61
260
  """
62
- NORMAL_FORMAT = "%(message)s"
63
- DEBUG_FORMAT = "%(levelname)s: %(message)s"
261
+ return _env_info
64
262
 
65
- def __init__(self):
66
- super().__init__(fmt=self.NORMAL_FORMAT, datefmt=None, style='%')
67
263
 
68
- def format(self, record: logging.LogRecord) -> str:
69
- """
70
- Format a log record for printing
71
- :param record: Record to format
72
- :return: Formatted record
73
- """
74
- if record.levelno == logging.DEBUG:
75
- self._style._fmt = self.DEBUG_FORMAT
76
- else:
77
- self._style._fmt = self.NORMAL_FORMAT
264
+ ########################################################################################################################
265
+ # Register Formatters Based on Availability
266
+ ########################################################################################################################
78
267
 
79
- # Call the original formatter class to do the grunt work
80
- result = logging.Formatter.format(self, record)
268
+ # Import and register pandas formatters if available
269
+ if _env_info.pandas_available:
270
+ try:
271
+ from .pandas_ext import render_dataframe, register_pandas_formatters
81
272
 
82
- return result
273
+ if _env_info.ipython_available:
274
+ from .ipython_ext import register_ipython_formatters
275
+ register_ipython_formatters()
276
+ register_pandas_formatters()
277
+ log.debug("[cnotebook] Registered Pandas formatters for iPython")
278
+ except Exception as e:
279
+ log.warning(f"[cnotebook] Failed to import/register Pandas extension: {e}")
83
280
 
281
+ # Import and register polars formatters if available
282
+ if _env_info.polars_available:
283
+ try:
284
+ from .polars_ext import render_polars_dataframe, register_polars_formatters
84
285
 
85
- ############################
86
- # Example of how to use it #
87
- ############################
286
+ if _env_info.ipython_available:
287
+ register_polars_formatters()
288
+ log.debug("[cnotebook] Registered Polars formatters for iPython")
289
+ except Exception as e:
290
+ log.warning(f"[cnotebook] Failed to import/register Polars extension: {e}")
88
291
 
89
- ch = logging.StreamHandler()
90
- ch.setLevel(logging.DEBUG)
292
+ # Import marimo extension if available
293
+ if _env_info.marimo_available:
294
+ try:
295
+ from . import marimo_ext
296
+ log.debug("[cnotebook] Imported Marimo extension")
297
+ except Exception as e:
298
+ log.warning(f"[cnotebook] Failed to import Marimo extension: {e}")
91
299
 
92
- ch.setFormatter(LevelSpecificFormatter())
93
- log.addHandler(ch)
300
+ # Export molgrid at top level if available
301
+ if _env_info.molgrid_available:
302
+ from .grid import molgrid, MolGrid
94
303
 
95
- log.setLevel(logging.INFO)
96
304
 
305
+ ########################################################################################################################
306
+ # Unified Display Function
307
+ ########################################################################################################################
97
308
 
98
- def enable_debugging():
309
+ def display(obj, ctx: CNotebookContext | None = None):
310
+ """Display an OpenEye molecule, display object, or DataFrame in the current notebook environment.
311
+
312
+ This function provides a unified way to display chemistry objects in both Jupyter
313
+ and Marimo notebooks. It automatically detects the environment and uses the
314
+ appropriate display mechanism.
315
+
316
+ :param obj: Object to display. Can be:
317
+ - ``oechem.OEMolBase`` - OpenEye molecule
318
+ - ``oedepict.OE2DMolDisplay`` - OpenEye display object
319
+ - ``pandas.DataFrame`` - Pandas DataFrame (if pandas available)
320
+ - ``polars.DataFrame`` - Polars DataFrame (if polars available)
321
+ :param ctx: Optional rendering context. Only applied to molecules and display
322
+ objects, not DataFrames. If None, uses the global context.
323
+ :returns: A displayable object appropriate for the current environment.
324
+ :raises TypeError: If the object type is not supported.
325
+
326
+ Example::
327
+
328
+ import cnotebook
329
+ from openeye import oechem
330
+
331
+ mol = oechem.OEGraphMol()
332
+ oechem.OESmilesToMol(mol, "c1ccccc1")
333
+
334
+ # Display with default context
335
+ cnotebook.display(mol)
336
+
337
+ # Display with custom context
338
+ ctx = cnotebook.cnotebook_context.get().copy()
339
+ ctx.width = 300
340
+ ctx.height = 300
341
+ cnotebook.display(mol, ctx=ctx)
99
342
  """
100
- Convenience function for enabling the debug log
343
+ from .render import oemol_to_html, oedisp_to_html
344
+
345
+ # Get environment info
346
+ env = get_env()
347
+
348
+ # Determine the context to use
349
+ if ctx is None:
350
+ render_ctx = cnotebook_context.get()
351
+ else:
352
+ render_ctx = ctx
353
+
354
+ # Handle OpenEye molecules
355
+ if isinstance(obj, oechem.OEMolBase):
356
+ html = oemol_to_html(obj, ctx=render_ctx)
357
+ return _display_html(html, env)
358
+
359
+ # Handle OpenEye display objects
360
+ if isinstance(obj, oedepict.OE2DMolDisplay):
361
+ html = oedisp_to_html(obj, ctx=render_ctx)
362
+ return _display_html(html, env)
363
+
364
+ # Handle Pandas DataFrame (if available)
365
+ if env.pandas_available:
366
+ import pandas as pd
367
+ if isinstance(obj, pd.DataFrame):
368
+ # noinspection PyTypeChecker
369
+ html = render_dataframe(obj, ctx=render_ctx)
370
+ return _display_html(html, env)
371
+
372
+ # Handle Polars DataFrame (if available)
373
+ if env.polars_available:
374
+ import polars as pl
375
+ if isinstance(obj, pl.DataFrame):
376
+ html = render_polars_dataframe(obj, ctx=render_ctx)
377
+ return _display_html(html, env)
378
+
379
+ raise TypeError(f"Cannot display object of type {type(obj).__name__}")
380
+
381
+
382
+ def _display_html(html: str, env: CNotebookEnvInfo):
383
+ """Display HTML content in the appropriate notebook environment.
384
+
385
+ :param html: HTML string to display.
386
+ :param env: Environment info.
387
+ :returns: Displayable object for the current environment.
101
388
  """
102
- log.setLevel(logging.DEBUG)
389
+ # Marimo environment
390
+ if env.is_marimo_notebook:
391
+ import marimo as mo
392
+ return mo.Html(html)
393
+
394
+ # Jupyter/IPython environment
395
+ if env.ipython_available:
396
+ from IPython.display import HTML, display as ipy_display
397
+ return ipy_display(HTML(html))
398
+
399
+ # Fallback: just return the HTML string
400
+ return html