diff --git a/.vscode/launch.json b/.vscode/launch.json index c5b48883..e8f5b369 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "type": "python", "request": "launch", "name": "Pytest: Current File", - "program": "${workspaceFolder}/.dev/bin/pytest", + "program": "${workspaceFolder}/.env/bin/pytest", "args": [ "${file}" ] diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 65852636..777c8fb6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -130,11 +130,11 @@ "value": "html" }, { - "label": "Extract gallery images", + "label": "Build Gallery", "value": "nbgallery" }, { - "label": "Extract tutorial", + "label": "Build Tutorial", "value": "nbtutorial" }, { @@ -200,6 +200,10 @@ { "label": "Gallery", "value": "blog/src/gallery" + }, + { + "label": "Tutorial", + "value": "docs/_build/nbtutorial" } ] } diff --git a/arlunio/doc/image.py b/arlunio/doc/image.py index 5afd4cf4..287959ef 100644 --- a/arlunio/doc/image.py +++ b/arlunio/doc/image.py @@ -2,6 +2,7 @@ import textwrap import traceback from typing import List +from typing import Optional from docutils import nodes from docutils.parsers.rst import directives @@ -45,24 +46,37 @@ def parse_content(state, content: StringList) -> List[nodes.Node]: return section.children -def reformat_content(caption: str, code: str, include_code: bool) -> StringList: +def reformat_content( + caption: str, code: str, include_code: Optional[str] +) -> StringList: """Reformat the content of the arlunio-image directive so that it's compatible with the standard figure directive.""" count = Count() src = "" + indent = " " content = StringList() content.append(caption, src, count()) content.append("", src, count()) - if include_code: + if include_code is not None: + level = 0 - content.append(".. code-block:: python", src, count()) + if include_code == "solution": + content.append(".. nbsolution::", src, count()) + content.append("", src, count()) + + level += 1 + + codeblock = textwrap.indent(".. code-block:: python", indent * level) + content.append(codeblock, src, count()) content.append("", src, count()) + level += 1 + for line in code.splitlines(): - line = textwrap.indent(line, " ") + line = textwrap.indent(line, indent * level) content.append(line, src, count()) return content @@ -147,9 +161,13 @@ class ArlunioImageDirective(Figure): has_content = True final_argument_whitespace = True + def inccode(arg): + arg = "" if arg is None else arg + return directives.choice(arg, ("", "solution")) + option_spec = Figure.option_spec.copy() option_spec["gallery"] = directives.unchanged - option_spec["include-code"] = directives.flag + option_spec["include-code"] = inccode def run(self): @@ -159,7 +177,7 @@ def run(self): imgpath = imgname.lower().replace(" ", "-") logger.debug("[arlunio-image]: Rendering: %s", imgname) - include_code = "include-code" in self.options.keys() + include_code = self.options.pop("include-code", None) # First we will process the content of the directive in order to produce an # image on disk. We will then defer to the default behavior of the Figure diff --git a/arlunio/doc/notebook.py b/arlunio/doc/notebook.py index 528f881c..ca13db5d 100644 --- a/arlunio/doc/notebook.py +++ b/arlunio/doc/notebook.py @@ -1,8 +1,8 @@ import os import pathlib import re +import shutil import textwrap -from pathlib import Path from typing import Iterable from typing import List from typing import Set @@ -16,11 +16,13 @@ from docutils.parsers.rst.directives.admonitions import BaseAdmonition from sphinx.builders import Builder from sphinx.util import logging +from sphinx.util.docutils import new_document import arlunio from .image import arlunio_image logger = logging.getLogger(__name__) +RESOURCE_DIR = "resources" class nbtutorial(nodes.General, nodes.Element): @@ -155,23 +157,33 @@ def astext(self): # --------------------------- Visitors ------------------------------------ - def visit_bullet_list(self, node: nodes.bullet_list) -> None: - self.new_cell("markdown") - - def depart_bullet_list(self, node: nodes.bullet_list) -> None: + def _no_op(self, node): pass - def visit_compound(self, node: nodes.compound) -> None: - pass + no_op = _no_op, _no_op - def depart_compound(self, node: nodes.compound) -> None: - pass + visit_arlunio_image, depart_arlunio_image = no_op + visit_compound, depart_compound = no_op + visit_compact_paragraph, depart_compact_paragraph = no_op + visit_document, depart_document = no_op + visit_figure, depart_figure = no_op + visit_legend, depart_legend = no_op + visit_nbtutorial, depart_nbtutorial = no_op - def visit_compact_paragraph(self, node) -> None: - pass + def _italics(self, node): + self.current_cell.source += "*" - def depart_compact_paragraph(self, node) -> None: - pass + italics = _italics, _italics + + visit_emphasis, depart_emphasis = italics + visit_caption, depart_caption = italics + + def visit_bullet_list(self, node: nodes.bullet_list) -> None: + self.new_cell("markdown") + self.current_cell.source += "\n" + + def depart_bullet_list(self, node: nodes.bullet_list) -> None: + self.current_cell.source += "\n" def visit_comment(self, node: nodes.comment) -> None: self.new_cell("markdown") @@ -179,17 +191,14 @@ def visit_comment(self, node: nodes.comment) -> None: def depart_comment(self, node: nodes.comment) -> None: pass - def visit_document(self, node: nodes.document) -> None: - pass - - def depart_document(self, node: nodes.document) -> None: - pass + def visit_image(self, node: nodes.image) -> None: + self.new_cell("markdown") - def visit_emphasis(self, node: nodes.emphasis) -> None: - self.current_cell.source += "*" + path = pathlib.Path(RESOURCE_DIR, pathlib.Path(node["uri"]).name) + self.current_cell.source += f"\n![]({path})\n" - def depart_emphasis(self, node: nodes.emphasis) -> None: - self.current_cell.source += "*" + def depart_image(self, node: nodes.image) -> None: + pass def visit_inline(self, node: nodes.inline) -> None: self.current_cell.source += "[" @@ -215,10 +224,10 @@ def visit_literal_block(self, node: nodes.literal_block) -> None: def depart_literal_block(self, node: nodes.literal_block) -> None: pass - def visit_nbtutorial(self, node: nbtutorial) -> None: - pass + def visit_nbsolution(self, node: nbsolution) -> None: + self.new_cell("code") - def depart_nbtutorial(self, node: nbtutorial) -> None: + def depart_nbsolution(self, node: nbsolution) -> None: pass def visit_note(self, node: nodes.note) -> None: @@ -394,32 +403,44 @@ def get_target_uri(self, docname: str, type: str = None) -> str: return uri - def _process_solutions(self, docname: str, solutions: List[nbsolution]) -> None: - """Given the solutions for a given tutorial save them to the solutions dir. + def _process_solutions( + self, docname: pathlib.Path, solutions: List[nbsolution] + ) -> None: + """Given the solutions for a given tutorial save them to the resources dir. This also rewrites the doctree so that the solutions are replaced by cells the :code:`%load` magic so that the user can load the results in. """ logger.debug("[nbtutorial]: Processing solutions for %s", docname) - DIRNAME = "solutions" - soln_dir = os.path.join(self.outdir, os.path.dirname(docname), DIRNAME) - soln_name = os.path.basename(docname) + if len(solutions) == 0: + return + + soln_dir = pathlib.Path(self.outdir, docname.parent, RESOURCE_DIR) + soln_name = docname.stem - if not os.path.exists(soln_dir): - os.makedirs(soln_dir) + if not soln_dir.exists(): + soln_dir.mkdir(parents=True) for idx, soln in enumerate(solutions): + logger.debug("[nbtutorial]: %s", soln) + soln_fname = f"{soln_name}-{idx + 1:02d}.py" - soln_path = os.path.join(DIRNAME, soln_fname) + soln_path = soln_dir / soln_fname + + # We need to wrap solutions inside a document for some reason + doc = new_document("") + doc += soln # Convert the solution to a valid Python file - translator = PythonTranslator(soln) - soln.walkabout(translator) + translator = PythonTranslator(doc) + doc.walkabout(translator) python_soln = translator.astext() # Insert a code block into the notebook that will load the solution - soln.children = [codeblock(f"%load {soln_path}")] + soln.children = [ + codeblock(f"%load {soln_path.relative_to(soln_dir.parent)}") + ] # Write the actual solution to the given file on disk soln_file = os.path.join(soln_dir, soln_fname) @@ -428,34 +449,60 @@ def _process_solutions(self, docname: str, solutions: List[nbsolution]) -> None: with open(soln_file, "w") as f: f.write(python_soln) + def _process_images(self, docname: pathlib.Path, images: List[nodes.image]) -> None: + """Given the images for a given tutorial, save them to the resources dir.""" + logger.debug("[nbtutorial]: Processing images for %s", docname) + + if len(images) == 0: + return + + img_dir = pathlib.Path(self.outdir, docname.parent, RESOURCE_DIR) + + if not img_dir.exists(): + img_dir.mkdir(parents=True) + + for img in images: + fname = pathlib.Path(img["uri"]).name + + source = pathlib.Path(self.app.confdir, img["uri"]) + destination = pathlib.Path(img_dir, fname) + + shutil.copy(source, destination) + def prepare_writing(self, docnames: Set[str]) -> None: """A place we can add logic to?""" - self.docwriter = NotebookWriter(self) def write_doc(self, docname: str, doctree: nodes.Node) -> None: logger.debug(f"[nbtutorial]: Called on {docname}") # Determine if the document represents a tutorial. - nodes = list(doctree.traverse(condition=nbtutorial)) + tutorial = list(doctree.traverse(condition=nbtutorial)) - if len(nodes) == 0: + if len(tutorial) == 0: return - # Find any solutions that it may constain. + docname = pathlib.Path(docname) + + base, fname = docname.parent, docname.stem + logger.debug("[nbtutorial]: Base: %s, Filename: %s", base, fname) + basedir = pathlib.Path(self.outdir, base) + + if not basedir.exists(): + basedir.mkdir(parents=True) + + # Find and write out any solutions. solutions = list(doctree.traverse(condition=nbsolution)) self._process_solutions(docname, solutions) + # Find and copy over any images + images = list(doctree.traverse(condition=nodes.image)) + self._process_images(docname, images) + destination = StringOutput(encoding="utf-8") self.docwriter.write(doctree, destination) - base, fname = os.path.split(docname) - basedir = os.path.join(self.outdir, base) - - if not os.path.exists(basedir): - os.makedirs(basedir) - - Path(basedir, "__init__.py").touch() + pathlib.Path(basedir, "__init__.py").touch() outfile = os.path.join(basedir, fname + ".ipynb") with open(outfile, "w") as f: diff --git a/docs/users/getstarted/first-image.rst b/docs/users/getstarted/first-image.rst index a384ed67..d9e69147 100644 --- a/docs/users/getstarted/first-image.rst +++ b/docs/users/getstarted/first-image.rst @@ -1,6 +1,8 @@ Your First Image ================ +.. nbtutorial:: + .. arlunio-image:: Sunny Day :align: center :gallery: examples @@ -90,8 +92,8 @@ that there is no "correct answer". Quite often there are multiple ways to achieve the same result! .. arlunio-image:: Sunset - :align: center :gallery: examples + :include-code: solution Sunset:: @@ -107,24 +109,5 @@ achieve the same result! hill = shape.Circle(xc=-1, yc=-2.1, r=1.5) img += image.fill(hill(width=w, height=h), foreground="forestgreen") - hill = shape.Circle(xc=1, yc=-1.8, r=1.3) - img += image.fill(hill(width=w, height=h), foreground="green") - -.. nbsolution:: - - .. code-block:: python - - import arlunio.image as image - import arlunio.shape as shape - - w, h = 1920, 1080 - img = image.new(w, h, color="darkorange") - - sun = shape.Circle(xc=-1.2, yc=0, r=0.6) - img += image.fill(sun(width=w, height=h), foreground="yellow") - - hill = shape.Circle(xc=-1, yc=-2.1, r=1.5) - img += image.fill(hill(width=w, height=h), foreground="forestgreen") - hill = shape.Circle(xc=1, yc=-1.8, r=1.3) img += image.fill(hill(width=w, height=h), foreground="green") \ No newline at end of file diff --git a/docs/users/getstarted/index.rst b/docs/users/getstarted/index.rst index 65129dce..ea54984a 100644 --- a/docs/users/getstarted/index.rst +++ b/docs/users/getstarted/index.rst @@ -3,7 +3,10 @@ Getting Started =============== +.. nbtutorial:: + .. toctree:: :caption: Index + :maxdepth: 1 first-image diff --git a/tests/conftest.py b/tests/conftest.py index 7468f086..6d8fbed2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ from sphinx.ext.doctest import DoctestDirective from arlunio.doc.image import ArlunioImageDirective +from arlunio.doc.notebook import NBSolutionDirective @py.test.fixture(scope="session") @@ -78,6 +79,7 @@ def parse_rst(rst_mock_settings): # Register any extended directives with docutils. directives.register_directive("doctest", DoctestDirective) directives.register_directive("arlunio-image", ArlunioImageDirective) + directives.register_directive("nbsolution", NBSolutionDirective) def parse(src): parser = Parser() diff --git a/tests/data/doc/arlunio_image/img-solution.rst b/tests/data/doc/arlunio_image/img-solution.rst new file mode 100644 index 00000000..0a55ff8f --- /dev/null +++ b/tests/data/doc/arlunio_image/img-solution.rst @@ -0,0 +1,9 @@ +.. arlunio-image:: Img Solution + :include-code: solution + + :: + + from arlunio.shape import Circle + from arlunio.image import fill + + image = fill(Circle()(width=16, height=16)) diff --git a/tests/data/doc/nbtutorial/bullet_list.ipynb b/tests/data/doc/nbtutorial/bullet_list.ipynb index 1cc17fe9..8c97de47 100644 --- a/tests/data/doc/nbtutorial/bullet_list.ipynb +++ b/tests/data/doc/nbtutorial/bullet_list.ipynb @@ -4,11 +4,13 @@ "cell_type": "markdown", "metadata": {}, "source": [ + "\n", "- Item one\n", "- Item two, for reasons it has been decided to make this item much longer than\n", "the other items to ensure that we can cover the case where the items contiain\n", "longer content\n", - "- Item three\n" + "- Item three\n", + "\n" ] } ], diff --git a/tests/data/doc/nbtutorial/image.ipynb b/tests/data/doc/nbtutorial/image.ipynb new file mode 100644 index 00000000..959cee5e --- /dev/null +++ b/tests/data/doc/nbtutorial/image.ipynb @@ -0,0 +1,15 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "![](resources/image.png)\n" + ] + } + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/tests/data/doc/nbtutorial/image.rst b/tests/data/doc/nbtutorial/image.rst new file mode 100644 index 00000000..eae5f12a --- /dev/null +++ b/tests/data/doc/nbtutorial/image.rst @@ -0,0 +1 @@ +.. image:: /path/to/image.png diff --git a/tests/doc/test_image.py b/tests/doc/test_image.py index 0b7c9150..6a6bc49a 100644 --- a/tests/doc/test_image.py +++ b/tests/doc/test_image.py @@ -6,6 +6,7 @@ import arlunio.image as image from arlunio.doc.image import arlunio_image +from arlunio.doc.notebook import nbsolution class TestArlunioImageDirective: @@ -186,3 +187,38 @@ def test_with_code(self, read_rst): code = legend.children[0] assert isinstance(code, nodes.literal_block), "Expected literal_block node." assert "from arlunio.shape import Circle" in code.astext() + + def test_with_solution(self, read_rst): + """Ensure that the directive handles the case where the user asks for the code + to be included as a solution block.""" + + with mock.patch("arlunio.doc.image.image.save") as m_save: + rst = read_rst(pathlib.Path("doc", "arlunio_image", "img-solution.rst")) + + # Ensure that the image produced by the code was saved to disk. + m_save.assert_called_once() + (img, path), _ = m_save.call_args + + assert isinstance(img, image.Image), "Expected image instance" + assert path == pathlib.Path("/project/docs/_images/img-solution.png") + + # Now ensure that the correct doctree was produced + node = rst.children[0] + assert isinstance(node, arlunio_image), "Expected arlunio image node." + + figure = node.children[0] + assert isinstance(figure, nodes.figure), "Expected figure node." + + imgnode, legend = figure.children + + assert isinstance(imgnode, nodes.image), "Expected image node." + assert imgnode["uri"] == "/_images/img-solution.png" + + assert isinstance(legend, nodes.legend), "Expected legend node." + + soln = legend.children[0] + assert isinstance(soln, nbsolution), "Expected nbsolution node." + + code = soln.children[0] + assert isinstance(code, nodes.literal_block), "Expected literal_block node." + assert "from arlunio.shape import Circle" in code.astext() diff --git a/tests/doc/test_builder.py b/tests/doc/test_notebook.py similarity index 98% rename from tests/doc/test_builder.py rename to tests/doc/test_notebook.py index 06a1dd5e..186e6e84 100644 --- a/tests/doc/test_builder.py +++ b/tests/doc/test_notebook.py @@ -15,6 +15,7 @@ "comment", "doctest_no_output", "heading", + "image", "inline_code", "inline_link", "italic",