diff --git a/CHANGES.rst b/CHANGES.rst index 5864955..31e9d85 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,8 @@ +v6.3.0 +------ + +Add support for reading deps from Jupyter Notebooks. + v6.2.0 ------ diff --git a/examples/plotter.ipynb b/examples/plotter.ipynb new file mode 100644 index 0000000..84317ae --- /dev/null +++ b/examples/plotter.ipynb @@ -0,0 +1,81 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# A sin wave plot\n", + "A simple demo plotting a sin wave to demonstrate executing a notebook with dependencies. Invoke with:\n", + "\n", + "```\n", + "pip-run -- -m notebook plotter.ipynb\n", + "```\n", + "\n", + "Or render with\n", + "\n", + "```\n", + "pip-run -- -m nbconvert --execute plotter.ipynb\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "__requires__ = ['jupyter', 'matplotlib', 'numpy']\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "%matplotlib inline" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "x = np.arange(0,4*np.pi,0.1) # start,stop,step\n", + "y = np.sin(x)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "plt.plot(x,y)\n", + "plt.show()" + ] + } + ], + "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.8.2" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/pip_run/scripts.py b/pip_run/scripts.py index 718e958..65eb572 100644 --- a/pip_run/scripts.py +++ b/pip_run/scripts.py @@ -5,6 +5,7 @@ import itertools import io import re +import json try: @@ -13,6 +14,8 @@ import pkg_resources +__metaclass__ = type + if sys.version_info < (3,): filter = itertools.ifilter map = itertools.imap @@ -37,12 +40,12 @@ def __init__(self, script): self.script = script @classmethod - def load(cls, script_path): - with io.open(script_path) as stream: - return cls(stream.read()) + def try_read(cls, script_path): + results = (subclass._try_read(script_path) for subclass in cls.__subclasses__()) + return next(filter(None, results), Dependencies()) @classmethod - def try_read(cls, script_path): + def _try_read(cls, script_path): """ Attempt to load the dependencies from the script, but return an empty list if unsuccessful. @@ -51,7 +54,7 @@ def try_read(cls, script_path): reader = cls.load(script_path) return reader.read() except Exception: - return Dependencies() + pass @classmethod def search(cls, params): @@ -105,6 +108,26 @@ def strip_f(match): return re.sub(r'\bf[\'"]', strip_f, removed) +class SourceDepsReader(DepsReader): + @classmethod + def load(cls, script_path): + with io.open(script_path) as stream: + return cls(stream.read()) + + +class NotebookDepsReader(DepsReader): + @classmethod + def load(cls, script_path): + doc = json.load(open(script_path)) + lines = ( + line + for cell in doc['cells'] + for line in cell['source'] + ['\n'] + if cell['cell_type'] == 'code' and not line.startswith('%') + ) + return cls(''.join(lines)) + + def run(cmdline): """ Execute the script as if it had been invoked naturally. diff --git a/pip_run/tests/test_scripts.py b/pip_run/tests/test_scripts.py index 7eef500..e8308d9 100644 --- a/pip_run/tests/test_scripts.py +++ b/pip_run/tests/test_scripts.py @@ -5,6 +5,9 @@ import sys import subprocess +import pytest +import nbformat + from pip_run import scripts @@ -27,7 +30,7 @@ def test_pkg_imported(tmpdir): assert 'Successfully imported path.py' in out -class TestDepsReader: +class TestSourceDepsReader: def test_reads_files_with_attribute_assignment(self): script = textwrap.dedent( ''' @@ -81,6 +84,57 @@ def test_fstrings_allowed(self): assert reqs == ['foo'] +class TestNotebookDepsReader: + @pytest.fixture + def notebook_factory(self, tmpdir, request): + class Factory: + def __init__(self): + self.nb = nbformat.v4.new_notebook() + self.path = tmpdir / (request.node.name + '.ipynb') + + @property + def filename(self): + return str(self.path) + + def write(self): + nbformat.write(self.nb, self.filename) + + def add_code(self, code): + self.nb['cells'].append(nbformat.v4.new_code_cell(code)) + + def add_markdown(self, text): + self.nb['cells'].append(nbformat.v4.new_markdown_cell(text)) + + return Factory() + + def test_one_code_block(self, notebook_factory): + notebook_factory.add_code('__requires__ = ["matplotlib"]') + notebook_factory.write() + reqs = scripts.DepsReader.try_read(notebook_factory.filename) + assert reqs == ['matplotlib'] + + def test_multiple_code_blocks(self, notebook_factory): + notebook_factory.add_code('__requires__ = ["matplotlib"]') + notebook_factory.add_code("import matplotlib") + notebook_factory.write() + reqs = scripts.DepsReader.try_read(notebook_factory.filename) + assert reqs == ['matplotlib'] + + def test_code_and_markdown(self, notebook_factory): + notebook_factory.add_code('__requires__ = ["matplotlib"]') + notebook_factory.add_markdown("Mark this down please") + notebook_factory.write() + reqs = scripts.DepsReader.try_read(notebook_factory.filename) + assert reqs == ['matplotlib'] + + def test_jupyter_directives(self, notebook_factory): + notebook_factory.add_code('__requires__ = ["matplotlib"]') + notebook_factory.add_code("%matplotlib inline\nimport matplotlib") + notebook_factory.write() + reqs = scripts.DepsReader.try_read(notebook_factory.filename) + assert reqs == ['matplotlib'] + + def test_pkg_loaded_from_alternate_index(tmpdir): """ Create a script that loads cython from an alternate index diff --git a/setup.cfg b/setup.cfg index a8527de..01ad34f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,7 @@ testing = pytest-cov # local + nbformat docs = # upstream