Zine

open source content publishing system


source: zine/config.py @ 1368:226040e8cbb2

Revision 1368:226040e8cbb2, 25.3 KB checked in by Jonas Fietz <info@…>, 2 years ago (diff)

Fix config handling for resetting the default

Fix this bug which was due to internal changes

Line 
1# -*- coding: utf-8 -*-
2"""
3    zine.config
4    ~~~~~~~~~~~
5
6    This module implements the configuration.  The configuration is a more or
7    less flat thing saved as ini in the instance folder.  If the configuration
8    changes the application is reloaded automatically.
9
10
11    :copyright: (c) 2010 by the Zine Team, see AUTHORS for more details.
12    :license: BSD, see LICENSE for more details.
13"""
14import os
15from os import path
16from threading import Lock
17
18from zine import environment
19from zine.i18n import lazy_gettext, _, list_timezones, list_languages
20from zine.utils import log
21from zine.utils.forms import TextField, IntegerField, BooleanField, \
22    ChoiceField, CommaSeparated
23from zine.utils.validators import ValidationError, is_valid_url_prefix, \
24    is_valid_url_format, is_netaddr, is_valid_email
25from zine.application import InternalError
26
27
28_dev_mode = environment.MODE == 'development'
29
30l_ = lazy_gettext
31
32#: variables the zine core uses
33DEFAULT_VARS = {
34    # core system settings
35    'database_uri':             TextField(default=u'', help_text=l_(
36        u'The database URI.  For more information about database settings '
37        u'consult the Zine help.')),
38    'force_https':              BooleanField(default=False, help_text=l_(
39        u'If a request to an http URL comes in, Zine will redirect to the same '
40        u'URL on https if this is safely possible.  This requires a working '
41        u'SSL setup, otherwise Zine will become unresponsive.')),
42    'database_debug':           BooleanField(default=False, help_text=l_(
43        u'If enabled, the database will collect all SQL statements and add '
44        u'them to the bottom of the page for easier debugging.')),
45    'blog_title':               TextField(default=l_(u'My Zine Blog')),
46    'blog_tagline':             TextField(default=l_(u'just another Zine blog')),
47    'blog_url':                 TextField(default=u'', help_text=l_(
48        u'The base URL of the blog.  This has to be set to a full canonical URL '
49        u'(including http or https).  If not set, the application will behave '
50        u'confusingly.  Remember to change this value if you move your blog '
51        u'to a new location.')),
52    'blog_email':               TextField(default=u'', help_text=l_(
53        u'The email address given here is used by the notification system to send '
54        u'emails from.  Also plugins that send mails will use this address as '
55        u'the sender address.'), validators=[is_valid_email()]),
56    'timezone':                 ChoiceField(choices=sorted(list_timezones()),
57        default=u'UTC', help_text=l_(
58        u'The timezone of the blog.  All times and dates in the user interface '
59        u'and on the website will be shown in this timezone.  It\'s save to '
60        u'change the timezone after posts are created because the information '
61        u'in the database is stored as UTC.')),
62    'primary_author':           TextField(default=u'', help_text=l_(
63        u'If this blog is written primarily by one author, some themes can ' \
64        u'skip the author\'s name on posts unless written by a guest.')),
65    'maintenance_mode':         BooleanField(default=False, help_text=l_(
66        u'If set to true, the blog enables the maintainance mode.')),
67    'session_cookie_name':      TextField(default=u'zine_session',
68        help_text=l_(u'If there are multiple Zine installations on '
69        u'the same host, the cookie name should be set to something different '
70        u'for each blog.')),
71    'theme':                    TextField(default=u'default'),
72    'secret_key':               TextField(default=u'', help_text=l_(
73        u'The secret key is used for various security related tasks in the '
74        u'system.  For example, the cookie is signed with this value.')),
75    'language':                 ChoiceField(choices=list_languages(False),
76                                            default=u'en'),
77
78    'iid':                      TextField(default=u'', help_text=l_(
79        u'The iid uniquely identifies the Zine instance.  Currently this '
80        u'value is unused, but once set you should not modify it.')),
81
82    # log and development settings
83    'log_file':                 TextField(default=u'zine.log'),
84    'log_level':                ChoiceField(choices=[(k, l_(k)) for k, v
85                                                in sorted(log.LEVELS.items(),
86                                                          key=lambda x: x[1])],
87                                            default=u'warning'),
88    'log_email_only':           BooleanField(default=_dev_mode,
89        help_text=l_(u'During development activating this is helpful to '
90        u'log emails into a mail.log file in your instance folder instead '
91        u'of delivering them to your MTA.')),
92    'passthrough_errors':       BooleanField(default=_dev_mode,
93        help_text=l_(u'If this is set to true, errors in Zine '
94        u'are not caught so that debuggers can catch it instead.  This is '
95        u'useful for plugin and core development.')),
96
97    # url settings
98    'blog_url_prefix':          TextField(default=u'',
99                                          validators=[is_valid_url_prefix()]),
100    'account_url_prefix':       TextField(default=u'/account',
101                                          validators=[is_valid_url_prefix()]),
102    'admin_url_prefix':         TextField(default=u'/admin',
103                                          validators=[is_valid_url_prefix()]),
104    'category_url_prefix':      TextField(default=u'/categories',
105                                          validators=[is_valid_url_prefix()]),
106    'tags_url_prefix':          TextField(default=u'/tags',
107                                          validators=[is_valid_url_prefix()]),
108    'profiles_url_prefix':      TextField(default=u'/authors',
109                                          validators=[is_valid_url_prefix()]),
110    'post_url_format':          TextField(default=u'%year%/%month%/%day%/%slug%',
111                                          validators=[is_valid_url_format()],
112                                          help_text=l_(
113        u'Use %year%, %month%, %day%, %hour%, %minute% and %second%. '
114        u'Changes here will only affect new posts.')),
115    'ascii_slugs':              BooleanField(default=True, help_text=l_(
116        u'Automatically generated slugs are limited to ASCII')),
117    'fixed_url_date_digits':    BooleanField(default=False,
118                                     help_text=l_(u'Dates are zero '
119                                     u'padded like 2009/04/22 instead of '
120                                     u'2009/4/22')),
121
122    # cache settings
123    'enable_eager_caching':     BooleanField(default=False),
124    'cache_timeout':            IntegerField(default=300, min_value=10),
125    'cache_system':             ChoiceField(choices=[
126        (u'null', l_(u'No Cache')),
127        (u'simple', l_(u'Simple Cache')),
128        (u'memcached', l_(u'memcached')),
129        (u'filesystem', l_(u'Filesystem'))
130    ], default=u'null'),
131    'memcached_servers':        CommaSeparated(TextField(
132                                                    validators=[is_netaddr()]),
133                                               default=list),
134    'filesystem_cache_path':    TextField(default=u'cache'),
135
136    # the default markup parser. Don't ever change the default value! The
137    # htmlprocessor module bypasses this test when falling back to
138    # the default parser. If there plans to change the default parser
139    # for future Zine versions that code must be altered first.
140    'default_parser':           ChoiceField(default=u'zeml'),
141    'comment_parser':           ChoiceField(default=u'text'),
142
143    # comments and pingback
144    'comments_enabled':         BooleanField(default=True),
145    'moderate_comments':        ChoiceField(choices=[
146        (0, l_(u'Automatically approve all comments')),
147        (1, l_(u'An administrator must always approve the comment')),
148        (2, l_(u'Automatically approve comments by known comment authors'))
149                                            ], default=1),
150    'comments_open_for':        IntegerField(default=0, help_text=l_(
151        u'The number of days commenting is possible.  If set to zero, comments '
152        u'will be open forever.')),
153    'pings_enabled':            BooleanField(default=True),
154    'plaintext_parser_nolinks': BooleanField(default=False, help_text=l_(
155        u'If set to true, the plaintext parser will not create links '
156        u'automatically.')),
157
158    # post view
159    'posts_per_page':           IntegerField(default=10, help_text=l_(
160        u'The number of posts that are shown on a page.  This value might not be '
161        u'honored by some themes and is probably only used for the index page.')),
162    'use_flat_comments':        BooleanField(default=False),
163    'index_content_types':      CommaSeparated(TextField(),
164                                               default=lambda: [u'entry']),
165
166    # pages
167    'show_page_title':          BooleanField(default=True),
168    'show_page_children':       BooleanField(default=True),
169
170    # email settings
171    'smtp_host':                TextField(default=u'localhost'),
172    'smtp_port':                IntegerField(default=25),
173    'smtp_user':                TextField(default=u''),
174    'smtp_password':            TextField(default=u''),
175    'smtp_use_tls':             BooleanField(default=False),
176
177    # network settings
178    'default_network_timeout':  IntegerField(default=5, help_text=l_(
179        u'This timeout is used by default for all network related operations. '
180        u'The default should be fine for most environments but if you have a '
181        u'very bad network connection during development you should increase '
182        u'it.')),
183
184    # plugin settings
185    'plugin_guard':             BooleanField(default=not _dev_mode),
186    'plugins':                  CommaSeparated(TextField(), default=list),
187    'plugin_searchpath':        CommaSeparated(TextField(), default=list,
188        help_text=l_(u'It\'s possible to put one or more comma '
189        u'separated paths here that are searched for plugins.  If a path '
190        u'is not absolute, it\'s considered relative to the instance '
191        u'folder.')),
192
193    #admin settings
194    'dashboard_reddit':         BooleanField(default=True, help_text=
195        l_(u'Set this to true if you want to see the most recent '
196        u'entries on the Zine reddit on your dashboard.'))
197}
198
199HIDDEN_KEYS = set(('iid', 'secret_key', 'blogger_auth_token',
200                   'smtp_password'))
201
202
203def unquote_value(value):
204    """Unquote a configuration value."""
205    if not value:
206        return ''
207    if value[0] in '"\'' and value[0] == value[-1]:
208        value = value[1:-1].decode('string-escape')
209    return value.decode('utf-8')
210
211
212def quote_value(value):
213    """Quote a configuration value."""
214    if not value:
215        return ''
216    if value.strip() == value and value[0] not in '"\'' and \
217       value[-1] not in '"\'' and len(value.splitlines()) == 1:
218        return value.encode('utf-8')
219    return '"%s"' % value.replace('\\', '\\\\') \
220                         .replace('\n', '\\n') \
221                         .replace('\r', '\\r') \
222                         .replace('\t', '\\t') \
223                         .replace('"', '\\"').encode('utf-8')
224
225
226def from_string(value, field):
227    """Try to convert a value from string or fall back to the default."""
228    try:
229        return field(value)
230    except ValidationError:
231        return field.get_default()
232
233
234# XXX: this function should probably go away, currently it only exists because
235# the config editor is not yet updated to use form fields for config vars
236def get_converter_name(conv):
237    """Get the name of a converter"""
238    return {
239        bool:   'boolean',
240        int:    'integer',
241        float:  'float'
242    }.get(conv, 'string')
243
244
245class ConfigurationTransactionError(InternalError):
246    """An exception that is raised if the transaction was unable to
247    write the changes to the config file.
248    """
249
250    help_text = lazy_gettext(u'''
251    <p>
252      This error can happen if the configuration file is not writeable.
253      Make sure the folder of the configuration file is writeable and
254      that the file itself is writeable as well.
255    ''')
256
257    def __init__(self, message_or_exception):
258        if isinstance(message_or_exception, basestring):
259            message = message_or_exception
260            error = None
261        else:
262            message = _(u'Could not save configuration file: %s') % \
263                      str(message_or_exception).decode('utf-8', 'ignore')
264            error = message_or_exception
265        InternalError.__init__(self, message)
266        self.original_exception = error
267
268
269class Configuration(object):
270    """Helper class that manages configuration values in a INI configuration
271    file.
272
273    >>> app.cfg['blog_title']
274    iu'My Zine Blog'
275    >>> app.cfg.change_single('blog_title', 'Test Blog')
276    >>> app.cfg['blog_title']
277    u'Test Blog'
278    >>> t = app.cfg.edit(); t.revert_to_default('blog_title'); t.commit()
279    """
280
281    def __init__(self, filename):
282        self.filename = filename
283
284        self.config_vars = DEFAULT_VARS.copy()
285        self._values = {}
286        self._converted_values = {}
287        self._comments = {}
288        self._lock = Lock()
289
290        # if the path does not exist yet set the existing flag to none and
291        # set the time timetamp for the filename to something in the past
292        if not path.exists(self.filename):
293            self.exists = False
294            self._load_time = 0
295            return
296
297        # otherwise parse the file and copy all values into the internal
298        # values dict.  Do that also for values not covered by the current
299        # `config_vars` dict to preserve variables of disabled plugins
300        self._load_time = path.getmtime(self.filename)
301        self.exists = True
302        section = 'zine'
303        current_comment = ''
304        f = file(self.filename)
305        try:
306            for line in f:
307                line = line.strip()
308                if not line or line[0] in '#;':
309                    current_comment += line + '\n'
310                    continue
311                elif line[0] == '[' and line[-1] == ']':
312                    section = line[1:-1].strip()
313                    if current_comment.strip():
314                        self._comments['[%s]' % section] = current_comment
315                    current_comment = ''
316                elif '=' not in line:
317                    key = line.strip()
318                    value = ''
319                    if current_comment.strip():
320                        self._comments[key] = current_comment
321                    current_comment = ''
322                else:
323                    key, value = line.split('=', 1)
324                    key = key.strip()
325                    if section != 'zine':
326                        key = section + '/' + key
327                    self._values[key] = unquote_value(value.strip())
328                    if current_comment.strip():
329                        self._comments[key] = current_comment
330                    current_comment = ''
331            # comments at the end of the file
332            if current_comment.strip():
333                self._comments[' end '] = current_comment
334        finally:
335            f.close()
336
337    def __getitem__(self, key):
338        """Return the value for a key."""
339        if key.startswith('zine/'):
340            key = key[5:]
341        try:
342            return self._converted_values[key]
343        except KeyError:
344            field = self.config_vars[key]
345        try:
346            value = from_string(self._values[key], field)
347        except KeyError:
348            value = field.get_default()
349        self._converted_values[key] = value
350        return value
351
352    def change_single(self, key, value):
353        """Create and commit a transaction for a single key-value-pair."""
354        t = self.edit()
355        t[key] = value
356        t.commit()
357
358    def edit(self):
359        """Return a new transaction object."""
360        return ConfigTransaction(self)
361
362    def touch(self):
363        """Touch the file to trigger a reload."""
364        os.utime(self.filename, None)
365
366    @property
367    def changed_external(self):
368        """True if there are changes on the file system."""
369        if not path.isfile(self.filename):
370            return False
371        return path.getmtime(self.filename) > self._load_time
372
373    def __iter__(self):
374        """Iterate over all keys"""
375        return iter(self.config_vars)
376
377    iterkeys = __iter__
378
379    def __contains__(self, key):
380        """Check if a given key exists."""
381        if key.startswith('zine/'):
382            key = key[5:]
383        return key in self.config_vars
384
385    def itervalues(self):
386        """Iterate over all values."""
387        for key in self:
388            yield self[key]
389
390    def iteritems(self):
391        """Iterate over all keys and values."""
392        for key in self:
393            yield key, self[key]
394
395    def values(self):
396        """Return a list of values."""
397        return list(self.itervalues())
398
399    def keys(self):
400        """Return a list of keys."""
401        return list(self)
402
403    def items(self):
404        """Return a list of all key, value tuples."""
405        return list(self.iteritems())
406
407    def export(self):
408        """Like iteritems but with the raw values."""
409        for key, value in self.iteritems():
410            value = self.config_vars[key].to_primitive(value)
411            if isinstance(value, basestring):
412                yield key, value
413
414    def get_detail_list(self):
415        """Return a list of categories with keys and some more
416        details for the advanced configuration editor.
417        """
418        categories = {}
419
420        for key, field in self.config_vars.iteritems():
421            if key in self._values:
422                use_default = False
423                value = field.to_primitive(from_string(self._values[key], field))
424            else:
425                use_default = True
426                value = field.to_primitive(field.get_default())
427            if '/' in key:
428                category, name = key.split('/', 1)
429            else:
430                category = 'zine'
431                name = key
432            categories.setdefault(category, []).append({
433                'name':         name,
434                'field':        field,
435                'value':        value,
436                'use_default':  use_default
437            })
438
439        def sort_func(item):
440            """Sort by key, case insensitive, ignore leading underscores and
441            move the implicit "zine" to the index.
442            """
443            if item[0] == 'zine':
444                return 1
445            return item[0].lower().lstrip('_')
446
447        return [{
448            'items':    sorted(children, key=lambda x: x['name']),
449            'name':     key
450        } for key, children in sorted(categories.items(), key=sort_func)]
451
452    def get_public_list(self, hide_insecure=False):
453        """Return a list of publicly available information about the
454        configuration.  This list is safe to share because dangerous keys
455        are either hidden or cloaked.
456        """
457        from zine.application import emit_event
458        from zine.database import secure_database_uri
459        result = []
460        for key, field in self.config_vars.iteritems():
461            value = self[key]
462            if hide_insecure:
463                if key in HIDDEN_KEYS:
464                    value = '****'
465                elif key == 'database_uri':
466                    value = repr(secure_database_uri(value))
467                else:
468                    #! this event is emitted if the application wants to
469                    #! display a configuration value in a publicly.  The
470                    #! return value of the listener is used as new value.
471                    #! A listener should return None if the return value
472                    #! is not used.
473                    for rv in emit_event('cloak-insecure-configuration-var',
474                                         key, value):
475                        if rv is not None:
476                            value = rv
477                            break
478                    else:
479                        value = repr(value)
480            else:
481                value = repr(value)
482            result.append({
483                'key':          key,
484                'default':      repr(field.get_default()),
485                'value':        value
486            })
487        result.sort(key=lambda x: x['key'].lower())
488        return result
489
490    def __len__(self):
491        return len(self.config_vars)
492
493    def __repr__(self):
494        return '<%s %r>' % (self.__class__.__name__, dict(self.items()))
495
496
497class ConfigTransaction(object):
498    """A configuration transaction class. Instances of this class are returned
499    by Config.edit(). Changes can then be added to the transaction and
500    eventually be committed and saved to the file system using the commit()
501    method.
502    """
503
504    def __init__(self, cfg):
505        self.cfg = cfg
506        self._values = {}
507        self._converted_values = {}
508        self._remove = []
509        self._committed = False
510
511    def __getitem__(self, key):
512        """Get an item from the transaction or the underlaying config."""
513        if key in self._converted_values:
514            return self._converted_values[key]
515        elif key in self._remove:
516            return self.cfg.config_vars[key].get_default()
517        return self.cfg[key]
518
519    def __setitem__(self, key, value):
520        """Set the value for a key by a python value."""
521        self._assert_uncommitted()
522
523        # do not change if we already have the same value.  Otherwise this
524        # would override defaulted values.
525        if value == self[key]:
526            return
527
528        if key.startswith('zine/'):
529            key = key[5:]
530        if key not in self.cfg.config_vars:
531            raise KeyError(key)
532        if isinstance(value, str):
533            value = value.decode('utf-8')
534        field = self.cfg.config_vars[key]
535
536        if value == field.get_default():
537            self.revert_to_default(key)
538            return
539
540        self._values[key] = field.to_primitive(value)
541        self._converted_values[key] = value
542
543    def _assert_uncommitted(self):
544        if self._committed:
545            raise ValueError('This transaction was already committed.')
546
547    def set_from_string(self, key, value, override=False):
548        """Set the value for a key from a string."""
549        self._assert_uncommitted()
550        if key.startswith('zine/'):
551            key = key[5:]
552        field = self.cfg.config_vars[key]
553        new = from_string(value, field)
554        old = self._converted_values.get(key, None) or self.cfg[key]
555        if override or field.to_primitive(old) != field.to_primitive(new):
556            self[key] = new
557
558    def revert_to_default(self, key):
559        """Revert a key to the default value."""
560        self._assert_uncommitted()
561        if key.startswith('zine'):
562            key = key[5:]
563        self._remove.append(key)
564
565    def update(self, *args, **kwargs):
566        """Update multiple items at once."""
567        for key, value in dict(*args, **kwargs).iteritems():
568            self[key] = value
569
570    def commit(self):
571        """Commit the transactions. This first tries to save the changes to the
572        configuration file and only updates the config in memory when that is
573        successful.
574        """
575        self._assert_uncommitted()
576        if not self._values and not self._remove:
577            self._committed = True
578            return
579        self.cfg._lock.acquire()
580        try:
581            all = self.cfg._values.copy()
582            all.update(self._values)
583            for key in self._remove:
584                all.pop(key, None)
585
586            sections = {}
587            for key, value in all.iteritems():
588                if '/' in key:
589                    section, key = key.split('/', 1)
590                else:
591                    section = 'zine'
592                sections.setdefault(section, []).append((key, value))
593            zine_section = sections.pop('zine')
594            sections = [('zine', zine_section)] + sorted(sections.items())
595            for section in sections:
596                section[1].sort()
597
598            try:
599                f = file(self.cfg.filename, 'w')
600                try:
601                    for idx, (section, items) in enumerate(sections):
602                        if '[%s]' % section in self.cfg._comments:
603                            f.write(self.cfg._comments['[%s]' % section])
604                        elif idx:
605                            f.write('\n')
606                        f.write('[%s]\n' % section.encode('utf-8'))
607                        for key, value in items:
608                            if section != 'zine':
609                                ckey = '%s/%s' % (section, key)
610                            else:
611                                ckey = key
612                            if ckey in self.cfg._comments:
613                                f.write(self.cfg._comments[ckey])
614                            f.write('%s = %s\n' % (key, quote_value(value)))
615                    if ' end ' in self.cfg._comments:
616                        f.write(self.cfg._comments[' end '])
617                finally:
618                    f.close()
619            except IOError, e:
620                log.error('Could not write configuration: %s' % e, 'config')
621                raise ConfigurationTransactionError(e)
622            self.cfg._values.update(self._values)
623            self.cfg._converted_values.update(self._converted_values)
624            for key in self._remove:
625                self.cfg._values.pop(key, None)
626                self.cfg._converted_values.pop(key, None)
627        finally:
628            self.cfg._lock.release()
629        self._committed = True
Note: See TracBrowser for help on using the repository browser.