diff --git a/.gitignore b/.gitignore index 156b44f7..440616bc 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,7 @@ dmypy.json .DS_Store .vscode/ +/hatch.toml todos.md _archive/ diff --git a/myst_nb/core/execute/__init__.py b/myst_nb/core/execute/__init__.py index 5945988a..41c9ad36 100644 --- a/myst_nb/core/execute/__init__.py +++ b/myst_nb/core/execute/__init__.py @@ -3,7 +3,12 @@ from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING -from .base import ExecutionError, ExecutionResult, NotebookClientBase # noqa: F401 +from .base import ( + ExecutionError, # noqa: F401 + ExecutionResult, # noqa: F401 + NotebookClientBase, + NotebookClientReadOnly, +) from .cache import NotebookClientCache from .direct import NotebookClientDirect from .inline import NotebookClientInline @@ -48,7 +53,7 @@ def create_client( for pattern in nb_config.execution_excludepatterns: if posix_path.match(pattern): logger.info(f"Excluded from execution by pattern: {pattern!r}") - return NotebookClientBase(notebook, path, nb_config, logger) + return NotebookClientReadOnly(notebook, path, nb_config, logger) # 'auto' mode only executes the notebook if it is missing at least one output missing_outputs = ( @@ -56,7 +61,7 @@ def create_client( ) if nb_config.execution_mode == "auto" and not any(missing_outputs): logger.info("Skipped execution in 'auto' mode (all outputs present)") - return NotebookClientBase(notebook, path, nb_config, logger) + return NotebookClientReadOnly(notebook, path, nb_config, logger) if nb_config.execution_mode in ("auto", "force"): return NotebookClientDirect(notebook, path, nb_config, logger) @@ -67,4 +72,4 @@ def create_client( if nb_config.execution_mode == "inline": return NotebookClientInline(notebook, path, nb_config, logger) - return NotebookClientBase(notebook, path, nb_config, logger) + return NotebookClientReadOnly(notebook, path, nb_config, logger) diff --git a/myst_nb/core/execute/base.py b/myst_nb/core/execute/base.py index befb6d29..b391aac3 100644 --- a/myst_nb/core/execute/base.py +++ b/myst_nb/core/execute/base.py @@ -2,9 +2,9 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any -from nbformat import NotebookNode +from nbformat import NotebookNode, from_dict from typing_extensions import TypedDict, final from myst_nb.core.config import NbParserConfig @@ -12,6 +12,9 @@ from myst_nb.core.nb_to_tokens import nb_node_to_dict from myst_nb.ext.glue import extract_glue_data +if TYPE_CHECKING: + from markdown_it.tree import SyntaxTreeNode + class ExecutionResult(TypedDict): """Result of executing a notebook.""" @@ -39,7 +42,7 @@ class EvalNameError(Exception): class NotebookClientBase: - """A base client for interacting with Jupyter notebooks. + """A client base class for interacting with Jupyter notebooks. This class is intended to be used as a context manager, and should only be entered once. @@ -166,7 +169,9 @@ def code_cell_outputs( cell = cells[cell_index] return cell.get("execution_count", None), cell.get("outputs", []) - def eval_variable(self, name: str) -> list[NotebookNode]: + def eval_variable( + self, name: str, current_cell: SyntaxTreeNode + ) -> list[NotebookNode]: """Retrieve the value of a variable from the kernel. :param name: the name of the variable, @@ -177,3 +182,29 @@ def eval_variable(self, name: str) -> list[NotebookNode]: :raises EvalNameError: if the variable name is invalid """ raise NotImplementedError + + +class NotebookClientReadOnly(NotebookClientBase): + """A notebook client that does not execute the notebook.""" + + def eval_variable( + self, name: str, current_cell: SyntaxTreeNode + ) -> list[NotebookNode]: + """Retrieve the value of a variable from the current cell metadata if possible. + + :param name: the name of the variable + :returns: code cell outputs + :raises RetrievalError: if the cell metadata does not contain the eval result + :raises EvalNameError: if the variable name is invalid + """ + for ux in current_cell.meta.get("metadata", {}).get("user_expressions", []): + if ux["expression"] == name: + return from_dict([ux["result"]]) + + from myst_nb.core.variables import RetrievalError + + msg = ( + f"Cell {current_cell.meta.get('index', '')} does " + f"not contain execution result for variable {name!r}" + ) + raise RetrievalError(msg) diff --git a/myst_nb/core/execute/inline.py b/myst_nb/core/execute/inline.py index 13d20786..388a121f 100644 --- a/myst_nb/core/execute/inline.py +++ b/myst_nb/core/execute/inline.py @@ -8,6 +8,7 @@ from tempfile import mkdtemp import time import traceback +from typing import TYPE_CHECKING from nbclient.client import ( CellControlSignal, @@ -25,6 +26,9 @@ from .base import EvalNameError, ExecutionError, NotebookClientBase +if TYPE_CHECKING: + from markdown_it.tree import SyntaxTreeNode + class NotebookClientInline(NotebookClientBase): """A notebook client that executes the notebook inline, @@ -148,7 +152,9 @@ def code_cell_outputs( cell = cells[cell_index] return cell.get("execution_count", None), cell.get("outputs", []) - def eval_variable(self, name: str) -> list[NotebookNode]: + def eval_variable( + self, name: str, current_cell: SyntaxTreeNode + ) -> list[NotebookNode]: if not re.match(self.nb_config.eval_name_regex, name): raise EvalNameError(name) return self._client.eval_expression(name) diff --git a/myst_nb/core/render.py b/myst_nb/core/render.py index 14b82816..caefc947 100644 --- a/myst_nb/core/render.py +++ b/myst_nb/core/render.py @@ -64,6 +64,7 @@ class MditRenderMixin: current_node: Any current_node_context: Any create_highlighted_code_block: Any + current_cell: SyntaxTreeNode @property def nb_config(self: SelfType) -> NbParserConfig: @@ -114,6 +115,7 @@ def render_nb_cell_markdown(self: SelfType, token: SyntaxTreeNode) -> None: # it would be nice to "wrap" this in a container that included the metadata, # but unfortunately this would break the heading structure of docutils/sphinx. # perhaps we add an "invisible" (non-rendered) marker node to the document tree, + self.current_cell = token # type: ignore[union-attr] self.render_children(token) def render_nb_cell_raw(self: SelfType, token: SyntaxTreeNode) -> None: diff --git a/myst_nb/ext/eval/__init__.py b/myst_nb/ext/eval/__init__.py index 52c04cd2..3cd5a9d6 100644 --- a/myst_nb/ext/eval/__init__.py +++ b/myst_nb/ext/eval/__init__.py @@ -39,7 +39,11 @@ def retrieve_eval_data(document: nodes.document, key: str) -> list[VariableOutpu # evaluate the variable try: - outputs = element.renderer.nb_client.eval_variable(key) + outputs = element.renderer.nb_client.eval_variable( + key, element.renderer.current_cell + ) + except RetrievalError: + raise except NotImplementedError: raise RetrievalError("This document does not have a running kernel") except EvalNameError: diff --git a/tests/notebooks/with_eval.md b/tests/notebooks/with_eval.md index e133f947..c96965d1 100644 --- a/tests/notebooks/with_eval.md +++ b/tests/notebooks/with_eval.md @@ -2,8 +2,6 @@ file_format: mystnb kernelspec: name: python3 -mystnb: - execution_mode: 'inline' --- # Inline evaluation diff --git a/tests/notebooks/with_eval_executed.ipynb b/tests/notebooks/with_eval_executed.ipynb new file mode 100644 index 00000000..ee0981c7 --- /dev/null +++ b/tests/notebooks/with_eval_executed.ipynb @@ -0,0 +1,55 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "x = 2" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "user_expressions": [ + { + "expression": "x", + "result": { + "data": { + "text/plain": "2'" + }, + "metadata": {}, + "status": "ok" + } + } + ] + }, + "source": [ + "{eval}`x`" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + }, + "test_name": "notebook1" + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/test_eval.py b/tests/test_eval.py index 3a87eab1..2be55cfc 100644 --- a/tests/test_eval.py +++ b/tests/test_eval.py @@ -14,3 +14,28 @@ def test_sphinx(sphinx_run, clean_doctree, file_regression): doctree.pformat(), encoding="utf-8", ) + + +@pytest.mark.sphinx_params( + "with_eval_executed.ipynb", conf={"nb_execution_mode": "off"} +) +def test_no_build(sphinx_run, clean_doctree, file_regression): + """Test using eval from cell metadata.""" + sphinx_run.build() + assert sphinx_run.warnings() == "" + doctree = clean_doctree(sphinx_run.get_resolved_doctree("with_eval_executed")) + file_regression.check(doctree.pformat(), encoding="utf-8") + + +@pytest.mark.sphinx_params("with_eval.md", conf={"nb_execution_mode": "off"}) +def test_no_build_error(sphinx_run): + """Test that lack of execution results errors.""" + sphinx_run.build() + assert ( + "Cell 2 does not contain execution result for variable 'a'" + in sphinx_run.warnings() + ) + assert ( + "Cell 4 does not contain execution result for variable 'img'" + in sphinx_run.warnings() + ) diff --git a/tests/test_eval/test_no_build.txt b/tests/test_eval/test_no_build.txt new file mode 100644 index 00000000..b3a1af6f --- /dev/null +++ b/tests/test_eval/test_no_build.txt @@ -0,0 +1,8 @@ + + + + + x = 2 + + + 2’