Skip to content
Snippets Groups Projects
Verified Commit a83d2654 authored by Nik | Klampfradler's avatar Nik | Klampfradler
Browse files

Add and update source comments and docstrings

parent 50d943b8
No related branches found
No related tags found
No related merge requests found
"""App configuration for django-iconify."""
from django.conf import settings from django.conf import settings
_prefix = "ICONIFY_" _prefix = "ICONIFY_"
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
Documentation: https://docs.iconify.design/types/ Documentation: https://docs.iconify.design/types/
""" """
from typing import Collection, Dict, List, Optional, Union from typing import Collection, Dict, List, Optional, Sequence, Union
from typing.io import TextIO from typing.io import TextIO
import json import json
...@@ -10,6 +10,7 @@ from .util import split_css_unit ...@@ -10,6 +10,7 @@ from .util import split_css_unit
class IconifyOptional: class IconifyOptional:
"""Mixin containing optional attributes all other types can contain."""
left: Optional[int] = None left: Optional[int] = None
top: Optional[int] = None top: Optional[int] = None
width: Optional[int] = None width: Optional[int] = None
...@@ -48,6 +49,10 @@ class IconifyOptional: ...@@ -48,6 +49,10 @@ class IconifyOptional:
class IconifyIcon(IconifyOptional): class IconifyIcon(IconifyOptional):
"""Single icon as loaded from Iconify JSON data.
Documentation: https://docs.iconify.design/types/iconify-icon.html
"""
_collection: Optional["IconifyJSON"] _collection: Optional["IconifyJSON"]
_name: str _name: str
body: str body: str
...@@ -69,6 +74,11 @@ class IconifyIcon(IconifyOptional): ...@@ -69,6 +74,11 @@ class IconifyIcon(IconifyOptional):
return res return res
def get_width(self): def get_width(self):
"""Get the width of the icon.
If the icon has an explicit width, it is returned.
If not, the width set in the collection is returned, or the default of 16.
"""
if self.width: if self.width:
return self.width return self.width
elif self._collection and self._collection.width: elif self._collection and self._collection.width:
...@@ -77,6 +87,11 @@ class IconifyIcon(IconifyOptional): ...@@ -77,6 +87,11 @@ class IconifyIcon(IconifyOptional):
return 16 return 16
def get_height(self): def get_height(self):
"""Get the height of the icon.
If the icon has an explicit height, it is returned.
If not, the height set in the collection is returned, or the default of 16.
"""
if self.height: if self.height:
return self.height return self.height
elif self._collection and self._collection.height: elif self._collection and self._collection.height:
...@@ -84,67 +99,112 @@ class IconifyIcon(IconifyOptional): ...@@ -84,67 +99,112 @@ class IconifyIcon(IconifyOptional):
else: else:
return 16 return 16
def as_svg(self, color: Optional[str] = None, width: Optional[str] = None, height: Optional[str] = None, rotate: Optional[str] = None, flip: Optional[str] = None, box: bool = False) -> str: def as_svg(self, color: Optional[str] = None, width: Optional[str] = None, height: Optional[str] = None, rotate: Optional[str] = None, flip: Optional[Union[str, Sequence]] = None, box: bool = False) -> str:
"""Generate a full SVG of this icon.
Some transformations can be applied by passing arguments:
width, height - Scale the icon; if only one is set and the other is not
(or set to 'auto'), the other is calculated, preserving aspect ratio.
Suffixes (i.e. CSS units) are allowed
rotate - Either a degress value with 'deg' suffix, or a number from 0 to 4
expressing the number of 90 degreee rotations
flip - horizontal, vertical, or both values with comma
box - Include a transparent box spanning the whole viewbox
Documentation: https://docs.iconify.design/types/iconify-icon.html
"""
# Original dimensions, the viewbox we use later
orig_width, orig_height = self.get_width(), self.get_height() orig_width, orig_height = self.get_width(), self.get_height()
if width and (height is None or height.lower() == "auto"): if width and (height is None or height.lower() == "auto"):
# Width given, determine height automatically
value, unit = split_css_unit(width) value, unit = split_css_unit(width)
height = str(value / (orig_width / orig_height)) + unit height = str(value / (orig_width / orig_height)) + unit
elif height and (width is None or width.lower() == "auto"): elif height and (width is None or width.lower() == "auto"):
# Height given, determine width automatically
value, unit = split_css_unit(height) value, unit = split_css_unit(height)
width = str(value / (orig_height / orig_width)) + unit width = str(value / (orig_height / orig_width)) + unit
elif width is None and height is None: elif width is None and height is None:
# Neither width nor height given, default to browser text size
width, height = "1em", "1em" width, height = "1em", "1em"
# Build attributes to inject into <svg> element
svg_dim_attrs = f'width="{width}" height="{height}"' svg_dim_attrs = f'width="{width}" height="{height}"'
head = f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" {svg_dim_attrs} preserveAspectRatio="xMidYMid meet" viewBox="0 0 {orig_width} {orig_height}" style="-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);">' # SVG root element (copied bluntly from example output on api.iconify.design)
head = ('<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" '
f'{svg_dim_attrs} '
'preserveAspectRatio="xMidYMid meet" '
f'viewBox="0 0 {orig_width} {orig_height}" '
'style="-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);">')
foot = "</svg>" foot = "</svg>"
# Build up all transformations, which are added as an SVG group (<g> element)
transform = [] transform = []
if rotate is not None: if rotate is not None:
# Rotation will be around center of viewbox
center_x, center_y = int(orig_width / 2), int(orig_height / 2) center_x, center_y = int(orig_width / 2), int(orig_height / 2)
if rotate.isnumeric(): if rotate.isnumeric():
# Plain number, calculate degrees in 90deg steps
deg = int(rotate) * 90 deg = int(rotate) * 90
elif rotate.endswith("deg"): elif rotate.endswith("deg"):
deg = int(rotate[:-3]) deg = int(rotate[:-3])
transform.append(f"rotate({deg} {center_x} {center_y})") transform.append(f"rotate({deg} {center_x} {center_y})")
if flip is not None: if flip is not None:
flip = flip.split(",") if isinstance(flip, str):
# Split flip attribute if passed verbatim from request
flip = flip.split(",")
# Seed with no-op values
translate_x, translate_y = 0, 0 translate_x, translate_y = 0, 0
scale_x, scale_y = 1, 1 scale_x, scale_y = 1, 1
if "horizontal" in flip: if "horizontal" in flip:
# Flip around X axis
translate_x = orig_width translate_x = orig_width
scale_x = -1 scale_x = -1
if "vertical" in flip: if "vertical" in flip:
# Flip around Y axis
translate_y = orig_height translate_y = orig_height
scale_y = -1 scale_y = -1
# Build transform functions for <g> attribute
transform.append(f"translate({translate_x} {translate_y})") transform.append(f"translate({translate_x} {translate_y})")
transform.append(f"scale({scale_x} {scale_y})") transform.append(f"scale({scale_x} {scale_y})")
if transform: if transform:
# Generate a <g> attribute if any transformations were generated
transform = " ".join(transform) transform = " ".join(transform)
g = f'<g transform="{transform}">', "</g>" g = f'<g transform="{transform}">', "</g>"
else: else:
# use dummy empty strings to make string building easier further down
g = "", "" g = "", ""
# Body from icon data
body = self.body body = self.body
if color is not None: if color is not None:
# Color is replaced anywhere it appears as attribute value
# FIXME Find a better way to repalce only color values safely
body = body.replace('"currentColor"', f'"{color}"') body = body.replace('"currentColor"', f'"{color}"')
if box: if box:
# Add a transparent box spanning the whole viewbox for browsers that do not support viewbox
box = f'<rect x="0" y="0" width="{orig_width}" height="{orig_height}" fill="rgba(0, 0, 0, 0)" />' box = f'<rect x="0" y="0" width="{orig_width}" height="{orig_height}" fill="rgba(0, 0, 0, 0)" />'
else: else:
# Dummy empty string for easier string building further down
box = "" box = ""
# Construct final SVG data
svg = f"{head}{g[0]}{body}{g[1]}{box}{foot}" svg = f"{head}{g[0]}{body}{g[1]}{box}{foot}"
return svg return svg
class IconifyAlias(IconifyOptional): class IconifyAlias(IconifyOptional):
"""Alias for an icon.
Documentation: https://docs.iconify.design/types/iconify-alias.html
"""
_collection: Optional["IconifyJSON"] _collection: Optional["IconifyJSON"]
parent: str parent: str
def get_icon(self): def get_icon(self):
"""Get the real icon by retrieving it from the parent collection, if any."""
if self._collection: if self._collection:
return self._collection.get_icon(self.parent) return self._collection.get_icon(self.parent)
...@@ -165,6 +225,10 @@ class IconifyAlias(IconifyOptional): ...@@ -165,6 +225,10 @@ class IconifyAlias(IconifyOptional):
class IconifyInfo(IconifyOptional): class IconifyInfo(IconifyOptional):
"""Meta information on a colelction.
No documentation; guessed from the JSON data provided by Iconify.
"""
name: str name: str
author: Dict[str, str] # FIXME turn intoreal object author: Dict[str, str] # FIXME turn intoreal object
license_: Dict[str, str] # FIXME turn into real object license_: Dict[str, str] # FIXME turn into real object
...@@ -174,6 +238,7 @@ class IconifyInfo(IconifyOptional): ...@@ -174,6 +238,7 @@ class IconifyInfo(IconifyOptional):
@property @property
def total(self): def total(self):
"""Determine icon count from parent collection."""
if self._collection: if self._collection:
return len(self._collection.icons) return len(self._collection.icons)
...@@ -213,6 +278,10 @@ class IconifyInfo(IconifyOptional): ...@@ -213,6 +278,10 @@ class IconifyInfo(IconifyOptional):
class IconifyJSON(IconifyOptional): class IconifyJSON(IconifyOptional):
"""One collection as a whole.
Documentation: https://docs.iconify.design/types/iconify-json.html
"""
prefix: str prefix: str
icons: Dict[str, IconifyIcon] icons: Dict[str, IconifyIcon]
aliases: Optional[Dict[str, IconifyAlias]] aliases: Optional[Dict[str, IconifyAlias]]
...@@ -220,6 +289,11 @@ class IconifyJSON(IconifyOptional): ...@@ -220,6 +289,11 @@ class IconifyJSON(IconifyOptional):
not_found: List[str] not_found: List[str]
def get_icon(self, name: str): def get_icon(self, name: str):
"""Get an icon by name.
First, tries to find a real icon with the name. If none is found, tries
to resolve the name from aliases.
"""
if name in self.icons.keys(): if name in self.icons.keys():
return self.icons[name] return self.icons[name]
elif name in self.aliases.keys(): elif name in self.aliases.keys():
...@@ -227,9 +301,16 @@ class IconifyJSON(IconifyOptional): ...@@ -227,9 +301,16 @@ class IconifyJSON(IconifyOptional):
@classmethod @classmethod
def from_dict(cls, collection: Optional[dict] = None, only: Optional[Collection[str]] = None) -> "IconifyJSON": def from_dict(cls, collection: Optional[dict] = None, only: Optional[Collection[str]] = None) -> "IconifyJSON":
"""Construct collection from a dictionary (probably from JSON, originally).
If the only parameter is passed a sequence or set, only icons and aliases with
these names are loaded (and real icons for aliases).
"""
if collection is None: if collection is None:
# Load from a dummy empty collection
collection = {} collection = {}
if only is None: if only is None:
# Construct a list of all names from source collection
only = set(collection["icons"].keys()) only = set(collection["icons"].keys())
if "aliases" in collection: if "aliases" in collection:
only |= set(collection["aliases"].keys()) only |= set(collection["aliases"].keys())
...@@ -240,17 +321,23 @@ class IconifyJSON(IconifyOptional): ...@@ -240,17 +321,23 @@ class IconifyJSON(IconifyOptional):
self.icons, self.aliases = {}, {} self.icons, self.aliases = {}, {}
self.not_found = [] self.not_found = []
for name in only: for name in only:
# Try to find a real icon with the name
icon_dict = collection["icons"].get(name, None) icon_dict = collection["icons"].get(name, None)
if icon_dict: if icon_dict:
self.icons[name] = IconifyIcon.from_dict(name, icon_dict, collection=self) self.icons[name] = IconifyIcon.from_dict(name, icon_dict, collection=self)
continue continue
# If we got here, try finding an alias with the name
alias_dict = collection["aliases"].get(name, None) alias_dict = collection["aliases"].get(name, None)
if alias_dict: if alias_dict:
self.aliases[name] = IconifyAlias.from_dict(alias_dict, collection=self) self.aliases[name] = IconifyAlias.from_dict(alias_dict, collection=self)
# Make sure we also get the real icon to resolve the alias
self.icons[alias_dict["parent"]] = IconifyIcon.from_dict(alias_dict["parent"], collection["icons"][alias_dict["parent"]], collection=self) self.icons[alias_dict["parent"]] = IconifyIcon.from_dict(alias_dict["parent"], collection["icons"][alias_dict["parent"]], collection=self)
continue continue
# If we got here, track the we did not find the icon
# Undocumented, but the original API server seems to return this field in its
# response instead of throwing a 404 error or so
self.not_found.append(name) self.not_found.append(name)
if "info" in collection: if "info" in collection:
...@@ -262,6 +349,7 @@ class IconifyJSON(IconifyOptional): ...@@ -262,6 +349,7 @@ class IconifyJSON(IconifyOptional):
@classmethod @classmethod
def from_file(cls, src_file: Union[str, TextIO] = None, **kwargs) -> "IconifyJSON": def from_file(cls, src_file: Union[str, TextIO] = None, **kwargs) -> "IconifyJSON":
"""Construct collection by reading a JSON file and calling from_dict."""
if isinstance(src_file, str): if isinstance(src_file, str):
with open(src_file, "r") as in_file: with open(src_file, "r") as in_file:
src = json.load(in_file) src = json.load(in_file)
......
"""Utility code used by other parts of django-iconify."""
import re import re
......
"""Iconify API endpoints as views.
Documentation: https://docs.iconify.design/sources/api/queries.html
"""
import json import json
import os import os
import re import re
...@@ -11,12 +15,24 @@ from .types import IconifyJSON ...@@ -11,12 +15,24 @@ from .types import IconifyJSON
class BaseJSONView(View): class BaseJSONView(View):
"""Base view that wraps JSON and JSONP responses.
It relies on the following query parameters:
callback - name of the JavaScript callback function to call via JSONP
pretty - 1 or true to pretty-print JSON (default is condensed)
The URL route has to pass an argument called format_ containing js or json
to determine the output format.
"""
default_callback: str = "Console.log" default_callback: str = "Console.log"
def get_data(self, request: HttpRequest) -> dict: def get_data(self, request: HttpRequest) -> dict:
"""Generate a dictionary contianing the data to return."""
raise NotImplementedError("You must implement this method in your view.") raise NotImplementedError("You must implement this method in your view.")
def get(self, request: HttpRequest, format_: str = "json", *args, **kwargs) -> HttpResponse: def get(self, request: HttpRequest, format_: str = "json", *args, **kwargs) -> HttpResponse:
"""Get the JSON or JSONP response containing the data from the get_data method."""
# For JSONP, the callback name has to be passed # For JSONP, the callback name has to be passed
if format_ == "js": if format_ == "js":
callback = request.GET.get("callback", self.default_callback) callback = request.GET.get("callback", self.default_callback)
...@@ -27,27 +43,36 @@ class BaseJSONView(View): ...@@ -27,27 +43,36 @@ class BaseJSONView(View):
else: else:
indent = None indent = None
# Call main function implemented by children
data = self.get_data(request, *args, **kwargs) data = self.get_data(request, *args, **kwargs)
# Get result JSON and form response # Get result JSON and form response
res = json.dumps(data, indent=indent, sort_keys=True) res = json.dumps(data, indent=indent, sort_keys=True)
if format_ == "js": if format_ == "js":
# Format is JSONP
res = f"{callback}({res})" res = f"{callback}({res})"
return HttpResponse(res, content_type="application/javascript") return HttpResponse(res, content_type="application/javascript")
else: else:
# Format is plain JSON
return HttpResponse(res, content_type="application/json") return HttpResponse(res, content_type="application/json")
class ConfigView(View): class ConfigView(View):
"""Get JavaScript snippet to conifugre Iconify for our API.""" """Get JavaScript snippet to conifugre Iconify for our API.
This sets the API base URL to the endpoint determined by Django's reverse
URL mapper.
"""
def get(self, request: HttpRequest) -> HttpResponse: def get(self, request: HttpRequest) -> HttpResponse:
# Guess the base URL by reverse-mapping the URL for a fake icon set
rev = reverse("iconify_json", kwargs={"collection": "prefix", "format_": "js"}) rev = reverse("iconify_json", kwargs={"collection": "prefix", "format_": "js"})
# Iconify SVG Framework expects placeholders {prefix} and {icons} in API URL
api_pattern = rev[:-9] + "{prefix}.js?icons={icons}" api_pattern = rev[:-9] + "{prefix}.js?icons={icons}"
# Put together configuration as dict and output as JSON
config = { config = {
"defaultAPI": api_pattern, "defaultAPI": api_pattern,
} }
config_json = json.dumps(config) config_json = json.dumps(config)
return HttpResponse(f"var IconifyConfig = {config_json}", content_type="text/javascript") return HttpResponse(f"var IconifyConfig = {config_json}", content_type="text/javascript")
...@@ -55,7 +80,7 @@ class ConfigView(View): ...@@ -55,7 +80,7 @@ class ConfigView(View):
class CollectionView(BaseJSONView): class CollectionView(BaseJSONView):
"""Retrieve the meta-data for a single collection.""" """Retrieve the meta-data for a single collection."""
def get_data(self, request: HttpRequest) -> dict: def get_data(self, request: HttpRequest) -> dict:
# Icon names are passed as comma-separated list # Collection name is passed in the prefix query parameter
collection = request.GET.get("prefix", None) collection = request.GET.get("prefix", None)
if collection is None or not re.match(r'[A-Za-z0-9-]+', collection): if collection is None or not re.match(r'[A-Za-z0-9-]+', collection):
return HttpResponseBadRequest("You must provide a valid prefix name.") return HttpResponseBadRequest("You must provide a valid prefix name.")
...@@ -67,12 +92,15 @@ class CollectionView(BaseJSONView): ...@@ -67,12 +92,15 @@ class CollectionView(BaseJSONView):
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise Http404(f"Icon collection {collection} not found") from exc raise Http404(f"Icon collection {collection} not found") from exc
# Return the info member, which holds the data we want
return icon_set.info.as_dict() return icon_set.info.as_dict()
class CollectionsView(BaseJSONView): class CollectionsView(BaseJSONView):
"""Retrieve the available collections with meta-data.""" """Retrieve the available collections with meta-data."""
def get_data(self, request: HttpRequest) -> dict: def get_data(self, request: HttpRequest) -> dict:
# Read the pre-compiled collections list and return it verbatim
# FIXME Consider using type models to generate from sources
collections_path = os.path.join(JSON_ROOT, "collections.json") collections_path = os.path.join(JSON_ROOT, "collections.json")
with open(collections_path, "r") as collections_file: with open(collections_path, "r") as collections_file:
data = json.load(collections_file) data = json.load(collections_file)
...@@ -84,11 +112,6 @@ class IconifyJSONView(BaseJSONView): ...@@ -84,11 +112,6 @@ class IconifyJSONView(BaseJSONView):
default_callback: str = "SimpleSVG._loaderCallback" default_callback: str = "SimpleSVG._loaderCallback"
def get_data(self, request: HttpRequest, collection: str) -> dict: def get_data(self, request: HttpRequest, collection: str) -> dict:
"""Retrieve a set of icons using a GET request.
The view supports both JSON and JSONP responses.
"""
# Icon names are passed as comma-separated list # Icon names are passed as comma-separated list
icons = request.GET.get("icons", None) icons = request.GET.get("icons", None)
if icons is not None: if icons is not None:
...@@ -105,9 +128,11 @@ class IconifyJSONView(BaseJSONView): ...@@ -105,9 +128,11 @@ class IconifyJSONView(BaseJSONView):
class IconifySVGView(View): class IconifySVGView(View):
"""Serve the Iconify SVG retrieval API.""" """Serve the Iconify SVG retrieval API.
It serves a single icon as SVG, allowing some transformations.
"""
def get(self, request: HttpRequest, collection: str, name: str) -> HttpResponse: def get(self, request: HttpRequest, collection: str, name: str) -> HttpResponse:
"""Retrieve a single icon as SVG."""
# General retrieval parameters # General retrieval parameters
download = request.GET.get("download", "0").lower() in ("1", "true") download = request.GET.get("download", "0").lower() in ("1", "true")
box = request.GET.get("box", "0").lower() in ("1", "true") box = request.GET.get("box", "0").lower() in ("1", "true")
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment