Skip to content

Commit

Permalink
Merge pull request jupyter-widgets#1676 from pganssle/img_url
Browse files Browse the repository at this point in the history
Add filename and URL support for `Image`
  • Loading branch information
maartenbreddels authored Mar 28, 2018
2 parents 6bfc2d9 + f1d578b commit 377594c
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 8 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
147 changes: 147 additions & 0 deletions ipywidgets/widgets/tests/test_widget_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.

"""Test Image widget"""

import io
import os

from ipywidgets import Image

import hashlib

import nose.tools as nt

import pkgutil

import tempfile
from contextlib import contextmanager

# Data
@contextmanager
def get_logo_png():
# Once the tests are not in the package, this context manager can be
# replaced with the location of the actual file
LOGO_DATA = pkgutil.get_data('ipywidgets.widgets.tests',
'data/jupyter-logo-transparent.png')
handle, fname = tempfile.mkstemp()
os.close(handle)
with open(fname, 'wb') as f:
f.write(LOGO_DATA)

yield fname

os.remove(fname)

LOGO_PNG_DIGEST = '3ff9eafd7197083153e83339a72e7a335539bae189c33554c680e4382c98af02'


def test_empty_image():
# Empty images shouldn't raise any errors
Image()


def test_image_value():
random_bytes = b'\x0ee\xca\x80\xcd\x9ak#\x7f\x07\x03\xa7'

Image(value=random_bytes)


def test_image_format():
# Test that these format names don't throw an error
Image(format='png')

Image(format='jpeg')

Image(format='url')


def test_from_filename():
with get_logo_png() as LOGO_PNG:
img = Image.from_file(LOGO_PNG)

assert_equal_hash(img.value, LOGO_PNG_DIGEST)


def test_set_from_filename():
img = Image()
with get_logo_png() as LOGO_PNG:
img.set_value_from_file(LOGO_PNG)

assert_equal_hash(img.value, LOGO_PNG_DIGEST)


def test_from_file():
with get_logo_png() as LOGO_PNG:
with open(LOGO_PNG, 'rb') as f:
img = Image.from_file(f)
assert_equal_hash(img.value, LOGO_PNG_DIGEST)


def test_set_value_from_file():
img = Image()
with get_logo_png() as LOGO_PNG:
with open(LOGO_PNG, 'rb') as f:
img.set_value_from_file(f)
assert_equal_hash(img.value, LOGO_PNG_DIGEST)


def test_from_url_unicode():
img = Image.from_url(u'https://jupyter.org/assets/main-logo.svg')
assert img.value == b'https://jupyter.org/assets/main-logo.svg'


def test_from_url_bytes():
img = Image.from_url(b'https://jupyter.org/assets/main-logo.svg')

assert img.value == b'https://jupyter.org/assets/main-logo.svg'


def test_format_inference_filename():
with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as f:
name = f.name
f.close() # Allow tests to run on Windows
img = Image.from_file(name)

assert img.format == 'svg+xml'


def test_format_inference_file():
with tempfile.NamedTemporaryFile(suffix='.gif', delete=False) as f:
img = Image.from_file(f)

assert img.format == 'gif'


def test_format_inference_stream():
# There's no way to infer the format, so it should default to png
fstream = io.BytesIO(b'')
img = Image.from_file(fstream)

assert img.format == 'png'


def test_format_inference_overridable():
with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as f:
name = f.name
f.close() # Allow tests to run on Windows
img = Image.from_file(name, format='gif')

assert img.format == 'gif'


# Helper functions
def get_hash_hex(byte_str):
m = hashlib.new('sha256')

m.update(byte_str)

return m.hexdigest()


def assert_equal_hash(byte_str, digest, msg=None):
kwargs = {}
if msg is not None:
kwargs['msg'] = msg

nt.eq_(get_hash_hex(byte_str), digest, **kwargs)
102 changes: 99 additions & 3 deletions ipywidgets/widgets/widget_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,23 @@
Represents an image in the frontend using a widget.
"""

import base64
import mimetypes

from .widget_core import CoreWidget
from .domwidget import DOMWidget
from .valuewidget import ValueWidget
from .widget import register
from traitlets import Unicode, CUnicode, Bytes, observe
from traitlets import Unicode, CUnicode, Bytes, validate


def _text_type():
# six is not a direct dependency of this module
# This replicates six.text_type
try:
return unicode
except NameError:
return str
_text_type = _text_type()


@register
Expand All @@ -23,6 +32,9 @@ class Image(DOMWidget, ValueWidget, CoreWidget):
raw image data that you want the browser to display. You can explicitly
define the format of the byte string using the `format` trait (which
defaults to "png").
If you pass `"url"` to the `"format"` trait, `value` will be interpreted
as a URL as bytes encoded in UTF-8.
"""
_view_name = Unicode('ImageView').tag(sync=True)
_model_name = Unicode('ImageModel').tag(sync=True)
Expand All @@ -32,3 +44,87 @@ class Image(DOMWidget, ValueWidget, CoreWidget):
width = CUnicode(help="Width of the image in pixels.").tag(sync=True)
height = CUnicode(help="Height of the image in pixels.").tag(sync=True)
value = Bytes(help="The image data as a byte string.").tag(sync=True)

@classmethod
def from_file(cls, filename, **kwargs):
"""
Create an :class:`Image` from a local file.
Parameters
----------
filename: str
The location of a file to read into the value from disk.
**kwargs:
The keyword arguments for `Image`
Returns an `Image` with the value set from the filename.
"""
value = cls._load_file_value(filename)

if 'format' not in kwargs:
img_format = cls._guess_format(filename)
if img_format is not None:
kwargs['format'] = img_format

return cls(value=value, **kwargs)

@classmethod
def from_url(cls, url, **kwargs):
"""
Create an :class:`Image` from a URL.
:code:`Image.from_url(url)` is equivalent to:
.. code-block: python
img = Image(value=url, format='url')
But both unicode and bytes arguments are allowed for ``url``.
Parameters
----------
url: [str, bytes]
The location of a URL to load.
"""
if isinstance(url, _text_type):
# If unicode (str in Python 3), it needs to be encoded to bytes
url = url.encode('utf-8')

return cls(value=url, format='url')

def set_value_from_file(self, filename):
"""
Convenience method for reading a file into `value`.
Parameters
----------
filename: str
The location of a file to read into value from disk.
"""
value = self._load_file_value(filename)

self.value = value

@classmethod
def _load_file_value(cls, filename):
if getattr(filename, 'read', None) is not None:
return filename.read()
else:
with open(filename, 'rb') as f:
return f.read()

@classmethod
def _guess_format(cls, filename):
# file objects may have a .name parameter
name = getattr(filename, 'name', None)
name = name or filename

try:
mtype, _ = mimetypes.guess_type(name)
if not mtype.startswith('image/'):
return None

return mtype[len('image/'):]
except Exception:
return None
1 change: 1 addition & 0 deletions packages/controls/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@types/mathjax": "0.0.31",
"@types/mocha": "^2.2.41",
"@types/node": "^8.0.1",
"@types/text-encoding": "^0.0.32",
"chai": "^4.0.0",
"css-loader": "^0.28.4",
"expect.js": "^0.3.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/controls/src/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"declaration": true,
//"noImplicitAny": true,
"lib": ["dom", "es5", "es2015.promise", "es2015.iterable"],
"types": ["mathjax", "node"],
"types": ["mathjax", "node", "text-encoding"],
"noEmitOnError": true,
"module": "commonjs",
"moduleResolution": "node",
Expand Down
16 changes: 13 additions & 3 deletions packages/controls/src/widget_image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,21 @@ class ImageView extends DOMWidgetView {
* Called when the model is changed. The model may have been
* changed by another view or by a state update from the back-end.
*/
let blob = new Blob([this.model.get('value')], {type: `image/${this.model.get('format')}`});
let url = URL.createObjectURL(blob);

let url;
let format = this.model.get('format');
let value = this.model.get('value');
if (format !== 'url') {
let blob = new Blob([value], {type: `image/${this.model.get('format')}`});
url = URL.createObjectURL(blob);
} else {
url = (new TextDecoder('utf-8')).decode(value.buffer);
}

// Clean up the old objectURL
let oldurl = this.el.src;
this.el.src = url;
if (oldurl) {
if (oldurl && typeof oldurl !== 'string') {
URL.revokeObjectURL(oldurl);
}
let width = this.model.get('width');
Expand Down
4 changes: 3 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@
scripts = [],
packages = packages,
package_data = {
'ipywidgets': [ 'state.schema.json', 'view.schema.json' ]
'ipywidgets': [ 'state.schema.json', 'view.schema.json' ],
# Test data needs to be packaged until tests are moved out of module
'ipywidgets.widgets.tests': ['data/jupyter-logo-transparent.png']
},
description = "IPython HTML widgets for Jupyter",
long_description = LONG_DESCRIPTION,
Expand Down

0 comments on commit 377594c

Please sign in to comment.