summaryrefslogtreecommitdiffstats
path: root/nikola/utils.py
diff options
context:
space:
mode:
Diffstat (limited to 'nikola/utils.py')
-rw-r--r--nikola/utils.py276
1 files changed, 226 insertions, 50 deletions
diff --git a/nikola/utils.py b/nikola/utils.py
index 3a268ff..3359514 100644
--- a/nikola/utils.py
+++ b/nikola/utils.py
@@ -31,6 +31,7 @@ import calendar
import datetime
import dateutil.tz
import hashlib
+import husl
import io
import locale
import logging
@@ -39,20 +40,30 @@ import os
import re
import json
import shutil
+import socket
import subprocess
import sys
import dateutil.parser
import dateutil.tz
import logbook
+try:
+ from urllib import quote as urlquote
+ from urllib import unquote as urlunquote
+ from urlparse import urlparse, urlunparse
+except ImportError:
+ from urllib.parse import quote as urlquote # NOQA
+ from urllib.parse import unquote as urlunquote # NOQA
+ from urllib.parse import urlparse, urlunparse # NOQA
import warnings
import PyRSS2Gen as rss
-from collections import defaultdict, Callable
+from collections import defaultdict, Callable, OrderedDict
from logbook.compat import redirect_logging
from logbook.more import ExceptionHandler, ColorizedStderrHandler
from pygments.formatters import HtmlFormatter
from zipfile import ZipFile as zipf
from doit import tools
from unidecode import unidecode
+from unicodedata import normalize as unicodenormalize
from pkg_resources import resource_filename
from doit.cmdparse import CmdParse
@@ -94,7 +105,6 @@ class ApplicationWarning(Exception):
class ColorfulStderrHandler(ColorizedStderrHandler):
-
"""Stream handler with colors."""
_colorful = False
@@ -116,7 +126,7 @@ def get_logger(name, handlers):
STDERR_HANDLER = [ColorfulStderrHandler(
- level=logbook.INFO if not DEBUG else logbook.DEBUG,
+ level=logbook.NOTICE if not DEBUG else logbook.DEBUG,
format_string=u'[{record.time:%Y-%m-%dT%H:%M:%SZ}] {record.level_name}: {record.channel}: {record.message}'
)]
@@ -228,7 +238,6 @@ def makedirs(path):
class Functionary(defaultdict):
-
"""Class that looks like a function, but is a defaultdict."""
def __init__(self, default, default_lang):
@@ -244,7 +253,6 @@ class Functionary(defaultdict):
class TranslatableSetting(object):
-
"""A setting that can be translated.
You can access it via: SETTING(lang). You can omit lang, in which
@@ -423,15 +431,20 @@ class TranslatableSetting(object):
def __eq__(self, other):
"""Test whether two TranslatableSettings are equal."""
- return self.values == other.values
+ try:
+ return self.values == other.values
+ except AttributeError:
+ return self(self.default_lang) == other
def __ne__(self, other):
"""Test whether two TranslatableSettings are inequal."""
- return self.values != other.values
+ try:
+ return self.values != other.values
+ except AttributeError:
+ return self(self.default_lang) != other
class TemplateHookRegistry(object):
-
r"""A registry for template hooks.
Usage:
@@ -497,7 +510,6 @@ class TemplateHookRegistry(object):
class CustomEncoder(json.JSONEncoder):
-
"""Custom JSON encoder."""
def default(self, obj):
@@ -513,7 +525,6 @@ class CustomEncoder(json.JSONEncoder):
class config_changed(tools.config_changed):
-
"""A copy of doit's config_changed, using pickle instead of serializing manually."""
def __init__(self, config, identifier=None):
@@ -610,7 +621,6 @@ language_incomplete_warned = []
class LanguageNotFoundError(Exception):
-
"""An exception thrown if language is not found."""
def __init__(self, lang, orig):
@@ -724,7 +734,7 @@ def remove_file(source):
elif os.path.isfile(source) or os.path.islink(source):
os.remove(source)
-# slugify is copied from
+# slugify is adopted from
# http://code.activestate.com/recipes/
# 577257-slugify-make-a-string-usable-in-a-url-or-filename/
_slugify_strip_re = re.compile(r'[^+\w\s-]')
@@ -782,11 +792,23 @@ def unslugify(value, discard_numbers=True):
return value
+def encodelink(iri):
+ """Given an encoded or unencoded link string, return an encoded string suitable for use as a link in HTML and XML."""
+ iri = unicodenormalize('NFC', iri)
+ link = OrderedDict(urlparse(iri)._asdict())
+ link['path'] = urlquote(urlunquote(link['path']).encode('utf-8'))
+ try:
+ link['netloc'] = link['netloc'].encode('utf-8').decode('idna').encode('idna').decode('utf-8')
+ except UnicodeDecodeError:
+ link['netloc'] = link['netloc'].encode('idna').decode('utf-8')
+ encoded_link = urlunparse(link.values())
+ return encoded_link
+
# A very slightly safer version of zip.extractall that works on
# python < 2.6
-class UnsafeZipException(Exception):
+class UnsafeZipException(Exception):
"""Exception for unsafe zip files."""
pass
@@ -945,12 +967,14 @@ def get_crumbs(path, is_file=False, index_folder=None):
return list(reversed(_crumbs))
-def get_asset_path(path, themes, files_folders={'files': ''}, _themes_dir='themes'):
+def get_asset_path(path, themes, files_folders={'files': ''}, _themes_dir='themes', output_dir='output'):
"""Return the "real", absolute path to the asset.
By default, it checks which theme provides the asset.
If the asset is not provided by a theme, then it will be checked for
in the FILES_FOLDERS.
+ If it's not provided by either, it will be chacked in output, where
+ it may have been created by another plugin.
>>> print(get_asset_path('assets/css/rst.css', ['bootstrap3', 'base']))
/.../nikola/data/themes/base/assets/css/rst.css
@@ -961,9 +985,12 @@ def get_asset_path(path, themes, files_folders={'files': ''}, _themes_dir='theme
>>> print(get_asset_path('nikola.py', ['bootstrap3', 'base'], {'nikola': ''}))
/.../nikola/nikola.py
- >>> print(get_asset_path('nikola/nikola.py', ['bootstrap3', 'base'], {'nikola':'nikola'}))
+ >>> print(get_asset_path('nikola.py', ['bootstrap3', 'base'], {'nikola': 'nikola'}))
None
+ >>> print(get_asset_path('nikola/nikola.py', ['bootstrap3', 'base'], {'nikola': 'nikola'}))
+ /.../nikola/nikola.py
+
"""
for theme_name in themes:
candidate = os.path.join(
@@ -973,7 +1000,14 @@ def get_asset_path(path, themes, files_folders={'files': ''}, _themes_dir='theme
if os.path.isfile(candidate):
return candidate
for src, rel_dst in files_folders.items():
- candidate = os.path.abspath(os.path.join(src, path))
+ relpath = os.path.normpath(os.path.relpath(path, rel_dst))
+ if not relpath.startswith('..' + os.path.sep):
+ candidate = os.path.abspath(os.path.join(src, relpath))
+ if os.path.isfile(candidate):
+ return candidate
+
+ if output_dir:
+ candidate = os.path.join(output_dir, path)
if os.path.isfile(candidate):
return candidate
@@ -982,7 +1016,6 @@ def get_asset_path(path, themes, files_folders={'files': ''}, _themes_dir='theme
class LocaleBorgUninitializedException(Exception):
-
"""Exception for unitialized LocaleBorg."""
def __init__(self):
@@ -991,7 +1024,6 @@ class LocaleBorgUninitializedException(Exception):
class LocaleBorg(object):
-
"""Provide locale related services and autoritative current_lang.
current_lang is the last lang for which the locale was set
@@ -1040,6 +1072,8 @@ class LocaleBorg(object):
assert initial_lang is not None and initial_lang in locales
cls.reset()
cls.locales = locales
+ cls.month_name_handlers = []
+ cls.formatted_date_handlers = []
# needed to decode some localized output in py2x
encodings = {}
@@ -1049,25 +1083,71 @@ class LocaleBorg(object):
encodings[lang] = encoding
cls.encodings = encodings
- cls.__shared_state['current_lang'] = initial_lang
+ cls.__initial_lang = initial_lang
cls.initialized = True
+ def __get_shared_state(self):
+ if not self.initialized:
+ raise LocaleBorgUninitializedException()
+ shared_state = getattr(self.__thread_local, 'shared_state', None)
+ if shared_state is None:
+ shared_state = {'current_lang': self.__initial_lang}
+ self.__thread_local.shared_state = shared_state
+ return shared_state
+
@classmethod
def reset(cls):
"""Reset LocaleBorg.
Used in testing to prevent leaking state between tests.
"""
+ import threading
+ cls.__thread_local = threading.local()
+ cls.__thread_lock = threading.Lock()
+
cls.locales = {}
cls.encodings = {}
- cls.__shared_state = {'current_lang': None}
cls.initialized = False
+ cls.month_name_handlers = []
+ cls.formatted_date_handlers = []
+ cls.thread_local = None
+ cls.thread_lock = None
+
+ @classmethod
+ def add_handler(cls, month_name_handler=None, formatted_date_handler=None):
+ """Allow to add month name and formatted date handlers.
+
+ If month_name_handler is not None, it is expected to be a callable
+ which accepts (month_no, lang) and returns either a string or None.
+
+ If formatted_date_handler is not None, it is expected to be a callable
+ which accepts (date_format, date, lang) and returns either a string or
+ None.
+
+ A handler is expected to either return the correct result for the given
+ language and data, or return None to indicate it is not able to do the
+ job. In that case, the next handler is asked, and finally the default
+ implementation is used.
+ """
+ if month_name_handler is not None:
+ cls.month_name_handlers.append(month_name_handler)
+ if formatted_date_handler is not None:
+ cls.formatted_date_handlers.append(formatted_date_handler)
def __init__(self):
"""Initialize."""
if not self.initialized:
raise LocaleBorgUninitializedException()
- self.__dict__ = self.__shared_state
+
+ @property
+ def current_lang(self):
+ """Return the current language."""
+ return self.__get_shared_state()['current_lang']
+
+ def __set_locale(self, lang):
+ """Set the locale for language lang without updating current_lang."""
+ locale_n = self.locales[lang]
+ locale.setlocale(locale.LC_ALL, locale_n)
def set_locale(self, lang):
"""Set the locale for language lang, returns an empty string.
@@ -1076,36 +1156,70 @@ class LocaleBorg(object):
in windows that cannot be guaranted.
In either case, the locale encoding is available in cls.encodings[lang]
"""
- # intentional non try-except: templates must ask locales with a lang,
- # let the code explode here and not hide the point of failure
- # Also, not guarded with an if lang==current_lang because calendar may
- # put that out of sync
- locale_n = self.locales[lang]
- self.__shared_state['current_lang'] = lang
- locale.setlocale(locale.LC_ALL, locale_n)
- return ''
+ with self.__thread_lock:
+ # intentional non try-except: templates must ask locales with a lang,
+ # let the code explode here and not hide the point of failure
+ # Also, not guarded with an if lang==current_lang because calendar may
+ # put that out of sync
+ self.__set_locale(lang)
+ self.__get_shared_state()['current_lang'] = lang
+ return ''
def get_month_name(self, month_no, lang):
"""Return localized month name in an unicode string."""
- if sys.version_info[0] == 3: # Python 3
- with calendar.different_locale(self.locales[lang]):
- s = calendar.month_name[month_no]
- # for py3 s is unicode
- else: # Python 2
- with calendar.TimeEncoding(self.locales[lang]):
- s = calendar.month_name[month_no]
- enc = self.encodings[lang]
- if not enc:
- enc = 'UTF-8'
-
- s = s.decode(enc)
- # paranoid about calendar ending in the wrong locale (windows)
- self.set_locale(self.current_lang)
- return s
+ # For thread-safety
+ with self.__thread_lock:
+ for handler in self.month_name_handlers:
+ res = handler(month_no, lang)
+ if res is not None:
+ return res
+ if sys.version_info[0] == 3: # Python 3
+ with calendar.different_locale(self.locales[lang]):
+ s = calendar.month_name[month_no]
+ # for py3 s is unicode
+ else: # Python 2
+ with calendar.TimeEncoding(self.locales[lang]):
+ s = calendar.month_name[month_no]
+ enc = self.encodings[lang]
+ if not enc:
+ enc = 'UTF-8'
+
+ s = s.decode(enc)
+ # paranoid about calendar ending in the wrong locale (windows)
+ self.__set_locale(self.current_lang)
+ return s
+ def formatted_date(self, date_format, date):
+ """Return the formatted date as unicode."""
+ with self.__thread_lock:
+ current_lang = self.current_lang
+ # For thread-safety
+ self.__set_locale(current_lang)
+ fmt_date = None
+ # Get a string out of a TranslatableSetting
+ if isinstance(date_format, TranslatableSetting):
+ date_format = date_format(current_lang)
+ # First check handlers
+ for handler in self.formatted_date_handlers:
+ fmt_date = handler(date_format, date, current_lang)
+ if fmt_date is not None:
+ break
+ # If no handler was able to format the date, ask Python
+ if fmt_date is None:
+ if date_format == 'webiso':
+ # Formatted after RFC 3339 (web ISO 8501 profile) with Zulu
+ # zone desgignator for times in UTC and no microsecond precision.
+ fmt_date = date.replace(microsecond=0).isoformat().replace('+00:00', 'Z')
+ else:
+ fmt_date = date.strftime(date_format)
-class ExtendedRSS2(rss.RSS2):
+ # Issue #383, this changes from py2 to py3
+ if isinstance(fmt_date, bytes_str):
+ fmt_date = fmt_date.decode('utf8')
+ return fmt_date
+
+class ExtendedRSS2(rss.RSS2):
"""Extended RSS class."""
xsl_stylesheet_href = None
@@ -1129,7 +1243,6 @@ class ExtendedRSS2(rss.RSS2):
class ExtendedItem(rss.RSSItem):
-
"""Extended RSS item."""
def __init__(self, **kw):
@@ -1326,7 +1439,6 @@ def ask_yesno(query, default=None):
class CommandWrapper(object):
-
"""Converts commands into functions."""
def __init__(self, cmd, commands_object):
@@ -1342,7 +1454,6 @@ class CommandWrapper(object):
class Commands(object):
-
"""Nikola Commands.
Sample usage:
@@ -1433,7 +1544,6 @@ def options2docstring(name, options):
class NikolaPygmentsHTML(HtmlFormatter):
-
"""A Nikola-specific modification of Pygments' HtmlFormatter."""
def __init__(self, anchor_ref, classes=None, linenos='table', linenostart=1):
@@ -1540,7 +1650,6 @@ def create_redirect(src, dst):
class TreeNode(object):
-
"""A tree node."""
indent_levels = None # use for formatting comments as tree
@@ -1673,6 +1782,73 @@ def join_hierarchical_category_path(category_path):
return '/'.join([escape(p) for p in category_path])
+def colorize_str_from_base_color(string, base_color):
+ """Find a perceptual similar color from a base color based on the hash of a string.
+
+ Make up to 16 attempts (number of bytes returned by hashing) at picking a
+ hue for our color at least 27 deg removed from the base color, leaving
+ lightness and saturation untouched using HUSL colorspace.
+ """
+ def hash_str(string, pos):
+ return hashlib.md5(string.encode('utf-8')).digest()[pos]
+
+ def degreediff(dega, degb):
+ return min(abs(dega - degb), abs((degb - dega) + 360))
+
+ def husl_similar_from_base(string, base_color):
+ h, s, l = husl.hex_to_husl(base_color)
+ old_h = h
+ idx = 0
+ while degreediff(old_h, h) < 27 and idx < 16:
+ h = 360.0 * (float(hash_str(string, idx)) / 255)
+ idx += 1
+ return husl.husl_to_hex(h, s, l)
+
+ return husl_similar_from_base(string, base_color)
+
+
+def color_hsl_adjust_hex(hexstr, adjust_h=None, adjust_s=None, adjust_l=None):
+ """Adjust a hex color using HSL arguments, adjustments in percentages 1.0 to -1.0. Returns a hex color."""
+ h, s, l = husl.hex_to_husl(hexstr)
+
+ if adjust_h:
+ h = h + (adjust_h * 360.0)
+
+ if adjust_s:
+ s = s + (adjust_s * 100.0)
+
+ if adjust_l:
+ l = l + (adjust_l * 100.0)
+
+ return husl.husl_to_hex(h, s, l)
+
+
+def dns_sd(port, inet6):
+ """Optimistically publish a HTTP service to the local network over DNS-SD.
+
+ Works only on Linux/FreeBSD. Requires the `avahi` and `dbus` modules (symlinks in virtualenvs)
+ """
+ try:
+ import avahi
+ import dbus
+ inet = avahi.PROTO_INET6 if inet6 else avahi.PROTO_INET
+ name = "{0}'s Nikola Server on {1}".format(os.getlogin(), socket.gethostname())
+ bus = dbus.SystemBus()
+ bus_server = dbus.Interface(bus.get_object(avahi.DBUS_NAME,
+ avahi.DBUS_PATH_SERVER),
+ avahi.DBUS_INTERFACE_SERVER)
+ bus_group = dbus.Interface(bus.get_object(avahi.DBUS_NAME,
+ bus_server.EntryGroupNew()),
+ avahi.DBUS_INTERFACE_ENTRY_GROUP)
+ bus_group.AddService(avahi.IF_UNSPEC, inet, dbus.UInt32(0),
+ name, '_http._tcp', '', '',
+ dbus.UInt16(port), '')
+ bus_group.Commit()
+ return bus_group # remember to bus_group.Reset() to unpublish
+ except Exception:
+ return None
+
+
# Stolen from textwrap in Python 3.4.3.
def indent(text, prefix, predicate=None):
"""Add 'prefix' to the beginning of selected lines in 'text'.