Coverage for databooks/data_models/cell.py: 94%
144 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 15:04 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 15:04 +0200
1"""Data models - Cells and components."""
3from __future__ import annotations
5from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Union, cast
7from pydantic import PositiveInt, RootModel, field_validator
8from rich.console import Console, ConsoleOptions, ConsoleRenderable, RenderResult
9from rich.panel import Panel
10from rich.syntax import Syntax
11from rich.text import Text
13from databooks.data_models.base import DatabooksBase
14from databooks.data_models.rich_helpers import (
15 HtmlTable,
16 NotebookMarkdown,
17 RichHtmlTableError,
18)
19from databooks.logging import get_logger
21logger = get_logger(__file__)
24class CellMetadata(DatabooksBase):
25 """Cell metadata. Empty by default but can accept extra fields."""
28class BaseCell(DatabooksBase):
29 """
30 Jupyter notebook cells.
32 Fields `outputs` and `execution_count` are not included since they should only be
33 present in code cells - thus are treated as extra fields.
34 """
36 metadata: CellMetadata
37 source: Union[List[str], str]
38 cell_type: str
40 def __hash__(self) -> int:
41 """Cells must be hashable for `difflib.SequenceMatcher`."""
42 return hash(
43 (type(self),) + tuple(v) if isinstance(v, list) else v
44 for v in self.__dict__.values()
45 )
47 def remove_fields(
48 self, fields: Iterable[str] = (), missing_ok: bool = True, **kwargs: Any
49 ) -> None:
50 """
51 Remove cell fields.
53 Similar to `databooks.data_models.base.remove_fields`, but will ignore required
54 fields for cell type.
55 """
56 # Ignore required `BaseCell` fields
57 cell_fields = BaseCell.model_fields # required fields
58 if any(field in fields for field in cell_fields):
59 logger.debug(
60 "Ignoring removal of required fields "
61 + str([f for f in fields if f in cell_fields])
62 + f" in `{type(self).__name__}`."
63 )
64 fields = [f for f in fields if f not in cell_fields]
66 super(BaseCell, self).remove_fields(fields, missing_ok=missing_ok)
68 if self.cell_type == "code":
69 self.outputs: CellOutputs = (
70 CellOutputs([]) if "outputs" not in dict(self) else self.outputs
71 )
72 self.execution_count: Optional[PositiveInt] = (
73 None if "execution_count" not in dict(self) else self.execution_count
74 )
76 def clear_fields(
77 self,
78 *,
79 cell_metadata_keep: Optional[Sequence[str]] = None,
80 cell_metadata_remove: Optional[Sequence[str]] = None,
81 cell_remove_fields: Sequence[str] = (),
82 ) -> None:
83 """
84 Clear cell metadata, execution count, outputs or other desired fields (id, ...).
86 You can also specify metadata to keep or remove from the `metadata` property of
87 `databooks.data_models.cell.BaseCell`.
88 :param cell_metadata_keep: Metadata values to keep - simply pass an empty
89 sequence (i.e.: `()`) to remove all extra fields.
90 :param cell_metadata_remove: Metadata values to remove
91 :param cell_remove_fields: Fields to remove from cell
92 :return:
93 """
94 nargs = sum((cell_metadata_keep is not None, cell_metadata_remove is not None))
95 if nargs != 1:
96 raise ValueError(
97 "Exactly one of `cell_metadata_keep` or `cell_metadata_remove` must"
98 f" be passed, got {nargs} arguments."
99 )
101 if cell_metadata_keep is not None:
102 cell_metadata_remove = tuple(
103 field for field, _ in self.metadata if field not in cell_metadata_keep
104 )
105 # Non-None here: exactly one of keep/remove is given and keep became remove
106 self.metadata.remove_fields(cast(Sequence[str], cell_metadata_remove))
108 self.remove_fields(fields=cell_remove_fields, missing_ok=True)
111class CellStreamOutput(DatabooksBase):
112 """Cell output of type `stream`."""
114 output_type: str
115 name: str
116 text: List[str]
118 def __rich__(
119 self,
120 ) -> ConsoleRenderable:
121 """Rich display of cell stream outputs."""
122 return Text("".join(self.text))
124 @field_validator("output_type")
125 @classmethod
126 def output_type_must_be_stream(cls, v: str) -> str:
127 """Check if stream has `stream` type."""
128 if v != "stream":
129 raise ValueError(f"Invalid output type. Expected `stream`, got {v}.")
130 return v
132 @field_validator("name")
133 @classmethod
134 def stream_name_must_match(cls, v: str) -> str:
135 """Check if stream name is either `stdout` or `stderr`."""
136 valid_names = ("stdout", "stderr")
137 if v not in valid_names:
138 raise ValueError(
139 f"Invalid stream name. Expected one of {valid_names}, got {v}."
140 )
141 return v
144class CellDisplayDataOutput(DatabooksBase):
145 """Cell output of type `display_data`."""
147 output_type: str
148 data: Dict[str, Any]
149 metadata: Dict[str, Any]
151 @property
152 def rich_output(self) -> Sequence[ConsoleRenderable]:
153 """Dynamically compute the rich output - also in `CellExecuteResultOutput`."""
155 def _try_parse_html(s: str) -> Optional[ConsoleRenderable]:
156 """Try to parse HTML table, return `None` if any errors are raised."""
157 try:
158 return HtmlTable("".join(s)).rich()
159 except RichHtmlTableError:
160 logger.debug("Could not generate rich HTML table.")
161 return None
163 mime_func: Dict[str, Callable[[str], Optional[ConsoleRenderable]]] = {
164 "text/html": lambda s: _try_parse_html(s),
165 "text/plain": lambda s: Text("".join(s)),
166 }
168 def unsupported(s: str) -> None:
169 return None
171 _rich = {
172 mime: mime_func.get(mime, unsupported)(content) # try to render element
173 for mime, content in self.data.items()
174 }
175 return [
176 *[
177 Text(f"<✨Rich✨ `{mime}` not available 😢>")
178 for mime, renderable in _rich.items()
179 if renderable is None
180 ],
181 next(renderable for renderable in _rich.values() if renderable is not None),
182 ]
184 def __rich_console__(
185 self, console: Console, options: ConsoleOptions
186 ) -> RenderResult:
187 """Rich display of data display outputs."""
188 yield from self.rich_output
190 @field_validator("output_type")
191 @classmethod
192 def output_type_must_match(cls, v: str) -> str:
193 """Check if stream has `display_data` type."""
194 if v != "display_data":
195 raise ValueError(f"Invalid output type. Expected `display_data`, got {v}.")
196 return v
199class CellExecuteResultOutput(CellDisplayDataOutput):
200 """Cell output of type `execute_result`."""
202 execution_count: PositiveInt
204 def __rich_console__(
205 self, console: Console, options: ConsoleOptions
206 ) -> RenderResult:
207 """Rich display of executed cell outputs."""
208 yield Text(f"Out [{self.execution_count or ' '}]:", style="out_count")
209 yield from self.rich_output
211 @field_validator("output_type")
212 @classmethod
213 def output_type_must_match(cls, v: str) -> str:
214 """Check if stream has `execute_result` type."""
215 if v != "execute_result":
216 raise ValueError(
217 f"Invalid output type. Expected `execute_result`, got {v}."
218 )
219 return v
222class CellErrorOutput(DatabooksBase):
223 """Cell output of type `error`."""
225 output_type: str
226 ename: str
227 evalue: str
228 traceback: List[str]
230 def __rich__(
231 self,
232 ) -> ConsoleRenderable:
233 """Rich display of error outputs."""
234 return Text.from_ansi("\n".join(self.traceback).rstrip("\n"))
236 @field_validator("output_type")
237 @classmethod
238 def output_type_must_match(cls, v: str) -> str:
239 """Check if stream has `error` type."""
240 if v != "error":
241 raise ValueError(f"Invalid output type. Expected `error`, got {v}.")
242 return v
245CellOutputType = Union[
246 CellStreamOutput, CellDisplayDataOutput, CellExecuteResultOutput, CellErrorOutput
247]
250class CellOutputs(RootModel):
251 """Outputs of notebook code cells."""
253 root: List[CellOutputType]
255 def __rich_console__(
256 self, console: Console, options: ConsoleOptions
257 ) -> RenderResult:
258 """Rich display of code cell outputs."""
259 yield from self.values
261 @property
262 def values(
263 self,
264 ) -> List[CellOutputType]:
265 """Alias `root` with outputs for easy referencing."""
266 return self.root
269class CodeCell(BaseCell):
270 """Cell of type `code` - defined for rich displaying in terminal."""
272 outputs: CellOutputs
273 cell_type: str = "code"
275 def __rich_console__(
276 self, console: Console, options: ConsoleOptions
277 ) -> RenderResult:
278 """Rich display of code cells."""
279 yield Text(f"In [{self.execution_count or ' '}]:", style="in_count")
280 yield Panel(
281 Syntax(
282 "".join(self.source) if isinstance(self.source, list) else self.source,
283 getattr(self.metadata, "lang", "text"),
284 )
285 )
286 yield self.outputs
288 @field_validator("cell_type")
289 @classmethod
290 def cell_has_code_type(cls, v: str) -> str:
291 """Extract the list values from the __root__ attribute of `CellOutputs`."""
292 if v != "code":
293 raise ValueError(f"Expected code of type `code`, got `{v}`.")
294 return v
297class MarkdownCell(BaseCell):
298 """Cell of type `markdown` - defined for rich displaying in terminal."""
300 cell_type: str = "markdown"
302 def __rich__(
303 self,
304 ) -> ConsoleRenderable:
305 """Rich display of markdown cells."""
306 return Panel(NotebookMarkdown("".join(self.source)))
308 @field_validator("cell_type")
309 @classmethod
310 def cell_has_md_type(cls, v: str) -> str:
311 """Extract the list values from the __root__ attribute of `CellOutputs`."""
312 if v != "markdown":
313 raise ValueError(f"Expected code of type `markdown`, got {v}.")
314 return v
317class RawCell(BaseCell):
318 """Cell of type `raw` - defined for rich displaying in terminal."""
320 cell_type: str = "raw"
322 def __rich__(
323 self,
324 ) -> ConsoleRenderable:
325 """Rich display of raw cells."""
326 return Panel(Text("".join(self.source)))
328 @field_validator("cell_type")
329 @classmethod
330 def cell_has_md_type(cls, v: str) -> str:
331 """Extract the list values from the __root__ attribute of `CellOutputs`."""
332 if v != "raw":
333 raise ValueError(f"Expected code of type `raw`, got {v}.")
334 return v