From 3bd3a83fdd78d4459dc4a172509ef8f9d4393ba1 Mon Sep 17 00:00:00 2001 From: Martin Mahner Date: Fri, 22 Jun 2018 12:37:04 +0200 Subject: [PATCH] Refactored settings to use AppConfig --- dpaste/apps.py | 207 ++++++++++++++++++++++++++++++++- dpaste/forms.py | 23 ++-- dpaste/highlight.py | 118 ++----------------- dpaste/models.py | 22 ++-- dpaste/tests/test_api.py | 8 +- dpaste/tests/test_highlight.py | 4 +- dpaste/tests/test_snippet.py | 20 ++-- dpaste/views.py | 23 ++-- 8 files changed, 267 insertions(+), 158 deletions(-) diff --git a/dpaste/apps.py b/dpaste/apps.py index f1f0676..fa4c0a3 100644 --- a/dpaste/apps.py +++ b/dpaste/apps.py @@ -1,6 +1,209 @@ -from django.apps import AppConfig - +from django.apps import AppConfig, apps +from django.utils.translation import ugettext_lazy as _ class dpasteAppConfig(AppConfig): name = 'dpaste' verbose_name = 'dpaste' + + # Integer. Length of the random slug for each new snippet. In the rare + # case an existing slug is generated again, the length will increase by + # one more character. + SLUG_LENGTH = 4 + + # String. A string of characters which are used to create the random slug. + SLUG_CHOICES = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNOPQRSTUVWXYZ1234567890' + + # String. The lexer key that is pre-selected in the dropdown. Note that + # this is only used if the user has not saved a snippet before, otherwise + LEXER_DEFAULT = 'python' + + # Integer. Maximum number of bytes per snippet. + MAX_CONTENT_LENGTH = 250 * 1024 * 1024 + + # A tuple of seconds and a descriptive string used in the lexer + # expiration dropdown. Example:: + # + # from django.utils.translation import ugettext_lazy as _ + # DPASTE_EXPIRE_CHOICES = ( + # (3600, _('In one hour')), + # (3600 * 24 * 7, _('In one week')), + # (3600 * 24 * 30, _('In one month')), + # (3600 * 24 * 30 * 12 * 100, _('100 Years')), + # ) + # + + # **Infinite snippets** are supported. You can keep snippets forever when + # you set the choice key to ``never``. The management command will ignore + # these snippets:: + # + # from django.utils.translation import ugettext_lazy as _ + # DPASTE_EXPIRE_CHOICES = ( + # (3600, _('In one hour')), + # ('never', _('Never')), + # ) + EXPIRE_CHOICES = ( + ('onetime', _('One-Time snippet')), + (3600, _('In one hour')), + (3600 * 24 * 7, _('In one week')), + (3600 * 24 * 30, _('In one month')), + ('never', _('Never')), + ) + + # Default value for ``EXPIRE_CHOICES`` + EXPIRE_DEFAULT = 3600 * 24 * 7 + + # **One-Time snippets** are supported. One-Time snippets are automatically + # deleted once a defined view count has reached (Default: ``2``). To + # enable one-time snippets you have to add a choice ``onetime`` to the + # expire choices:: + # + # from django.utils.translation import ugettext_lazy as _ + # DPASTE_EXPIRE_CHOICES = ( + # ('onetime', _('One-Time snippet')), + # (3600, _('In one hour')), + # (3600 * 24 * 7, _('In one week')), + # (3600 * 24 * 30, _('In one month')), + # ) + # + # You can also set the maximum view count after what the snippet gets + # deleted. The default is ``2``. One view is from the author, one view + # is from another user. + ONETIME_LIMIT = 2 + + # Lexers which have wordwrap enabled by default + LEXER_WORDWRAP = ('rst',) + + @property + def BASE_URL(self, request=None): + """ + String. The full qualified hostname and path to the dpaste instance. + This is used to generate a link in the API response. If the "Sites" + framework is installed, it uses the current Site domain. Otherwise + it falls back to 'https://dpaste.de' + """ + if apps.is_installed('django.contrib.sites'): + from django.contrib.sites.shortcuts import get_current_site + site = get_current_site(request) + if site: + return 'https://{0}'.format(site.domain) + return 'https://dpaste.de' + + + # Key names of the default text and code lexer. + PLAIN_TEXT_SYMBOL = '_text' + PLAIN_CODE_SYMBOL = '_code' + + @property + def TEXT_FORMATTER(self): + """ + Choices list with all "Text" lexer. Prepend keys with an underscore + so they don't accidentally clash with a Pygments Lexer name. + + Each list contains a lexer tuple of: + + (Lexer key, + Lexer Display Name, + Lexer Highlight Class) + + If the Highlight Class is not given, PygmentsHighlighter is used. + """ + from dpaste.highlight import ( + PlainTextHighlighter, + MarkdownHighlighter, + RestructuredTextHighlighter + ) + return [ + (self.PLAIN_TEXT_SYMBOL, 'Plain Text', PlainTextHighlighter), + ('_markdown', 'Markdown', MarkdownHighlighter), + ('_rst', 'reStructuredText', RestructuredTextHighlighter), + ] + + @property + def CODE_FORMATTER(self): + """ + Choices list with all "Code" Lexer. Each list + contains a lexer tuple of: + + (Lexer key, + Lexer Display Name, + Lexer Highlight Class) + + If the Highlight Class is not given, PygmentsHighlighter is used. + """ + from dpaste.highlight import ( + PlainCodeHighlighter, + SolidityHighlighter + ) + return [ + (self.PLAIN_CODE_SYMBOL, 'Plain Code', PlainCodeHighlighter), + ('abap', 'ABAP'), + ('apacheconf', 'ApacheConf'), + ('applescript', 'AppleScript'), + ('as', 'ActionScript'), + ('bash', 'Bash'), + ('bbcode', 'BBCode'), + ('c', 'C'), + ('cpp', 'C++'), + ('clojure', 'Clojure'), + ('cobol', 'COBOL'), + ('css', 'CSS'), + ('cuda', 'CUDA'), + ('dart', 'Dart'), + ('delphi', 'Delphi'), + ('diff', 'Diff'), + ('django', 'Django'), + ('erlang', 'Erlang'), + ('fortran', 'Fortran'), + ('go', 'Go'), + ('groovy', 'Groovy'), + ('haml', 'Haml'), + ('haskell', 'Haskell'), + ('html', 'HTML'), + ('http', 'HTTP'), + ('ini', 'INI'), + ('irc', 'IRC'), + ('java', 'Java'), + ('js', 'JavaScript'), + ('json', 'JSON'), + ('lua', 'Lua'), + ('make', 'Makefile'), + ('mako', 'Mako'), + ('mason', 'Mason'), + ('matlab', 'Matlab'), + ('modula2', 'Modula'), + ('monkey', 'Monkey'), + ('mysql', 'MySQL'), + ('numpy', 'NumPy'), + ('objc', 'Obj-C'), + ('ocaml', 'OCaml'), + ('perl', 'Perl'), + ('php', 'PHP'), + ('postscript', 'PostScript'), + ('powershell', 'PowerShell'), + ('prolog', 'Prolog'), + ('properties', 'Properties'), + ('puppet', 'Puppet'), + ('python', 'Python'), # Default lexer + ('r', 'R'), + ('rb', 'Ruby'), + ('rst', 'reStructuredText'), + ('rust', 'Rust'), + ('sass', 'Sass'), + ('scala', 'Scala'), + ('scheme', 'Scheme'), + ('scilab', 'Scilab'), + ('scss', 'SCSS'), + ('smalltalk', 'Smalltalk'), + ('smarty', 'Smarty'), + ('solidity', 'Solidity', SolidityHighlighter), + ('sql', 'SQL'), + ('tcl', 'Tcl'), + ('tcsh', 'Tcsh'), + ('tex', 'TeX'), + ('vb.net', 'VB.net'), + ('vim', 'VimL'), + ('xml', 'XML'), + ('xquery', 'XQuery'), + ('xslt', 'XSLT'), + ('yaml', 'YAML'), + ] diff --git a/dpaste/forms.py b/dpaste/forms.py index 63b1e89..0abe2c9 100644 --- a/dpaste/forms.py +++ b/dpaste/forms.py @@ -1,22 +1,13 @@ import datetime from django import forms -from django.conf import settings +from django.apps import apps from django.utils.translation import ugettext_lazy as _ -from .highlight import LEXER_DEFAULT, LEXER_KEYS, LEXER_CHOICES +from .highlight import LEXER_CHOICES, LEXER_DEFAULT, LEXER_KEYS from .models import Snippet -EXPIRE_CHOICES = getattr(settings, 'DPASTE_EXPIRE_CHOICES', ( - ('onetime', _('One-Time snippet')), - (3600, _('In one hour')), - (3600 * 24 * 7, _('In one week')), - (3600 * 24 * 30, _('In one month')), - ('never', _('Never')), -)) - -EXPIRE_DEFAULT = getattr(settings, 'DPASTE_EXPIRE_DEFAULT', 3600) -MAX_CONTENT_LENGTH = getattr(settings, 'DPASTE_MAX_CONTENT_LENGTH', 250*1024*1024) +config = apps.get_app_config('dpaste') def get_expire_values(expires): @@ -28,7 +19,7 @@ def get_expire_values(expires): expires = None else: expire_type = Snippet.EXPIRE_TIME - expires = expires and expires or EXPIRE_DEFAULT + expires = expires and expires or config.EXPIRE_DEFAULT expires = datetime.datetime.now() + datetime.timedelta(seconds=int(expires)) return expires, expire_type @@ -37,7 +28,7 @@ class SnippetForm(forms.ModelForm): content = forms.CharField( label=_('Content'), widget=forms.Textarea(attrs={'placeholder': _('Awesome code goes here...')}), - max_length=MAX_CONTENT_LENGTH, + max_length=config.MAX_CONTENT_LENGTH, strip=False ) @@ -49,8 +40,8 @@ class SnippetForm(forms.ModelForm): expires = forms.ChoiceField( label=_('Expires'), - choices=EXPIRE_CHOICES, - initial=EXPIRE_DEFAULT + choices=config.EXPIRE_CHOICES, + initial=config.EXPIRE_DEFAULT ) # Honeypot field diff --git a/dpaste/highlight.py b/dpaste/highlight.py index 08c555c..50661a7 100644 --- a/dpaste/highlight.py +++ b/dpaste/highlight.py @@ -1,6 +1,6 @@ from logging import getLogger -from django.conf import settings +from django.apps import apps from django.template.defaultfilters import escape, linebreaksbr from django.template.loader import render_to_string from django.utils.safestring import mark_safe @@ -12,7 +12,7 @@ from pygments.lexers.python import PythonLexer from pygments.util import ClassNotFound logger = getLogger(__file__) - +config = apps.get_app_config('dpaste') class NakedHtmlFormatter(HtmlFormatter): """Pygments HTML formatter with no further HTML tags.""" @@ -37,7 +37,7 @@ class Highlighter(object): @staticmethod def get_lexer_display_name(lexer_name, fallback=_('(Deprecated Lexer)')): - for l in TEXT_FORMATTER + CODE_FORMATTER: + for l in config.TEXT_FORMATTER + config.CODE_FORMATTER: if l[0] == lexer_name: return l[1] return fallback @@ -111,7 +111,7 @@ class PlainCodeHighlighter(Highlighter): class PygmentsHighlighter(Highlighter): """ - Highlight code string with Pygments. The lexer is automaticially + Highlight code string with Pygments. The lexer is automatically determined by the lexer name. """ formatter = NakedHtmlFormatter() @@ -149,7 +149,7 @@ def get_highlighter_class(lexer_name): If no suitable highlighter is found, return the generic PlainCode Highlighter. """ - for c in TEXT_FORMATTER + CODE_FORMATTER: + for c in config.TEXT_FORMATTER + config.CODE_FORMATTER: if c[0] == lexer_name: if len(c) == 3: return c[2] @@ -161,113 +161,19 @@ def get_highlighter_class(lexer_name): # Lexer List # ----------------------------------------------------------------------------- -# Lexer list. Each list contains a lexer tuple of: -# -# (Lexer key, -# Lexer Display Name, -# Lexer Highlight Class) -# -# If the Highlight Class is not given, PygmentsHighlighter is used. - -# Default Highlight Types -PLAIN_TEXT = '_text' # lexer name whats rendered as text (paragraphs) -PLAIN_CODE = '_code' # lexer name of code with no hihglighting - -TEXT_FORMATTER = [ - (PLAIN_TEXT, 'Plain Text', PlainTextHighlighter), - ('_markdown', 'Markdown', MarkdownHighlighter), - ('_rst', 'reStructuredText', RestructuredTextHighlighter), - #('_textile', 'Textile', MarkdownHighlighter), -] - -CODE_FORMATTER = [ - (PLAIN_CODE, 'Plain Code', PlainCodeHighlighter), - ('abap', 'ABAP'), - ('apacheconf', 'ApacheConf'), - ('applescript', 'AppleScript'), - ('as', 'ActionScript'), - ('bash', 'Bash'), - ('bbcode', 'BBCode'), - ('c', 'C'), - ('cpp', 'C++'), - ('clojure', 'Clojure'), - ('cobol', 'COBOL'), - ('css', 'CSS'), - ('cuda', 'CUDA'), - ('dart', 'Dart'), - ('delphi', 'Delphi'), - ('diff', 'Diff'), - ('django', 'Django'), - ('erlang', 'Erlang'), - ('fortran', 'Fortran'), - ('go', 'Go'), - ('groovy', 'Groovy'), - ('haml', 'Haml'), - ('haskell', 'Haskell'), - ('html', 'HTML'), - ('http', 'HTTP'), - ('ini', 'INI'), - ('irc', 'IRC'), - ('java', 'Java'), - ('js', 'JavaScript'), - ('json', 'JSON'), - ('lua', 'Lua'), - ('make', 'Makefile'), - ('mako', 'Mako'), - ('mason', 'Mason'), - ('matlab', 'Matlab'), - ('modula2', 'Modula'), - ('monkey', 'Monkey'), - ('mysql', 'MySQL'), - ('numpy', 'NumPy'), - ('objc', 'Obj-C'), - ('ocaml', 'OCaml'), - ('perl', 'Perl'), - ('php', 'PHP'), - ('postscript', 'PostScript'), - ('powershell', 'PowerShell'), - ('prolog', 'Prolog'), - ('properties', 'Properties'), - ('puppet', 'Puppet'), - ('python', 'Python'), - ('r', 'R'), - ('rb', 'Ruby'), - ('rst', 'reStructuredText'), - ('rust', 'Rust'), - ('sass', 'Sass'), - ('scala', 'Scala'), - ('scheme', 'Scheme'), - ('scilab', 'Scilab'), - ('scss', 'SCSS'), - ('smalltalk', 'Smalltalk'), - ('smarty', 'Smarty'), - ('solidity', 'Solidity', SolidityHighlighter), - ('sql', 'SQL'), - ('tcl', 'Tcl'), - ('tcsh', 'Tcsh'), - ('tex', 'TeX'), - ('vb.net', 'VB.net'), - ('vim', 'VimL'), - ('xml', 'XML'), - ('xquery', 'XQuery'), - ('xslt', 'XSLT'), - ('yaml', 'YAML'), -] - -# Generat a list of Form choices of all lexer. +# Generate a list of Form choices of all lexer. LEXER_CHOICES = ( - (_('Text'), [i[:2] for i in TEXT_FORMATTER]), - (_('Code'), [i[:2] for i in CODE_FORMATTER]) + (_('Text'), [i[:2] for i in config.TEXT_FORMATTER]), + (_('Code'), [i[:2] for i in config.CODE_FORMATTER]) ) # List of all Lexer Keys -LEXER_KEYS = [i[0] for i in TEXT_FORMATTER] + [i[0] for i in CODE_FORMATTER] +LEXER_KEYS = [i[0] for i in config.TEXT_FORMATTER] + \ + [i[0] for i in config.CODE_FORMATTER] # The default lexer which we fallback in case of # an error or if not supplied in an API call. -LEXER_DEFAULT = getattr(settings, 'DPASTE_LEXER_DEFAULT', 'python') +LEXER_DEFAULT = config.LEXER_DEFAULT # Lexers which have wordwrap enabled by default -LEXER_WORDWRAP = getattr(settings, 'DPASTE_LEXER_WORDWRAP', - ('rst',) -) +LEXER_WORDWRAP = config.LEXER_WORDWRAP diff --git a/dpaste/models.py b/dpaste/models.py index 1888540..db63c1e 100644 --- a/dpaste/models.py +++ b/dpaste/models.py @@ -1,6 +1,7 @@ +from logging import getLogger from random import SystemRandom -from django.conf import settings +from django.apps import apps from django.db import models from django.urls import reverse from django.utils.translation import ugettext_lazy as _ @@ -8,15 +9,19 @@ from six import python_2_unicode_compatible from dpaste import highlight +config = apps.get_app_config('dpaste') +logger = getLogger(__file__) R = SystemRandom() -ONETIME_LIMIT = getattr(settings, 'DPASTE_ONETIME_LIMIT', 2) -def generate_secret_id(length=None, alphabet=None, tries=0): - length = length or getattr(settings, 'DPASTE_SLUG_LENGTH', 4) - alphabet = alphabet or getattr(settings, 'DPASTE_SLUG_CHOICES', - 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNOPQRSTUVWXYZ1234567890') - secret_id = ''.join([R.choice(alphabet) for i in range(length)]) +def generate_secret_id(length=None, tries=0): + if tries >= 10000: + m = 'Tried to often to generate a unique slug. Inceease the slug length.' + logger.critical(m) + raise Exception(m) + + secret_id = ''.join([R.choice(config.SLUG_CHOICES) + for i in range(config.SLUG_LENGTH)]) # Check if this slug already exists, if not, return this new slug try: @@ -84,5 +89,6 @@ class Snippet(models.Model): @property def remaining_views(self): if self.expire_type == self.EXPIRE_ONETIME: - remaining = ONETIME_LIMIT - self.view_count + + remaining = config.ONETIME_LIMIT - self.view_count return remaining > 0 and remaining or 0 diff --git a/dpaste/tests/test_api.py b/dpaste/tests/test_api.py index 91f1598..08cdf61 100644 --- a/dpaste/tests/test_api.py +++ b/dpaste/tests/test_api.py @@ -1,12 +1,14 @@ # -*- encoding: utf-8 -*- -from django.urls import reverse +from django.apps import apps from django.test import TestCase from django.test.client import Client +from django.urls import reverse -from ..highlight import PLAIN_CODE from ..models import Snippet +config = apps.get_app_config('dpaste') + class SnippetAPITestCase(TestCase): @@ -224,7 +226,7 @@ class SnippetAPITestCase(TestCase): }) self.assertEqual(response.status_code, 200) self.assertEqual(Snippet.objects.count(), 1) - self.assertEqual(Snippet.objects.all()[0].lexer, PLAIN_CODE) + self.assertEqual(Snippet.objects.all()[0].lexer, config.PLAIN_CODE_SYMBOL) def test_filename_and_lexer_given(self): """ diff --git a/dpaste/tests/test_highlight.py b/dpaste/tests/test_highlight.py index 8dc5698..bf0b514 100644 --- a/dpaste/tests/test_highlight.py +++ b/dpaste/tests/test_highlight.py @@ -4,8 +4,8 @@ from textwrap import dedent from django.test import TestCase -from dpaste.highlight import PLAIN_CODE, PygmentsHighlighter, \ - PlainCodeHighlighter, RestructuredTextHighlighter +from dpaste.highlight import PlainCodeHighlighter, PygmentsHighlighter, \ + RestructuredTextHighlighter class HighlightAPITestCase(TestCase): diff --git a/dpaste/tests/test_snippet.py b/dpaste/tests/test_snippet.py index 094c33d..f16bde5 100644 --- a/dpaste/tests/test_snippet.py +++ b/dpaste/tests/test_snippet.py @@ -2,16 +2,17 @@ from datetime import timedelta +from django.apps import apps from django.core import management -from django.urls import reverse from django.test import TestCase from django.test.client import Client -from django.test.utils import override_settings +from django.urls import reverse -from ..forms import EXPIRE_DEFAULT -from ..highlight import LEXER_DEFAULT, PLAIN_CODE, PLAIN_TEXT, PygmentsHighlighter +from ..highlight import PygmentsHighlighter from ..models import Snippet +config = apps.get_app_config('dpaste') + class SnippetTestCase(TestCase): @@ -22,8 +23,8 @@ class SnippetTestCase(TestCase): def valid_form_data(self, **kwargs): data = { 'content': u"Hello Wörld.\n\tGood Bye", - 'lexer': LEXER_DEFAULT, - 'expires': EXPIRE_DEFAULT, + 'lexer': config.LEXER_DEFAULT, + 'expires': config.EXPIRE_DEFAULT, } if kwargs: data.update(kwargs) @@ -216,13 +217,15 @@ class SnippetTestCase(TestCase): def test_xss_text_lexer(self): # Simple 'text' lexer - data = self.valid_form_data(content=self.XSS_ORIGINAL, lexer=PLAIN_TEXT) + data = self.valid_form_data(content=self.XSS_ORIGINAL, + lexer=config.PLAIN_TEXT_SYMBOL) response = self.client.post(self.new_url, data, follow=True) self.assertContains(response, self.XSS_ESCAPED) def test_xss_code_lexer(self): # Simple 'code' lexer - data = self.valid_form_data(content=self.XSS_ORIGINAL, lexer=PLAIN_CODE) + data = self.valid_form_data(content=self.XSS_ORIGINAL, + lexer=config.PLAIN_CODE_SYMBOL) response = self.client.post(self.new_url, data, follow=True) self.assertContains(response, self.XSS_ESCAPED) @@ -313,7 +316,6 @@ class SnippetTestCase(TestCase): PygmentsHighlighter().highlight('code', 'doesnotexist') - @override_settings(DPASTE_SLUG_LENGTH=1) def test_random_slug_generation(self): """ Set the max length of a slug to 1, so we wont have more than 60 diff --git a/dpaste/views.py b/dpaste/views.py index 918bc93..ecc6282 100644 --- a/dpaste/views.py +++ b/dpaste/views.py @@ -2,7 +2,7 @@ import datetime import difflib import json -from django.conf import settings +from django.apps import apps from django.db.models import Count from django.http import (Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect) @@ -18,9 +18,11 @@ from pygments.lexers import get_lexer_for_filename from pygments.util import ClassNotFound from dpaste import highlight -from dpaste.forms import EXPIRE_CHOICES, SnippetForm, get_expire_values +from dpaste.forms import SnippetForm, get_expire_values from dpaste.highlight import PygmentsHighlighter -from dpaste.models import ONETIME_LIMIT, Snippet +from dpaste.models import Snippet + +config = apps.get_app_config('dpaste') # ----------------------------------------------------------------------------- @@ -78,7 +80,7 @@ class SnippetDetailView(SnippetView, DetailView): # One-Time snippet get deleted if the view count matches our limit if snippet.expire_type == Snippet.EXPIRE_ONETIME \ - and snippet.view_count >= ONETIME_LIMIT: + and snippet.view_count >= config.ONETIME_LIMIT: snippet.delete() raise Http404() @@ -201,30 +203,27 @@ class AboutView(TemplateView): def _format_default(s): """The default response is the snippet URL wrapped in quotes.""" - return '"%s%s"' % (BASE_URL, s.get_absolute_url()) + return '"%s%s"' % (config.BASE_URL, s.get_absolute_url()) def _format_url(s): """The `url` format returns the snippet URL, no quotes, but a linebreak after.""" - return '%s%s\n' % (BASE_URL, s.get_absolute_url()) + return '%s%s\n' % (config.BASE_URL, s.get_absolute_url()) def _format_json(s): """The `json` format export.""" return json.dumps({ - 'url': '%s%s' % (BASE_URL, s.get_absolute_url()), + 'url': '%s%s' % (config.BASE_URL, s.get_absolute_url()), 'content': s.content, 'lexer': s.lexer, }) -BASE_URL = getattr(settings, 'DPASTE_BASE_URL', 'https://dpaste.de') - FORMAT_MAPPING = { 'default': _format_default, 'url': _format_url, 'json': _format_json, } - class APIView(View): """ API View @@ -257,10 +256,10 @@ class APIView(View): lexer_cls = get_lexer_for_filename(filename) lexer = lexer_cls.aliases[0] except (ClassNotFound, IndexError): - lexer = highlight.PLAIN_CODE + lexer = config.PLAIN_CODE_SYMBOL if expires: - expire_options = [str(i) for i in dict(EXPIRE_CHOICES).keys()] + expire_options = [str(i) for i in dict(config.EXPIRE_CHOICES).keys()] if not expires in expire_options: return HttpResponseBadRequest('Invalid expire choice "{}" given. ' 'Valid values are: {}'.format(expires, ', '.join(expire_options)))