Skip to content

Commit

Permalink
Initial attempt at producing a notebook version of the tutorial (#253)
Browse files Browse the repository at this point in the history
  • Loading branch information
alcarney authored Jul 8, 2020
1 parent 239e636 commit 7d4a62c
Show file tree
Hide file tree
Showing 13 changed files with 198 additions and 77 deletions.
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "python",
"request": "launch",
"name": "Pytest: Current File",
"program": "${workspaceFolder}/.dev/bin/pytest",
"program": "${workspaceFolder}/.env/bin/pytest",
"args": [
"${file}"
]
Expand Down
8 changes: 6 additions & 2 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,11 @@
"value": "html"
},
{
"label": "Extract gallery images",
"label": "Build Gallery",
"value": "nbgallery"
},
{
"label": "Extract tutorial",
"label": "Build Tutorial",
"value": "nbtutorial"
},
{
Expand Down Expand Up @@ -200,6 +200,10 @@
{
"label": "Gallery",
"value": "blog/src/gallery"
},
{
"label": "Tutorial",
"value": "docs/_build/nbtutorial"
}
]
}
Expand Down
30 changes: 24 additions & 6 deletions arlunio/doc/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = "<arlunio_image>"
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
Expand Down Expand Up @@ -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):

Expand All @@ -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
Expand Down
141 changes: 94 additions & 47 deletions arlunio/doc/notebook.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -155,41 +157,48 @@ 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")

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 += "["
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
23 changes: 3 additions & 20 deletions docs/users/getstarted/first-image.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
Your First Image
================

.. nbtutorial::

.. arlunio-image:: Sunny Day
:align: center
:gallery: examples
Expand Down Expand Up @@ -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::

Expand All @@ -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")
3 changes: 3 additions & 0 deletions docs/users/getstarted/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
Getting Started
===============

.. nbtutorial::

.. toctree::
:caption: Index
:maxdepth: 1

first-image
Loading

0 comments on commit 7d4a62c

Please sign in to comment.