diff --git a/circle.yml b/circle.yml index 30ae291017a..62a7bdb505a 100644 --- a/circle.yml +++ b/circle.yml @@ -8,6 +8,11 @@ machine: PLOTLY_TOX_PYTHON_33: /home/ubuntu/.pyenv/versions/3.3.3/bin/python3.3 PLOTLY_TOX_PYTHON_34: /home/ubuntu/.pyenv/versions/3.4.3/bin/python3.4 PLOTLY_TOX_PYTHON_35: /home/ubuntu/.pyenv/versions/3.5.0/bin/python3.5 + PLOTLY_JUPYTER_TEST_DIR: /home/ubuntu/${CIRCLE_PROJECT_REPONAME}/plotly/tests/test_optional/test_jupyter + + node: + # use a pre-installed version of node so we don't need to download it. + version: 4.2.2 dependencies: @@ -21,10 +26,14 @@ dependencies: # we need to cd out of the project root to ensure the install worked - cd ~ && python -c "import plotly" + # install jupyter test JS requirements + - cd ${PLOTLY_JUPYTER_TEST_DIR} && npm i + cache_directories: # cache everything that tox installs for us. - .tox + - ${PLOTLY_JUPYTER_TEST_DIR}/node_modules test: diff --git a/optional-requirements.txt b/optional-requirements.txt index 22342b1be71..9dcc04023b1 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -12,10 +12,11 @@ numpy # matplotlib==1.3.1 ## testing dependencies ## -nose==1.3.3 +nose +coverage -## ipython dependencies ## -ipython[all]==3.0.0 +## ipython ## +ipython ## pandas deps for some matplotlib functionality ## pandas @@ -23,3 +24,6 @@ pandas ## scipy deps for some FigureFactory functions ## scipy +## jupyter ## +jupyter +ipykernel diff --git a/plotly/tests/test_core/test_image/test_image.py b/plotly/tests/test_core/test_image/test_image.py index 7ccd298a845..409bb8cb48a 100644 --- a/plotly/tests/test_core/test_image/test_image.py +++ b/plotly/tests/test_core/test_image/test_image.py @@ -5,9 +5,11 @@ import tempfile import os import itertools +import warnings from nose.plugins.attrib import attr +from plotly import exceptions from plotly.plotly import plotly as py @@ -24,9 +26,20 @@ def setUp(self): def _generate_image_get_returns_valid_image_test(image_format, width, height, scale): def test(self): - image = py.image.get(self.data, image_format, width, height, scale) - if image_format in ['png', 'jpeg']: - assert imghdr.what('', image) == image_format + # TODO: better understand why this intermittently fails. See #649 + num_attempts = 5 + for i in range(num_attempts): + if i > 0: + warnings.warn('image test intermittently failed, retrying...') + try: + image = py.image.get(self.data, image_format, width, height, + scale) + if image_format in ['png', 'jpeg']: + assert imghdr.what('', image) == image_format + return + except (KeyError, exceptions.PlotlyError): + if i == num_attempts - 1: + raise return test diff --git a/plotly/tests/test_optional/test_jupyter/.gitignore b/plotly/tests/test_optional/test_jupyter/.gitignore new file mode 100644 index 00000000000..226eaffa30d --- /dev/null +++ b/plotly/tests/test_optional/test_jupyter/.gitignore @@ -0,0 +1,3 @@ +node_modules +fixtures/*.html +!fixtures/*.ipynb diff --git a/plotly/tests/test_optional/test_jupyter/fixtures/connected_false.ipynb b/plotly/tests/test_optional/test_jupyter/fixtures/connected_false.ipynb new file mode 100644 index 00000000000..4fe8216a85f --- /dev/null +++ b/plotly/tests/test_optional/test_jupyter/fixtures/connected_false.ipynb @@ -0,0 +1,57 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "from plotly.offline import plot, iplot, init_notebook_mode\n", + "import plotly.graph_objs as go\n", + "\n", + "# Make plotly work with Jupyter notebook\n", + "init_notebook_mode()\n", + "\n", + "keys=['one','two','three']\n", + "values=[1,2,3]\n", + "\n", + "iplot({\n", + " \"data\": [go.Bar(x=keys, y=values)],\n", + " \"layout\": go.Layout(title=\"Sample Bar Chart\")\n", + "})" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [] + } + ], + "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.5.2" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/plotly/tests/test_optional/test_jupyter/fixtures/connected_true.ipynb b/plotly/tests/test_optional/test_jupyter/fixtures/connected_true.ipynb new file mode 100644 index 00000000000..74eb39fc850 --- /dev/null +++ b/plotly/tests/test_optional/test_jupyter/fixtures/connected_true.ipynb @@ -0,0 +1,57 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "from plotly.offline import plot, iplot, init_notebook_mode\n", + "import plotly.graph_objs as go\n", + "\n", + "# Make plotly work with Jupyter notebook\n", + "init_notebook_mode(connected=True)\n", + "\n", + "keys=['one','two','three']\n", + "values=[1,2,3]\n", + "\n", + "iplot({\n", + " \"data\": [go.Bar(x=keys, y=values)],\n", + " \"layout\": go.Layout(title=\"Sample Bar Chart\")\n", + "})" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [] + } + ], + "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.5.2" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/plotly/tests/test_optional/test_jupyter/js_tests/connected_false.js b/plotly/tests/test_optional/test_jupyter/js_tests/connected_false.js new file mode 100644 index 00000000000..bcb500aa769 --- /dev/null +++ b/plotly/tests/test_optional/test_jupyter/js_tests/connected_false.js @@ -0,0 +1,31 @@ +'use strict'; + +var test = require('../lib/tape-wrapper'); + +test('should load plotly.js', function(t) { + t.plan(1); + + window.require(['plotly'], function(Plotly) { + t.equal(typeof Plotly, 'object'); + }); +}); + +test('should have one plotly.js graph', function(t) { + t.plan(1); + + var nodes = document.querySelectorAll('.js-plotly-plot'); + t.equal(nodes.length, 1); +}); + +test('should inject raw plotly.js code into DOM', function(t) { + t.plan(1); + + var nodes = document.querySelectorAll('script'); + nodes = Array.prototype.slice.call(nodes, 0, 10); + + var results = nodes.filter(function(node) { + return node.innerHTML.substr(0, 19) === 'if(!window.Plotly){'; + }); + + t.equal(results.length, 1); +}); diff --git a/plotly/tests/test_optional/test_jupyter/js_tests/connected_true.js b/plotly/tests/test_optional/test_jupyter/js_tests/connected_true.js new file mode 100644 index 00000000000..8ec2cc02df7 --- /dev/null +++ b/plotly/tests/test_optional/test_jupyter/js_tests/connected_true.js @@ -0,0 +1,31 @@ +'use strict'; + +var test = require('../lib/tape-wrapper'); + +test('should load plotly.js', function(t) { + t.plan(1); + + window.require(['plotly'], function(Plotly) { + t.equal(typeof Plotly, 'object'); + }); +}); + +test('should have one plotly.js graph', function(t) { + t.plan(1); + + var nodes = document.querySelectorAll('.js-plotly-plot'); + t.equal(nodes.length, 1); +}); + +test('should link to plotly.js CDN', function(t) { + t.plan(1); + + var nodes = document.querySelectorAll('script'); + nodes = Array.prototype.slice.call(nodes, 0); + + var results = nodes.filter(function(node) { + return node.src === 'https://cdn.plot.ly/plotly-latest.min.js'; + }); + + t.equal(results.length, 1); +}); diff --git a/plotly/tests/test_optional/test_jupyter/lib/server.js b/plotly/tests/test_optional/test_jupyter/lib/server.js new file mode 100644 index 00000000000..f9464ad0afe --- /dev/null +++ b/plotly/tests/test_optional/test_jupyter/lib/server.js @@ -0,0 +1,125 @@ +var http = require('http'); +var url = require('url'); +var fs = require('fs'); +var path = require('path'); + +var ecstatic = require('ecstatic'); +var browserify = require('browserify'); +var cheerio = require('cheerio'); +var tapParser = require('tap-parser'); +var chrome = require('chrome-launch'); + +var PORT = 8080; +var PATH_ROOT = path.join(__dirname, '..'); +var PATH_INDEX_STUB = path.join(PATH_ROOT, 'index.tmp.html'); +var PATH_TEST_BUNDLE = path.join(PATH_ROOT, 'test.tmp.js'); + +var URL = 'http://localhost:' + PORT + '/index.tmp.html'; +var EXIT_CODE = 0; + +if(process.argv.length !== 4) { + throw new Error('must provide path to html and js files'); +} + +var PATH_INDEX = process.argv[2]; +var PATH_TEST_FILE = process.argv[3]; + +main(); + +function main() { + scanInput(); + + stubIndex() + .then(bundleTests) + .then(startServer) + .then(launch); +} + +function scanInput() { + var reqFiles = [PATH_INDEX, PATH_TEST_FILE]; + + reqFiles.forEach(function(filePath) { + if(!doesFileExist(filePath)) { + throw new Error(filePath + ' does not exist'); + } + }); +} + +function stubIndex() { + return new Promise(function(resolve, reject) { + var html = fs.readFileSync(PATH_INDEX, 'utf-8'); + var $ = cheerio.load(html); + + $('body').append(''); + + fs.writeFile(PATH_INDEX_STUB, $.html(), resolve); + }); +} + +function bundleTests() { + return new Promise(function(resolve, reject) { + var wsBundle = fs.createWriteStream(PATH_TEST_BUNDLE); + + browserify(PATH_TEST_FILE, { debug: true }) + .bundle() + .pipe(wsBundle); + + wsBundle.on('close', resolve); + }); +} + +function startServer() { + return new Promise(function(resolve, reject) { + var server = http.createServer(ecstatic({ root: PATH_ROOT })); + + server.on('request', handle); + + server.listen(PORT, resolve); + }); +} + +function handle(req, res) { + var query = url.parse(req.url).query || ''; + var parser = tapParser(); + + function is(query, root) { + return query.indexOf(root) !== -1; + } + + if(is(query, 'data')) handleData(req, res); + if(is(query, 'done')) handleDone(); + + function handleData(req, res) { + req.pipe(parser); + req.pipe(process.stdout); + } + + parser.on('assert', function(assert) { + if(EXIT_CODE === 0 && assert.ok === false) EXIT_CODE = 1; + }) + + function handleDone() { + removeBuildFiles(); + process.exit(EXIT_CODE); + } +} + +function launch() { + chrome(URL); +} + +function removeBuildFiles() { + fs.unlinkSync(PATH_INDEX_STUB); + fs.unlinkSync(PATH_TEST_BUNDLE); +} + +function doesFileExist(filePath) { + try { + if(fs.statSync(filePath).isFile()) return true; + } + catch(e) { + return false; + } + + return false; +} diff --git a/plotly/tests/test_optional/test_jupyter/lib/tape-wrapper.js b/plotly/tests/test_optional/test_jupyter/lib/tape-wrapper.js new file mode 100644 index 00000000000..88759ddfc78 --- /dev/null +++ b/plotly/tests/test_optional/test_jupyter/lib/tape-wrapper.js @@ -0,0 +1,33 @@ +'use strict'; + +var test = require('tape'); +var xhr = require('xhr'); +var domready = require('domready'); + +var cnt = 0; +var noop = function() {}; + +var post = function(query, data) { + var opts = data ? { body: data } : {}; + xhr.post('/?' + query + '&' + (cnt++), opts, noop); +}; + +var ws = test.createStream(); + +ws.on('data', function(data) { + post('data', data) +}); + +test.onFinish(function() { + post('done'); +}); + +test('should not crash browser', function(t) { + t.plan(1); + + domready(function() { + t.pass('domready'); + }); +}); + +module.exports = test; diff --git a/plotly/tests/test_optional/test_jupyter/package.json b/plotly/tests/test_optional/test_jupyter/package.json new file mode 100644 index 00000000000..c99e7062d7a --- /dev/null +++ b/plotly/tests/test_optional/test_jupyter/package.json @@ -0,0 +1,20 @@ +{ + "name": "plotly.py-jupyter-tests", + "version": "1.0.0", + "description": "js deps for plotly.py jupyter tests", + "scripts": { + "test": "node lib/server.js" + }, + "author": "Plotly Inc.", + "license": "MIT", + "dependencies": { + "browserify": "^13.1.0", + "cheerio": "^0.20.0", + "chrome-launch": "^1.1.4", + "domready": "^1.0.8", + "ecstatic": "^2.1.0", + "tap-parser": "^2.0.0", + "tape": "^4.6.0", + "xhr": "^2.2.2" + } +} diff --git a/plotly/tests/test_optional/test_jupyter/test_jupyter.py b/plotly/tests/test_optional/test_jupyter/test_jupyter.py new file mode 100644 index 00000000000..b42a04739b0 --- /dev/null +++ b/plotly/tests/test_optional/test_jupyter/test_jupyter.py @@ -0,0 +1,65 @@ +""" +test__jupyter + +""" +import nbformat +from nbconvert import HTMLExporter +from nbconvert.preprocessors import ExecutePreprocessor +from ipykernel import kernelspec + +from unittest import TestCase +from os import path +import subprocess + +PATH_ROOT = path.dirname(__file__) +PATH_FIXTURES = path.join(PATH_ROOT, 'fixtures') +PATH_JS_TESTS = path.join(PATH_ROOT, 'js_tests') + + +class Common(TestCase): + __test__ = False + name = None + + def setUp(self): + self.path_test_nb = path.join(PATH_FIXTURES, self.name + '.ipynb') + self.path_test_html = path.join(PATH_FIXTURES, self.name + '.html') + self.path_test_js = path.join(PATH_JS_TESTS, self.name + '.js') + + self.kernel_name = kernelspec.KERNEL_NAME + + with open(self.path_test_nb, 'r') as f: + self.nb = nbformat.read(f, as_version=4) + + self.ep = ExecutePreprocessor(timeout=600, + kernel_name=self.kernel_name) + + self.html_exporter = HTMLExporter() + + self.ep.preprocess(self.nb, {'metadata': {'path': '.'}}) + (self.body, _) = self.html_exporter.from_notebook_node(self.nb) + + with open(self.path_test_html, 'w') as f: + f.write(self.body) + + def test_js(self): + cmd = ['npm', 'test', '--', self.path_test_html, self.path_test_js] + + proc = subprocess.Popen(cmd, + cwd=PATH_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + + (_, stderr) = proc.communicate() + + if stderr: + self.fail('One or more javascript test failed') + + +class PlotlyJupyterConnectedFalseTestCase(Common): + __test__ = True + name = 'connected_false' + + +class PlotlyJupyterConnectedTrueTestCase(Common): + __test__ = True + name = 'connected_true' diff --git a/tox.ini b/tox.ini index 312d0b5e36d..15ffaa0101f 100644 --- a/tox.ini +++ b/tox.ini @@ -58,6 +58,7 @@ deps= pytz==2016.10 optional: numpy==1.11.3 optional: ipython[all]==5.1.0 + optional: jupyter==1.0.0 optional: pandas==0.19.2 optional: scipy==0.18.1