diff --git a/.gitignore b/.gitignore index d5c63d4..f0d1478 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .coverage +.cache __pycache__ experimental wiki @@ -12,3 +13,11 @@ images build/ dist/ .eggs/ +.idea +pytest.ini +venv +sandbox +pandoc-* + +# internal script for development +convert_testcases.sh diff --git a/.jupyter/jupyter_notebook_config.py b/.jupyter/jupyter_notebook_config.py new file mode 100644 index 0000000..af08e00 --- /dev/null +++ b/.jupyter/jupyter_notebook_config.py @@ -0,0 +1,526 @@ +#--- nbextensions configuration --- +import sys +#--- nbextensions configuration --- +# Configuration file for jupyter-notebook. + +#------------------------------------------------------------------------------ +# Configurable configuration +#------------------------------------------------------------------------------ + +#------------------------------------------------------------------------------ +# SingletonConfigurable configuration +#------------------------------------------------------------------------------ + +# A configurable that only allows one instance. +# +# This class is for classes that should only have one instance of itself or +# *any* subclass. To create and retrieve such a class use the +# :meth:`SingletonConfigurable.instance` method. + +#------------------------------------------------------------------------------ +# Application configuration +#------------------------------------------------------------------------------ + +# This is an application. + +# Set the log level by value or name. +# c.Application.log_level = 30 + +# The date format used by logging formatters for %(asctime)s +# c.Application.log_datefmt = '%Y-%m-%d %H:%M:%S' + +# The Logging format template +# c.Application.log_format = '[%(name)s]%(highlevel)s %(message)s' + +#------------------------------------------------------------------------------ +# JupyterApp configuration +#------------------------------------------------------------------------------ + +# Base class for Jupyter applications + +# Full path of a config file. +# c.JupyterApp.config_file = '' + +# Generate default config file. +# c.JupyterApp.generate_config = False + +# Answer yes to any prompts. +# c.JupyterApp.answer_yes = False + +# Specify a config file to load. +# c.JupyterApp.config_file_name = '' + +#------------------------------------------------------------------------------ +# NotebookApp configuration +#------------------------------------------------------------------------------ + +# Use a regular expression for the Access-Control-Allow-Origin header +# +# Requests from an origin matching the expression will get replies with: +# +# Access-Control-Allow-Origin: origin +# +# where `origin` is the origin of the request. +# +# Ignored if allow_origin is set. +# c.NotebookApp.allow_origin_pat = '' + +# Set the Access-Control-Allow-Credentials: true header +# c.NotebookApp.allow_credentials = False + +# Whether to trust or not X-Scheme/X-Forwarded-Proto and X-Real-Ip/X-Forwarded- +# For headerssent by the upstream reverse proxy. Necessary if the proxy handles +# SSL +# c.NotebookApp.trust_xheaders = False + +# Reraise exceptions encountered loading server extensions? +# c.NotebookApp.reraise_server_extension_failures = False + +# Hashed password to use for web authentication. +# +# To generate, type in a python/IPython shell: +# +# from notebook.auth import passwd; passwd() +# +# The string should be of the form type:salt:hashed-password. +# c.NotebookApp.password = '' + +# Specify what command to use to invoke a web browser when opening the notebook. +# If not specified, the default browser will be determined by the `webbrowser` +# standard library module, which allows setting of the BROWSER environment +# variable to override it. +# c.NotebookApp.browser = '' + +# DEPRECATED use base_url +# c.NotebookApp.base_project_url = '/' + +# The port the notebook server will listen on. +# c.NotebookApp.port = 8888 + +# The base URL for the notebook server. +# +# Leading and trailing slashes can be omitted, and will automatically be added. +# c.NotebookApp.base_url = '/' + +# Supply extra arguments that will be passed to Jinja environment. +# c.NotebookApp.jinja_environment_options = traitlets.Undefined + +# Supply overrides for the tornado.web.Application that the IPython notebook +# uses. +# c.NotebookApp.tornado_settings = traitlets.Undefined + +# The random bytes used to secure cookies. By default this is a new random +# number every time you start the Notebook. Set it to a value in a config file +# to enable logins to persist across server sessions. +# +# Note: Cookie secrets should be kept private, do not share config files with +# cookie_secret stored in plaintext (you can read the value from a file). +# c.NotebookApp.cookie_secret = b'' + +# The url for MathJax.js. +# c.NotebookApp.mathjax_url = '' + +# extra paths to look for Javascript notebook extensions +# c.NotebookApp.extra_nbextensions_path = traitlets.Undefined + +# The session manager class to use. +# c.NotebookApp.session_manager_class = + +# The full path to an SSL/TLS certificate file. +# c.NotebookApp.certfile = '' + +# Python modules to load as notebook server extensions. This is an experimental +# API, and may change in future releases. +# c.NotebookApp.server_extensions = traitlets.Undefined + +# The kernel manager class to use. +# c.NotebookApp.kernel_manager_class = + +# The IP address the notebook server will listen on. +# c.NotebookApp.ip = 'localhost' + +# DISABLED: use %pylab or %matplotlib in the notebook to enable matplotlib. +# c.NotebookApp.pylab = 'disabled' + +# The full path to a private key file for usage with SSL/TLS. +# c.NotebookApp.keyfile = '' + +# DEPRECATED, use tornado_settings +# c.NotebookApp.webapp_settings = traitlets.Undefined + +# Set the Access-Control-Allow-Origin header +# +# Use '*' to allow any origin to access your server. +# +# Takes precedence over allow_origin_pat. +# c.NotebookApp.allow_origin = '' + +# The notebook manager class to use. +# c.NotebookApp.contents_manager_class = + +# The config manager class to use +# c.NotebookApp.config_manager_class = + +# Whether to enable MathJax for typesetting math/TeX +# +# MathJax is the javascript library IPython uses to render math/LaTeX. It is +# very large, so you may want to disable it if you have a slow internet +# connection, or for offline use of the notebook. +# +# When disabled, equations etc. will appear as their untransformed TeX source. +# c.NotebookApp.enable_mathjax = True + +# The kernel spec manager class to use. Should be a subclass of +# `jupyter_client.kernelspec.KernelSpecManager`. +# +# The Api of KernelSpecManager is provisional and might change without warning +# between this version of IPython and the next stable one. +# c.NotebookApp.kernel_spec_manager_class = + +# The number of additional ports to try if the specified port is not available. +# c.NotebookApp.port_retries = 50 + +# Extra paths to search for serving jinja templates. +# +# Can be used to override templates from notebook.templates. +# c.NotebookApp.extra_template_paths = traitlets.Undefined + +# Whether to open in a browser after starting. The specific browser used is +# platform dependent and determined by the python standard library `webbrowser` +# module, unless it is overridden using the --browser (NotebookApp.browser) +# configuration option. +# c.NotebookApp.open_browser = True + +# The default URL to redirect to from `/` +# c.NotebookApp.default_url = '/tree' + +# The file where the cookie secret is stored. +# c.NotebookApp.cookie_secret_file = '' + +# The directory to use for notebooks and kernels. +# c.NotebookApp.notebook_dir = '' + +# Extra paths to search for serving static files. +# +# This allows adding javascript/css to be available from the notebook server +# machine, or overriding individual files in the IPython +# c.NotebookApp.extra_static_paths = traitlets.Undefined + +# The base URL for websockets, if it differs from the HTTP server (hint: it +# almost certainly doesn't). +# +# Should be in the form of an HTTP origin: ws[s]://hostname[:port] +# c.NotebookApp.websocket_url = '' + +# The logout handler class to use. +# c.NotebookApp.logout_handler_class = + +# Extra variables to supply to jinja templates when rendering. +# c.NotebookApp.jinja_template_vars = traitlets.Undefined + +# Supply SSL options for the tornado HTTPServer. See the tornado docs for +# details. +# c.NotebookApp.ssl_options = traitlets.Undefined + +# +# c.NotebookApp.file_to_run = '' + +# The login handler class to use. +# c.NotebookApp.login_handler_class = + +#------------------------------------------------------------------------------ +# LoggingConfigurable configuration +#------------------------------------------------------------------------------ + +# A parent class for Configurables that log. +# +# Subclasses have a log trait, and the default behavior is to get the logger +# from the currently running Application. + +#------------------------------------------------------------------------------ +# ConnectionFileMixin configuration +#------------------------------------------------------------------------------ + +# Mixin for configurable classes that work with connection files + +# JSON file in which to store connection info [default: kernel-.json] +# +# This file will contain the IP, ports, and authentication key needed to connect +# clients to this kernel. By default, this file will be created in the security +# dir of the current profile, but can be specified by absolute path. +# c.ConnectionFileMixin.connection_file = '' + +# set the iopub (PUB) port [default: random] +# c.ConnectionFileMixin.iopub_port = 0 + +# set the control (ROUTER) port [default: random] +# c.ConnectionFileMixin.control_port = 0 + +# set the stdin (ROUTER) port [default: random] +# c.ConnectionFileMixin.stdin_port = 0 + +# set the heartbeat port [default: random] +# c.ConnectionFileMixin.hb_port = 0 + +# set the shell (ROUTER) port [default: random] +# c.ConnectionFileMixin.shell_port = 0 + +# +# c.ConnectionFileMixin.transport = 'tcp' + +# Set the kernel's IP address [default localhost]. If the IP address is +# something other than localhost, then Consoles on other machines will be able +# to connect to the Kernel, so be careful! +# c.ConnectionFileMixin.ip = '' + +#------------------------------------------------------------------------------ +# KernelManager configuration +#------------------------------------------------------------------------------ + +# Manages a single kernel in a subprocess on this host. +# +# This version starts kernels with Popen. + +# DEPRECATED: Use kernel_name instead. +# +# The Popen Command to launch the kernel. Override this if you have a custom +# kernel. If kernel_cmd is specified in a configuration file, Jupyter does not +# pass any arguments to the kernel, because it cannot make any assumptions about +# the arguments that the kernel understands. In particular, this means that the +# kernel does not receive the option --debug if it given on the Jupyter command +# line. +# c.KernelManager.kernel_cmd = traitlets.Undefined + +# Should we autorestart the kernel if it dies. +# c.KernelManager.autorestart = False + +#------------------------------------------------------------------------------ +# Session configuration +#------------------------------------------------------------------------------ + +# Object for handling serialization and sending of messages. +# +# The Session object handles building messages and sending them with ZMQ sockets +# or ZMQStream objects. Objects can communicate with each other over the +# network via Session objects, and only need to work with the dict-based IPython +# message spec. The Session will handle serialization/deserialization, security, +# and metadata. +# +# Sessions support configurable serialization via packer/unpacker traits, and +# signing with HMAC digests via the key/keyfile traits. +# +# Parameters ---------- +# +# debug : bool +# whether to trigger extra debugging statements +# packer/unpacker : str : 'json', 'pickle' or import_string +# importstrings for methods to serialize message parts. If just +# 'json' or 'pickle', predefined JSON and pickle packers will be used. +# Otherwise, the entire importstring must be used. +# +# The functions must accept at least valid JSON input, and output *bytes*. +# +# For example, to use msgpack: +# packer = 'msgpack.packb', unpacker='msgpack.unpackb' +# pack/unpack : callables +# You can also set the pack/unpack callables for serialization directly. +# session : bytes +# the ID of this Session object. The default is to generate a new UUID. +# username : unicode +# username added to message headers. The default is to ask the OS. +# key : bytes +# The key used to initialize an HMAC signature. If unset, messages +# will not be signed or checked. +# keyfile : filepath +# The file containing a key. If this is set, `key` will be initialized +# to the contents of the file. + +# Threshold (in bytes) beyond which a buffer should be sent without copying. +# c.Session.copy_threshold = 65536 + +# The digest scheme used to construct the message signatures. Must have the form +# 'hmac-HASH'. +# c.Session.signature_scheme = 'hmac-sha256' + +# The UUID identifying this session. +# c.Session.session = '' + +# The name of the packer for serializing messages. Should be one of 'json', +# 'pickle', or an import name for a custom callable serializer. +# c.Session.packer = 'json' + +# The maximum number of digests to remember. +# +# The digest history will be culled when it exceeds this value. +# c.Session.digest_history_size = 65536 + +# path to file containing execution key. +# c.Session.keyfile = '' + +# The maximum number of items for a container to be introspected for custom +# serialization. Containers larger than this are pickled outright. +# c.Session.item_threshold = 64 + +# Threshold (in bytes) beyond which an object's buffer should be extracted to +# avoid pickling. +# c.Session.buffer_threshold = 1024 + +# Debug output in the Session +# c.Session.debug = False + +# The name of the unpacker for unserializing messages. Only used with custom +# functions for `packer`. +# c.Session.unpacker = 'json' + +# execution key, for signing messages. +# c.Session.key = b'' + +# Metadata dictionary, which serves as the default top-level metadata dict for +# each message. +# c.Session.metadata = traitlets.Undefined + +# Username for the Session. Default is your system username. +# c.Session.username = 'sturm' + +#------------------------------------------------------------------------------ +# MultiKernelManager configuration +#------------------------------------------------------------------------------ + +# A class for managing multiple kernels. + +# The name of the default kernel to start +# c.MultiKernelManager.default_kernel_name = 'python3' + +# The kernel manager class. This is configurable to allow subclassing of the +# KernelManager for customized behavior. +# c.MultiKernelManager.kernel_manager_class = 'jupyter_client.ioloop.IOLoopKernelManager' + +#------------------------------------------------------------------------------ +# MappingKernelManager configuration +#------------------------------------------------------------------------------ + +# A KernelManager that handles notebook mapping and HTTP error handling + +# +# c.MappingKernelManager.root_dir = '' + +#------------------------------------------------------------------------------ +# ContentsManager configuration +#------------------------------------------------------------------------------ + +# Base class for serving files and directories. +# +# This serves any text or binary file, as well as directories, with special +# handling for JSON notebook documents. +# +# Most APIs take a path argument, which is always an API-style unicode path, and +# always refers to a directory. +# +# - unicode, not url-escaped +# - '/'-separated +# - leading and trailing '/' will be stripped +# - if unspecified, path defaults to '', +# indicating the root path. + +# Python callable or importstring thereof +# +# To be called on a contents model prior to save. +# +# This can be used to process the structure, such as removing notebook outputs +# or other side effects that should not be saved. +# +# It will be called as (all arguments passed by keyword):: +# +# hook(path=path, model=model, contents_manager=self) +# +# - model: the model to be saved. Includes file contents. +# Modifying this dict will affect the file that is stored. +# - path: the API path of the save destination +# - contents_manager: this ContentsManager instance +# c.ContentsManager.pre_save_hook = None + +# +# c.ContentsManager.checkpoints = traitlets.Undefined + +# The base name used when creating untitled notebooks. +# c.ContentsManager.untitled_notebook = 'Untitled' + +# The base name used when creating untitled files. +# c.ContentsManager.untitled_file = 'untitled' + +# +# c.ContentsManager.checkpoints_kwargs = traitlets.Undefined + +# +# c.ContentsManager.checkpoints_class = + +# The base name used when creating untitled directories. +# c.ContentsManager.untitled_directory = 'Untitled Folder' + +# Glob patterns to hide in file and directory listings. +# c.ContentsManager.hide_globs = traitlets.Undefined + +#------------------------------------------------------------------------------ +# FileContentsManager configuration +#------------------------------------------------------------------------------ + +# DEPRECATED, use post_save_hook +# c.FileContentsManager.save_script = False + +# +# c.FileContentsManager.root_dir = '' + +# Python callable or importstring thereof +# +# to be called on the path of a file just saved. +# +# This can be used to process the file on disk, such as converting the notebook +# to a script or HTML via nbconvert. +# +# It will be called as (all arguments passed by keyword):: +# +# hook(os_path=os_path, model=model, contents_manager=instance) +# +# - path: the filesystem path to the file just written - model: the model +# representing the file - contents_manager: this ContentsManager instance +# c.FileContentsManager.post_save_hook = None + +#------------------------------------------------------------------------------ +# NotebookNotary configuration +#------------------------------------------------------------------------------ + +# A class for computing and verifying notebook signatures. + +# The file where the secret key is stored. +# c.NotebookNotary.secret_file = '' + +# The secret key with which notebooks are signed. +# c.NotebookNotary.secret = b'' + +# The number of notebook signatures to cache. When the number of signatures +# exceeds this value, the oldest 25% of signatures will be culled. +# c.NotebookNotary.cache_size = 65535 + +# The hashing algorithm used to sign notebooks. +# c.NotebookNotary.algorithm = 'sha256' + +# The sqlite file in which to store notebook signatures. By default, this will +# be in your Jupyter runtime directory. You can set it to ':memory:' to disable +# sqlite writing to the filesystem. +# c.NotebookNotary.db_file = '' + +#------------------------------------------------------------------------------ +# KernelSpecManager configuration +#------------------------------------------------------------------------------ + +# Whitelist of allowed kernel names. +# +# By default, all installed kernels are allowed. +# c.KernelSpecManager.whitelist = traitlets.Undefined + +c.NotebookApp.server_extensions.append('ipyparallel.nbextension') + +c.NotebookApp.contents_manager_class = 'ipymd.IPymdContentsManager' +c.IPymdContentsManager.format = 'rmarkdown' + +c.NotebookApp.iopub_data_rate_limit = 1e8 diff --git a/Makefile b/Makefile index f487e3c..53c7924 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,18 @@ +VENV=venv/bin +PIP=$(VENV)/pip +PYTHON=$(VENV)/python + help: @echo "clean - remove all build, test, coverage and Python artifacts" @echo "clean-build - remove build artifacts" @echo "clean-pyc - remove Python file artifacts" + @echo "clean-venv - remove Python virtual environment" + @echo "python - create Python virtual environment and install all required packages" @echo "lint - check style with flake8" @echo "test - run tests quickly with the default Python" + @echo "jupyter - run jupyter notebook in a virtual environment with ipymd" -clean: clean-build clean-pyc +clean: clean-build clean-pyc clean-venv clean-build: rm -fr build/ @@ -18,8 +25,37 @@ clean-pyc: find . -name '*~' -exec rm -f {} + find . -name '__pycache__' -exec rm -fr {} + -lint: - flake8 ipymd setup.py --exclude=ipymd/ext/six.py,ipymd/core/contents_manager.py --ignore=E226,E265,F401,F403,F811 +clean-venv: + rm -rf venv/ + +lint: | $(PYTHON) + $(VENV)/flake8 ipymd setup.py --exclude=ipymd/ext/six.py,ipymd/core/contents_manager.py,ipymd/formats/tests/test_rmarkdown.py --ignore=E226,E265,F401,F403,F811 + +test: lint | $(PYTHON) + $(PYTHON) setup.py test + +jupyter: | $(PYTHON) + ./venv/bin/jupyter notebook --config=./.jupyter/jupyter_notebook_config.py + +################################################################################ +# Setup python virtual environment +################################################################################ + +.PHONY: python +python: $(PYTHON) + +venv: + virtualenv venv -p /usr/bin/python3 + +$(PIP): | venv + $(PIP) install --upgrade pip + +venv/.installed: requirements-dev.txt | venv + $(PIP) install -Ur requirements-dev.txt + $(PIP) install -e . + $(PYTHON) -c 'import pypandoc; pypandoc.download_pandoc(version="1.19.1")' + echo "pip install successful" > $@ + +$(PYTHON): | $(PIP) venv/.installed + -test: lint - python setup.py test diff --git a/README.md b/README.md index e96f004..966c451 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,183 @@ [![Build Status](https://travis-ci.org/rossant/ipymd.svg?branch=travis)](https://travis-ci.org/rossant/ipymd) [![Coverage Status](https://coveralls.io/repos/rossant/ipymd/badge.svg)](https://coveralls.io/r/rossant/ipymd) -# Replace .ipynb with .md in the IPython Notebook - -The goal of ipymd is to replace `.ipynb` notebook files like: - -```json -{ - "cells": [ - { - "cell_type": "markdown", - "source": [ - "Here is some Python code:" - ] - }, - { - "cell_type": "code", - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Hello world!\n" - ] - } - ], - "source": [ - "print(\"Hello world!\")" - ] - } - ... - ] -} -``` - -with: - - Here is some Python code: - - ```python - >>> print("Hello world!") - Hello world! - ``` - -The JSON `.ipynb` are removed from the equation, and the conversion happens on the fly. The IPython Notebook becomes an interactive Markdown text editor! +# Store Jupyter notebooks in markdown format. +This package provides an alternative content manager for jupyter. +It allows to store notebooks in text-based formats, replacing the native json-based `.ipynb`. -A drawback is that you lose prompt numbers and images (for now). +This combines the advantages of a simple, text-based format (vi and git-friendly) with jupyter's powerful UI for interactively editing code and text. -This is useful when you write technical documents, blog posts, books, etc. -![image](https://cloud.githubusercontent.com/assets/1942359/5570181/f656a484-8f7d-11e4-8ec2-558d022b13d3.png) +## Overview of formats +Ipymd currently supports the following formats: -## Installation +| Format | Extension | vi | git | images | +| ------------ | ------------------ | -- | --- | ------ | +| [notebook](#ipython-notebook-ipynb) | `.ipynb` | | | ✔ | +| [rmarkdown](#rmarkdown-rmd--rnotebook-nbhtml) | `.Rmd`, `.nb.html` | ✔ | ✔ | ✔ | +| [markdown]() | `.md` | ✔ | (✔) | | +| [atlas](#oreilly-atlas-md) | `.md` | ✔ | (✔) | | +| [opendocument](#opendocument-odt) | `.odt` | | | | +| [python](#python-py) | `.py` | ✔ | (✔) | | -1. Install ipymd: +✔ works; (✔) works with limitations - To install the latest release version: - ```shell - pip install ipymd - ``` +## Usage +Ipymd hooks into jupyter, enabling to open the files directly in jupyter notebook. - Alternatively, to install the development version: +Alternatively, you can use ipymd to convert between the formats from command line: - ```shell - pip install git+https://github.com/rossant/ipymd - ``` +``` +ipymd my_notebook.ipynb --from notebook --to markdown +``` -2. **Optional:** - To interact with `.ipynb` files: +Additional options: +``` + -h, --help show this help message and exit + --output OUTPUT output folder + --extension EXTENSION + output file extension + --overwrite overwrite target file if it exists (false by default) +``` - ```shell - pip install jupyter ipython - ``` - To interact with `.odt` files: +## Installation +There are two possibilities to use ipymd: +1. **Within a virtual environment, for testing and developing** ```shell - pip install git+https://github.com/eea/odfpy + git clone https://github.com/rossant/ipymd + make jupyter ``` + will setup a virtual environment and run a `jupyter notebook` instance with ipymd activated. -3. Open your `jupyter_notebook_config.py`. Here's how to find it: - - - ``` - jupyter notebook --generate-config # generate a default config file - jupyter --config-dir # find out the path to the config file + You can choose the format by editing `.jupyter/jupyter_notebook_config.py`: + ```python + c.IPymdContentsManager.format = 'rmarkdown' # choose the format here ``` -4. Add the following in `jupyter_notebook_config.py`: - ```python - c.NotebookApp.contents_manager_class = 'ipymd.IPymdContentsManager' - ``` +2. **Integrated into your local jupyter installation** + * Install ipymd + ```shell + pip install ipymd + ``` -5. Now, you can open `.md` files in the Notebook. + * Open your `jupyter_notebook_config.py`. Here's how to find it: + ``` + jupyter notebook --generate-config # generate a default config file + jupyter --config-dir # find out the path to the config file + ``` -## Why? + * Add the following in `jupyter_notebook_config.py`: + ```python + c.NotebookApp.contents_manager_class = 'ipymd.IPymdContentsManager' + c.IPymdContentsManager.format = 'rmarkdown' # choose the format here + ``` -### IPython Notebook + * (re)start jupyter -Pros: +**Optional:** +To interact with `.odt` files: -* Excellent UI for executing code interactively *and* writing text +```shell +pip install git+https://github.com/eea/odfpy +``` -Cons: +## Caveats -* `.ipynb` not git-friendly -* Cannot easily edit in a text editor -* Cannot easily edit on GitHub's web interface +**WARNING**: use this library at your own risks, backup your data, and version-control your notebooks and Markdown files! +* Renaming doesn't work yet (issue #4) +* New notebook doesn't work yet (issue #5) +* Only nbformat v4 is supported currently (IPython 3.0) -### Markdown -Pros: -* Simple ASCII/Unicode format to write code and text -* Can easily edit in a text editor -* Can easily edit on GitHub's web interface -* Git-friendly +## Formats +### IPython notebook (`.ipynb`) +Jupyter's default notebook format. It stores cells as json-objects. +The main downsides of this format are +* not git-friendly +* cannot easily edit in a text editor +* cannot easily edit on GitHub's web interface -Cons: +[Format documentation](http://nbformat.readthedocs.io/en/latest/) -* No UI to execute code interactively +### RMarkdown (`.Rmd`) / RNotebook (`.nb.html`) +RMarkdown is propagated by rstudio and widely adopted within the R community. +Unlike the name suggests, it can very well be used with python. +The clue about this format is, that it strictly separates source code from output. +This makes it the format of choice when working with version control. -### ipymd +While the source code is stored as markdown in a `.Rmd` file, the results go into a +`.nb.html` file which can also be viewed in a browser. -All pros of IPython Notebook and Markdown, no cons! +[Format documentation](http://rmarkdown.rstudio.com/r_notebooks.html) -## How it works +#### Known issues +See [grst/ipymd/issues](https://github.com/grst/ipymd/issues) for issues related to rmarkdown. Major issues: +* HTML formatting can be improved +* Some output is not compatible with rstudio -* Write in Markdown in `document.md` - * Either in a text editor (convenient when working on text) - * Or in the Notebook (convenient when writing code examples) -* Markdown cells, code cells and (optionally) notebook metadata are saved in - the file -* Collaborators can work on the Markdown document using GitHub's web interface. +#### Implementation of `.Rmd` format +* markdown cells are saved as plain markdown +* code cells are saved as code chunks, separated by a newline + ~~~ + ```{python, some="meta", data=True} + print("Hello World!") + ``` + ~~~ + Note the curly braced `{}` which distinguish an executed code chunk from + a code chunk within markdown. +* metadata is saved as chunk options. + * Both python and R literals are supported (`NULL`, `None`, `TRUE`, `True`, `FALSE`, `False`), + but always saved as R literals to maintain compatibility with rstudio. + * Both single and double quoted strings are supported. + * We try to parse unquoted options as literal, then as integer, then as float. If all three fail a `TypeError` is raised. + +#### Implementation of `nb.html` format. +* This format stores the outputs of the notebook in a way that + * the outputs can be read from jupyter + * the entire notebook can be viewed from a browser +* a html templated is used, which is filled using `jinja2`. +* markdown cells are saved within `...` tags +* code cells are saved within `chunk` tags: + ``` + + +
...
+ + + ... + + + + + + ``` +* tags cannot be nested +* a `chunk` may hold an arbitrary number of `outputs` +* tags hold data as base64 encoded json dictionaries as follows: + * rnb-source-begin: + ~~~ + {'data': '```python\n chunk as markdown```'} + ~~~ + * rnb-output-begin/rnb-plot-begin + ~~~ + {'data': '', # fallback for rstudio + 'ipymd.data': {'text/plain': ..., # output['data'] from jupyter nbformat + 'image/png': ..., + ... }, + 'ipymd.metadata': {}, # output['metadata'] from jupyter nbformat + 'ipymd.output_type': 'display_data'} # output['output_type'] from jupyter nbformat + ~~~ + + +### Markdown (`.md`) * By convention, a **notebook code cell** is equivalent to a **Markdown code block with explicit `python` syntax highlighting**: ``` @@ -194,26 +234,23 @@ All pros of IPython Notebook and Markdown, no cons! * Text output and standard output are combined into a single text output (stdout lines first, output lines last) -## Caveats -**WARNING**: use this library at your own risks, backup your data, and version-control your notebooks and Markdown files! +### O'Reilly Atlas (`.md`) +* `.md` with special HTML tags for code and mathematical equations +[Format documentation](http://odewahn.github.io/publishing-workflows-for-jupyter/#1) ( -* Renaming doesn't work yet (issue #4) -* New notebook doesn't work yet (issue #5) -* Only nbformat v4 is supported currently (IPython 3.0) +### Python (`.py`) +* code cells are delimited by double line breaks. +* Markdown cells = Python comments. +* [TODO: this doesn't work well, see #28 and #31] +### Opendocument (`.odt`). +* You need to install the [development version of odfpy](https://github.com/eea/odfpy/). -## Formats -ipymd uses a modular architecture that lets you define new formats. The following formats are currently implemented, and can be selected by modifying `~/.ipython/profile_/ipython_notebook_config.py`: -* IPython notebook (`.ipynb`) -* Markdown (`.md`) - * `c.IPymdContentsManager.format = 'markdown'` -* [O'Reilly Atlas](http://odewahn.github.io/publishing-workflows-for-jupyter/#1) (`.md` with special HTML tags for code and mathematical equations) - * `c.IPymdContentsManager.format = 'atlas'` -* Python (`.py`): code cells are delimited by double line breaks. Markdown cells = Python comments. [TODO: this doesn't work well, see #28 and #31] -* Opendocument (`.odt`). You need to install the [development version of odfpy](https://github.com/eea/odfpy/). +## Implementing your own format +ipymd uses a modular architecture that lets you define new formats. The following formats are currently implemented, and can be selected by modifying `~/.ipython/profile_/ipython_notebook_config.py`: You can convert from any supported format to any supported format. This works by converting to an intermediate format that is basically a list of notebook cells. diff --git a/examples/ex5.notebook.ipynb b/examples/ex5.notebook.ipynb new file mode 100644 index 0000000..9def03b --- /dev/null +++ b/examples/ex5.notebook.ipynb @@ -0,0 +1,86 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Header\n", + "\n", + "A paragraph.\n", + "\n", + "test" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "foo = 'bar'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Python code:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Hello world!\n" + ] + } + ], + "source": [ + "print(\"Hello world!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "JavaScript code:\n", + "\n", + "```javascript\n", + "console.log(\"Hello world!\");\n", + "```\n", + "\n", + "test" + ] + } + ], + "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.6.2" + }, + "output": "html_notebook", + "title": "Example 5" + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/ex5.rmarkdown.Rmd b/examples/ex5.rmarkdown.Rmd new file mode 100644 index 0000000..2d0b0be --- /dev/null +++ b/examples/ex5.rmarkdown.Rmd @@ -0,0 +1,42 @@ +--- +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.6.2 +output: html_notebook +title: Example 5 +--- + +# Header + +A paragraph. + +test + +```{python, collapsed=TRUE} +foo = 'bar' +``` + +Python code: + +```{python} +print("Hello world!") +``` + +JavaScript code: + +```javascript +console.log("Hello world!"); +``` + +test diff --git a/examples/ex5.rmarkdown.nb.html b/examples/ex5.rmarkdown.nb.html new file mode 100644 index 0000000..273fec5 --- /dev/null +++ b/examples/ex5.rmarkdown.nb.html @@ -0,0 +1,243 @@ + + + + + + + + + + + + + +Example 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +

Header

+

A paragraph.

+

test

+ + + +
foo = 'bar'
+ + + +

Python code:

+ + + +
print("Hello world!")
+ + +
Hello world!
+ + + +

JavaScript code:

+
console.log("Hello world!");
+

test

+ + + +
LS0tCmtlcm5lbHNwZWM6CiAgZGlzcGxheV9uYW1lOiBQeXRob24gMwogIGxhbmd1YWdlOiBweXRob24KICBuYW1lOiBweXRob24zCmxhbmd1YWdlX2luZm86CiAgY29kZW1pcnJvcl9tb2RlOgogICAgbmFtZTogaXB5dGhvbgogICAgdmVyc2lvbjogMwogIGZpbGVfZXh0ZW5zaW9uOiAucHkKICBtaW1ldHlwZTogdGV4dC94LXB5dGhvbgogIG5hbWU6IHB5dGhvbgogIG5iY29udmVydF9leHBvcnRlcjogcHl0aG9uCiAgcHlnbWVudHNfbGV4ZXI6IGlweXRob24zCiAgdmVyc2lvbjogMy42LjIKb3V0cHV0OiBodG1sX25vdGVib29rCnRpdGxlOiBFeGFtcGxlIDUKLS0tCgojIEhlYWRlcgoKQSBwYXJhZ3JhcGguCgp0ZXN0CgpgYGB7cHl0aG9uLCBjb2xsYXBzZWQ9VFJVRX0KZm9vID0gJ2JhcicKYGBgCgpQeXRob24gY29kZToKCmBgYHtweXRob259CnByaW50KCJIZWxsbyB3b3JsZCEiKQpgYGAKCkphdmFTY3JpcHQgY29kZToKCmBgYGphdmFzY3JpcHQKY29uc29sZS5sb2coIkhlbGxvIHdvcmxkISIpOwpgYGAKCnRlc3QK
+ + + +
+ + + + + + + + diff --git a/examples/ex5.rmarkdown.rstudio.Rmd b/examples/ex5.rmarkdown.rstudio.Rmd new file mode 100644 index 0000000..2d0b0be --- /dev/null +++ b/examples/ex5.rmarkdown.rstudio.Rmd @@ -0,0 +1,42 @@ +--- +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.6.2 +output: html_notebook +title: Example 5 +--- + +# Header + +A paragraph. + +test + +```{python, collapsed=TRUE} +foo = 'bar' +``` + +Python code: + +```{python} +print("Hello world!") +``` + +JavaScript code: + +```javascript +console.log("Hello world!"); +``` + +test diff --git a/examples/ex5.rmarkdown.rstudio.nb.html b/examples/ex5.rmarkdown.rstudio.nb.html new file mode 100644 index 0000000..f1f0058 --- /dev/null +++ b/examples/ex5.rmarkdown.rstudio.nb.html @@ -0,0 +1,244 @@ + + + + + + + + + + + + + +Example 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
LS0tCmtlcm5lbHNwZWM6CiAgZGlzcGxheV9uYW1lOiBQeXRob24gMwogIGxhbmd1YWdlOiBweXRob24KICBuYW1lOiBweXRob24zCmxhbmd1YWdlX2luZm86CiAgY29kZW1pcnJvcl9tb2RlOgogICAgbmFtZTogaXB5dGhvbgogICAgdmVyc2lvbjogMwogIGZpbGVfZXh0ZW5zaW9uOiAucHkKICBtaW1ldHlwZTogdGV4dC94LXB5dGhvbgogIG5hbWU6IHB5dGhvbgogIG5iY29udmVydF9leHBvcnRlcjogcHl0aG9uCiAgcHlnbWVudHNfbGV4ZXI6IGlweXRob24zCiAgdmVyc2lvbjogMy42LjIKb3V0cHV0OiBodG1sX25vdGVib29rCnRpdGxlOiBFeGFtcGxlIDUKLS0tCgojIEhlYWRlcgoKQSBwYXJhZ3JhcGguCgp0ZXN0CgpgYGB7cHl0aG9uLCBjb2xsYXBzZWQ9VFJVRX0KZm9vID0gJ2JhcicKYGBgCgpQeXRob24gY29kZToKCmBgYHtweXRob259CnByaW50KCJIZWxsbyB3b3JsZCEiKQpgYGAKCkphdmFTY3JpcHQgY29kZToKCmBgYGphdmFzY3JpcHQKY29uc29sZS5sb2coIkhlbGxvIHdvcmxkISIpOwpgYGAKCnRlc3QK
+ + + +
+ + + + + + + + diff --git a/examples/ex6.notebook.ipynb b/examples/ex6.notebook.ipynb new file mode 100644 index 0000000..ddb744e --- /dev/null +++ b/examples/ex6.notebook.ipynb @@ -0,0 +1,271 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Python notebook tests\n", + "\n", + "Some literal python which is not evaluated: \n", + "\n", + "```python\n", + "print(\"Hello World!\")\n", + "```\n", + "\n", + "## Advanved Markdown\n", + "\n", + "Foo bar kk\n", + "\n", + "$\\sum_{i=1}^n 2^i$\n", + "\n", + "## Special characters, text output" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ä'<>$& 0\n", + "ä'<>$& 1\n", + "ä'<>$& 2\n", + "ä'<>$& 3\n", + "ä'<>$& 4\n", + "ä'<>$& 5\n", + "ä'<>$& 6\n", + "ä'<>$& 7\n", + "ä'<>$& 8\n", + "ä'<>$& 9\n" + ] + } + ], + "source": [ + "for i in range(10):\n", + " print(\"ä'<>$& \" + str(i))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multiple images in the same output" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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\nrkJggg==\n", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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\nQmCC\n", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "n = 256\n", + "X = np.linspace(-np.pi,np.pi,n,endpoint=True)\n", + "Y = np.sin(2*X)\n", + "\n", + "fig, ax = plt.subplots( nrows=1, ncols=1 )\n", + "ax.plot (X, Y+1, color='blue', alpha=1.00)\n", + "ax.plot (X, Y-1, color='blue', alpha=1.00)\n", + "plt.show()\n", + "\n", + "fig, ax = plt.subplots( nrows=1, ncols=1 )\n", + "ax.plot (X, Y+1, color='red', alpha=1.00)\n", + "ax.plot (X, Y-1, color='red', alpha=1.00)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "collapsed": true + }, + "source": [ + "## A table" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
xy
000
112
224
336
448
5510
6612
7714
8816
9918
\n", + "
" + ], + "text/plain": [ + " x y\n", + "0 0 0\n", + "1 1 2\n", + "2 2 4\n", + "3 3 6\n", + "4 4 8\n", + "5 5 10\n", + "6 6 12\n", + "7 7 14\n", + "8 8 16\n", + "9 9 18" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "df = pd.DataFrame().assign(x=range(10), y=range(0, 20, 2))\n", + "df" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## An Error" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "ename": "SyntaxError", + "evalue": "invalid syntax (, line 3)", + "output_type": "error", + "traceback": [ + "\u001b[0;36m File \u001b[0;32m\"\"\u001b[0;36m, line \u001b[0;32m3\u001b[0m\n\u001b[0;31m knitr::knit_engines$set(python = reticulate::eng_python)\u001b[0m\n\u001b[0m ^\u001b[0m\n\u001b[0;31mSyntaxError\u001b[0m\u001b[0;31m:\u001b[0m invalid syntax\n" + ] + } + ], + "source": [ + "library(knitr)\n", + "library(reticulate)\n", + "knitr::knit_engines$set(python = reticulate::eng_python)" + ] + } + ], + "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.6.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/ex6.rmarkdown.Rmd b/examples/ex6.rmarkdown.Rmd new file mode 100644 index 0000000..f1ea095 --- /dev/null +++ b/examples/ex6.rmarkdown.Rmd @@ -0,0 +1,74 @@ +--- +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.6.3 +--- + +# Python notebook tests + +Some literal python which is not evaluated: + +```python +print("Hello World!") +``` + +## Advanved Markdown + +Foo bar kk + +$\sum_{i=1}^n 2^i$ + +## Special characters, text output + +```{python} +for i in range(10): + print("ä'<>$& " + str(i)) +``` + +## Multiple images in the same output + +```{python} +import numpy as np +import matplotlib.pyplot as plt + +n = 256 +X = np.linspace(-np.pi,np.pi,n,endpoint=True) +Y = np.sin(2*X) + +fig, ax = plt.subplots( nrows=1, ncols=1 ) +ax.plot (X, Y+1, color='blue', alpha=1.00) +ax.plot (X, Y-1, color='blue', alpha=1.00) +plt.show() + +fig, ax = plt.subplots( nrows=1, ncols=1 ) +ax.plot (X, Y+1, color='red', alpha=1.00) +ax.plot (X, Y-1, color='red', alpha=1.00) +plt.show() +``` + +## A table + +```{python} +import pandas as pd +df = pd.DataFrame().assign(x=range(10), y=range(0, 20, 2)) +df +``` + +## An Error + +```{python} +library(knitr) +library(reticulate) +knitr::knit_engines$set(python = reticulate::eng_python) +``` diff --git a/examples/ex6.rmarkdown.nb.html b/examples/ex6.rmarkdown.nb.html new file mode 100644 index 0000000..84ef3cd --- /dev/null +++ b/examples/ex6.rmarkdown.nb.html @@ -0,0 +1,821 @@ + + + + + + + + + + + + + +IPYMD Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +

Python notebook tests

+

Some literal python which is not evaluated:

+
print("Hello World!")
+

Advanved Markdown

+

Foo bar kk

+

$\sum_{i=1}^n 2^i$

+

Special characters, text output

+ + + +
for i in range(10):
+    print("ä'<>$& " + str(i))
+ + +
ä'<>$& 0
+ä'<>$& 1
+ä'<>$& 2
+ä'<>$& 3
+ä'<>$& 4
+ä'<>$& 5
+ä'<>$& 6
+ä'<>$& 7
+ä'<>$& 8
+ä'<>$& 9
+ + + +

Multiple images in the same output

+ + + +
import numpy as np
+import matplotlib.pyplot as plt
+
+n = 256
+X = np.linspace(-np.pi,np.pi,n,endpoint=True)
+Y = np.sin(2*X)
+
+fig, ax = plt.subplots( nrows=1, ncols=1 )
+ax.plot (X, Y+1, color='blue', alpha=1.00)
+ax.plot (X, Y-1, color='blue', alpha=1.00)
+plt.show()
+
+fig, ax = plt.subplots( nrows=1, ncols=1 )
+ax.plot (X, Y+1, color='red', alpha=1.00)
+ax.plot (X, Y-1, color='red', alpha=1.00)
+plt.show()
+ + +

+ +

+ + +

A table

+ + + +
import pandas as pd
+df = pd.DataFrame().assign(x=range(10), y=range(0, 20, 2))
+df
+ + +['
\n', '\n', '\n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', ' \n', '
xy
000
112
224
336
448
5510
6612
7714
8816
9918
\n', '
'] + + +

An Error

+ + + +
library(knitr)
+library(reticulate)
+knitr::knit_engines$set(python = reticulate::eng_python)
+ + +
  File "", line 3
+    knitr::knit_engines$set(python = reticulate::eng_python)
+          ^
+SyntaxError: invalid syntax
+ + + + +
LS0tCmtlcm5lbHNwZWM6CiAgZGlzcGxheV9uYW1lOiBQeXRob24gMwogIGxhbmd1YWdlOiBweXRob24KICBuYW1lOiBweXRob24zCmxhbmd1YWdlX2luZm86CiAgY29kZW1pcnJvcl9tb2RlOgogICAgbmFtZTogaXB5dGhvbgogICAgdmVyc2lvbjogMwogIGZpbGVfZXh0ZW5zaW9uOiAucHkKICBtaW1ldHlwZTogdGV4dC94LXB5dGhvbgogIG5hbWU6IHB5dGhvbgogIG5iY29udmVydF9leHBvcnRlcjogcHl0aG9uCiAgcHlnbWVudHNfbGV4ZXI6IGlweXRob24zCiAgdmVyc2lvbjogMy42LjMKLS0tCgojIFB5dGhvbiBub3RlYm9vayB0ZXN0cwoKU29tZSBsaXRlcmFsIHB5dGhvbiB3aGljaCBpcyBub3QgZXZhbHVhdGVkOgoKYGBgcHl0aG9uCnByaW50KCJIZWxsbyBXb3JsZCEiKQpgYGAKCiMjIEFkdmFudmVkIE1hcmtkb3duCgpGb28gYmFyIGtrCgokXHN1bV97aT0xfV5uIDJeaSQKCiMjIFNwZWNpYWwgY2hhcmFjdGVycywgdGV4dCBvdXRwdXQKCmBgYHtweXRob259CmZvciBpIGluIHJhbmdlKDEwKToKICAgIHByaW50KCLDpCc8PiQmICIgKyBzdHIoaSkpCmBgYAoKIyMgTXVsdGlwbGUgaW1hZ2VzIGluIHRoZSBzYW1lIG91dHB1dAoKYGBge3B5dGhvbn0KaW1wb3J0IG51bXB5IGFzIG5wCmltcG9ydCBtYXRwbG90bGliLnB5cGxvdCBhcyBwbHQKCm4gPSAyNTYKWCA9IG5wLmxpbnNwYWNlKC1ucC5waSxucC5waSxuLGVuZHBvaW50PVRydWUpClkgPSBucC5zaW4oMipYKQoKZmlnLCBheCA9IHBsdC5zdWJwbG90cyggbnJvd3M9MSwgbmNvbHM9MSApCmF4LnBsb3QgKFgsIFkrMSwgY29sb3I9J2JsdWUnLCBhbHBoYT0xLjAwKQpheC5wbG90IChYLCBZLTEsIGNvbG9yPSdibHVlJywgYWxwaGE9MS4wMCkKcGx0LnNob3coKQoKZmlnLCBheCA9IHBsdC5zdWJwbG90cyggbnJvd3M9MSwgbmNvbHM9MSApCmF4LnBsb3QgKFgsIFkrMSwgY29sb3I9J3JlZCcsIGFscGhhPTEuMDApCmF4LnBsb3QgKFgsIFktMSwgY29sb3I9J3JlZCcsIGFscGhhPTEuMDApCnBsdC5zaG93KCkKYGBgCgojIyBBIHRhYmxlCgpgYGB7cHl0aG9ufQppbXBvcnQgcGFuZGFzIGFzIHBkCmRmID0gcGQuRGF0YUZyYW1lKCkuYXNzaWduKHg9cmFuZ2UoMTApLCB5PXJhbmdlKDAsIDIwLCAyKSkKZGYKYGBgCgojIyBBbiBFcnJvcgoKYGBge3B5dGhvbn0KbGlicmFyeShrbml0cikKbGlicmFyeShyZXRpY3VsYXRlKQprbml0cjo6a25pdF9lbmdpbmVzJHNldChweXRob24gPSByZXRpY3VsYXRlOjplbmdfcHl0aG9uKQpgYGAK
+ + + +
+ + + + + + + + diff --git a/examples/ex7.notebook.ipynb b/examples/ex7.notebook.ipynb new file mode 100644 index 0000000..992fd9b --- /dev/null +++ b/examples/ex7.notebook.ipynb @@ -0,0 +1,103 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "## R notebook with R kernel\n\nSome literal R code which should not be evaluated\n```r\nstop(\"not evaluated\")\n```" + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "trusted": true + }, + "outputs": [ + { + "data": { + "text/plain": "Loading tidyverse: ggplot2\nLoading tidyverse: tibble\nLoading tidyverse: tidyr\nLoading tidyverse: readr\nLoading tidyverse: purrr\nLoading tidyverse: dplyr\nConflicts with tidy packages ---------------------------------------------------\nfilter(): dplyr, stats\nlag(): dplyr, stats" + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": "library(tidyverse)" + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "trusted": true + }, + "outputs": [ + { + "data": { + "text/html": "
    \n\t
  1. 2
  2. \n\t
  3. 4
  4. \n\t
  5. 6
  6. \n\t
  7. 8
  8. \n\t
  9. 10
  10. \n\t
  11. 12
  12. \n\t
  13. 14
  14. \n\t
  15. 16
  16. \n\t
  17. 18
  18. \n\t
  19. 20
  20. \n\t
  21. 22
  22. \n\t
  23. 24
  24. \n\t
  25. 26
  26. \n\t
  27. 28
  28. \n\t
  29. 30
  30. \n\t
  31. 32
  32. \n\t
  33. 34
  34. \n\t
  35. 36
  36. \n\t
  37. 38
  38. \n\t
  39. 40
  40. \n
\n", + "text/latex": "\\begin{enumerate*}\n\\item 2\n\\item 4\n\\item 6\n\\item 8\n\\item 10\n\\item 12\n\\item 14\n\\item 16\n\\item 18\n\\item 20\n\\item 22\n\\item 24\n\\item 26\n\\item 28\n\\item 30\n\\item 32\n\\item 34\n\\item 36\n\\item 38\n\\item 40\n\\end{enumerate*}\n", + "text/markdown": "1. 2\n2. 4\n3. 6\n4. 8\n5. 10\n6. 12\n7. 14\n8. 16\n9. 18\n10. 20\n11. 22\n12. 24\n13. 26\n14. 28\n15. 30\n16. 32\n17. 34\n18. 36\n19. 38\n20. 40\n\n\n", + "text/plain": " [1] 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": "data = 1:20 * 2\ndata" + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "trusted": true + }, + "outputs": [ + { + "data": { + "text/html": "\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\n
xdata
1 2
2 4
3 6
4 8
510
612
714
816
918
1020
1122
1224
1326
1428
1530
1632
1734
1836
1938
2040
\n", + "text/latex": "\\begin{tabular}{r|ll}\n x & data\\\\\n\\hline\n\t 1 & 2\\\\\n\t 2 & 4\\\\\n\t 3 & 6\\\\\n\t 4 & 8\\\\\n\t 5 & 10\\\\\n\t 6 & 12\\\\\n\t 7 & 14\\\\\n\t 8 & 16\\\\\n\t 9 & 18\\\\\n\t 10 & 20\\\\\n\t 11 & 22\\\\\n\t 12 & 24\\\\\n\t 13 & 26\\\\\n\t 14 & 28\\\\\n\t 15 & 30\\\\\n\t 16 & 32\\\\\n\t 17 & 34\\\\\n\t 18 & 36\\\\\n\t 19 & 38\\\\\n\t 20 & 40\\\\\n\\end{tabular}\n", + "text/markdown": "\nx | data | \n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|\n| 1 | 2 | \n| 2 | 4 | \n| 3 | 6 | \n| 4 | 8 | \n| 5 | 10 | \n| 6 | 12 | \n| 7 | 14 | \n| 8 | 16 | \n| 9 | 18 | \n| 10 | 20 | \n| 11 | 22 | \n| 12 | 24 | \n| 13 | 26 | \n| 14 | 28 | \n| 15 | 30 | \n| 16 | 32 | \n| 17 | 34 | \n| 18 | 36 | \n| 19 | 38 | \n| 20 | 40 | \n\n\n", + "text/plain": " x data\n1 1 2 \n2 2 4 \n3 3 6 \n4 4 8 \n5 5 10 \n6 6 12 \n7 7 14 \n8 8 16 \n9 9 18 \n10 10 20 \n11 11 22 \n12 12 24 \n13 13 26 \n14 14 28 \n15 15 30 \n16 16 32 \n17 17 34 \n18 18 36 \n19 19 38 \n20 20 40 " + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": "df = tibble(x=1:20, data=data)\ndf" + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "trusted": true + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA0gAAANICAMAAADKOT/pAAACWFBMVEUAAAABAQEDAwMEBAQF\nBQUGBgYHBwcJCQkKCgoNDQ0PDw8QEBARERESEhITExMVFRUWFhYXFxcZGRkbGxscHBwfHx8g\nICAhISEmJiYnJycrKystLS0yMjIzMzM1NTU4ODg5OTk7OztBQUFCQkJDQ0NFRUVGRkZKSkpM\nTExNTU1OTk5PT09QUFBRUVFSUlJTU1NUVFRVVVVXV1dYWFhZWVlaWlpbW1tcXFxdXV1eXl5f\nX19gYGBhYWFiYmJjY2NkZGRlZWVmZmZoaGhpaWlqampra2tsbGxubm5vb29ycnJzc3N1dXV2\ndnZ3d3d4eHh5eXl6enp8fHx/f3+AgICCgoKDg4OFhYWGhoaHh4eJiYmLi4uMjIyNjY2Ojo6P\nj4+QkJCRkZGTk5OUlJSVlZWWlpaXl5eZmZmampqbm5ucnJydnZ2enp6fn5+goKChoaGioqKj\no6OkpKSmpqaoqKipqamqqqqrq6usrKytra2urq6wsLCxsbGysrKzs7O0tLS1tbW2tra3t7e4\nuLi5ubm6urq7u7u8vLy9vb2+vr6/v7/AwMDBwcHCwsLDw8PExMTFxcXGxsbHx8fIyMjJycnK\nysrLy8vMzMzNzc3Ozs7Pz8/Q0NDR0dHS0tLT09PU1NTW1tbX19fY2NjZ2dna2trb29vc3Nzd\n3d3f39/g4ODh4eHi4uLj4+Pk5OTl5eXm5ubn5+fo6Ojp6enq6urr6+vs7Ozt7e3u7u7v7+/w\n8PDx8fHy8vLz8/P09PT19fX29vb39/f4+Pj5+fn6+vr7+/v8/Pz9/f3+/v7////IoxbwAAAA\nCXBIWXMAABJ0AAASdAHeZh94AAAgAElEQVR4nO2d/Z9d1XWfx05a9z1t+pqmJG1St71CqMIm\nOCpFsWlLmpS4hZi3pA2ucZMUYhqDIXZK7cSExGlMS0qoIsuWQFVFIlwsZAkhgTRIM3PPv9U7\ns7UuXDxaa9/v2Wdmn3Oe54erl883ey99135gBMhZaQCgNSu7PQDAEEAkgAIgEkABEAmgAIgE\nUABEAigAIgEUAJEACtBCpDffWGDjyhud8Nblbs69vHGxm4Ovnu/mXAq+Rk0FlxDpje++l7PN\n1e92wsXL3Zx7uXmzm4PXz3ZyLAUbNRWMSOPYcw4UnEAkjVHsOQcKTiCSxij2nAMFJxBJYxR7\n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\nkQAKgEgABfj/eVKPyALu1jYAAAAASUVORK5CYII=", + "text/plain": "plot without title" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": "ggplot(df, aes(x=x, y=data)) + geom_bar(stat='identity')" + } + ], + "metadata": { + "kernelspec": { + "display_name": "R", + "language": "R", + "name": "ir" + }, + "language_info": { + "codemirror_mode": "r", + "file_extension": ".r", + "mimetype": "text/x-r-source", + "name": "R", + "pygments_lexer": "r", + "version": "3.4.2" + }, + "output": "html_notebook" + }, + "nbformat": 4, + "nbformat_minor": 2 +} \ No newline at end of file diff --git a/examples/ex7.rmarkdown.Rmd b/examples/ex7.rmarkdown.Rmd new file mode 100644 index 0000000..ce04613 --- /dev/null +++ b/examples/ex7.rmarkdown.Rmd @@ -0,0 +1,39 @@ +--- +kernelspec: + display_name: R + language: R + name: ir +language_info: + codemirror_mode: r + file_extension: .r + mimetype: text/x-r-source + name: R + pygments_lexer: r + version: 3.4.2 +output: html_notebook +--- + +## R notebook with R kernel + +Some literal R code which should not be evaluated +```r +stop("not evaluated") +``` + +```{R, trusted=TRUE} +library(tidyverse) +``` + +```{R, trusted=TRUE} +data = 1:20 * 2 +data +``` + +```{R, trusted=TRUE} +df = tibble(x=1:20, data=data) +df +``` + +```{R, trusted=TRUE} +ggplot(df, aes(x=x, y=data)) + geom_bar(stat='identity') +``` diff --git a/examples/ex7.rmarkdown.nb.html b/examples/ex7.rmarkdown.nb.html new file mode 100644 index 0000000..23e45cd --- /dev/null +++ b/examples/ex7.rmarkdown.nb.html @@ -0,0 +1,509 @@ + + + + + + + + + + + + + +IPYMD Notebook + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +

R notebook with R kernel

+

Some literal R code which should not be evaluated

+
stop("not evaluated")
+ + + +
library(tidyverse)
+ + +
Loading tidyverse: ggplot2
+Loading tidyverse: tibble
+Loading tidyverse: tidyr
+Loading tidyverse: readr
+Loading tidyverse: purrr
+Loading tidyverse: dplyr
+Conflicts with tidy packages ---------------------------------------------------
+filter(): dplyr, stats
+lag():    dplyr, stats
+ + + + +
data = 1:20 * 2
+data
+ + +
    +
  1. 2
  2. +
  3. 4
  4. +
  5. 6
  6. +
  7. 8
  8. +
  9. 10
  10. +
  11. 12
  12. +
  13. 14
  14. +
  15. 16
  16. +
  17. 18
  18. +
  19. 20
  20. +
  21. 22
  22. +
  23. 24
  24. +
  25. 26
  26. +
  27. 28
  28. +
  29. 30
  30. +
  31. 32
  32. +
  33. 34
  34. +
  35. 36
  36. +
  37. 38
  38. +
  39. 40
  40. +
+ + + + +
df = tibble(x=1:20, data=data)
+df
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
xdata
1 2
2 4
3 6
4 8
510
612
714
816
918
1020
1122
1224
1326
1428
1530
1632
1734
1836
1938
2040
+ + + + +
ggplot(df, aes(x=x, y=data)) + geom_bar(stat='identity')
+ + +

+ + + +
LS0tCmtlcm5lbHNwZWM6CiAgZGlzcGxheV9uYW1lOiBSCiAgbGFuZ3VhZ2U6IFIKICBuYW1lOiBpcgpsYW5ndWFnZV9pbmZvOgogIGNvZGVtaXJyb3JfbW9kZTogcgogIGZpbGVfZXh0ZW5zaW9uOiAucgogIG1pbWV0eXBlOiB0ZXh0L3gtci1zb3VyY2UKICBuYW1lOiBSCiAgcHlnbWVudHNfbGV4ZXI6IHIKICB2ZXJzaW9uOiAzLjQuMgpvdXRwdXQ6IGh0bWxfbm90ZWJvb2sKLS0tCgojIyBSIG5vdGVib29rIHdpdGggUiBrZXJuZWwKClNvbWUgbGl0ZXJhbCBSIGNvZGUgd2hpY2ggc2hvdWxkIG5vdCBiZSBldmFsdWF0ZWQKYGBgcgpzdG9wKCJub3QgZXZhbHVhdGVkIikKYGBgCgpgYGB7UiwgdHJ1c3RlZD1UUlVFfQpsaWJyYXJ5KHRpZHl2ZXJzZSkKYGBgCgpgYGB7UiwgdHJ1c3RlZD1UUlVFfQpkYXRhID0gMToyMCAqIDIKZGF0YQpgYGAKCmBgYHtSLCB0cnVzdGVkPVRSVUV9CmRmID0gdGliYmxlKHg9MToyMCwgZGF0YT1kYXRhKQpkZgpgYGAKCmBgYHtSLCB0cnVzdGVkPVRSVUV9CmdncGxvdChkZiwgYWVzKHg9eCwgeT1kYXRhKSkgKyBnZW9tX2JhcihzdGF0PSdpZGVudGl0eScpCmBgYAo=
+ + + +
+ + + + + + + + diff --git a/ipymd/core/contents_manager.py b/ipymd/core/contents_manager.py index dda1591..a475681 100644 --- a/ipymd/core/contents_manager.py +++ b/ipymd/core/contents_manager.py @@ -9,6 +9,8 @@ import io import os import os.path as op +import notebook.transutils + from tornado import web diff --git a/ipymd/core/format_manager.py b/ipymd/core/format_manager.py index 66b5a20..71f9849 100644 --- a/ipymd/core/format_manager.py +++ b/ipymd/core/format_manager.py @@ -12,6 +12,7 @@ import os.path as op import glob import json +import notebook.transutils from pkg_resources import iter_entry_points, DistributionNotFound @@ -252,6 +253,18 @@ def convert(self, if to_kwargs is None: to_kwargs = {} + if from_ == 'rmarkdown' or to == 'rmarkdown': + # TODO: HACK: do rmarkdown conversion in external function. + # As Rmarkdown uses jupyter nbformat as internal format instead of + # ipymd cells, the usual 'conversion' scheme does not work. + # atm, I cannot think of an elegant solution which does not require + # to change a good deal of the ipymd code. + # + # The jupyter format was chosen as internal format to enable + # more complex outputs (i.e. multiple outputs per cell, including + # multiple images). + return convert_rmarkdown(from_, to, contents) + if reader is None: reader = (self.create_reader(from_, **from_kwargs) if from_ is not None else None) @@ -334,3 +347,19 @@ def format_manager(): def convert(*args, **kwargs): """Alias for format_manager().convert().""" return format_manager().convert(*args, **kwargs) + + +def convert_rmarkdown(from_, to, contents): + from ..formats.rmarkdown import RmarkdownReader, RmarkdownWriter + if to == 'rmarkdown' and from_ == 'notebook': + writer = RmarkdownWriter() + writer.write_contents(contents) + return writer.contents + + elif to == 'notebook' and from_ == 'rmarkdown': + reader = RmarkdownReader() + return reader.read(contents) + + else: + raise RuntimeError("Rmarkdown conversion is only possible between" + " 'notebook' and 'rmarkdown' format. ") diff --git a/ipymd/formats/markdown.py b/ipymd/formats/markdown.py index 20828a3..332adb4 100644 --- a/ipymd/formats/markdown.py +++ b/ipymd/formats/markdown.py @@ -116,7 +116,8 @@ def __init__(self): def _new_paragraph(self): self._output.write('\n\n') - def meta(self, source, is_notebook=False): + @staticmethod + def meta(source, is_notebook=False): if source is None: return '' @@ -139,6 +140,11 @@ def meta(self, source, is_notebook=False): return meta + @staticmethod + def format_code(code, lang="python"): + return '```{lang}\n{code}\n```'.format(lang=lang, + code=code.rstrip()) + def append_markdown(self, source, metadata): source = _ensure_string(source) self._output.write(self.meta(metadata) + source.rstrip()) @@ -243,7 +249,7 @@ def __init__(self, prompt=None): def append_code(self, input, output=None, metadata=None): code = self._prompt.from_cell(input, output) - wrapped = '```python\n{code}\n```'.format(code=code.rstrip()) + wrapped = self.format_code(code) self._output.write(self.meta(metadata) + wrapped) diff --git a/ipymd/formats/notebook.py b/ipymd/formats/notebook.py index 02ed0ab..142632e 100644 --- a/ipymd/formats/notebook.py +++ b/ipymd/formats/notebook.py @@ -19,44 +19,7 @@ from ..lib.python import PythonFilter from ..ext.six import string_types from ..utils.utils import _ensure_string - - -#------------------------------------------------------------------------------ -# Utility functions -#------------------------------------------------------------------------------ - -def _cell_input(cell): - """Return the input of an ipynb cell.""" - return _ensure_string(cell.get('source', [])) - - -def _cell_output(cell): - """Return the output of an ipynb cell.""" - outputs = cell.get('outputs', []) - # Add stdout. - stdout = ('\n'.join(_ensure_string(output.get('text', '')) - for output in outputs)).rstrip() - # Add text output. - text_outputs = [] - for output in outputs: - out = output.get('data', {}).get('text/plain', []) - out = _ensure_string(out) - # HACK: skip outputs. - if out.startswith('' + r'([\s\S]+?)' + r'' + ) + + def_chunk_element = re.compile( + r'' + r'([\s\S]+?)' + r'' + ) + + def_image_element = re.compile( + r'' + ) + + def __init__(self): + self._nb = nbf.v4.new_notebook() + self._count = 1 + + def read(self, html): + if html is not None: + for block_type, block_content in self._parse_html(html): + if block_type == 'text': + self._nb['cells'].append(self._text_cell(block_content)) + else: + self._nb['cells'].append(self._chunk_cell(block_content)) + + return self._nb + + def _parse_html(self, html): + """ get a list of rnb-text and rnb-chunks""" + for start_tag, contents, end_tag in ( + self.def_text_or_chunk.findall(html)): + assert start_tag == end_tag, \ + "text and chunk blocks must not be nested." + yield start_tag, contents.strip() + + def _parse_image(self, html): + try: + mime, data = next(iter(self.def_image_element.findall(html))) + except StopIteration: + mime = 'text/plain' + data = 'IPYMD: Error reading image.' + return mime, data + + @staticmethod + def _text_cell(text_block): + # new markdown cell will be filled with html. The corresponding + # source is found in .Rmd file. + return nbf.v4.new_markdown_cell(text_block) + + def _chunk_cell(self, chunk_block): + """parse an rnb-chunk consisting of source/plot/...""" + # A chunk block can be divided in multiple sub-blocks, namely + # + # "source", "plot", "output", + # "warning", "error", "message" + cell = HtmlNbChunkCell(self._count) + self._count += 1 + + for start_tag, b64, contents, end_tag in ( + self.def_chunk_element.findall(chunk_block)): + assert start_tag == end_tag, \ + "different chunk elements must not be nested. " + b64 = b64.strip() + contents = contents.strip() + if start_tag == 'source': + # we ignore the source as we obtain it from .Rmd + pass + elif start_tag in ['output', 'warning', 'message']: + assert cell is not None, "output without source" + cell.new_output(start_tag, b64) + elif start_tag == 'error': + assert cell is not None, "error without source" + cell.new_error(b64) + elif start_tag == 'plot': + assert cell is not None, "plot without source" + mime, data = self._parse_image(contents) + cell.new_plot(mime, data, b64) + + return cell.cell + + +class RmarkdownWriter(object): + """Write R notebook (combine .Rmd and .nb.html) """ + + DEFAULT_LANGUAGE = "python" # if, for whatever reason, no metadata is set + + def __init__(self): + self._language = self.DEFAULT_LANGUAGE + self.rmd_writer = RmdWriter(self) + self.nb_html_writer = NbHtmlWriter(self) + + def write_contents(self, nb): + """convert a jupyter notebook dict to rmarkdown""" + # Parsing the notebook using nbf will get rid of the + # multi line strings. + # nb = nbf.from_dict(nb) + assert nb['nbformat'] >= 4 + + try: + self._language = nb['metadata']['kernelspec']['language'] + except KeyError: + pass + + self.write_notebook_metadata(nb['metadata']) + for cell in nb['cells']: + self.write(cell) + + def write(self, cell): + self.rmd_writer.write(cell) + self.nb_html_writer.write(cell) + + def write_notebook_metadata(self, metadata): + self.rmd_writer.write_notebook_metadata(metadata) + self.nb_html_writer.write_notebook_metadata(metadata) + + def close(self): + self.rmd_writer.close() + + @property + def kernel_lang(self): + """get the kernel language""" + return self._language + + @property + def contents(self): + return { + 'rmd': self.rmd_writer.contents, + 'html': self.nb_html_writer.contents + } + + def __del__(self): + self.close() + + +class RmdWriter(BaseMarkdownWriter): + """.Rmd writer""" + + def __init__(self, rmarkdown_writer): + super(RmdWriter, self).__init__() + self._rmarkdown_writer = rmarkdown_writer + + def append_code(self, input, output=None, metadata=None): + input = _ensure_string(input) + self._output.write(self._code_block(input, metadata)) + + def _code_block(self, code, meta): + return '```{{{meta}}}\n{code}\n```'.format( + meta=self._encode_metadata(meta), code=code.rstrip()) + + def append_markdown(self, source, metadata): + source = _ensure_string(source) + if metadata is not None and len(metadata) > 0: + print("WARNING: Metadata for markdown cells is currently " + "not supported.") + + self._output.write(source.rstrip()) + + def _encode_metadata(self, metadata): + def encode_option(key, value): + return "{}={}".format(key, _option_value_str(value)) + + metadata = {} if metadata is None else metadata + + lang = metadata.pop('lang', self._rmarkdown_writer.kernel_lang) + name = metadata.pop('name', None) + options = ", ".join(encode_option(k, v) + for k, v in metadata.items()) + + out = [lang] + if name is not None: + out.append(" " + name) + if len(options): + out.append(", " + options) + + return "".join(out) + + def write(self, cell): + """Write a ipynb cell to markdown""" + metadata = cell.get('metadata', None) + if cell['cell_type'] == 'markdown': + self.append_markdown(cell['source'], metadata) + elif cell['cell_type'] == 'code': + # output is not handled by RmdReader + self.append_code(cell['source'], output=None, metadata=metadata) + self._new_paragraph() + + @property + def contents(self): + return self._output.getvalue().rstrip() + '\n' # end of file \n + + +class NbHtmlWriter(object): + """ Write R notebook .nb.html """ + def __init__(self, rmarkdown_writer): + """ + + Parameters + ---------- + rmarkdown_writer: RmarkdownWriter + a reference to the parent RmarkdownWriter + """ + self._rmarkdown_writer = rmarkdown_writer + self._rmd_writer = rmarkdown_writer.rmd_writer + self._output = StringIO() + self._metadata = {} + + @property + def template(self): + """ Load the jinja2 template from the package resources. """ + env = Environment( + loader=PackageLoader('ipymd', 'ressources'), + autoescape=select_autoescape(['html', 'xml']) + ) + return env.get_template('r_notebook.template.html') + + def append_markdown(self, markdown, metadata): + markdown = _ensure_string(markdown) + html = pypandoc.convert_text(markdown, 'html', format='md') + # ignore metadata, not supported. + self._output.write(self._create_tag('text', html) + "\n") + + def append_code(self, source, outputs, metadata): + source = _ensure_string(source) + + # Markdown representation of code is given as b64 in nb.html + lang = metadata.get('lang', self._rmarkdown_writer.kernel_lang) + source_as_markdown = BaseMarkdownWriter.format_code(source, lang) + + child_tags = [] + child_tags.append( + self._create_tag('source', + tag_content=self._format_source(source, lang), + tag_meta={'data': source_as_markdown}) + ) + for output in outputs: + child_tags.extend(self._create_output_tag(output)) + + self._output.write( + self._create_tag('chunk', "\n".join(child_tags) + "\n") + "\n" + ) + + def _create_output_tag(self, output): + """yield tags such as + tag_meta: dict + meta-dictionary which can be added to the tag. + Will be base64 encoded. Example + tag_content: + html content which will be enclosed in the `begin` and `end` tags. + + Returns + ------- + str + \n + + """ + meta_b64 = "" if tag_meta is None or tag_meta == {} else _b64_encode( + json.dumps(tag_meta, sort_keys=True)) + return "\n" \ + "{contents}" \ + "".format(tag=tag_name, b64=meta_b64, + contents=tag_content) + + def write(self, cell): + metadata = cell.get('metadata', None) + if cell['cell_type'] == 'markdown': + self.append_markdown(cell['source'], metadata) + elif cell['cell_type'] == 'code': + self.append_code(cell['source'], cell['outputs'], metadata) + + def write_notebook_metadata(self, metadata): + self._metadata = metadata + + @property + def contents(self): + base64_rmd = _b64_encode(self._rmd_writer.contents) + html_nb = Markup(self._output.getvalue().rstrip() + '\n') + # TODO is the filename in javascript necessary for anything_ + # TODO set title (-> filename) + # the writer does not know anything about the filename + # therefore we use a hardcoded filename as workaround. + filename = self._metadata.get("filename", "notebook.Rmd") + title = self._metadata.get("title", "IPYMD Notebook") + return self.template.render(filename=filename, + title=title, + html_nb=html_nb, + base64_rmd=base64_rmd) + + +def load_rmarkdown(path): + """ + Read .Rmd and the corresponding .html.nb + If .html.nb is not available, outputs will be empty. + """ + html_path = _get_nb_html_path(path) + return { + 'rmd': _read_text(path), + 'html': _read_text(html_path) if os.path.isfile(html_path) else None + } + + +def save_rmarkdown(path, contents): + """ + store cells to .Rmd and outputs to .html.nb + """ + html_path = _get_nb_html_path(path) + _write_text(path, contents['rmd']) + if contents['html'] is not None: + _write_text(html_path, contents['html']) + + +RMD_FORMAT = dict( + reader=RmarkdownReader, + writer=RmarkdownWriter, + file_extension='.Rmd', + load=load_rmarkdown, + save=save_rmarkdown, +) diff --git a/ipymd/formats/tests/test_markdown.py b/ipymd/formats/tests/test_markdown.py index f40ab29..66d6691 100644 --- a/ipymd/formats/tests/test_markdown.py +++ b/ipymd/formats/tests/test_markdown.py @@ -10,6 +10,7 @@ from ...utils.utils import _diff, _show_outputs from ._utils import (_test_reader, _test_writer, _exec_test_file, _read_test_file) +from ..markdown import MarkdownReader, MarkdownWriter #------------------------------------------------------------------------------ @@ -87,3 +88,32 @@ def test_decorator(): markdown_bis = convert(cells, to='markdown') assert _diff(markdown, markdown_bis.replace('python', '')) == '' + + +def test_md_notebook_metadata(): + """Test that reading and writing complex notebook metadata + results in the identity. """ + mock_metadata = '\n'.join(('---', + 'author: John Doe', + 'date: 2017-05-07', + 'output:', + ' html_document:', + ' - --title-prefix', + ' - Foo', + ' - --id-prefix', + ' - Bar', + ' toc: true', + ' toc_float:', + ' collapsed: false', + ' smooth_scroll: false', + 'title: Habits', + '---', + '')) + + mdreader = MarkdownReader() + cells = mdreader.read(mock_metadata) + + mdwriter = MarkdownWriter() + mdwriter.write_notebook_metadata(cells[0]['metadata']) + + assert mdwriter.contents == mock_metadata diff --git a/ipymd/formats/tests/test_notebook.py b/ipymd/formats/tests/test_notebook.py index 167edb7..21c9683 100644 --- a/ipymd/formats/tests/test_notebook.py +++ b/ipymd/formats/tests/test_notebook.py @@ -7,7 +7,7 @@ #------------------------------------------------------------------------------ from ...core.format_manager import format_manager, convert -from ..notebook import _compare_notebooks +from ...lib.notebook import _assert_notebooks_equal from ...utils.utils import _diff, _show_outputs from ._utils import (_test_reader, _test_writer, _exec_test_file, _read_test_file) @@ -23,21 +23,24 @@ def _test_notebook_reader(basename): assert converted == expected -def _test_notebook_writer(basename): +def _test_notebook_writer(basename, check_outputs=True): """Check that (test nb) and (test cells ==> nb) are the same. """ converted, expected = _test_writer(basename, 'notebook') - assert _compare_notebooks(converted, expected) + _assert_notebooks_equal(converted, expected, check_notebook_metadata=False, + check_cell_outputs=check_outputs) -def _test_notebook_notebook(basename): + +def _test_notebook_notebook(basename, check_outputs=True): """Check that the double conversion is the identity.""" contents = _read_test_file(basename, 'notebook') cells = convert(contents, from_='notebook') converted = convert(cells, to='notebook') - assert _compare_notebooks(contents, converted) + _assert_notebooks_equal(contents, converted, check_notebook_metadata=False, + check_cell_outputs=check_outputs) def test_notebook_reader(): @@ -48,11 +51,13 @@ def test_notebook_reader(): def test_notebook_writer(): _test_notebook_writer('ex1') - _test_notebook_writer('ex2') + # Ex2 contains an image, which is not supported by ipymd internal format + _test_notebook_writer('ex2', check_outputs=False) _test_notebook_writer('ex3') def test_notebook_notebook(): _test_notebook_notebook('ex1') - _test_notebook_notebook('ex2') + # Ex2 contains an image, which is not supported by ipymd internal format + _test_notebook_notebook('ex2', check_outputs=False) _test_notebook_notebook('ex3') diff --git a/ipymd/formats/tests/test_rmarkdown.py b/ipymd/formats/tests/test_rmarkdown.py new file mode 100644 index 0000000..9e8118a --- /dev/null +++ b/ipymd/formats/tests/test_rmarkdown.py @@ -0,0 +1,533 @@ +# -*- coding: utf-8 -*- + +"""Test Markdown parser and reader.""" + +# ------------------------------------------------------------------------------ +# Imports +# ------------------------------------------------------------------------------ + +from ipymd.core.format_manager import convert, format_manager +from ._utils import _read_test_file +from ...utils.utils import _full_diff +from ...lib.notebook import _assert_notebooks_equal, _assert_cell_outputs_equal +from ipymd.formats.rmarkdown import HtmlNbChunkCell, RmarkdownWriter, \ + RmarkdownReader, RmdWriter, RmdReader, NbHtmlWriter, HtmlNbReader, \ + RMD_FORMAT +import pytest + +from collections import OrderedDict +import json + + +# ------------------------------------------------------------------------------ +# Test Rmarkdown classes and helper functions +# ------------------------------------------------------------------------------ + +def test_htmlnb_parse_html(): + html = """ + + text contents + + + + + """ + htmlnbreader = HtmlNbReader() + result = list(htmlnbreader._parse_html(html)) + assert result == [('text', 'text contents'), + ('chunk', '')] + + +def test_htmlnb_parse_image(): + htmlnbreader = HtmlNbReader() + + html = '

' + mime, data = list(htmlnbreader._parse_image(html)) + assert mime == 'image/png' + assert data == "iVBORw0KGgoAAAANSUhEUgAAAjwAAAFhCAMAAABDKYAcAAACT" \ + "FBMVEUAAAA\nBAQEJCQ...==" + + html = ('\n' + '

\n' + '') + mime, data = list(htmlnbreader._parse_image(html)) + assert mime == 'image/png' + assert data == "iVBORw0KGgoAAAANSUhEUgAAAWQAAAD8CAYAAABAWd66AAAABHNCSV" \ + "QICAgIfAhkiAAAAAlwSFlz\n" \ + "AAALEgAACxIB0t1+/AAACDBJREFUeJzt3c2LnWcZx/Hf1RmlScW" \ + "XklJwWpzKiCUIUglSLbiwLnxD\n" + + html = """

some other html

""" + mime, data = list(htmlnbreader._parse_image(html)) + assert mime == 'text/plain' + assert data == 'IPYMD: Error reading image.' + + +def test_htmlnb_parse_error(): + error_output = { + "ename": "SyntaxError", + "evalue": "invalid syntax (, line 3)", + "output_type": "error", + "traceback": ["\u001b[0;36m File \u001b[0;32m\"\"\u001b[0;36m, line \u001b[0;32m3\u001b" + "[0m\n\u001b[0;31m knitr::knit_engines$set(python =" + " reticulate::eng_python)\u001b[0m\n\u001b[0m " + "^\u001b[0m\n\u001b[0;31mSyntaxError\u001b[0m\u001b" + "[0;31m:\u001b[0m invalid syntax\n"] + } + rmarkdown_writer = RmarkdownWriter() + nb_html_writer = NbHtmlWriter(rmarkdown_writer) + nb_html_reader = HtmlNbReader() + html_chunk = "".join(nb_html_writer._create_output_tag(error_output)) + + cell = nb_html_reader._chunk_cell(html_chunk) + + assert cell.outputs[0] == error_output + + +def test_htmlnb_chunk_cell(): + html = """ + + +
ggplot(data.frame(x=1:10), aes(y=x, x=x)) + geom_point()
+ + +

+ + """ + + htmlnbreader = HtmlNbReader() + assert htmlnbreader._chunk_cell(html) == { + "cell_type": "code", + "execution_count": 1, + "source": HtmlNbChunkCell.NO_CODE_FROM_HTMLNB, + "metadata": {}, + "outputs": [{ + # list of output dicts (described below) + "execution_count": 1, + "output_type": "execute_result", + "metadata": {}, + "data": {'image/png': "iVBORw0KGgoAAAANSUhEUgAAAjwAAAFhCAMAAABD" + "KYAcAAACTFBMVEUAAAABAQEJCQ...=="} + }] + } + + +def test_htmlnb_chunk_cell2(): + html = """ + +
print(1:10)
+ + +
 [1]  1  2  3  4  5  6  7  8  9 10
+ + """ + + htmlnbreader = HtmlNbReader() + assert htmlnbreader._chunk_cell(html) == { + "cell_type": "code", + "execution_count": 1, + "source": HtmlNbChunkCell.NO_CODE_FROM_HTMLNB, + "metadata": {}, + "outputs": [{ + # list of output dicts (described below) + "execution_count": 1, + "output_type": "execute_result", + "metadata": {}, + "data": {'text/plain': " [1] 1 2 3 4 5 6 7 8 9 10\n"} + }] + } + + +def test_rmd_read_cell_metadata(): + """test that metadata from chunk options is correctly read. """ + chunk1 = "```{r test, str_type=\"foo\", str_single_quote='bar', " \ + "int_type=42, bool_type=TRUE, null_type=NULL}\n\n```" + chunk2 = "```{python, str_type=\"foo\", str_single_quote='bar', " \ + "int_type=42, bool_type=TRUE, null_type=NULL}\n\n```""" + chunk_meta = OrderedDict([('str_type', 'foo'), + ('str_single_quote', 'bar'), + ('int_type', 42), + ('bool_type', True), + ('null_type', None)]) + + rmdreader = RmdReader() + cell1 = rmdreader.read(chunk1)[0] + cell2 = rmdreader.read(chunk2)[0] + assert cell1['lang'] == 'r' + assert cell2['lang'] == 'python' + assert cell1['metadata'] == chunk_meta + assert cell2['metadata'] == chunk_meta + + +def test_rmd_read_notebook_metadata(): + """test that notebook metadata is properly read.""" + rmd = """---\ntitle: "R Notebook"\noutput: html_notebook\n---""" + + rmdreader = RmdReader() + nb_metadata = rmdreader.read(rmd)[0] + assert nb_metadata['metadata'] == { + "title": "R Notebook", + "output": "html_notebook" + } + + +def test_rmarkdown_read_notebook_metadata(): + """test that notebook metadata is properly read.""" + contents = { + "rmd": """---\ntitle: "R Notebook"\noutput: html_notebook\n---""", + "html": None + } + + reader = RmarkdownReader() + nb = reader.read(contents) + assert nb['metadata'] == { + "title": "R Notebook", + "output": "html_notebook" + } + + +def test_nbhtml_write_output(): + """test that output is correctly read and written. """ + + rmarkdown_writer = RmarkdownWriter() + nb_html_writer = NbHtmlWriter(rmarkdown_writer) + nb_html_reader = HtmlNbReader() + + text_output = { + "data": { + "text/plain": "some text" + }, + "output_type": "execute_result", + "metadata": {}, + 'execution_count': 1 + } + + html_chunk = "".join(nb_html_writer._create_output_tag(text_output)) + cell = nb_html_reader._chunk_cell(html_chunk) + _assert_cell_outputs_equal(cell.outputs[0], text_output) + assert text_output['data']['text/plain'] in html_chunk + + # should write the image to html, not the plain text! + image_output = { + "data": { + "text/plain": ["some image"], + "image/png": "base64 data" + }, + "output_type": "display_data", + "metadata": {"x": "y"} + } + + html_chunk = "".join(nb_html_writer._create_output_tag(image_output)) + cell = nb_html_reader._chunk_cell(html_chunk) + _assert_cell_outputs_equal(cell.outputs[0], image_output) + # test that image is part of html + assert image_output['data']['image/png'] in html_chunk + + unknown_output = { + "data": { + "foo/bar": "should raise an error. " + }, + "output_type": "execute_result", + "metadata": {}, + 'execution_count': 2 + } + + with pytest.raises(RuntimeError): + "".join(nb_html_writer._create_output_tag(unknown_output)) + + # should write the html to html, not the plain text. + html_output = { + "data": { + "text/plain": "should raise an error. ", + "text/html": "Hello World!" + }, + "output_type": "execute_result", + "metadata": {}, + 'execution_count': 3 + } + + html_chunk = "".join(nb_html_writer._create_output_tag(html_output)) + cell = nb_html_reader._chunk_cell(html_chunk) + _assert_cell_outputs_equal(cell.outputs[0], html_output) + # test that image is part of html + assert html_output['data']['text/html'] in html_chunk + + text_output_with_additional_data = { + "data": { + "text/plain": "some data", + "foo/bar": "will not appear in html, but should be preserved" + "in base64" + }, + "output_type": "execute_result", + "metadata": {}, + 'execution_count': 4 + } + + html_chunk = "".join(nb_html_writer._create_output_tag( + text_output_with_additional_data)) + cell = nb_html_reader._chunk_cell(html_chunk) + _assert_cell_outputs_equal(cell.outputs[0], + text_output_with_additional_data) + # test that image is part of html + assert text_output_with_additional_data['data']['text/plain'] in html_chunk + + +def test_rmarkdown_merge_cells(): + """test that source and output are correctly merged. """ + contents = { + "rmd": """```{r}\nprint(1:10)\n```""", + "html": """ + + +
print(1:10)
+ + +
 [1]  1  2  3  4  5  6  7  8  9 10
+ + + """ + } + + reader = RmarkdownReader() + nb = reader.read(contents) + cell = nb['cells'][0] + assert cell['source'] == 'print(1:10)' + assert cell['outputs'] == [{ + "output_type": "execute_result", + 'metadata': {}, + 'data': {"text/plain": " [1] 1 2 3 4 5 6 7 8 9 10\n"}, + "execution_count": 1 + }] + + +def test_rmarkdown_merge_cells_inconsistent_input(): + """test that source and output are not merged when the sources + are inconsistent. """ + contents = { + "rmd": """```{r}\nprint(1:10)\n```\n\n```{r}\nprint(1:10)\n```""", + "html": """ + + +
print(1:10)
+ + +
 [1]  1  2  3  4  5  6  7  8  9 10
+ + + """ + } + + reader = RmarkdownReader() + nb = reader.read(contents) + assert (nb['cells'][0]['source'] == nb['cells'][1]['source'] == + 'print(1:10)') + assert nb['cells'][0]['outputs'] == nb['cells'][0]['outputs'] == [] + + +def test_rmd_write_cell_metadata(): + """test that cell metadata is properly encoded.""" + expected = "r test, str_type=\"foo\", str_single_quote='bar'," \ + " int_type=42, bool_type=TRUE, null_type=NULL" + chunk_meta = OrderedDict([('lang', 'r'), + ('name', 'test'), + ('str_type', 'foo'), + ('str_single_quote', 'bar'), + ('int_type', 42), + ('bool_type', True), + ('null_type', None)]) + + rmdreader = RmdWriter(RmarkdownWriter()) + result = rmdreader._encode_metadata(chunk_meta) + # we know that all strings will be double-quoted. + expected = expected.replace("'", '"') + assert expected == result + + +# ------------------------------------------------------------------------------ +# Test Format +# ------------------------------------------------------------------------------ + +""" +Testing reading and writing of Rmd notebooks. + +We have the issue that we cannot *exactly* reproduce the HTML +generated by rstudio (and there is no point in trying hard +to do so, as this might also change over time). + +Therefore, an assertion for *writing* exactly the same .nb.html is futile. +We circumvent this problem by splitting up the test files in +*.rstudio.{rmd,nb.html} and *.{rmd,nb.html}. + +While the reader should be able to correctly read both of the file formats +(i.e. nb.html as generated by rstudio and ipymd), the writer only has +to be consistent with the ipymd version. + +## The round-trip-conversion test +By checking that rmarkdown can be correctly converted to rmarkdown +we ensure that no information is lost. + +By checking that a notebook can be correctly converted to a notebook +and back, we ensure that a notebook can be represented +in rmarkdown without any information loss, i.e. rmarkdown is a +full replacement for .ipynb. + +## Compatibility with rstudio +While we can ensure automatically, that reading rmarkdown generated +with rstudio works, we cannot test (easily) that rstudio correctly +reads rmarkdown generated by ipymd. This would require to implement +tests in R instead of python. +""" + +# register rstudio rmarkdown dialect to the format manager. +_fm = format_manager() +_fm.register(name='rmarkdown.rstudio', **RMD_FORMAT) +# ipymd only makes sense with verbose metadata +_fm.verbose_metadata = True + + +def _test_rmarkdown_reader(basename): + """Check that reading Rmarkdown (.Rmd + .nb.html) results + in the correct notebook (.ipynb). + """ + contents = _read_test_file(basename, 'rmarkdown') + expected = _read_test_file(basename, 'notebook') + converted = convert(contents, from_='rmarkdown', to='notebook') + + # for ipymd rmd notebook, metadata must be equal + _assert_notebooks_equal(expected, converted, check_cell_metadata=True, + check_notebook_metadata=True) + + +def _test_rmarkdown_reader_rstudio(basename): + """Check that reading Rmarkdown (.Rmd + .nb.html) generated + by rstudio results in the correct notebook (.ipynb) + ignoring metadata""" + contents_rstudio = _read_test_file(basename, 'rmarkdown.rstudio') + expected = _read_test_file(basename, 'notebook') + converted_rstudio = convert(contents_rstudio, from_='rmarkdown', + to='notebook') + + # we must be able to correctly read an Rmd created by rstudio, + # but we cannot expect all metadata to be there + _assert_notebooks_equal(expected, converted_rstudio, + check_cell_metadata=False, + check_notebook_metadata=False) + + +def _test_rmarkdown_writer(basename): + """Check that writing a notebook (.ipynb) to Rmarkdown results + in the correct .Rmd + .nb.html files""" + contents = _read_test_file(basename, 'notebook') + expected = _read_test_file(basename, 'rmarkdown') + converted = convert(contents, to='rmarkdown', from_='notebook') + + # The writer must produce exactly the rmd/html we want + assert converted['rmd'] == expected['rmd'] + assert converted['html'].strip() == expected['html'].strip() + + +def _diff_rmarkdown_writer_rstudio(basename): + """print a diff to the Rmd/.nb.html generated by rstudio + + We cannot expect to generate exactly the same result + as rstudio (minor things like markdown->HTML conversion. + Therefore, we do not use a test here. + + We want to have a diff for development purposes, though + """ + contents = _read_test_file(basename, 'notebook') + expected_rstudio = _read_test_file(basename, 'rmarkdown.rstudio') + converted = convert(contents, to='rmarkdown', from_='notebook') + + print("\n\n" + "#########################################################\n" + "Diff converted Rmd with {}.rmarkdown.rstudio.Rmd\n" + "#########################################################" + "\n\n".format(basename)) + print(_full_diff(converted['rmd'], expected_rstudio['rmd'])) + print('\n\n') + + print("\n\n" + "#########################################################\n" + "Diff converted nb.html with {}.rmarkdown.rstudio.nb.html\n" + "#########################################################" + "\n\n".format(basename)) + print(_full_diff(converted['html'], expected_rstudio['html'])) + print('\n\n') + + +def _test_rmarkdown_rmarkdown(basename): + """Check that the double conversion is the identity.""" + contents = _read_test_file(basename, 'rmarkdown') + notebook = convert(contents, from_='rmarkdown', to='notebook') + notebook_dict = json.loads(json.dumps(notebook)) + converted = convert(notebook_dict, from_='notebook', to='rmarkdown') + + assert converted['rmd'] == contents['rmd'] + assert converted['html'].strip() == contents['html'].strip() + + +def _test_notebook_notebook(basename): + """check that converting a notebook to Rmarkdown and back + is the identity""" + contents = _read_test_file(basename, 'notebook') + rmarkdown = convert(contents, from_='notebook', to='rmarkdown') + converted = convert(rmarkdown, from_='rmarkdown', to='notebook') + + _assert_notebooks_equal(contents, converted, check_notebook_metadata=True, + check_cell_metadata=True) + + +def test_ex5_reader(): + _test_rmarkdown_reader('ex5') + _test_rmarkdown_reader_rstudio('ex5') + + +def test_ex5_writer(): + _test_rmarkdown_writer('ex5') + _diff_rmarkdown_writer_rstudio('ex5') + + +def test_ex5_rmarkdown_rmarkdown(): + _test_rmarkdown_rmarkdown('ex5') + + +def test_ex5_notebook_notebook(): + _test_notebook_notebook('ex5') + + +def test_ex6_reader(): + _test_rmarkdown_reader('ex6') + + +def test_ex6_writer(): + _test_rmarkdown_writer('ex6') + + +def test_ex6_rmarkdown_rmarkdown(): + _test_rmarkdown_rmarkdown('ex6') + + +def test_ex6_notebook_notebook(): + _test_notebook_notebook('ex6') + + +def test_ex7_reader(): + _test_rmarkdown_reader('ex7') + + +def test_ex7_writer(): + _test_rmarkdown_writer('ex7') + + +def test_ex7_rmarkdown_rmarkdown(): + _test_rmarkdown_rmarkdown('ex7') + + +def test_ex7_notebook_notebook(): + _test_notebook_notebook('ex7') diff --git a/ipymd/lib/markdown.py b/ipymd/lib/markdown.py index 2d7ea24..0d0de34 100644 --- a/ipymd/lib/markdown.py +++ b/ipymd/lib/markdown.py @@ -68,7 +68,8 @@ class BlockGrammar(object): newline = re.compile(r'^\n+') block_code = re.compile(r'^( {4}[^\n]+\n*)+') fences = re.compile( - r'^ *(`{3,}|~{3,}) *(\S+)? *\n' # ```lang + # ```lang | ```{lang, option1=foo, option2='bar'} + r'^ *(`{3,}|~{3,}) *(\S+|\{.+\})? *\n' r'([\s\S]+?)\s*' r'\1 *(?:\n+|$)' # ``` ) @@ -654,7 +655,7 @@ def _filter_markdown(source, filters): class MarkdownFilter(object): - """Filter Marakdown contents by keeping a subset of the contents. + """Filter Markdown contents by keeping a subset of the contents. Parameters ---------- diff --git a/ipymd/lib/notebook.py b/ipymd/lib/notebook.py new file mode 100644 index 0000000..0dcc116 --- /dev/null +++ b/ipymd/lib/notebook.py @@ -0,0 +1,107 @@ +"""Test helper functions for comparing ipynb notebooks. """ +from ipymd.utils.utils import _ensure_string + + +def _cell_source(cell): + """Return the input of an ipynb cell.""" + return _ensure_string(cell.get('source', [])) + + +def _cell_outputs(cell): + """Return the output of an ipynb cell.""" + outputs = cell.get('outputs', []) + return outputs + + +def _stream_output_to_result(output): + """Convert a 'stream' output cell to an 'execute_result' cell. """ + if output['output_type'] == 'stream': + return { + 'output_type': 'execute_result', + 'metadata': {}, + 'data': {'text/plain': _ensure_string(output['text']).rstrip()}, + 'execution_count': None + } + else: + return output + + +def _output_ensure_string(*args): + """make sure that strings split up as a list are coerced into a + single string. """ + for output in args: + for mime, out in output.get('data', {}).items(): + output['data'][mime] = _ensure_string(out) + + +def _assert_dict_key_equals(field, dict0, dict1): + """assert that a field does either not exist in both + dictionaries or is equal""" + KEY_DOESNT_EXIST = object() + assert dict0.get(field, KEY_DOESNT_EXIST) \ + == dict1.get(field, KEY_DOESNT_EXIST) + + +def _assert_cell_outputs_equal(output_0, output_1, check_metadata=True): + output_0 = _stream_output_to_result(output_0) + output_1 = _stream_output_to_result(output_1) + _output_ensure_string(output_0, output_1) + + fields_to_check = ['output_type', 'data', 'ename', 'evalue', 'traceback'] + if check_metadata: + fields_to_check.append('metadata') + + for field in fields_to_check: + _assert_dict_key_equals(field, output_0, output_1) + + if 'execution_count' in (set(output_0) | set(output_1)): + assert output_0['execution_count'] == output_1['execution_count'] or \ + output_1['execution_count'] is None or \ + output_0['execution_count'] is None + + +def _assert_cells_equal(cell_0, cell_1, check_metadata=True, + check_outputs=True): + assert cell_0['cell_type'] == cell_1['cell_type'] + assert _cell_source(cell_0) == _cell_source(cell_1) + if check_outputs: + outputs_0 = _cell_outputs(cell_0) + outputs_1 = _cell_outputs(cell_1) + assert len(outputs_0) == len(outputs_1) + for output_0, output_1 in zip(outputs_0, outputs_1): + _assert_cell_outputs_equal(output_0, output_1, + check_metadata=check_metadata) + + +def _assert_notebooks_equal(nb_0, nb_1, check_notebook_metadata=True, + check_cell_metadata=True, + check_cell_outputs=True): + if check_notebook_metadata: + assert nb_0['metadata'] == nb_1['metadata'] + assert len(nb_0['cells']) == len(nb_1['cells']) + for cell_0, cell_1 in zip(nb_0['cells'], nb_1['cells']): + _assert_cells_equal(cell_0, cell_1, check_metadata=check_cell_metadata, + check_outputs=check_cell_outputs) + + +def _cell_input(cell): + """Return the input of an ipynb cell.""" + return _ensure_string(cell.get('source', [])) + + +def _cell_output(cell): + """Return the output of an ipynb cell.""" + outputs = cell.get('outputs', []) + # Add stdout. + stdout = ('\n'.join(_ensure_string(output.get('text', '')) + for output in outputs)).rstrip() + # Add text output. + text_outputs = [] + for output in outputs: + out = output.get('data', {}).get('text/plain', []) + out = _ensure_string(out) + # HACK: skip outputs. + if out.startswith('([^,=]+ *)= *(".*"|\'.*\'|[^,=}]+))' + ARG = r'(?P\w+)' + OPEN = r'(?P{ *)' + DELIM = r'(?P *, *)' + CLOSE = r'(?P})' + BLANK = r'(?P\s+)' + + master_pat = re.compile('|'.join([KWARG, ARG, OPEN, DELIM, + CLOSE, BLANK])) + + def generate_tokens(pat, text): + scanner = pat.scanner(text) + for m in iter(scanner.match, None): + yield Token(m.lastgroup, m.group(m.lastgroup)) + + tok = list(generate_tokens(master_pat, options_line)) + return tok + + +def _parse_option_value(value): + """Parse a value given as string to the appropriate data type. """ + value = value.strip() + if value in str_to_literal: + # special value + return str_to_literal[value] + elif value.startswith('"') and value.endswith('"'): + # double quoted string + return value.strip('"') + elif value.startswith("'") and value.endswith("'"): + # single quoted string + return value.strip("'") + else: + try: + # Number: int + return int(value) + except ValueError: + try: + # Number: float + return float(value) + except ValueError: + # something else + raise TypeError( + "Unknown data type in chunk option: {}".format(value)) + + +def _option_value_str(value): + """Convert an option value to the corresponding string""" + try: + return literal_to_str[value] + except KeyError: + # quote a string + if isinstance(value, string_types): + return '"{}"'.format(value) + else: + return str(value) + + +def _process_cell_metadata(kwargs): + """process kwargs such as foo='bar', cat="gold", horse=9, bool_val=TRUE""" + def process_kwarg(kwarg): + key, value = kwarg.split("=") + return key, _parse_option_value(value) + + return OrderedDict([process_kwarg(kwarg) for kwarg in kwargs]) + + +def _is_code_chunk(chunk_lang): + """determine from ```... if the chunk is executable code + or documentation code (markdown) """ + return chunk_lang.startswith('{') and chunk_lang.endswith('}') + + +def _parse_chunk_meta(meta_string): + """Process a string in the form + {r chunk_name, foo='bar', cat="gold", horse=9, bool_val=TRUE}""" + tokens = _tokenize_chunk_options(meta_string) + args = [] + kwargs = [] + for kind, value in tokens: + if kind == "ARG": + args.append(value) + elif kind == "KWARG": + kwargs.append(value) + + lang = args[0] + name = None if len(args) <= 1 else args[1] + meta = _process_cell_metadata(kwargs) + + return lang, name, meta + + +def _read_rmd_b64(b64): + decoded = base64.b64decode(b64).decode('utf-8') + return json.loads(decoded, encoding='utf-8') + + +def _get_nb_html_path(rmd_path): + assert rmd_path.endswith(".Rmd"), "invalid file extension" + return re.sub(r"\.Rmd$", ".nb.html", rmd_path) + + +def _merge_consecutive_markdown_cells(cells): + """Merge consecutive cells with cell_type == 'markdown'. + + Parameters + ---------- + cells : a list of jupyter notebook cells. + """ + merged = [] + tmp_cell = None + + def done_merging(): + """execute, when switching back from a series of markdown + cells to other cell types""" + nonlocal merged, tmp_cell + if tmp_cell is not None: + merged.append(tmp_cell) + tmp_cell = None + + for cell in cells: + if cell['cell_type'] == 'markdown': + if tmp_cell is None: + tmp_cell = cell + else: + if 'source' in cell: + tmp_cell['source'] = (tmp_cell.get('source', "") + "\n\n" + + cell['source']) + if 'metadata' in cell: + tmp_cell['metadata'] = (tmp_cell.get('metadata', {}) + .update(cell['metadata'])) + else: + done_merging() + merged.append(cell) + + done_merging() + + return merged + + +def html_escape(s): + """escape HTML and double quotes only. """ + return _html_escape(s, quote=False).replace('"', """) + + +class HtmlNbChunkCell(object): + NO_CODE_FROM_HTMLNB = "Code is not parsed from .html.nb. " \ + "Use code provided by *.Rmd instead. " + NO_META_FROM_HTMLNB = "Cell metadata is not parsed from .html.nb. " \ + "Use metadata provided by *.Rmd instead. " + + def __init__(self, execution_count): + self._count = execution_count + self._cell = nbf.v4.new_code_cell(self.NO_CODE_FROM_HTMLNB, + execution_count=self._count) + + def new_output(self, tag, b64): + b64_data = _read_rmd_b64(b64) + + # fallback for rstudio + data = b64_data.get('ipymd.data', {'text/plain': b64_data['data']}) + + self._new_generic_output(b64_data, data) + + def new_plot(self, mime, data, b64): + b64_data = {} if not b64 else _read_rmd_b64(b64) + + # fallback for rstudio + data = b64_data.get('ipymd.data', {mime: data}) + + self._new_generic_output(b64_data, data) + + def _new_generic_output(self, b64_data, data): + """ + + Parameters + ---------- + b64_data: data read from base64 string + data: data dictionary passed to the output = {'data' : {...} } + + """ + metadata = b64_data.get('ipymd.metadata', {}) + output_type = b64_data.get('ipymd.output_type', "execute_result") + + kwargs = {} + if output_type == "execute_result": + kwargs['execution_count'] = self._count + + self._cell.outputs.append( + nbf.v4.new_output(output_type, + data, + metadata=metadata, + **kwargs) + ) + + def new_error(self, b64): + err_dict = {} if not b64 else _read_rmd_b64(b64) + traceback = [str(x) for x in err_dict.get("traceback", [])] + ename = err_dict.get("ename", "") + evalue = err_dict.get("evalue", "") + self._cell.outputs.append( + nbf.v4.new_output('error', + traceback=traceback, + ename=ename, + evalue=evalue) + ) + + @property + def cell(self): + return self._cell diff --git a/ipymd/lib/tests/test_notebook_utils.py b/ipymd/lib/tests/test_notebook_utils.py new file mode 100644 index 0000000..eee5002 --- /dev/null +++ b/ipymd/lib/tests/test_notebook_utils.py @@ -0,0 +1,67 @@ +"""Test the notebook utils. """ +from ..notebook import _assert_cell_outputs_equal, _assert_notebooks_equal +from ipymd.formats.tests._utils import _read_test_file +import pytest + + +def test_cell_outputs_equal(): + out1 = { + "execution_count": 1, + "output_type": "execute_result", + "metadata": {'output_type': 'output'}, + "data": {'text/plain': "[1] 1 2 3 4 5 6 7 8 9 10"} + } + # execution_count modified + out2 = { + "execution_count": 2, + "output_type": "execute_result", + "metadata": {'output_type': 'output'}, + "data": {'text/plain': "[1] 1 2 3 4 5 6 7 8 9 10"} + } + # execution_count None + out3 = { + "execution_count": None, + "output_type": "execute_result", + "metadata": {'output_type': 'output'}, + "data": {'text/plain': "[1] 1 2 3 4 5 6 7 8 9 10"} + } + # stream + out4 = { + "output_type": "stream", + "name": 'stdout', + "text": "[1] 1 2 3 4 5 6 7 8 9 10" + } + # differing metadata + out5 = { + "execution_count": 1, + "output_type": "execute_result", + "metadata": {'foo': 'bar'}, + "data": {'text/plain': "[1] 1 2 3 4 5 6 7 8 9 10"} + } + # output split up as list. + out6 = { + "execution_count": 1, + "output_type": "execute_result", + "metadata": {'output_type': 'output'}, + "data": {'text/plain': ["[1] 1 2 3 4 5 6 7 8 9 10"]} + } + _assert_cell_outputs_equal(out1, out1) + with pytest.raises(AssertionError): + _assert_cell_outputs_equal(out1, out2) + _assert_cell_outputs_equal(out1, out3) + _assert_cell_outputs_equal(out1, out4, check_metadata=False) + _assert_cell_outputs_equal(out1, out5, check_metadata=False) + with pytest.raises(AssertionError): + _assert_cell_outputs_equal(out1, out5) + _assert_cell_outputs_equal(out1, out6) + + +def test_assert_notebook_equals(): + ex1 = _read_test_file('ex1', 'notebook') + ex4 = _read_test_file('ex4', 'notebook') + _assert_notebooks_equal(ex1, ex1) + _assert_notebooks_equal(ex4, ex4) + with pytest.raises(AssertionError): + _assert_notebooks_equal(ex1, ex4) + with pytest.raises(AssertionError): + _assert_notebooks_equal(ex4, ex1) diff --git a/ipymd/lib/tests/test_rmarkdown.py b/ipymd/lib/tests/test_rmarkdown.py new file mode 100644 index 0000000..8b8e8f8 --- /dev/null +++ b/ipymd/lib/tests/test_rmarkdown.py @@ -0,0 +1,83 @@ +"""Test rmarkdown helper functions. """ + + +from ipymd.lib.rmarkdown import _merge_consecutive_markdown_cells, \ + _read_rmd_b64, _option_value_str, \ + _parse_option_value, _parse_chunk_meta +from collections import OrderedDict + + +def test_merge_consecutive_markdown_cells(): + """Test that consecutive markdown cells are correctly merged. """ + cells = [ + {'cell_type': 'notebook_metadata'}, + {'cell_type': 'markdown', 'source': '1'}, + {'cell_type': 'markdown', 'source': '2', 'x': 'a'}, + {'cell_type': 'markdown'}, + {'cell_type': 'code', 'source': '1'}, + {'cell_type': 'markdown', 'source': '1'}, + {'cell_type': 'markdown'}, + ] + + assert _merge_consecutive_markdown_cells(cells) == [ + {'cell_type': 'notebook_metadata'}, + {'cell_type': 'markdown', 'source': '1\n\n2'}, + {'cell_type': 'code', 'source': '1'}, + {'cell_type': 'markdown', 'source': '1'} + ] + + +def test_merge_consecutive_markdown_cells_2(): + """Test, that consecutive code cells are not merged. """ + cells = [ + {'cell_type': 'notebook_metadata'}, + {'cell_type': 'code', 'source': '1'}, + {'cell_type': 'code', 'source': '2', 'x': 'a'}, + {'cell_type': 'code'}, + {'cell_type': 'code', 'source': '1'}, + {'cell_type': 'markdown', 'source': '1'}, + {'cell_type': 'markdown'}, + ] + + assert _merge_consecutive_markdown_cells(cells) == [ + {'cell_type': 'notebook_metadata'}, + {'cell_type': 'code', 'source': '1'}, + {'cell_type': 'code', 'source': '2', 'x': 'a'}, + {'cell_type': 'code'}, + {'cell_type': 'code', 'source': '1'}, + {'cell_type': 'markdown', 'source': '1'} + ] + + +def test_read_rmd_base64(): + expected = {"data": "```python\nfoo = 'bar'\n```"} + b64 = "eyJkYXRhIjoiYGBgcHl0aG9uXG5mb28gPSAnYmFyJ1xuYGBgIn0=" + assert _read_rmd_b64(b64) == expected + + +def test_parse_chunk_meta(): + chunk_meta = \ + """{r chunk_name, foo='bar', cat="gold", horse=9, bool_val=TRUE}""" + lang, name, meta = _parse_chunk_meta(chunk_meta) + assert lang == 'r' + assert name == 'chunk_name' + assert meta == OrderedDict([ + ('foo', 'bar'), ('cat', 'gold'), ('horse', 9), ('bool_val', True) + ]) + + +def test_option_value_str(): + assert _option_value_str(True) == "TRUE" + assert _option_value_str("foo") == '"foo"' + assert _option_value_str(42) == "42" + assert _option_value_str(None) == "NULL" + + +def test_parse_option_value(): + assert _parse_option_value("'True'") == "True" + assert _parse_option_value('"TRUE"') == "TRUE" + assert _parse_option_value("True") + assert _parse_option_value("TRUE") + assert type(_parse_option_value("42")) == int + assert type(_parse_option_value("42.")) == float + assert type(_parse_option_value("42")) == int diff --git a/ipymd/ressources/r_notebook.template.html b/ipymd/ressources/r_notebook.template.html new file mode 100644 index 0000000..c9c5b68 --- /dev/null +++ b/ipymd/ressources/r_notebook.template.html @@ -0,0 +1,217 @@ + + + + + + + + + + + + + +{{ title }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + +{{ html_nb }} + +
{{ base64_rmd }}
+ + + +
+ + + + + + + + diff --git a/ipymd/utils/utils.py b/ipymd/utils/utils.py index d249809..f4b6f57 100644 --- a/ipymd/utils/utils.py +++ b/ipymd/utils/utils.py @@ -98,6 +98,13 @@ def _diff(text_0, text_1): return _diff_removed_lines(diff) +def _full_diff(text_0, text_1): + """Return a full diff between two strings""" + diff = difflib.ndiff(text_0.splitlines(keepends=True), + text_1.splitlines(keepends=True)) + return "".join(diff) + + def _show_outputs(*outputs): for output in outputs: print() @@ -105,6 +112,10 @@ def _show_outputs(*outputs): pprint(output) +def _get_cell_types(cells): + return [cell['cell_type'] for cell in cells] + + #------------------------------------------------------------------------------ # Reading/writing files from/to disk #------------------------------------------------------------------------------ diff --git a/requirements-dev.txt b/requirements-dev.txt index 6aa7fcf..630835b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ https://github.com/eea/odfpy/archive/master.zip pytest pytest-cov python-coveralls +jupyter diff --git a/setup.py b/setup.py index 09a4261..e79490b 100644 --- a/setup.py +++ b/setup.py @@ -77,6 +77,7 @@ def run_tests(self): 'ipymd=ipymd.core.scripts:main', ], 'ipymd.format': [ + 'rmarkdown=ipymd.formats.rmarkdown:RMD_FORMAT', 'markdown=ipymd.formats.markdown:MARKDOWN_FORMAT', 'atlas=ipymd.formats.atlas:ATLAS_FORMAT', 'notebook=ipymd.formats.notebook:NOTEBOOK_FORMAT', @@ -84,7 +85,7 @@ def run_tests(self): 'python=ipymd.formats.python:PYTHON_FORMAT', ] }, - install_requires=['pyyaml'], + install_requires=['pyyaml', 'jinja2', 'pypandoc'], extras_require={ 'odf': ['odfpy'], },