aboutsummaryrefslogtreecommitdiffstats
path: root/nikola/utils.py
diff options
context:
space:
mode:
Diffstat (limited to 'nikola/utils.py')
-rw-r--r--nikola/utils.py725
1 files changed, 587 insertions, 138 deletions
diff --git a/nikola/utils.py b/nikola/utils.py
index 46e159e..9420595 100644
--- a/nikola/utils.py
+++ b/nikola/utils.py
@@ -26,10 +26,11 @@
"""Utility functions."""
-from __future__ import print_function, unicode_literals
+from __future__ import print_function, unicode_literals, absolute_import
from collections import defaultdict, Callable
import calendar
import datetime
+import dateutil.tz
import hashlib
import locale
import logging
@@ -39,17 +40,18 @@ import json
import shutil
import subprocess
import sys
-from zipfile import ZipFile as zip
+from zipfile import ZipFile as zipf
try:
from imp import reload
except ImportError:
pass
+import dateutil.parser
+import dateutil.tz
import logbook
from logbook.more import ExceptionHandler, ColorizedStderrHandler
-import pytz
-from . import DEBUG
+from nikola import DEBUG
class ApplicationWarning(Exception):
@@ -70,29 +72,65 @@ def get_logger(name, handlers):
l = logbook.Logger(name)
for h in handlers:
if isinstance(h, list):
- l.handlers += h
+ l.handlers = h
else:
- l.handlers.append(h)
+ l.handlers = [h]
return l
STDERR_HANDLER = [ColorfulStderrHandler(
- level=logbook.NOTICE if not DEBUG else logbook.DEBUG,
+ level=logbook.INFO if not DEBUG else logbook.DEBUG,
format_string=u'[{record.time:%Y-%m-%dT%H:%M:%SZ}] {record.level_name}: {record.channel}: {record.message}'
)]
LOGGER = get_logger('Nikola', STDERR_HANDLER)
STRICT_HANDLER = ExceptionHandler(ApplicationWarning, level='WARNING')
+# This will block out the default handler and will hide all unwanted
+# messages, properly.
+logbook.NullHandler().push_application()
+
if DEBUG:
logging.basicConfig(level=logging.DEBUG)
else:
- logging.basicConfig(level=logging.WARNING)
+ logging.basicConfig(level=logging.INFO)
+
+
+import warnings
+
+
+def showwarning(message, category, filename, lineno, file=None, line=None):
+ """Show a warning (from the warnings subsystem) to the user."""
+ try:
+ n = category.__name__
+ except AttributeError:
+ n = str(category)
+ get_logger(n, STDERR_HANDLER).warn('{0}:{1}: {2}'.format(filename, lineno, message))
+
+warnings.showwarning = showwarning
def req_missing(names, purpose, python=True, optional=False):
- """Log that we are missing some requirements."""
+ """Log that we are missing some requirements.
+
+ `names` is a list/tuple/set of missing things.
+ `purpose` is a string, specifying the use of the missing things.
+ It completes the sentence:
+ In order to {purpose}, you must install ...
+ `python` specifies whether the requirements are Python packages
+ or other software.
+ `optional` specifies whether the things are required
+ (this is an error and we exit with code 5)
+ or not (this is just a warning).
+
+ Returns the message shown to the user (which you can usually discard).
+ If no names are specified, False is returned and nothing is shown
+ to the user.
+
+ """
if not (isinstance(names, tuple) or isinstance(names, list) or isinstance(names, set)):
names = (names,)
+ if not names:
+ return False
if python:
whatarethey_s = 'Python package'
whatarethey_p = 'Python packages'
@@ -121,6 +159,7 @@ if sys.version_info[0] == 3:
bytes_str = bytes
unicode_str = str
unichr = chr
+ raw_input = input
from imp import reload as _reload
else:
bytes_str = str
@@ -130,6 +169,7 @@ else:
from doit import tools
from unidecode import unidecode
+from pkg_resources import resource_filename
import PyRSS2Gen as rss
@@ -137,9 +177,13 @@ __all__ = ['get_theme_path', 'get_theme_chain', 'load_messages', 'copy_tree',
'copy_file', 'slugify', 'unslugify', 'to_datetime', 'apply_filters',
'config_changed', 'get_crumbs', 'get_tzname', 'get_asset_path',
'_reload', 'unicode_str', 'bytes_str', 'unichr', 'Functionary',
- 'LocaleBorg', 'sys_encode', 'sys_decode', 'makedirs',
- 'get_parent_theme_name', 'ExtendedRSS2', 'demote_headers',
- 'get_translation_candidate']
+ 'TranslatableSetting', 'TemplateHookRegistry', 'LocaleBorg',
+ 'sys_encode', 'sys_decode', 'makedirs', 'get_parent_theme_name',
+ 'demote_headers', 'get_translation_candidate', 'write_metadata',
+ 'ask', 'ask_yesno']
+
+# Are you looking for 'generic_rss_renderer'?
+# It's defined in nikola.nikola.Nikola (the site object).
ENCODING = sys.getfilesystemencoding() or sys.stdin.encoding
@@ -185,10 +229,256 @@ class Functionary(defaultdict):
return self[lang][key]
+class TranslatableSetting(object):
+
+ """
+ A setting that can be translated.
+
+ You can access it via: SETTING(lang). You can omit lang, in which
+ case Nikola will ask LocaleBorg, unless you set SETTING.lang,
+ which overrides that call.
+
+ You can also stringify the setting and you will get something
+ sensible (in what LocaleBorg claims the language is, can also be
+ overriden by SETTING.lang). Note that this second method is
+ deprecated. It is kept for backwards compatibility and
+ safety. It is not guaranteed.
+
+ The underlying structure is a defaultdict. The language that
+ is the default value of the dict is provided with __init__().
+ If you need access the underlying dict (you generally don’t,
+ """
+
+ # WARNING: This is generally not used and replaced with a call to
+ # LocaleBorg(). Set this to a truthy value to override that.
+ lang = None
+
+ # Note that this setting is global. DO NOT set on a per-instance basis!
+ default_lang = 'en'
+
+ def __getattribute__(self, attr):
+ """Return attributes, falling back to string attributes."""
+ try:
+ return super(TranslatableSetting, self).__getattribute__(attr)
+ except AttributeError:
+ return self().__getattribute__(attr)
+
+ def __dir__(self):
+ return list(set(self.__dict__).union(set(dir(str))))
+
+ def __init__(self, name, inp, translations):
+ """Initialize a translated setting.
+
+ Valid inputs include:
+
+ * a string -- the same will be used for all languages
+ * a dict ({lang: value}) -- each language will use the value specified;
+ if there is none, default_lang is used.
+
+ """
+ self.name = name
+ self._inp = inp
+ self.translations = translations
+ self.overriden_default = False
+ self.values = defaultdict()
+
+ if isinstance(inp, dict):
+ self.translated = True
+ self.values.update(inp)
+ if self.default_lang not in self.values.keys():
+ self.default_lang = list(self.values.keys())[0]
+ self.overridden_default = True
+ self.values.default_factory = lambda: self.values[self.default_lang]
+ for k in translations.keys():
+ if k not in self.values.keys():
+ self.values[k] = inp[self.default_lang]
+ else:
+ self.translated = False
+ self.values[self.default_lang] = inp
+ self.values.default_factory = lambda: inp
+
+ def get_lang(self):
+ """Return the language that should be used to retrieve settings."""
+ if self.lang:
+ return self.lang
+ elif not self.translated:
+ return self.default_lang
+ else:
+ try:
+ return LocaleBorg().current_lang
+ except AttributeError:
+ return self.default_lang
+
+ def __call__(self, lang=None):
+ """
+ Return the value in the requested language.
+
+ While lang is None, self.lang (currently set language) is used.
+ Otherwise, the standard algorithm is used (see above).
+
+ """
+ if lang is None:
+ return self.values[self.get_lang()]
+ else:
+ return self.values[lang]
+
+ def __str__(self):
+ """Return the value in the currently set language. (deprecated)"""
+ return self.values[self.get_lang()]
+
+ def __unicode__(self):
+ """Return the value in the currently set language. (deprecated)"""
+ return self.values[self.get_lang()]
+
+ def __repr__(self):
+ """Provide a representation for programmers."""
+ return '<TranslatableSetting: {0!r}>'.format(self.name)
+
+ def format(self, *args, **kwargs):
+ """Format ALL the values in the setting the same way."""
+ for l in self.values:
+ self.values[l] = self.values[l].format(*args, **kwargs)
+ self.values.default_factory = lambda: self.values[self.default_lang]
+ return self
+
+ def langformat(self, formats):
+ """Format ALL the values in the setting, on a per-language basis."""
+ if not formats:
+ # Input is empty.
+ return self
+ else:
+ # This is a little tricky.
+ # Basically, we have some things that may very well be dicts. Or
+ # actually, TranslatableSettings in the original unprocessed dict
+ # form. We need to detect them.
+
+ # First off, we need to check what languages we have and what
+ # should we use as the default.
+ keys = list(formats)
+ if self.default_lang in keys:
+ d = formats[self.default_lang]
+ else:
+ d = formats[keys[0]]
+ # Discovering languages of the settings here.
+ langkeys = []
+ for f in formats.values():
+ for a in f[0] + tuple(f[1].values()):
+ if isinstance(a, dict):
+ langkeys += list(a)
+ # Now that we know all this, we go through all the languages we have.
+ allvalues = set(keys + langkeys + list(self.values))
+ for l in allvalues:
+ if l in keys:
+ oargs, okwargs = formats[l]
+ else:
+ oargs, okwargs = d
+
+ args = []
+ kwargs = {}
+
+ for a in oargs:
+ # We create temporary TranslatableSettings and replace the
+ # values with them.
+ if isinstance(a, dict):
+ a = TranslatableSetting('NULL', a)
+ args.append(a(l))
+ else:
+ args.append(a)
+
+ for k, v in okwargs.items():
+ if isinstance(v, dict):
+ v = TranslatableSetting('NULL', v)
+ kwargs.update({k: v(l)})
+ else:
+ kwargs.update({k: v})
+
+ self.values[l] = self.values[l].format(*args, **kwargs)
+ self.values.default_factory = lambda: self.values[self.default_lang]
+
+ return self
+
+ def __getitem__(self, key):
+ """Provide an alternate interface via __getitem__."""
+ return self.values[key]
+
+ def __setitem__(self, key, value):
+ """Set values for translations."""
+ self.values[key] = value
+
+ def __eq__(self, other):
+ """Test whether two TranslatableSettings are equal."""
+ return self.values == other.values
+
+ def __ne__(self, other):
+ """Test whether two TranslatableSettings are inequal."""
+ return self.values != other.values
+
+
+class TemplateHookRegistry(object):
+
+ """
+ A registry for template hooks.
+
+ Usage:
+
+ >>> r = TemplateHookRegistry('foo', None)
+ >>> r.append('Hello!')
+ >>> r.append(lambda x: 'Hello ' + x + '!', False, 'world')
+ >>> str(r()) # str() call is not recommended in real use
+ 'Hello!\\nHello world!'
+ >>>
+ """
+
+ def __init__(self, name, site):
+ """Initialize a hook registry."""
+ self._items = []
+ self.name = name
+ self.site = site
+ self.context = None
+
+ def generate(self):
+ """Generate items."""
+ for c, inp, site, args, kwargs in self._items:
+ if c:
+ if site:
+ kwargs['site'] = self.site
+ kwargs['context'] = self.context
+ yield inp(*args, **kwargs)
+ else:
+ yield inp
+
+ def __call__(self):
+ """Return items, in a string, separated by newlines."""
+ return '\n'.join(self.generate())
+
+ def append(self, inp, wants_site_and_context=False, *args, **kwargs):
+ """
+ Register an item.
+
+ `inp` can be a string or a callable returning one.
+ `wants_site` tells whether there should be a `site` keyword
+ argument provided, for accessing the site.
+
+ Further positional and keyword arguments are passed as-is to the
+ callable.
+
+ `wants_site`, args and kwargs are ignored (but saved!) if `inp`
+ is not callable. Callability of `inp` is determined only once.
+ """
+ c = callable(inp)
+ self._items.append((c, inp, wants_site_and_context, args, kwargs))
+
+ def __hash__(self):
+ return config_changed({self.name: self._items})
+
+ def __str__(self):
+ return '<TemplateHookRegistry: {0}>'.format(self._items)
+
+
class CustomEncoder(json.JSONEncoder):
def default(self, obj):
try:
- return json.JSONEncoder.default(self, obj)
+ return super(CustomEncoder, self).default(obj)
except TypeError:
s = repr(obj).split('0x', 1)[0]
return s
@@ -206,7 +496,9 @@ class config_changed(tools.config_changed):
byte_data = data.encode("utf-8")
else:
byte_data = data
- return hashlib.md5(byte_data).hexdigest()
+ digest = hashlib.md5(byte_data).hexdigest()
+ # LOGGER.debug('{{"{0}": {1}}}'.format(digest, byte_data))
+ return digest
else:
raise Exception('Invalid type of config_changed parameter -- got '
'{0}, must be string or dict'.format(type(
@@ -217,24 +509,23 @@ class config_changed(tools.config_changed):
cls=CustomEncoder))
-def get_theme_path(theme):
+def get_theme_path(theme, _themes_dir='themes'):
"""Given a theme name, returns the path where its files are located.
Looks in ./themes and in the place where themes go when installed.
"""
- dir_name = os.path.join('themes', theme)
+ dir_name = os.path.join(_themes_dir, theme)
if os.path.isdir(dir_name):
return dir_name
- dir_name = os.path.join(os.path.dirname(__file__),
- 'data', 'themes', theme)
+ dir_name = resource_filename('nikola', os.path.join('data', 'themes', theme))
if os.path.isdir(dir_name):
return dir_name
raise Exception("Can't find theme '{0}'".format(theme))
-def get_template_engine(themes):
+def get_template_engine(themes, _themes_dir='themes'):
for theme_name in themes:
- engine_path = os.path.join(get_theme_path(theme_name), 'engine')
+ engine_path = os.path.join(get_theme_path(theme_name, _themes_dir), 'engine')
if os.path.isfile(engine_path):
with open(engine_path) as fd:
return fd.readlines()[0].strip()
@@ -242,20 +533,20 @@ def get_template_engine(themes):
return 'mako'
-def get_parent_theme_name(theme_name):
- parent_path = os.path.join(get_theme_path(theme_name), 'parent')
+def get_parent_theme_name(theme_name, _themes_dir='themes'):
+ parent_path = os.path.join(get_theme_path(theme_name, _themes_dir), 'parent')
if os.path.isfile(parent_path):
with open(parent_path) as fd:
return fd.readlines()[0].strip()
return None
-def get_theme_chain(theme):
+def get_theme_chain(theme, _themes_dir='themes'):
"""Create the full theme inheritance chain."""
themes = [theme]
while True:
- parent = get_parent_theme_name(themes[-1])
+ parent = get_parent_theme_name(themes[-1], _themes_dir)
# Avoid silly loops
if parent is None or parent in themes:
break
@@ -266,6 +557,15 @@ def get_theme_chain(theme):
warned = []
+class LanguageNotFoundError(Exception):
+ def __init__(self, lang, orig):
+ self.lang = lang
+ self.orig = orig
+
+ def __str__(self):
+ return 'cannot find language {0}'.format(self.lang)
+
+
def load_messages(themes, translations, default_lang):
""" Load theme's messages into context.
@@ -281,18 +581,23 @@ def load_messages(themes, translations, default_lang):
sys.path.insert(0, msg_folder)
english = __import__('messages_en')
for lang in list(translations.keys()):
- # If we don't do the reload, the module is cached
- translation = __import__('messages_' + lang)
- reload(translation)
- if sorted(translation.MESSAGES.keys()) !=\
- sorted(english.MESSAGES.keys()) and \
- lang not in warned:
- warned.append(lang)
- LOGGER.warn("Incomplete translation for language "
- "'{0}'.".format(lang))
- messages[lang].update(english.MESSAGES)
- messages[lang].update(translation.MESSAGES)
- del(translation)
+ try:
+ translation = __import__('messages_' + lang)
+ # If we don't do the reload, the module is cached
+ reload(translation)
+ if sorted(translation.MESSAGES.keys()) !=\
+ sorted(english.MESSAGES.keys()) and \
+ lang not in warned:
+ warned.append(lang)
+ LOGGER.warn("Incomplete translation for language "
+ "'{0}'.".format(lang))
+ messages[lang].update(english.MESSAGES)
+ for k, v in translation.MESSAGES.items():
+ if v:
+ messages[lang][k] = v
+ del(translation)
+ except ImportError as orig:
+ raise LanguageNotFoundError(lang, orig)
sys.path = oldpath
return messages
@@ -314,7 +619,7 @@ def copy_tree(src, dst, link_cutoff=None):
"""
ignore = set(['.svn'])
base_len = len(src.split(os.sep))
- for root, dirs, files in os.walk(src):
+ for root, dirs, files in os.walk(src, followlinks=True):
root_parts = root.split(os.sep)
if set(root_parts) & ignore:
continue
@@ -325,12 +630,8 @@ def copy_tree(src, dst, link_cutoff=None):
continue
dst_file = os.path.join(dst_dir, src_name)
src_file = os.path.join(root, src_name)
- if sys.version_info[0] == 2:
- # Python2 prefers encoded str here
- dst_file = sys_encode(dst_file)
- src_file = sys_encode(src_file)
yield {
- 'name': str(dst_file),
+ 'name': dst_file,
'file_dep': [src_file],
'targets': [dst_file],
'actions': [(copy_file, (src_file, dst_file, link_cutoff))],
@@ -397,11 +698,14 @@ def slugify(value):
return _slugify_hyphenate_re.sub('-', value)
-def unslugify(value):
- """
- Given a slug string (as a filename), return a human readable string
+def unslugify(value, discard_numbers=True):
+ """Given a slug string (as a filename), return a human readable string.
+
+ If discard_numbers is True, numbers right at the beginning of input
+ will be removed.
"""
- value = re.sub('^[0-9]+', '', value)
+ if discard_numbers:
+ value = re.sub('^[0-9]+', '', value)
value = re.sub('([_\-\.])', ' ', value)
value = value.strip().capitalize()
return value
@@ -418,55 +722,31 @@ def extract_all(zipfile, path='themes'):
pwd = os.getcwd()
makedirs(path)
os.chdir(path)
- with zip(zipfile) as z:
- namelist = z.namelist()
- for f in namelist:
- if f.endswith('/') and '..' in f:
- raise UnsafeZipException('The zip file contains ".." and is '
- 'not safe to expand.')
- for f in namelist:
- if f.endswith('/'):
- makedirs(f)
- else:
- z.extract(f)
+ z = zipf(zipfile)
+ namelist = z.namelist()
+ for f in namelist:
+ if f.endswith('/') and '..' in f:
+ raise UnsafeZipException('The zip file contains ".." and is '
+ 'not safe to expand.')
+ for f in namelist:
+ if f.endswith('/'):
+ makedirs(f)
+ else:
+ z.extract(f)
+ z.close()
os.chdir(pwd)
-# From https://github.com/lepture/liquidluck/blob/develop/liquidluck/utils.py
def to_datetime(value, tzinfo=None):
- if isinstance(value, datetime.datetime):
- return value
- supported_formats = [
- '%Y/%m/%d %H:%M',
- '%Y/%m/%d %H:%M:%S',
- '%Y/%m/%d %I:%M:%S %p',
- '%a %b %d %H:%M:%S %Y',
- '%Y-%m-%d %H:%M:%S',
- '%Y-%m-%d %H:%M',
- '%Y-%m-%dT%H:%M',
- '%Y%m%d %H:%M:%S',
- '%Y%m%d %H:%M',
- '%Y-%m-%d',
- '%Y%m%d',
- ]
- for format in supported_formats:
- try:
- dt = datetime.datetime.strptime(value, format)
- if tzinfo is None:
- return dt
- # Build a localized time by using a given time zone.
- return tzinfo.localize(dt)
- except ValueError:
- pass
- # So, let's try dateutil
try:
- from dateutil import parser
- dt = parser.parse(value)
- if tzinfo is None or dt.tzinfo:
- return dt
- return tzinfo.localize(dt)
- except ImportError:
- raise ValueError('Unrecognized date/time: {0!r}, try installing dateutil...'.format(value))
+ if not isinstance(value, datetime.datetime):
+ # dateutil does bad things with TZs like UTC-03:00.
+ dateregexp = re.compile(r' UTC([+-][0-9][0-9]:[0-9][0-9])')
+ value = re.sub(dateregexp, r'\1', value)
+ value = dateutil.parser.parse(value)
+ if not value.tzinfo:
+ value = value.replace(tzinfo=tzinfo)
+ return value
except Exception:
raise ValueError('Unrecognized date/time: {0!r}'.format(value))
@@ -474,28 +754,18 @@ def to_datetime(value, tzinfo=None):
def get_tzname(dt):
"""
Given a datetime value, find the name of the time zone.
- """
- try:
- from dateutil import tz
- except ImportError:
- raise ValueError('Unrecognized date/time: {0!r}, try installing dateutil...'.format(dt))
- tzoffset = dt.strftime('%z')
- for name in pytz.common_timezones:
- timezone = tz.gettz(name)
- now = dt.now(timezone)
- offset = now.strftime('%z')
- if offset == tzoffset:
- return name
- raise ValueError('Unrecognized date/time: {0!r}'.format(dt))
+ DEPRECATED: This thing returned basically the 1st random zone
+ that matched the offset.
+ """
+ return dt.tzname()
def current_time(tzinfo=None):
- dt = datetime.datetime.utcnow()
if tzinfo is not None:
- dt = tzinfo.fromutc(dt)
+ dt = datetime.datetime.now(tzinfo)
else:
- dt = pytz.UTC.localize(dt)
+ dt = datetime.datetime.now(dateutil.tz.tzlocal())
return dt
@@ -589,7 +859,7 @@ def get_crumbs(path, is_file=False, index_folder=None):
return list(reversed(_crumbs))
-def get_asset_path(path, themes, files_folders={'files': ''}):
+def get_asset_path(path, themes, files_folders={'files': ''}, _themes_dir='themes'):
"""
.. versionchanged:: 6.1.0
@@ -599,22 +869,22 @@ def get_asset_path(path, themes, files_folders={'files': ''}):
If the asset is not provided by a theme, then it will be checked for
in the FILES_FOLDERS
- >>> print(get_asset_path('assets/css/rst.css', ['bootstrap', 'base'])) # doctest: +SKIP
- [...]/nikola/data/themes/base/assets/css/rst.css
+ >>> print(get_asset_path('assets/css/rst.css', ['bootstrap', 'base']))
+ /.../nikola/data/themes/base/assets/css/rst.css
- >>> print(get_asset_path('assets/css/theme.css', ['bootstrap', 'base'])) # doctest: +SKIP
- [...]/nikola/data/themes/bootstrap/assets/css/theme.css
+ >>> print(get_asset_path('assets/css/theme.css', ['bootstrap', 'base']))
+ /.../nikola/data/themes/bootstrap/assets/css/theme.css
- >>> print(get_asset_path('nikola.py', ['bootstrap', 'base'], {'nikola': ''})) # doctest: +SKIP
- [...]/nikola/nikola.py
+ >>> print(get_asset_path('nikola.py', ['bootstrap', 'base'], {'nikola': ''}))
+ /.../nikola/nikola.py
- >>> print(get_asset_path('nikola/nikola.py', ['bootstrap', 'base'], {'nikola':'nikola'})) # doctest: +SKIP
- [...]/nikola/nikola.py
+ >>> print(get_asset_path('nikola/nikola.py', ['bootstrap', 'base'], {'nikola':'nikola'}))
+ None
"""
for theme_name in themes:
candidate = os.path.join(
- get_theme_path(theme_name),
+ get_theme_path(theme_name, _themes_dir),
path
)
if os.path.isfile(candidate):
@@ -628,6 +898,11 @@ def get_asset_path(path, themes, files_folders={'files': ''}):
return None
+class LocaleBorgUninitializedException(Exception):
+ def __init__(self):
+ super(LocaleBorgUninitializedException, self).__init__("Attempt to use LocaleBorg before initialization")
+
+
class LocaleBorg(object):
"""
Provides locale related services and autoritative current_lang,
@@ -662,6 +937,9 @@ class LocaleBorg(object):
Examples: "Spanish", "French" can't do the full circle set / get / set
That used to break calendar, but now seems is not the case, with month at least
"""
+
+ initialized = False
+
@classmethod
def initialize(cls, locales, initial_lang):
"""
@@ -696,7 +974,7 @@ class LocaleBorg(object):
def __init__(self):
if not self.initialized:
- raise Exception("Attempt to use LocaleBorg before initialization")
+ raise LocaleBorgUninitializedException()
self.__dict__ = self.__shared_state
def set_locale(self, lang):
@@ -734,26 +1012,10 @@ class LocaleBorg(object):
return s
-class ExtendedRSS2(rss.RSS2):
- def publish_extensions(self, handler):
- if self.self_url:
- handler.startElement("atom:link", {
- 'href': self.self_url,
- 'rel': "self",
- 'type': "application/rss+xml"
- })
- handler.endElement("atom:link")
-
-
class ExtendedItem(rss.RSSItem):
def __init__(self, **kw):
- author = kw.pop('author')
- if author and '@' in author[1:]: # Yes, this is a silly way to validate an email
- kw['author'] = author
- self.creator = None
- else:
- self.creator = author
+ self.creator = kw.pop('creator')
# It's an old style class
return rss.RSSItem.__init__(self, **kw)
@@ -824,9 +1086,196 @@ def get_root_dir():
def get_translation_candidate(config, path, lang):
"""
Return a possible path where we can find the translated version of some page
- based on the TRANSLATIONS_PATTERN configuration variable
+ based on the TRANSLATIONS_PATTERN configuration variable.
+
+ >>> config = {'TRANSLATIONS_PATTERN': '{path}.{lang}.{ext}', 'DEFAULT_LANG': 'en', 'TRANSLATIONS': {'es':'1', 'en': 1}}
+ >>> print(get_translation_candidate(config, '*.rst', 'es'))
+ *.es.rst
+ >>> print(get_translation_candidate(config, 'fancy.post.rst', 'es'))
+ fancy.post.es.rst
+ >>> print(get_translation_candidate(config, '*.es.rst', 'es'))
+ *.es.rst
+ >>> print(get_translation_candidate(config, '*.es.rst', 'en'))
+ *.rst
+ >>> print(get_translation_candidate(config, 'cache/posts/fancy.post.es.html', 'en'))
+ cache/posts/fancy.post.html
+ >>> print(get_translation_candidate(config, 'cache/posts/fancy.post.html', 'es'))
+ cache/posts/fancy.post.es.html
+ >>> print(get_translation_candidate(config, 'cache/stories/charts.html', 'es'))
+ cache/stories/charts.es.html
+ >>> print(get_translation_candidate(config, 'cache/stories/charts.html', 'en'))
+ cache/stories/charts.html
+
+ >>> config = {'TRANSLATIONS_PATTERN': '{path}.{ext}.{lang}', 'DEFAULT_LANG': 'en', 'TRANSLATIONS': {'es':'1', 'en': 1}}
+ >>> print(get_translation_candidate(config, '*.rst', 'es'))
+ *.rst.es
+ >>> print(get_translation_candidate(config, '*.rst.es', 'es'))
+ *.rst.es
+ >>> print(get_translation_candidate(config, '*.rst.es', 'en'))
+ *.rst
+ >>> print(get_translation_candidate(config, 'cache/posts/fancy.post.html.es', 'en'))
+ cache/posts/fancy.post.html
+ >>> print(get_translation_candidate(config, 'cache/posts/fancy.post.html', 'es'))
+ cache/posts/fancy.post.html.es
+
"""
+ # FIXME: this is rather slow and this function is called A LOT
+ # Convert the pattern into a regexp
pattern = config['TRANSLATIONS_PATTERN']
- path, ext = os.path.splitext(path)
- ext = ext[1:] if len(ext) > 0 else ext
- return pattern.format(path=path, lang=lang, ext=ext)
+ # This will still break if the user has ?*[]\ in the pattern. But WHY WOULD HE?
+ pattern = pattern.replace('.', r'\.')
+ pattern = pattern.replace('{path}', '(?P<path>.+?)')
+ pattern = pattern.replace('{ext}', '(?P<ext>[^\./]+)')
+ pattern = pattern.replace('{lang}', '(?P<lang>{0})'.format('|'.join(config['TRANSLATIONS'].keys())))
+ m = re.match(pattern, path)
+ if m and all(m.groups()): # It's a translated path
+ p, e, l = m.group('path'), m.group('ext'), m.group('lang')
+ if l == lang: # Nothing to do
+ return path
+ elif lang == config['DEFAULT_LANG']: # Return untranslated path
+ return '{0}.{1}'.format(p, e)
+ else: # Change lang and return
+ return config['TRANSLATIONS_PATTERN'].format(path=p, ext=e, lang=lang)
+ else:
+ # It's a untranslated path, assume it's path.ext
+ p, e = os.path.splitext(path)
+ e = e[1:] # No initial dot
+ if lang == config['DEFAULT_LANG']: # Nothing to do
+ return path
+ else: # Change lang and return
+ return config['TRANSLATIONS_PATTERN'].format(path=p, ext=e, lang=lang)
+
+
+def write_metadata(data):
+ """Write metadata."""
+ order = ('title', 'slug', 'date', 'tags', 'link', 'description', 'type')
+ f = '.. {0}: {1}'
+ meta = []
+ for k in order:
+ try:
+ meta.append(f.format(k, data.pop(k)))
+ except KeyError:
+ pass
+
+ # Leftover metadata (user-specified/non-default).
+ for k, v in data.items():
+ meta.append(f.format(k, v))
+
+ meta.append('')
+
+ return '\n'.join(meta)
+
+
+def ask(query, default=None):
+ """Ask a question."""
+ if default:
+ default_q = ' [{0}]'.format(default)
+ else:
+ default_q = ''
+ inp = raw_input("{query}{default_q}: ".format(query=query, default_q=default_q)).strip()
+ if inp or default is None:
+ return inp
+ else:
+ return default
+
+
+def ask_yesno(query, default=None):
+ """Ask a yes/no question."""
+ if default is None:
+ default_q = ' [y/n]'
+ elif default is True:
+ default_q = ' [Y/n]'
+ elif default is False:
+ default_q = ' [y/N]'
+ inp = raw_input("{query}{default_q} ".format(query=query, default_q=default_q)).strip()
+ if inp:
+ return inp.lower().startswith('y')
+ elif default is not None:
+ return default
+ else:
+ # Loop if no answer and no default.
+ return ask_yesno(query, default)
+
+
+from nikola.plugin_categories import Command
+from doit.cmdparse import CmdParse
+
+
+class CommandWrapper(object):
+ """Converts commands into functions."""
+
+ def __init__(self, cmd, commands_object):
+ self.cmd = cmd
+ self.commands_object = commands_object
+
+ def __call__(self, *args, **kwargs):
+ if args or (not args and not kwargs):
+ self.commands_object._run([self.cmd] + list(args))
+ else:
+ # Here's where the keyword magic would have to go
+ self.commands_object._run_with_kw(self.cmd, *args, **kwargs)
+
+
+class Commands(object):
+
+ """Nikola Commands.
+
+ Sample usage:
+ >>> commands.check('-l') # doctest: +SKIP
+
+ Or, if you know the internal argument names:
+ >>> commands.check(list=True) # doctest: +SKIP
+ """
+
+ def __init__(self, main):
+ """Takes a main instance, works as wrapper for commands."""
+ self._cmdnames = []
+ for k, v in main.get_commands().items():
+ self._cmdnames.append(k)
+ if k in ['run', 'init']:
+ continue
+ if sys.version_info[0] == 2:
+ k2 = bytes(k)
+ else:
+ k2 = k
+ nc = type(
+ k2,
+ (CommandWrapper,),
+ {
+ '__doc__': options2docstring(k, main.sub_cmds[k].options)
+ })
+ setattr(self, k, nc(k, self))
+ self.main = main
+
+ def _run(self, cmd_args):
+ self.main.run(cmd_args)
+
+ def _run_with_kw(self, cmd, *a, **kw):
+ cmd = self.main.sub_cmds[cmd]
+ options, _ = CmdParse(cmd.options).parse([])
+ options.update(kw)
+ if isinstance(cmd, Command):
+ cmd.execute(options=options, args=a)
+ else: # Doit command
+ cmd.execute(options, a)
+
+ def __repr__(self):
+ """Return useful and verbose help."""
+
+ return """\
+<Nikola Commands>
+
+ Sample usage:
+ >>> commands.check('-l')
+
+ Or, if you know the internal argument names:
+ >>> commands.check(list=True)
+
+Available commands: {0}.""".format(', '.join(self._cmdnames))
+
+
+def options2docstring(name, options):
+ result = ['Function wrapper for command %s' % name, 'arguments:']
+ for opt in options:
+ result.append('{0} type {1} default {2}'.format(opt.name, opt.type.__name__, opt.default))
+ return '\n'.join(result)