summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHenry-Nicolas Tourneur <debian@nilux.be>2020-04-11 16:38:30 +0200
committerHenry-Nicolas Tourneur <debian@nilux.be>2020-04-11 16:38:30 +0200
commitf295c0b51d45f936cc07a0847f8847744eaa2cf2 (patch)
tree42e6ec04fd17719fa0949e820e7137afc5cffaeb
New upstream version 0.1.1
-rw-r--r--.editorconfig8
-rw-r--r--.gitignore105
-rw-r--r--.travis.yml25
-rw-r--r--LICENSE21
-rw-r--r--README.md49
-rw-r--r--examples/__init__.py0
-rw-r--r--examples/gtk_app.py78
-rw-r--r--pyfavicon/__init__.py357
-rw-r--r--requirements.txt3
-rw-r--r--setup.py36
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/html/base64_favicon_link.html9
-rw-r--r--tests/html/largest_gitlab.html10
-rw-r--r--tests/html/meta_favicon.html8
-rw-r--r--tests/html/url_apple_touch_icon_link.html7
-rw-r--r--tests/html/url_apple_touch_icon_precomposed_link.html7
-rw-r--r--tests/html/url_fluid_icon_link.html7
-rw-r--r--tests/html/url_icon_link.html7
-rw-r--r--tests/html/url_shortcut_icon_link.html7
-rw-r--r--tests/test_base64_favicon.py43
-rw-r--r--tests/test_download_favicon.py28
-rw-r--r--tests/test_favicon_size.py40
-rw-r--r--tests/test_favicon_url.py33
-rw-r--r--tests/test_from_html.py58
-rw-r--r--tox.ini19
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
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fe834e7
--- /dev/null
+++ b/LICENSE
@@ -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()
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..4cf1eb6
--- /dev/null
+++ b/tox.ini
@@ -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