diff options
author | Henry-Nicolas Tourneur <debian@nilux.be> | 2020-04-11 16:38:30 +0200 |
---|---|---|
committer | Henry-Nicolas Tourneur <debian@nilux.be> | 2020-04-11 16:38:30 +0200 |
commit | f295c0b51d45f936cc07a0847f8847744eaa2cf2 (patch) | |
tree | 42e6ec04fd17719fa0949e820e7137afc5cffaeb |
New upstream version 0.1.1
-rw-r--r-- | .editorconfig | 8 | ||||
-rw-r--r-- | .gitignore | 105 | ||||
-rw-r--r-- | .travis.yml | 25 | ||||
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | README.md | 49 | ||||
-rw-r--r-- | examples/__init__.py | 0 | ||||
-rw-r--r-- | examples/gtk_app.py | 78 | ||||
-rw-r--r-- | pyfavicon/__init__.py | 357 | ||||
-rw-r--r-- | requirements.txt | 3 | ||||
-rw-r--r-- | setup.py | 36 | ||||
-rw-r--r-- | tests/__init__.py | 0 | ||||
-rw-r--r-- | tests/html/base64_favicon_link.html | 9 | ||||
-rw-r--r-- | tests/html/largest_gitlab.html | 10 | ||||
-rw-r--r-- | tests/html/meta_favicon.html | 8 | ||||
-rw-r--r-- | tests/html/url_apple_touch_icon_link.html | 7 | ||||
-rw-r--r-- | tests/html/url_apple_touch_icon_precomposed_link.html | 7 | ||||
-rw-r--r-- | tests/html/url_fluid_icon_link.html | 7 | ||||
-rw-r--r-- | tests/html/url_icon_link.html | 7 | ||||
-rw-r--r-- | tests/html/url_shortcut_icon_link.html | 7 | ||||
-rw-r--r-- | tests/test_base64_favicon.py | 43 | ||||
-rw-r--r-- | tests/test_download_favicon.py | 28 | ||||
-rw-r--r-- | tests/test_favicon_size.py | 40 | ||||
-rw-r--r-- | tests/test_favicon_url.py | 33 | ||||
-rw-r--r-- | tests/test_from_html.py | 58 | ||||
-rw-r--r-- | tox.ini | 19 |
25 files changed, 965 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd6bcb3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = true
\ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a3b5e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,105 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +cache/ + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..9bc0ae7 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,25 @@ +sudo: false +language: python +dist: xenial +matrix: + include: + - python: 3.7 + env: TOXENV=py37 + - python: 3.7 + env: TOXENV=check +install: + - pip install -r requirements.txt + - pip install tox-travis coveralls pdoc3 +script: tox +after_success: + - coveralls + - pdoc pyfavicon --html + +deploy: + provider: pages + skip_cleanup: true + local_dir: ./html/pyfavicon/ + github_token: $GH_REPO_TOKEN + verbose: true + on: + branch: master @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Bilal Elmoussaoui + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..70b4057 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# pyfavicon + +[![Build Status](https://travis-ci.org/bilelmoussaoui/pyfavicon.svg?branch=master)](https://travis-ci.org/bilelmoussaoui/pyfavicon) +[![Coverage Status](https://coveralls.io/repos/github/bilelmoussaoui/pyfavicon/badge.svg?branch=master)](https://coveralls.io/github/bilelmoussaoui/pyfavicon) +[![https://pypi.org/project/pyfavicon/](https://img.shields.io/pypi/v/pyfavicon.svg)](https://pypi.org/project/pyfavicon/) +[![https://pypi.org/project/pyfavicon/](https://img.shields.io/pypi/pyversions/pyfavicon.svg)](https://pypi.org/project/pyfavicon/) +[![https://bilelmoussaoui.github.io/pyfavicon/](https://img.shields.io/badge/-docs-blue.svg)](https://bilelmoussaoui.github.io/pyfavicon/) + + +Async favicon fetcher + + +### Requirements +- `Python 3.7` +- `aiohttp` +- `beautifulsoup4` +- `Pillow` + +### How to use + +```python +from pyfavicon import Favicon +import asyncio +from pathlib import Path + + +async def download_favicon(): + favicon_manager = Favicon(download_dir=Path('.'), + headers={'DNT': '1'}) + + icons = await favicon_manager.from_url('https://gitlab.com') + # icons = await favicon_manager.from_file('my_html_file.html') + # icons = await avicon_manager.from_html('<link rel="icon" href="favicon.png">') + for icon in icons: + # We use PIL to get the exact size of images. + print("Favicon from : {}".format(icon.link)) + print("Favicon export name : {}".format(icon.path)) + print("Favicon size : {}".format(icon.size)) + print("Favicon format: {}".format(icon.extension)) + # Select the largest icon + largest_icon = icons.get_largest() + await largest_icon.save() + +asyncio.run(download_favicon()) +``` + +### Examples +You can find a bunch of usage examples here: +- [Gtk Application](examples/gtk_app.py) diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/examples/__init__.py diff --git a/examples/gtk_app.py b/examples/gtk_app.py new file mode 100644 index 0000000..e40e8bf --- /dev/null +++ b/examples/gtk_app.py @@ -0,0 +1,78 @@ +from pathlib import Path +from urllib.parse import urlparse +import asyncio +from pyfavicon import Favicon +from gi.repository import Gtk, GLib + + +def validate_url(url: str): + url = urlparse(url) + return all([url.scheme, url.netloc, url.path]) + + +class Window(Gtk.Window): + + def __init__(self): + Gtk.Window.__init__(self) + self.set_title("pyfavicon example") + self.resize(400, 400) + self.connect("destroy", Gtk.main_quit) + + spinner = Gtk.Spinner() + image = Gtk.Image.new_from_icon_name("image-missing", + Gtk.IconSize.DIALOG) + image.set_valign(Gtk.Align.CENTER) + image.set_halign(Gtk.Align.CENTER) + + stack = Gtk.Stack() + stack.add_named(spinner, "loading") + stack.add_named(image, "favicon") + stack.set_visible_child_name("favicon") + + entry = Gtk.Entry() + entry.set_valign(Gtk.Align.CENTER) + entry.set_halign(Gtk.Align.CENTER) + entry.connect("changed", self.__on_website_changed, + image, stack, spinner) + entry.set_input_purpose(Gtk.InputPurpose.URL) + + container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + container.props.expand = True + container.pack_start(stack, False, False, 6) + container.pack_start(entry, False, False, 6) + + self.add(container) + + def __on_website_changed(self, entry, image, stack, spinner): + website = entry.get_text() + if not validate_url(website): + entry.get_style_context().add_class("error") + return + entry.get_style_context().remove_class("error") + spinner.start() + stack.set_visible_child_name("loading") + + async def set_largest_image(*args): + favicon = Favicon(download_dir=Path('.')) + icons = await favicon.from_url(website) + largest = icons.get_largest() + assert largest + await largest.save() + image.set_from_file(str(largest.path)) + spinner.stop() + stack.set_visible_child_name("favicon") + + def run(*args): + try: + asyncio.run(set_largest_image()) + except AssertionError: + image.set_from_icon_name("image-missing") + spinner.stop() + stack.set_visible_child_name("favicon") + GLib.idle_add(run, GLib.PRIORITY_DEFAULT_IDLE) + + +window = Window() +window.show_all() +window.present() +Gtk.main() diff --git a/pyfavicon/__init__.py b/pyfavicon/__init__.py new file mode 100644 index 0000000..88b9f0d --- /dev/null +++ b/pyfavicon/__init__.py @@ -0,0 +1,357 @@ +__title__ = 'pyfavicon' +__version__ = '0.0.1' +__author__ = 'Bilal Elmoussaoui' +__license__ = 'MIT' + +import os +import bs4 +import binascii +import aiohttp +import yarl +import pathlib +from enum import Enum +import urllib +from PIL import ImageFile +import re + +__all__ = ['Favicon', 'FaviconType', 'Icon', 'Icons'] + + +LINK_RELS = [ + 'icon', + 'shortcut icon', + 'apple-touch-icon', + 'apple-touch-icon-precomposed', + 'fluid-icon' +] +META_NAMES = [ + 'msapplication-TileImage' +] + +TAGS = [ + {'name': 'link', 'attrs': {'rel': LINK_RELS, 'href': True}, 'attr': 'href'}, + {'name': 'meta', 'attrs': {'name': META_NAMES, 'content': True}, 'attr': 'content'} +] + + +def parse_base64_icon(data: str) -> bytes: + if data: + _, data = data.split(":") + mimetype, data = data.split(",") + data = urllib.parse.unquote_to_bytes(data) + assert mimetype.endswith('base64') + return binascii.a2b_base64(data) + return None + + +class FaviconType(Enum): + URL = 0 + DATA = 1 + + +class Icon: + ''' + The Icon object + + Attributes: + size (int, int) : The dimensions of the favicon + + extension (str) : The icon extension, .png, .ico... + + type (FaviconType) : Whether the icon scheme is of type data or not. + + link (yarl.URL) : The favicon URL + + data (bytes) : The favicon image content + ''' + + def __init__(self, **kwargs): + self.link = kwargs.get("link") + self._size = None + self._extension = None + # If the icon of type FaviconType.DATA + self.data = parse_base64_icon(kwargs.get("data")) + self._path = None + + self.type = FaviconType.DATA if self.data else FaviconType.URL + + @staticmethod + async def new(source: str, url: yarl.URL): + ''' + Create a new Icon from the source tag content. + + Args: + source (str) : The source tag content; + + url (yarl.URL) : The website URL + + Returns: + Icon + ''' + parsed_url = urllib.parse.urlparse(source) + if parsed_url.scheme != 'data': + fav_url = None + # Missing scheme + if not parsed_url.netloc: + if parsed_url.path.startswith(':'): + fav_url = yarl.URL(url.scheme + parsed_url.path) + else: + favicon_path = parsed_url.path + match_results = re.match(r'^([\/\.\/\:]+)(.+)$', favicon_path) + if match_results and len(match_results.groups()) == 2: + favicon_path = '/' + match_results.group(2) + else: + favicon_path = '/' + favicon_path + fav_url = yarl.URL.build(host=url.host, + path=favicon_path) + # Link look fine + elif parsed_url.netloc: + fav_url = yarl.URL.build(host=parsed_url.netloc, + path=parsed_url.path) + if parsed_url.scheme: + fav_url = fav_url.with_scheme(parsed_url.scheme) + else: + fav_url = fav_url.with_scheme(url.scheme) + icon = Icon(link=fav_url, website_url=url) + else: # Data scheme: + icon = Icon(data=source, website_url=url) + await icon.parse(url) + return icon + + @property + def size(self) -> (int, int): + return self._size + + @property + def path(self) -> str: + return self._path + + @property + def extension(self) -> str: + return self._extension + + def __str__(self): + return str(self.link) + + async def save(self): + '''Save the icon + + You can retrieve the favicon cached path using the path property. + ''' + if os.path.exists(self.path): + return + + buffer = b'' + if self.type is FaviconType.DATA: + buffer = self.data + else: + async with aiohttp.ClientSession() as session: + response = await session.get(self.link, + headers=Favicon.HEADERS) + async for chunck, _ in response.content.iter_chunks(): + buffer += chunck + with open(self.path, 'wb') as fd: + fd.write(buffer) + + async def parse(self, website_url: yarl.URL): + try: + with ImageFile.Parser() as image_parser: + + if self.type is FaviconType.DATA: + image_parser.feed(self.data) + else: + + async with aiohttp.ClientSession() as session: + + response = await session.get(self.link, + headers=Favicon.HEADERS) + + async for chunk in response.content.iter_chunked(1024): + image_parser.feed(chunk) + if image_parser.image: + break + except OSError: + # PIL failed to decode the image + pass + # If PIL successfully decoded the image + if image_parser and image_parser.image: + self._size = image_parser.image.size + self._extension = image_parser.image.format.lower() + else: + self._size = (-1, -1) + self._generate_icon_name(website_url) + + def _generate_icon_name(self, website_url: yarl.URL) -> str: + ''' + Generate an icon name + + Args: + website_url (yarl.URL): The website url + + Returns: + str, the icon name + ''' + # If we don't have a base64 data image. + from tempfile import NamedTemporaryFile, gettempdir + if website_url: + image_name = website_url.host + else: + image_name = os.path.basename(NamedTemporaryFile().name) + if self._size: + image_name += '_{}x{}'.format(*self._size) + + if self.type is not FaviconType.DATA: + image_name += os.path.basename(self.link.path) + + if Favicon.DOWNLOAD_DIR: + self._path = Favicon.DOWNLOAD_DIR.joinpath(image_name) + else: + self._path = os.path.join(gettempdir(), image_name) + + +class Icons: + ''' + Icons, contains a lot of Icon. + ''' + + def __init__(self, **kwargs): + self._data = [] + self._current = 0 + + def get_largest(self, extension: str = None) -> Icon: + '''Get the largest icon + + Args: + extension (str) : The required extension + + Returns: + Icon + ''' + icons = self._data + if extension: + icons = list(filter(lambda icon: icon.extension == extension, + icons)) + if icons: + largest = max(icons, key=lambda icon: icon.size) + return largest + return None + + def append(self, icon: Icon): + self._data.append(icon) + + def __iter__(self): + return self + + def __next__(self): + if self._current >= len(self._data): + raise StopIteration + else: + current = self._data[self._current] + self._current += 1 + return current + + def __str__(self): + return str(self._data) + + def __getitem__(self, key): + return self._data[key] + + +class Favicon: + ''' + The favicon manager object + + Args: + download_dir (pathlib.Path) : The location to save the icons on + + headers (dict) : The headers to send with each request + ''' + DOWNLOAD_DIR = None + HEADERS = {} + + def __init__(self, download_dir: pathlib.Path = None, + headers={}): + Favicon.HEADERS = headers + Favicon.DOWNLOAD_DIR = download_dir + + async def from_url(self, url: str) -> Icons: + '''Fetch all the favicons from a URL + + Args: + url (str) : The website url to load the favicons from + + Returns: + Icons + ''' + # Read the html content of the page async + favicons = Icons() + async with aiohttp.ClientSession() as session: + buffer = b'' + response = await session.get(url, headers=Favicon.HEADERS) + async for chunk in response.content.iter_chunked(1024): + if not chunk: + break + buffer += chunk + html_content = buffer.decode("utf-8") + favicons = await self._find_favicons_links(html_content, + response.url) + return favicons + + async def from_html(self, html_content: str, website_url: str = None) -> Icons: + '''Fetch all the favicons from an HTML content + + Args: + html_content (str) : The HTML content. + + website_url (str) : The website url, the source of the HTML file + + Returns: + Icons + ''' + website_url = yarl.URL(website_url) if website_url else None + favicons = await self._find_favicons_links(html_content, + website_url) + return favicons + + async def from_file(self, html_file: pathlib.Path, website_url: str = None) -> Icons: + '''Fetch all the favicons from an HTML file. + + Args: + html_file (pathlib.Path) : The HTML file path. + + website_url (str) : The website url, the source of the HTML file + + Returns: + Icons + ''' + with html_file.open() as f: + html_content = f.read() + favicons = await self.from_html(html_content, website_url) + return favicons + + async def _find_favicons_links(self, html_content: str, + url: yarl.URL = None) -> Icons: + '''Find the favicon links in a parsed HTML content/ + + Args: + html_content (str) : The HTML content. + + url (yarl.URL) : The website url, the source of the HTML content + + Returns: + Icons + ''' + + bsoup = bs4.BeautifulSoup(html_content, features="html.parser") + + icons = Icons() + _added = [] + + for tag in TAGS: + sources = bsoup.find_all(tag['name'], attrs=tag['attrs']) + for elem in sources: + icon = await Icon.new(elem.attrs[tag['attr']], url=url) + if icon.link not in _added: + icons.append(icon) + _added.append(icon.link) + return icons diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8263638 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +aiohttp +beautifulsoup4 +Pillow
\ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..efac19a --- /dev/null +++ b/setup.py @@ -0,0 +1,36 @@ +import setuptools + +with open("README.md", "r") as fh: + long_description = fh.read() + +setuptools.setup( + name="pyfavicon", + version="0.1.1", + author="Bilal Elmoussaoui", + author_email="bil.elmoussaoui@gmail.com", + description="Async favicon fetcher", + long_description_content_type="text/markdown", + long_description=long_description, + license='MIT', + url="https://github.com/bilelmoussaoui/pyfavicon", + packages=['pyfavicon'], + install_requires=[ + 'aiohttp', + 'beautifulsoup4', + 'Pillow' + ], + classifiers=[ + 'Intended Audience :: Developers', + 'Programming Language :: Python :: 3.7', + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + 'Topic :: Utilities', + 'Topic :: Internet :: WWW/HTTP', + ], + tests_require=[ + 'pytest', + 'coveralls', + 'pytest-cov' + ], + test_suite='tests', +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/html/base64_favicon_link.html b/tests/html/base64_favicon_link.html new file mode 100644 index 0000000..6360e82 --- /dev/null +++ b/tests/html/base64_favicon_link.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> + <head> + + <link href="data:image/x-icon;base64,AAABAAEAEBAAAAAAAABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8AJCQk/yQkJP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/0ZGRv8XFxf/////AP///wD///8A////AP///////////////+7u7v9OTk7/sbGx/11dXf9KSkr///////////+lpaX/4eHh/////wD///8A////AAAAAP8AAAD///////////+8vLz///////X19f/t7e3/gICA/5eXl////////////wAAAP8AAAD/////AKysrP8AAAD/AAAA/wAAAP+enp7/////////////////////////////////5OTk/wAAAP8AAAD/AAAA/6ysrP////8A////AP///wCurq6PlJSUzP///wD///8A////AP///wD///8A////AJSUlMyQkJDY////AP///wD///8A////AP///wD///8AAAAA/wAAAP////8A////AP///wD///8A////AP///wAAAAD/AAAA/////wD///8A////AP///wD///8AAAAA/wAAAP8AAAD/////AP///wD///8A////AP///wD///8AAAAA/wAAAP8AAAD/////AP///wD///8ACwsL/wAAAP8AAAD/lJSU2AAAAP8AAAD/cnJy/1hYWP8AAAD/AAAA/2FhYdgHBwf/AAAA/wAAAP////8A////AP///wD///8A////AP///wAAAAD/s7Oz/wAAAP8AAAD/x8fH/wAAAP////8A////AP///wD///8A////AP///wD///8A////AP///wAAAAD/AAAA/////wAAAAD/AAAA/////wAAAAD/AAAA/////wD///8A////AP///wD///8A////AP///wD///8AAAAA/wAAAP+qqqqlAAAA/wAAAP9lZWXjAAAA/wAAAP////8A////AP///wD///8A////AP///wD///8A////ALa2tv////8A9vb2UQAAAP8AAAD/////Uf///wDAwMD/////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wBcXFz/AAAA/////wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A//8AAP//AADAAwAAwAMAAIABAAAAAAAA5+cAAOfnAADH4wAAgAEAAPgfAADyTwAA8A8AAPZvAAD+fwAA//8AAA==" rel="icon" type="image/x-icon" /> + + </head> + +</html>
\ No newline at end of file diff --git a/tests/html/largest_gitlab.html b/tests/html/largest_gitlab.html new file mode 100644 index 0000000..dde4015 --- /dev/null +++ b/tests/html/largest_gitlab.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="shortcut icon" type="image/png" href="https://gitlab.com/assets/favicon-7901bd695fb93edb07975966062049829afb56cf11511236e61bcf425070e36e.png" /> + <link rel="apple-touch-icon" type="image/x-icon" href="https://assets.gitlab-static.net/assets/touch-icon-iphone-5a9cee0e8a51212e70b90c87c12f382c428870c0ff67d1eb034d884b78d2dae7.png" /> + <link rel="apple-touch-icon" type="image/x-icon" href="https://assets.gitlab-static.net/assets/touch-icon-ipad-a6eec6aeb9da138e507593b464fdac213047e49d3093fc30e90d9a995df83ba3.png" sizes="76x76" /> + <link rel="apple-touch-icon" type="image/x-icon" href="https://assets.gitlab-static.net/assets/touch-icon-iphone-retina-72e2aadf86513a56e050e7f0f2355deaa19cc17ed97bbe5147847f2748e5a3e3.png" sizes="120x120" /> + <link rel="apple-touch-icon" type="image/x-icon" href="https://assets.gitlab-static.net/assets/touch-icon-ipad-retina-8ebe416f5313483d9c1bc772b5bbe03ecad52a54eba443e5215a22caed2a16a2.png" sizes="152x152" /> + </head> +</html>
\ No newline at end of file diff --git a/tests/html/meta_favicon.html b/tests/html/meta_favicon.html new file mode 100644 index 0000000..09deb37 --- /dev/null +++ b/tests/html/meta_favicon.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<html> + <head> + <meta content="https://assets.gitlab-static.net/assets/msapplication-tile-1196ec67452f618d39cdd85e2e3a542f76574c071051ae7effbfde01710eb17d.png" name="msapplication-TileImage"> + <meta name="msapplication-TileImage" content="//abs.twimg.com/favicons/win8-tile-144.png"/> + </head> + +</html>
\ No newline at end of file diff --git a/tests/html/url_apple_touch_icon_link.html b/tests/html/url_apple_touch_icon_link.html new file mode 100644 index 0000000..d3233bb --- /dev/null +++ b/tests/html/url_apple_touch_icon_link.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="apple-touch-icon" href="https://github.githubassets.com/favicon.ico"> + </head> + +</html>
\ No newline at end of file diff --git a/tests/html/url_apple_touch_icon_precomposed_link.html b/tests/html/url_apple_touch_icon_precomposed_link.html new file mode 100644 index 0000000..95a8214 --- /dev/null +++ b/tests/html/url_apple_touch_icon_precomposed_link.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="apple-touch-icon-precomposed" href="https://github.githubassets.com/favicon.ico"> + </head> + +</html>
\ No newline at end of file diff --git a/tests/html/url_fluid_icon_link.html b/tests/html/url_fluid_icon_link.html new file mode 100644 index 0000000..d1aefea --- /dev/null +++ b/tests/html/url_fluid_icon_link.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="fluid-icon" href="https://github.githubassets.com/favicon.ico"> + </head> + +</html>
\ No newline at end of file diff --git a/tests/html/url_icon_link.html b/tests/html/url_icon_link.html new file mode 100644 index 0000000..ce2279a --- /dev/null +++ b/tests/html/url_icon_link.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="icon" href="https://github.githubassets.com/favicon.ico"> + </head> + +</html>
\ No newline at end of file diff --git a/tests/html/url_shortcut_icon_link.html b/tests/html/url_shortcut_icon_link.html new file mode 100644 index 0000000..56d1d73 --- /dev/null +++ b/tests/html/url_shortcut_icon_link.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> +<html> + <head> + <link rel="shortcut icon" href="https://github.githubassets.com/favicon.ico"> + </head> + +</html>
\ No newline at end of file diff --git a/tests/test_base64_favicon.py b/tests/test_base64_favicon.py new file mode 100644 index 0000000..4056d50 --- /dev/null +++ b/tests/test_base64_favicon.py @@ -0,0 +1,43 @@ +import unittest +import asyncio +from pyfavicon import Favicon, FaviconType +from pathlib import Path +import filecmp +import tempfile + + +class HTMLTest(unittest.TestCase): + + def setUp(self): + self.favicon = Favicon(download_dir=Path(tempfile.gettempdir())) + + def test_url_icon_link_type(self): + files = [ + Path('./tests/html/base64_favicon_link.html'), + ] + + async def run_test(): + for html_file in files: + favicons = await self.favicon.from_file(html_file) + icon = favicons[0] + self.assertEqual(icon.type, FaviconType.DATA) + # Ensure that save works correctly + self.assertTupleEqual(icon.size, (16, 16)) + self.assertEqual(icon.extension, 'ico') + await icon.save() + self.assertTrue(icon.path.exists()) + # Compare file content + temp_file = Path(tempfile.NamedTemporaryFile().name) + with temp_file.open('wb') as fd: + fd.write(icon.data) + self.assertTrue(filecmp.cmp(temp_file, icon.path)) + + # Remove the test file + temp_file.unlink() + icon.path.unlink() + + asyncio.run(run_test()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_download_favicon.py b/tests/test_download_favicon.py new file mode 100644 index 0000000..417a5ed --- /dev/null +++ b/tests/test_download_favicon.py @@ -0,0 +1,28 @@ +import unittest +import asyncio +from pyfavicon import Favicon +from pathlib import Path +import tempfile +import yarl + + +class HTMLTest(unittest.TestCase): + + def setUp(self): + self.favicon = Favicon(download_dir=Path(tempfile.gettempdir())) + + def test_url_icon_link_type(self): + + async def run_test(): + icons = await self.favicon.from_url(yarl.URL('https://gitlab.com')) + icon = icons[0] + # Ensure that save works correctly + await icon.save() + self.assertTrue(icon.path.exists()) + # Remove the test file + icon.path.unlink() + asyncio.run(run_test()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_favicon_size.py b/tests/test_favicon_size.py new file mode 100644 index 0000000..6a0d136 --- /dev/null +++ b/tests/test_favicon_size.py @@ -0,0 +1,40 @@ +import unittest +import asyncio +from pyfavicon import Favicon + + +GITLAB_FAVICONS = { + 'https://about.gitlab.com/ico/favicon.ico': (-1, -1), + 'https://about.gitlab.com/ico/favicon-192x192.png': (190, 175), + 'https://about.gitlab.com/ico/favicon-160x160.png': (158, 145), + 'https://about.gitlab.com/ico/favicon-96x96.png': (95, 87), + 'https://about.gitlab.com/ico/favicon-16x16.png': (16, 14), + 'https://about.gitlab.com/ico/favicon-32x32.png': (32, 29), + 'https://about.gitlab.com/ico/apple-touch-icon-57x57.png': (57, 57), + 'https://about.gitlab.com/ico/apple-touch-icon-114x114.png': (114, 114), + 'https://about.gitlab.com/ico/apple-touch-icon-72x72.png': (72, 72), + 'https://about.gitlab.com/ico/apple-touch-icon-144x144.png': (144, 144), + 'https://about.gitlab.com/ico/apple-touch-icon-60x60.png': (60, 60), + 'https://about.gitlab.com/ico/apple-touch-icon-120x120.png': (120, 120), + 'https://about.gitlab.com/ico/apple-touch-icon-76x76.png': (76, 76), + 'https://about.gitlab.com/ico/apple-touch-icon-152x152.png': (152, 152), + 'https://about.gitlab.com/ico/apple-touch-icon-180x180.png': (180, 180), + 'https://about.gitlab.com/ico/mstile-144x144.png': (144, 144) +} + + +class HTMLTest(unittest.TestCase): + + def setUp(self): + self.favicon = Favicon() + + def test_icon_sizes(self): + async def run_test(): + icons = await self.favicon.from_url('https://gitlab.com') + for icon in icons: + self.assertEqual(GITLAB_FAVICONS[str(icon.link)], icon.size) + + largest = icons.get_largest(extension='png') + self.assertEqual(largest.size, (190, 175)) + self.assertEqual(largest.extension, 'png') + asyncio.run(run_test()) diff --git a/tests/test_favicon_url.py b/tests/test_favicon_url.py new file mode 100644 index 0000000..a1c9c4b --- /dev/null +++ b/tests/test_favicon_url.py @@ -0,0 +1,33 @@ +import unittest +import asyncio +from pyfavicon import Favicon + +CASES = [ + ('<link rel="icon" href="/favicon.ico">', 'https://gitlab.com/favicon.ico'), + ('<link rel="icon" href="://gitlab.com/favicon.ico">', + 'https://gitlab.com/favicon.ico'), + ('<link rel="shortcut icon" type="image/png" href="/uploads/-/system/appearance/favicon/1/GnomeLogoVertical.svg.png">', + 'https://gitlab.com/uploads/-/system/appearance/favicon/1/GnomeLogoVertical.svg.png'), + ('<link rel="shortcut icon" href="images/favicon.png">', 'https://gitlab.com/images/favicon.png'), + ('<link rel="icon" href="/Areas/FirstTech.Web/Assets/images/apple-touch-icon-144x144.png">', + 'https://gitlab.com/Areas/FirstTech.Web/Assets/images/apple-touch-icon-144x144.png'), + ('<link rel="shortcut icon" href="favicon.ico" />', 'https://gitlab.com/favicon.ico') +] + + +class TestFaviconUrl(unittest.TestCase): + def setUp(self): + self.favicon = Favicon() + + def test_favicon_url(self): + async def run_tests(): + favicon = Favicon() + for html_content, expected_result in CASES: + icons = await favicon.from_html(html_content, + "https://gitlab.com") + self.assertEqual(str(icons[0].link), expected_result) + asyncio.run(run_tests()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_from_html.py b/tests/test_from_html.py new file mode 100644 index 0000000..bda8f2e --- /dev/null +++ b/tests/test_from_html.py @@ -0,0 +1,58 @@ +import unittest +import asyncio +from pyfavicon import Favicon, FaviconType +from pathlib import Path + + +class HTMLTest(unittest.TestCase): + + def setUp(self): + self.favicon = Favicon() + + def test_url_icon_link_type(self): + files = [ + Path('./tests/html/url_icon_link.html'), + Path('./tests/html/url_shortcut_icon_link.html'), + Path('./tests/html/url_apple_touch_icon_precomposed_link.html'), + Path('./tests/html/url_apple_touch_icon_link.html'), + Path('./tests/html/url_fluid_icon_link.html'), + ] + + async def run_test(): + for html_file in files: + favicons = await self.favicon.from_file(html_file, + 'https://github.com') + icon = favicons[0] + + self.assertEqual(icon.type, FaviconType.URL) + self.assertEqual(str(icon.link), + 'https://github.githubassets.com/favicon.ico') + asyncio.run(run_test()) + + def test_meta_link(self): + html_file = Path('./tests/html/meta_favicon.html') + + async def run_test(): + icons = await self.favicon.from_file(html_file, + 'https://gitlab.com') + icon = icons[0] + + self.assertEqual(icon.type, FaviconType.URL) + self.assertEqual(str(icon.link), + 'https://assets.gitlab-static.net/assets/msapplication-tile-1196ec67452f618d39cdd85e2e3a542f76574c071051ae7effbfde01710eb17d.png') + asyncio.run(run_test()) + + def test_largest_icon(self): + html_file = Path('./tests/html/largest_gitlab.html') + + async def run_tests(): + icons = await self.favicon.from_file(html_file) + + largest_icon = icons.get_largest() + self.assertTupleEqual(largest_icon.size, (188, 188)) + + asyncio.run(run_tests()) + + +if __name__ == '__main__': + unittest.main() @@ -0,0 +1,19 @@ +[tox] +envlist = check,py37 + +[testenv] +setenv = + PYTHONPATH={toxinidir}/tests + PYTHONUNBUFFERED=yes +deps = + pytest + coveralls + pytest-cov +commands = {posargs:pytest -vv --cov=pyfavicon tests} + +[testenv:check] +deps = + flake8 +skip_install = true +commands = + flake8 --ignore=E501 pyfavicon tests examples setup.py |