From d39d4b7fe9cba00ff892967ac9e1e52cfd1d441b Mon Sep 17 00:00:00 2001 From: Brian Quinlan Date: Thu, 14 Mar 2019 10:14:51 -0700 Subject: [PATCH] Add the ability to imbed logos. Fixes #7. --- README.md | 13 +++++++++ pybadges/__init__.py | 56 ++++++++++++++++++++++++++++++++++++-- pybadges/__main__.py | 13 ++++++++- setup.py | 4 +-- tests/test-badges.json | 28 ++++++++++++++++++- tests/test_pybadges.py | 62 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 84ac1cc..fb1cf52 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ python -m pybadges \ --left-link=http://www.complete.com/ \ --right-link=http://www.example.com \ --logo='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAD0lEQVQI12P4zwAD/xkYAA/+Af8iHnLUAAAAAElFTkSuQmCC' \ + --embed-logo \ --browser ``` @@ -77,6 +78,18 @@ python -m pybadges \ ![pip installation](tests/golden-images/python.svg) +If the `--logo` option is set, the `--embed-logo` option can also be set. +The `--embed-logo` option causes the content of the URL provided in `--logo` +to be embedded in the badge rather than be referenced through a link. + +The advantage of using this option is an extra HTTP request will not be required +to render the badge and that some browsers will not load image references at all. + +You can see the difference in your browser: + +![--embed-logo=yes](tests/golden-images/embedded-logo.svg) ![--embed-logo=no](tests/golden-images/no-embedded-logo.svg) + + ### Library usage pybadges is primarily meant to be used as a Python library. diff --git a/pybadges/__init__.py b/pybadges/__init__.py index 7627720..b1e6c66 100644 --- a/pybadges/__init__.py +++ b/pybadges/__init__.py @@ -30,10 +30,16 @@ gh-badges library '' """ -import jinja2 +import base64 +import imghdr +import mimetypes from typing import Optional +import urllib.parse from xml.dom import minidom +import jinja2 +import requests + from pybadges import text_measurer from pybadges import precalculated_text_measurer @@ -70,12 +76,48 @@ def _remove_blanks(node): elif x.nodeType == minidom.Node.ELEMENT_NODE: _remove_blanks(x) +def _embed_image(url: str) -> str: + parsed_url = urllib.parse.urlparse(url) + + if parsed_url.scheme == 'data': + return url + elif parsed_url.scheme.startswith('http'): + r = requests.get(url) + r.raise_for_status() + content_type = r.headers.get('content-type') + if content_type is None: + raise ValueError('no "Content-Type" header') + content_type, image_type = content_type.split('/') + if content_type != 'image': + raise ValueError('expected an image, got "{0}"'.format( + content_type)) + image_data = r.content + elif parsed_url.scheme: + raise ValueError('unsupported scheme "{0}"'.format(parsed_url.scheme)) + else: + with open(url, 'rb') as f: + image_data = f.read() + image_type = imghdr.what(None, image_data) + if not image_type: + mime_type, _ = mimetypes.guess_type(url, strict=False) + if not mime_type: + raise ValueError('not able to determine file type') + else: + content_type, image_type = mime_type.split('/') + if content_type != 'image': + raise ValueError('expected an image, got "{0}"'.format( + content_type or 'unknown')) + + encoded_image = base64.b64encode(image_data).decode('ascii') + return 'data:image/{};base64,{}'.format(image_type, encoded_image) + def badge(left_text: str, right_text: str, left_link: Optional[str] = None, right_link: Optional[str] = None, whole_link: Optional[str] = None, logo: Optional[str] = None, left_color: str = '#555', right_color: str = '#007ec6', - measurer: Optional[text_measurer.TextMeasurer] = None) -> str: + measurer: Optional[text_measurer.TextMeasurer] = None, + embed_logo: bool = False) -> str: """Creates a github-style badge as an SVG image. >>> badge(left_text='coverage', right_text='23%', right_color='red') @@ -109,7 +151,11 @@ def badge(left_text: str, right_text: str, left_link: Optional[str] = None, https://github.com/badges/shields/blob/master/lib/colorscheme.json measurer: A text_measurer.TextMeasurer that can be used to measure the width of left_text and right_text. - + embed_logo: If True then embed the logo image directly in the badge. + This can prevent an HTTP request and some browsers will not render + external image referenced. When True, `logo` must be a HTTP/HTTPS + URI or a filesystem path. Also, the `badge` call may raise an + exception if the logo cannot be loaded, is not an image, etc. """ if measurer is None: measurer = ( @@ -120,6 +166,10 @@ def badge(left_text: str, right_text: str, left_link: Optional[str] = None, raise ValueError( 'whole_link may not bet set with left_link or right_link') template = _JINJA2_ENVIRONMENT.get_template('badge-template-full.svg') + + if logo and embed_logo: + logo = _embed_image(logo) + svg = template.render( left_text=left_text, right_text=right_text, diff --git a/pybadges/__main__.py b/pybadges/__main__.py index c5f7555..96faaa9 100644 --- a/pybadges/__main__.py +++ b/pybadges/__main__.py @@ -64,6 +64,16 @@ def main(): '--logo', default=None, help='a URI reference to a logo to display in the badge') + parser.add_argument( + '--embed-logo', + nargs='?', + type=lambda x: x.lower() in ['y', 'yes', 't', 'true', '1', ''], + const='yes', + default='no', + help='if the logo is specified then include the image data directly in ' + 'the badge (this will prevent a URL fetch and may work around the ' + 'fact that some browsers do not fetch external image references); ' + 'only works if --logo is a HTTP/HTTPS URI or a file path') parser.add_argument( '--browser', action='store_true', @@ -109,7 +119,8 @@ def main(): left_color=args.left_color, right_color=args.right_color, logo=args.logo, - measurer=measurer) + measurer=measurer, + embed_logo=args.embed_logo) if args.browser: _, badge_path = tempfile.mkstemp(suffix='.svg') diff --git a/setup.py b/setup.py index 73966bc..6092576 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ def get_long_description(): setup( name='pybadges', - version='1.0.3', + version='1.1.0', author='Brian Quinlan', author_email='brian@sweetapp.com', classifiers=[ @@ -64,7 +64,7 @@ setup( long_description=get_long_description(), long_description_content_type='text/markdown', python_requires='>=3', - install_requires=['Jinja2>=2'], + install_requires=['Jinja2>=2', 'requests>=2.21'], extras_require={ 'pil-measurement': ['Pillow>=5'], 'dev': ['fonttools>=3.26', 'nox-automation>=0.19', 'Pillow>=5', diff --git a/tests/test-badges.json b/tests/test-badges.json index dbd9b31..4ee7bd5 100644 --- a/tests/test-badges.json +++ b/tests/test-badges.json @@ -32,7 +32,19 @@ "right_color": "#fb3", "left_link": "http://www.complete.com/", "right_link": "http://www.example.com", - "logo": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAD0lEQVQI12P4zwAD/xkYAA/+Af8iHnLUAAAAAElFTkSuQmCC" + "logo": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAD0lEQVQI12P4zwAD/xkYAA/+Af8iHnLUAAAAAElFTkSuQmCC", + "embed_logo": false + }, + { + "file_name": "complete.svg", + "left_text": "complete", + "right_text": "example", + "left_color": "green", + "right_color": "#fb3", + "left_link": "http://www.complete.com/", + "right_link": "http://www.example.com", + "logo": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAD0lEQVQI12P4zwAD/xkYAA/+Af8iHnLUAAAAAElFTkSuQmCC", + "embed_logo": true }, { "file_name": "github.svg", @@ -64,6 +76,20 @@ "logo": "https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/python.svg", "whole_link": "https://www.python.org/" }, + { + "file_name": "embedded-logo.svg", + "left_text": "--embed-logo", + "right_text": "yes", + "logo": "https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/python.svg", + "embed_logo": true + }, + { + "file_name": "no-embedded-logo.svg", + "left_text": "--embed-logo", + "right_text": "no", + "logo": "https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/python.svg", + "embed_logo": false + }, { "file_name": "saying-arabic.svg", "left_text": "saying", diff --git a/tests/test_pybadges.py b/tests/test_pybadges.py index c7ff634..9710511 100644 --- a/tests/test_pybadges.py +++ b/tests/test_pybadges.py @@ -14,15 +14,23 @@ """Tests for pybadges.""" +import base64 import doctest import json import os.path import unittest +import pathlib +import tempfile import pybadges TEST_DIR = os.path.dirname(__file__) +PNG_IMAGE_B64 = ( + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAD0lEQVQI12P4zw' + 'AD/xkYAA/+Af8iHnLUAAAAAElFTkSuQmCC') +PNG_IMAGE = base64.b64decode(PNG_IMAGE_B64) + class TestPybadgesBadge(unittest.TestCase): """Tests for pybadges.badge.""" @@ -51,5 +59,59 @@ class TestPybadgesBadge(unittest.TestCase): self.assertEqual(golden_image, pybadge_image) +class TestEmbedImage(unittest.TestCase): + """Tests for pybadges._embed_image.""" + + def test_data_url(self): + url = 'data:image/png;base64,' + PNG_IMAGE_B64 + self.assertEqual(url, pybadges._embed_image(url)) + + def test_http_url(self): + url = 'https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/python.svg' + self.assertRegex(pybadges._embed_image(url), + r'^data:image/svg(\+xml)?;base64,') + + def test_not_image_url(self): + with self.assertRaisesRegex(ValueError, + 'expected an image, got "text"'): + pybadges._embed_image('http://www.google.com/') + + def test_svg_file_path(self): + image_path = os.path.abspath( + os.path.join(TEST_DIR, 'golden-images', 'build-failure.svg')) + self.assertRegex(pybadges._embed_image(image_path), + r'^data:image/svg(\+xml)?;base64,') + + def test_png_file_path(self): + with tempfile.NamedTemporaryFile() as png: + png.write(PNG_IMAGE) + png.flush() + self.assertEqual(pybadges._embed_image(png.name), + 'data:image/png;base64,' + PNG_IMAGE_B64) + + def test_unknown_type_file_path(self): + with tempfile.NamedTemporaryFile() as non_image: + non_image.write(b'Hello') + non_image.flush() + with self.assertRaisesRegex(ValueError, + 'not able to determine file type'): + pybadges._embed_image(non_image.name) + + def test_text_file_path(self): + with tempfile.NamedTemporaryFile(suffix='.txt') as non_image: + non_image.write(b'Hello') + non_image.flush() + with self.assertRaisesRegex(ValueError, + 'expected an image, got "text"'): + pybadges._embed_image(non_image.name) + + def test_file_url(self): + image_path = os.path.abspath( + os.path.join(TEST_DIR, 'golden-images', 'build-failure.svg')) + + with self.assertRaisesRegex(ValueError, 'unsupported scheme "file"'): + pybadges._embed_image(pathlib.Path(image_path).as_uri()) + + if __name__ == '__main__': unittest.main()