Code

9a8d165c60e549f76b15b56852078705082f89f2
[roundup.git] / roundup / configuration.py
1 # Roundup Issue Tracker configuration support
2 #
3 # $Id: configuration.py,v 1.51 2008-09-01 02:30:06 richard Exp $
4 #
5 __docformat__ = "restructuredtext"
7 import ConfigParser
8 import getopt
9 import imp
10 import logging, logging.config
11 import os
12 import re
13 import sys
14 import time
15 import smtplib
17 import roundup.date
19 # XXX i don't think this module needs string translation, does it?
21 ### Exceptions
23 class ConfigurationError(Exception):
24     pass
26 class NoConfigError(ConfigurationError):
28     """Raised when configuration loading fails
30     Constructor parameters: path to the directory that was used as HOME
32     """
34     def __str__(self):
35         return "No valid configuration files found in directory %s" \
36             % self.args[0]
38 class InvalidOptionError(ConfigurationError, KeyError, AttributeError):
40     """Attempted access to non-existing configuration option
42     Configuration options may be accessed as configuration object
43     attributes or items.  So this exception instances also are
44     instances of KeyError (invalid item access) and AttributeError
45     (invalid attribute access).
47     Constructor parameter: option name
49     """
51     def __str__(self):
52         return "Unsupported configuration option: %s" % self.args[0]
54 class OptionValueError(ConfigurationError, ValueError):
56     """Raised upon attempt to assign an invalid value to config option
58     Constructor parameters: Option instance, offending value
59     and optional info string.
61     """
63     def __str__(self):
64         _args = self.args
65         _rv = "Invalid value for %(option)s: %(value)r" % {
66             "option": _args[0].name, "value": _args[1]}
67         if len(_args) > 2:
68             _rv += "\n".join(("",) + _args[2:])
69         return _rv
71 class OptionUnsetError(ConfigurationError):
73     """Raised when no Option value is available - neither set, nor default
75     Constructor parameters: Option instance.
77     """
79     def __str__(self):
80         return "%s is not set and has no default" % self.args[0].name
82 class UnsetDefaultValue:
84     """Special object meaning that default value for Option is not specified"""
86     def __str__(self):
87         return "NO DEFAULT"
89 NODEFAULT = UnsetDefaultValue()
91 ### Option classes
93 class Option:
95     """Single configuration option.
97     Options have following attributes:
99         config
100             reference to the containing Config object
101         section
102             name of the section in the tracker .ini file
103         setting
104             option name in the tracker .ini file
105         default
106             default option value
107         description
108             option description.  Makes a comment in the tracker .ini file
109         name
110             "canonical name" of the configuration option.
111             For items in the 'main' section this is uppercased
112             'setting' name.  For other sections, the name is
113             composed of the section name and the setting name,
114             joined with underscore.
115         aliases
116             list of "also known as" names.  Used to access the settings
117             by old names used in previous Roundup versions.
118             "Canonical name" is also included.
120     The name and aliases are forced to be uppercase.
121     The setting name is forced to lowercase.
123     """
125     class_description = None
127     def __init__(self, config, section, setting,
128         default=NODEFAULT, description=None, aliases=None
129     ):
130         self.config = config
131         self.section = section
132         self.setting = setting.lower()
133         self.default = default
134         self.description = description
135         self.name = setting.upper()
136         if section != "main":
137             self.name = "_".join((section.upper(), self.name))
138         if aliases:
139             self.aliases = [alias.upper() for alias in list(aliases)]
140         else:
141             self.aliases = []
142         self.aliases.insert(0, self.name)
143         # convert default to internal representation
144         if default is NODEFAULT:
145             _value = default
146         else:
147             _value = self.str2value(default)
148         # value is private.  use get() and set() to access
149         self._value = self._default_value = _value
151     def str2value(self, value):
152         """Return 'value' argument converted to internal representation"""
153         return value
155     def _value2str(self, value):
156         """Return 'value' argument converted to external representation
158         This is actual conversion method called only when value
159         is not NODEFAULT.  Heirs with different conversion rules
160         override this method, not the public .value2str().
162         """
163         return str(value)
165     def value2str(self, value=NODEFAULT, current=0):
166         """Return 'value' argument converted to external representation
168         If 'current' is True, use current option value.
170         """
171         if current:
172             value = self._value
173         if value is NODEFAULT:
174             return str(value)
175         else:
176             return self._value2str(value)
178     def get(self):
179         """Return current option value"""
180         if self._value is NODEFAULT:
181             raise OptionUnsetError(self)
182         return self._value
184     def set(self, value):
185         """Update the value"""
186         self._value = self.str2value(value)
188     def reset(self):
189         """Reset the value to default"""
190         self._value = self._default_value
192     def isdefault(self):
193         """Return True if current value is the default one"""
194         return self._value == self._default_value
196     def isset(self):
197         """Return True if the value is available (either set or default)"""
198         return self._value != NODEFAULT
200     def __str__(self):
201         return self.value2str(self._value)
203     def __repr__(self):
204         if self.isdefault():
205             _format = "<%(class)s %(name)s (default): %(value)s>"
206         else:
207             _format = "<%(class)s %(name)s (default: %(default)s): %(value)s>"
208         return _format % {
209             "class": self.__class__.__name__,
210             "name": self.name,
211             "default": self.value2str(self._default_value),
212             "value": self.value2str(self._value),
213         }
215     def format(self):
216         """Return .ini file fragment for this option"""
217         _desc_lines = []
218         for _description in (self.description, self.class_description):
219             if _description:
220                 _desc_lines.extend(_description.split("\n"))
221         # comment out the setting line if there is no value
222         if self.isset():
223             _is_set = ""
224         else:
225             _is_set = "#"
226         _rv = "# %(description)s\n# Default: %(default)s\n" \
227             "%(is_set)s%(name)s = %(value)s\n" % {
228                 "description": "\n# ".join(_desc_lines),
229                 "default": self.value2str(self._default_value),
230                 "name": self.setting,
231                 "value": self.value2str(self._value),
232                 "is_set": _is_set
233             }
234         return _rv
236     def load_ini(self, config):
237         """Load value from ConfigParser object"""
238         if config.has_option(self.section, self.setting):
239             self.set(config.get(self.section, self.setting))
241     def load_pyconfig(self, config):
242         """Load value from old-style config (python module)"""
243         for _name in self.aliases:
244             if hasattr(config, _name):
245                 self.set(getattr(config, _name))
246                 break
248 class BooleanOption(Option):
250     """Boolean option: yes or no"""
252     class_description = "Allowed values: yes, no"
254     def _value2str(self, value):
255         if value:
256             return "yes"
257         else:
258             return "no"
260     def str2value(self, value):
261         if type(value) == type(""):
262             _val = value.lower()
263             if _val in ("yes", "true", "on", "1"):
264                 _val = 1
265             elif _val in ("no", "false", "off", "0"):
266                 _val = 0
267             else:
268                 raise OptionValueError(self, value, self.class_description)
269         else:
270             _val = value and 1 or 0
271         return _val
273 class WordListOption(Option):
275     """List of strings"""
277     class_description = "Allowed values: comma-separated list of words"
279     def _value2str(self, value):
280         return ','.join(value)
282     def str2value(self, value):
283         return value.split(',')
285 class RunDetectorOption(Option):
287     """When a detector is run: always, never or for new items only"""
289     class_description = "Allowed values: yes, no, new"
291     def str2value(self, value):
292         _val = value.lower()
293         if _val in ("yes", "no", "new"):
294             return _val
295         else:
296             raise OptionValueError(self, value, self.class_description)
298 class MailAddressOption(Option):
300     """Email address
302     Email addresses may be either fully qualified or local.
303     In the latter case MAIL_DOMAIN is automatically added.
305     """
307     def get(self):
308         _val = Option.get(self)
309         if "@" not in _val:
310             _val = "@".join((_val, self.config["MAIL_DOMAIN"]))
311         return _val
313 class FilePathOption(Option):
315     """File or directory path name
317     Paths may be either absolute or relative to the HOME.
319     """
321     class_description = "The path may be either absolute or relative\n" \
322         "to the directory containig this config file."
324     def get(self):
325         _val = Option.get(self)
326         if _val and not os.path.isabs(_val):
327             _val = os.path.join(self.config["HOME"], _val)
328         return _val
330 class FloatNumberOption(Option):
332     """Floating point numbers"""
334     def str2value(self, value):
335         try:
336             return float(value)
337         except ValueError:
338             raise OptionValueError(self, value,
339                 "Floating point number required")
341     def _value2str(self, value):
342         _val = str(value)
343         # strip fraction part from integer numbers
344         if _val.endswith(".0"):
345             _val = _val[:-2]
346         return _val
348 class IntegerNumberOption(Option):
350     """Integer numbers"""
352     def str2value(self, value):
353         try:
354             return int(value)
355         except ValueError:
356             raise OptionValueError(self, value, "Integer number required")
358 class OctalNumberOption(Option):
360     """Octal Integer numbers"""
362     def str2value(self, value):
363         try:
364             return int(value, 8)
365         except ValueError:
366             raise OptionValueError(self, value, "Octal Integer number required")
368     def _value2str(self, value):
369         return oct(value)
371 class NullableOption(Option):
373     """Option that is set to None if its string value is one of NULL strings
375     Default nullable strings list contains empty string only.
376     There is constructor parameter allowing to specify different nullables.
378     Conversion to external representation returns the first of the NULL
379     strings list when the value is None.
381     """
383     NULL_STRINGS = ("",)
385     def __init__(self, config, section, setting,
386         default=NODEFAULT, description=None, aliases=None,
387         null_strings=NULL_STRINGS
388     ):
389         self.null_strings = list(null_strings)
390         Option.__init__(self, config, section, setting, default,
391             description, aliases)
393     def str2value(self, value):
394         if value in self.null_strings:
395             return None
396         else:
397             return value
399     def _value2str(self, value):
400         if value is None:
401             return self.null_strings[0]
402         else:
403             return value
405 class NullableFilePathOption(NullableOption, FilePathOption):
407     # .get() and class_description are from FilePathOption,
408     get = FilePathOption.get
409     class_description = FilePathOption.class_description
410     # everything else taken from NullableOption (inheritance order)
412 class TimezoneOption(Option):
414     class_description = \
415         "If pytz module is installed, value may be any valid\n" \
416         "timezone specification (e.g. EET or Europe/Warsaw).\n" \
417         "If pytz is not installed, value must be integer number\n" \
418         "giving local timezone offset from UTC in hours."
420     def str2value(self, value):
421         try:
422             roundup.date.get_timezone(value)
423         except KeyError:
424             raise OptionValueError(self, value,
425                     "Timezone name or numeric hour offset required")
426         return value
428 class RegExpOption(Option):
430     """Regular Expression option (value is Regular Expression Object)"""
432     class_description = "Value is Python Regular Expression (UTF8-encoded)."
434     RE_TYPE = type(re.compile(""))
436     def __init__(self, config, section, setting,
437         default=NODEFAULT, description=None, aliases=None,
438         flags=0,
439     ):
440         self.flags = flags
441         Option.__init__(self, config, section, setting, default,
442             description, aliases)
444     def _value2str(self, value):
445         assert isinstance(value, self.RE_TYPE)
446         return value.pattern
448     def str2value(self, value):
449         if not isinstance(value, unicode):
450             value = str(value)
451             # if it is 7-bit ascii, use it as string,
452             # otherwise convert to unicode.
453             try:
454                 value.decode("ascii")
455             except UnicodeError:
456                 value = value.decode("utf-8")
457         return re.compile(value, self.flags)
459 ### Main configuration layout.
460 # Config is described as a sequence of sections,
461 # where each section name is followed by a sequence
462 # of Option definitions.  Each Option definition
463 # is a sequence containing class name and constructor
464 # parameters, starting from the setting name:
465 # setting, default, [description, [aliases]]
466 # Note: aliases should only exist in historical options for backwards
467 # compatibility - new options should *not* have aliases!
468 SETTINGS = (
469     ("main", (
470         (FilePathOption, "database", "db", "Database directory path."),
471         (FilePathOption, "templates", "html",
472             "Path to the HTML templates directory."),
473         (NullableFilePathOption, "static_files", "",
474             "Path to directory holding additional static files\n"
475             "available via Web UI.  This directory may contain\n"
476             "sitewide images, CSS stylesheets etc. and is searched\n"
477             "for these files prior to the TEMPLATES directory\n"
478             "specified above.  If this option is not set, all static\n"
479             "files are taken from the TEMPLATES directory"),
480         (MailAddressOption, "admin_email", "roundup-admin",
481             "Email address that roundup will complain to if it runs\n"
482             "into trouble.\n"
483             "If no domain is specified then the config item\n"
484             "mail -> domain is added."),
485         (MailAddressOption, "dispatcher_email", "roundup-admin",
486             "The 'dispatcher' is a role that can get notified\n"
487             "of new items to the database.\n"
488             "It is used by the ERROR_MESSAGES_TO config setting.\n"
489             "If no domain is specified then the config item\n"
490             "mail -> domain is added."),
491         (Option, "email_from_tag", "",
492             "Additional text to include in the \"name\" part\n"
493             "of the From: address used in nosy messages.\n"
494             "If the sending user is \"Foo Bar\", the From: line\n"
495             "is usually: \"Foo Bar\" <issue_tracker@tracker.example>\n"
496             "the EMAIL_FROM_TAG goes inside the \"Foo Bar\" quotes like so:\n"
497             "\"Foo Bar EMAIL_FROM_TAG\" <issue_tracker@tracker.example>"),
498         (Option, "new_web_user_roles", "User",
499             "Roles that a user gets when they register"
500             " with Web User Interface.\n"
501             "This is a comma-separated string of role names"
502             " (e.g. 'Admin,User')."),
503         (Option, "new_email_user_roles", "User",
504             "Roles that a user gets when they register"
505             " with Email Gateway.\n"
506             "This is a comma-separated string of role names"
507             " (e.g. 'Admin,User')."),
508         (Option, "error_messages_to", "user",
509             # XXX This description needs better wording,
510             #   with explicit allowed values list.
511             "Send error message emails to the dispatcher, user, or both?\n"
512             "The dispatcher is configured using the DISPATCHER_EMAIL"
513             " setting."),
514         (Option, "html_version", "html4",
515             "HTML version to generate. The templates are html4 by default.\n"
516             "If you wish to make them xhtml, then you'll need to change this\n"
517             "var to 'xhtml' too so all auto-generated HTML is compliant.\n"
518             "Allowed values: html4, xhtml"),
519         (TimezoneOption, "timezone", "UTC", "Default timezone offset,"
520             " applied when user's timezone is not set.",
521             ["DEFAULT_TIMEZONE"]),
522         (BooleanOption, "instant_registration", "no",
523             "Register new users instantly, or require confirmation via\n"
524             "email?"),
525         (BooleanOption, "email_registration_confirmation", "yes",
526             "Offer registration confirmation by email or only through the web?"),
527         (WordListOption, "indexer_stopwords", "",
528             "Additional stop-words for the full-text indexer specific to\n"
529             "your tracker. See the indexer source for the default list of\n"
530             "stop-words (eg. A,AND,ARE,AS,AT,BE,BUT,BY, ...)"),
531         (OctalNumberOption, "umask", "02",
532             "Defines the file creation mode mask."),
533         (IntegerNumberOption, 'csv_field_size', '131072',
534             "Maximum size of a csv-field during import. Roundups export\n"
535             "format is a csv (comma separated values) variant. The csv\n"
536             "reader has a limit on the size of individual fields\n"
537             "starting with python 2.5. Set this to a higher value if you\n"
538             "get the error 'Error: field larger than field limit' during\n"
539             "import."),
540     )),
541     ("tracker", (
542         (Option, "name", "Roundup issue tracker",
543             "A descriptive name for your roundup instance."),
544         (Option, "web", NODEFAULT,
545             "The web address that the tracker is viewable at.\n"
546             "This will be included in information"
547             " sent to users of the tracker.\n"
548             "The URL MUST include the cgi-bin part or anything else\n"
549             "that is required to get to the home page of the tracker.\n"
550             "You MUST include a trailing '/' in the URL."),
551         (MailAddressOption, "email", "issue_tracker",
552             "Email address that mail to roundup should go to.\n"
553             "If no domain is specified then mail_domain is added."),
554         (NullableOption, "language", "",
555             "Default locale name for this tracker.\n"
556             "If this option is not set, the language is determined\n"
557             "by OS environment variable LANGUAGE, LC_ALL, LC_MESSAGES,\n"
558             "or LANG, in that order of preference."),
559     )),
560     ("web", (
561         (BooleanOption, "allow_html_file", "no",
562             "Setting this option enables Roundup to serve uploaded HTML\n"
563             "file content *as HTML*. This is a potential security risk\n"
564             "and is therefore disabled by default. Set to 'yes' if you\n"
565             "trust *all* users uploading content to your tracker."),
566         (BooleanOption, 'http_auth', "yes",
567             "Whether to use HTTP Basic Authentication, if present.\n"
568             "Roundup will use either the REMOTE_USER or HTTP_AUTHORIZATION\n"
569             "variables supplied by your web server (in that order).\n"
570             "Set this option to 'no' if you do not wish to use HTTP Basic\n"
571             "Authentication in your web interface."),
572         (BooleanOption, 'use_browser_language', "yes",
573             "Whether to use HTTP Accept-Language, if present.\n"
574             "Browsers send a language-region preference list.\n"
575             "It's usually set in the client's browser or in their\n"
576             "Operating System.\n"
577             "Set this option to 'no' if you want to ignore it."),
578         (BooleanOption, "debug", "no",
579             "Setting this option makes Roundup display error tracebacks\n"
580             "in the user's browser rather than emailing them to the\n"
581             "tracker admin."),
582         (BooleanOption, "migrate_passwords", "yes",
583             "Setting this option makes Roundup migrate passwords with\n"
584             "an insecure password-scheme to a more secure scheme\n"
585             "when the user logs in via the web-interface."),
586     )),
587     ("rdbms", (
588         (Option, 'name', 'roundup',
589             "Name of the database to use.",
590             ['MYSQL_DBNAME']),
591         (NullableOption, 'host', 'localhost',
592             "Database server host.",
593             ['MYSQL_DBHOST']),
594         (NullableOption, 'port', '',
595             "TCP port number of the database server.\n"
596             "Postgresql usually resides on port 5432 (if any),\n"
597             "for MySQL default port number is 3306.\n"
598             "Leave this option empty to use backend default"),
599         (NullableOption, 'user', 'roundup',
600             "Database user name that Roundup should use.",
601             ['MYSQL_DBUSER']),
602         (NullableOption, 'password', 'roundup',
603             "Database user password.",
604             ['MYSQL_DBPASSWORD']),
605         (NullableOption, 'read_default_file', '~/.my.cnf',
606             "Name of the MySQL defaults file.\n"
607             "Only used in MySQL connections."),
608         (NullableOption, 'read_default_group', 'roundup',
609             "Name of the group to use in the MySQL defaults file (.my.cnf).\n"
610             "Only used in MySQL connections."),
611         (IntegerNumberOption, 'sqlite_timeout', '30',
612             "Number of seconds to wait when the SQLite database is locked\n"
613             "Default: use a 30 second timeout (extraordinarily generous)\n"
614             "Only used in SQLite connections."),
615         (IntegerNumberOption, 'cache_size', '100',
616             "Size of the node cache (in elements)"),
617         (BooleanOption, "allow_create", "yes",
618             "Setting this option to 'no' protects the database against table creations."),
619         (BooleanOption, "allow_alter", "yes",
620             "Setting this option to 'no' protects the database against table alterations."),
621         (BooleanOption, "allow_drop", "yes",
622             "Setting this option to 'no' protects the database against table drops."),
623         (NullableOption, 'template', '',
624             "Name of the PostgreSQL template for database creation.\n"
625             "For database creation the template used has to match\n"
626             "the character encoding used (UTF8), there are different\n"
627             "PostgreSQL installations using different templates with\n"
628             "different encodings. If you get an error:\n"
629             "  new encoding (UTF8) is incompatible with the encoding of\n"
630             "  the template database (SQL_ASCII)\n"
631             "  HINT:  Use the same encoding as in the template database,\n"
632             "  or use template0 as template.\n"
633             "then set this option to the template name given in the\n"
634             "error message."),
635     ), "Settings in this section are used"
636         " by RDBMS backends only"
637     ),
638     ("logging", (
639         (FilePathOption, "config", "",
640             "Path to configuration file for standard Python logging module.\n"
641             "If this option is set, logging configuration is loaded\n"
642             "from specified file; options 'filename' and 'level'\n"
643             "in this section are ignored."),
644         (FilePathOption, "filename", "",
645             "Log file name for minimal logging facility built into Roundup.\n"
646             "If no file name specified, log messages are written on stderr.\n"
647             "If above 'config' option is set, this option has no effect."),
648         (Option, "level", "ERROR",
649             "Minimal severity level of messages written to log file.\n"
650             "If above 'config' option is set, this option has no effect.\n"
651             "Allowed values: DEBUG, INFO, WARNING, ERROR"),
652     )),
653     ("mail", (
654         (Option, "domain", NODEFAULT,
655             "The email domain that admin_email, issue_tracker and\n"
656             "dispatcher_email belong to.\n"
657             "This domain is added to those config items if they don't\n"
658             "explicitly include a domain.\n"
659             "Do not include the '@' symbol."),
660         (Option, "host", NODEFAULT,
661             "SMTP mail host that roundup will use to send mail",
662             ["MAILHOST"],),
663         (Option, "username", "", "SMTP login name.\n"
664             "Set this if your mail host requires authenticated access.\n"
665             "If username is not empty, password (below) MUST be set!"),
666         (Option, "password", NODEFAULT, "SMTP login password.\n"
667             "Set this if your mail host requires authenticated access."),
668         (IntegerNumberOption, "port", smtplib.SMTP_PORT,
669             "Default port to send SMTP on.\n"
670             "Set this if your mail server runs on a different port."),
671         (NullableOption, "local_hostname", '',
672             "The local hostname to use during SMTP transmission.\n"
673             "Set this if your mail server requires something specific."),
674         (BooleanOption, "tls", "no",
675             "If your SMTP mail host provides or requires TLS\n"
676             "(Transport Layer Security) then set this option to 'yes'."),
677         (NullableFilePathOption, "tls_keyfile", "",
678             "If TLS is used, you may set this option to the name\n"
679             "of a PEM formatted file that contains your private key."),
680         (NullableFilePathOption, "tls_certfile", "",
681             "If TLS is used, you may set this option to the name\n"
682             "of a PEM formatted certificate chain file."),
683         (Option, "charset", "utf-8",
684             "Character set to encode email headers with.\n"
685             "We use utf-8 by default, as it's the most flexible.\n"
686             "Some mail readers (eg. Eudora) can't cope with that,\n"
687             "so you might need to specify a more limited character set\n"
688             "(eg. iso-8859-1).",
689             ["EMAIL_CHARSET"]),
690         (FilePathOption, "debug", "",
691             "Setting this option makes Roundup to write all outgoing email\n"
692             "messages to this file *instead* of sending them.\n"
693             "This option has the same effect as environment variable"
694             " SENDMAILDEBUG.\nEnvironment variable takes precedence."),
695         (BooleanOption, "add_authorinfo", "yes",
696             "Add a line with author information at top of all messages\n"
697             "sent by roundup"),
698         (BooleanOption, "add_authoremail", "yes",
699             "Add the mail address of the author to the author information at\n"
700             "the top of all messages.\n"
701             "If this is false but add_authorinfo is true, only the name\n"
702             "of the actor is added which protects the mail address of the\n"
703             "actor from being exposed at mail archives, etc."),
704     ), "Outgoing email options.\nUsed for nozy messages and approval requests"),
705     ("mailgw", (
706         (BooleanOption, "keep_quoted_text", "yes",
707             "Keep email citations when accepting messages.\n"
708             "Setting this to \"no\" strips out \"quoted\" text"
709             " from the message.\n"
710             "Signatures are also stripped.",
711             ["EMAIL_KEEP_QUOTED_TEXT"]),
712         (BooleanOption, "leave_body_unchanged", "no",
713             "Preserve the email body as is - that is,\n"
714             "keep the citations _and_ signatures.",
715             ["EMAIL_LEAVE_BODY_UNCHANGED"]),
716         (Option, "default_class", "issue",
717             "Default class to use in the mailgw\n"
718             "if one isn't supplied in email subjects.\n"
719             "To disable, leave the value blank.",
720             ["MAIL_DEFAULT_CLASS"]),
721         (NullableOption, "language", "",
722             "Default locale name for the tracker mail gateway.\n"
723             "If this option is not set, mail gateway will use\n"
724             "the language of the tracker instance."),
725         (Option, "subject_prefix_parsing", "strict",
726             "Controls the parsing of the [prefix] on subject\n"
727             "lines in incoming emails. \"strict\" will return an\n"
728             "error to the sender if the [prefix] is not recognised.\n"
729             "\"loose\" will attempt to parse the [prefix] but just\n"
730             "pass it through as part of the issue title if not\n"
731             "recognised. \"none\" will always pass any [prefix]\n"
732             "through as part of the issue title."),
733         (Option, "subject_suffix_parsing", "strict",
734             "Controls the parsing of the [suffix] on subject\n"
735             "lines in incoming emails. \"strict\" will return an\n"
736             "error to the sender if the [suffix] is not recognised.\n"
737             "\"loose\" will attempt to parse the [suffix] but just\n"
738             "pass it through as part of the issue title if not\n"
739             "recognised. \"none\" will always pass any [suffix]\n"
740             "through as part of the issue title."),
741         (Option, "subject_suffix_delimiters", "[]",
742             "Defines the brackets used for delimiting the prefix and \n"
743             'suffix in a subject line. The presence of "suffix" in\n'
744             "the config option name is a historical artifact and may\n"
745             "be ignored."),
746         (Option, "subject_content_match", "always",
747             "Controls matching of the incoming email subject line\n"
748             "against issue titles in the case where there is no\n"
749             "designator [prefix]. \"never\" turns off matching.\n"
750             "\"creation + interval\" or \"activity + interval\"\n"
751             "will match an issue for the interval after the issue's\n"
752             "creation or last activity. The interval is a standard\n"
753             "Roundup interval."),
754         (BooleanOption, "subject_updates_title", "yes",
755             "Update issue title if incoming subject of email is different.\n"
756             "Setting this to \"no\" will ignore the title part of"
757             " the subject\nof incoming email messages.\n"),
758         (RegExpOption, "refwd_re", "(\s*\W?\s*(fw|fwd|re|aw|sv|ang)\W)+",
759             "Regular expression matching a single reply or forward\n"
760             "prefix prepended by the mailer. This is explicitly\n"
761             "stripped from the subject during parsing."),
762         (RegExpOption, "origmsg_re",
763             "^[>|\s]*-----\s?Original Message\s?-----$",
764             "Regular expression matching start of an original message\n"
765             "if quoted the in body."),
766         (RegExpOption, "sign_re", "^[>|\s]*-- ?$",
767             "Regular expression matching the start of a signature\n"
768             "in the message body."),
769         (RegExpOption, "eol_re", r"[\r\n]+",
770             "Regular expression matching end of line."),
771         (RegExpOption, "blankline_re", r"[\r\n]+\s*[\r\n]+",
772             "Regular expression matching a blank line."),
773         (BooleanOption, "unpack_rfc822", "no",
774             "Unpack attached messages (encoded as message/rfc822 in MIME)\n"
775             "as multiple parts attached as files to the issue, if not\n"
776             "set we handle message/rfc822 attachments as a single file."),
777         (BooleanOption, "ignore_alternatives", "no",
778             "When parsing incoming mails, roundup uses the first\n"
779             "text/plain part it finds. If this part is inside a\n"
780             "multipart/alternative, and this option is set, all other\n"
781             "parts of the multipart/alternative are ignored. The default\n"
782             "is to keep all parts and attach them to the issue."),
783     ), "Roundup Mail Gateway options"),
784     ("pgp", (
785         (BooleanOption, "enable", "no",
786             "Enable PGP processing. Requires pyme."),
787         (NullableOption, "roles", "",
788             "If specified, a comma-separated list of roles to perform\n"
789             "PGP processing on. If not specified, it happens for all\n"
790             "users."),
791         (NullableOption, "homedir", "",
792             "Location of PGP directory. Defaults to $HOME/.gnupg if\n"
793             "not specified."),
794     ), "OpenPGP mail processing options"),
795     ("nosy", (
796         (RunDetectorOption, "messages_to_author", "no",
797             "Send nosy messages to the author of the message.",
798             ["MESSAGES_TO_AUTHOR"]),
799         (Option, "signature_position", "bottom",
800             "Where to place the email signature.\n"
801             "Allowed values: top, bottom, none",
802             ["EMAIL_SIGNATURE_POSITION"]),
803         (RunDetectorOption, "add_author", "new",
804             "Does the author of a message get placed on the nosy list\n"
805             "automatically?  If 'new' is used, then the author will\n"
806             "only be added when a message creates a new issue.\n"
807             "If 'yes', then the author will be added on followups too.\n"
808             "If 'no', they're never added to the nosy.\n",
809             ["ADD_AUTHOR_TO_NOSY"]),
810         (RunDetectorOption, "add_recipients", "new",
811             "Do the recipients (To:, Cc:) of a message get placed on the\n"
812             "nosy list?  If 'new' is used, then the recipients will\n"
813             "only be added when a message creates a new issue.\n"
814             "If 'yes', then the recipients will be added on followups too.\n"
815             "If 'no', they're never added to the nosy.\n",
816             ["ADD_RECIPIENTS_TO_NOSY"]),
817         (Option, "email_sending", "single",
818             "Controls the email sending from the nosy reactor. If\n"
819             "\"multiple\" then a separate email is sent to each\n"
820             "recipient. If \"single\" then a single email is sent with\n"
821             "each recipient as a CC address."),
822         (IntegerNumberOption, "max_attachment_size", sys.maxint,
823             "Attachments larger than the given number of bytes\n"
824             "won't be attached to nosy mails. They will be replaced by\n"
825             "a link to the tracker's download page for the file.")
826     ), "Nosy messages sending"),
829 ### Configuration classes
831 class Config:
833     """Base class for configuration objects.
835     Configuration options may be accessed as attributes or items
836     of instances of this class.  All option names are uppercased.
838     """
840     # Config file name
841     INI_FILE = "config.ini"
843     # Object attributes that should not be taken as common configuration
844     # options in __setattr__ (most of them are initialized in constructor):
845     # builtin pseudo-option - package home directory
846     HOME = "."
847     # names of .ini file sections, in order
848     sections = None
849     # section comments
850     section_descriptions = None
851     # lists of option names for each section, in order
852     section_options = None
853     # mapping from option names and aliases to Option instances
854     options = None
855     # actual name of the config file.  set on load.
856     filepath = os.path.join(HOME, INI_FILE)
858     def __init__(self, config_path=None, layout=None, settings={}):
859         """Initialize confing instance
861         Parameters:
862             config_path:
863                 optional directory or file name of the config file.
864                 If passed, load the config after processing layout (if any).
865                 If config_path is a directory name, use default base name
866                 of the config file.
867             layout:
868                 optional configuration layout, a sequence of
869                 section definitions suitable for .add_section()
870             settings:
871                 optional setting overrides (dictionary).
872                 The overrides are applied after loading config file.
874         """
875         # initialize option containers:
876         self.sections = []
877         self.section_descriptions = {}
878         self.section_options = {}
879         self.options = {}
880         # add options from the layout structure
881         if layout:
882             for section in layout:
883                 self.add_section(*section)
884         if config_path is not None:
885             self.load(config_path)
886         for (name, value) in settings.items():
887             self[name.upper()] = value
889     def add_section(self, section, options, description=None):
890         """Define new config section
892         Parameters:
893             section - name of the config.ini section
894             options - a sequence of Option definitions.
895                 Each Option definition is a sequence
896                 containing class object and constructor
897                 parameters, starting from the setting name:
898                 setting, default, [description, [aliases]]
899             description - optional section comment
901         Note: aliases should only exist in historical options
902         for backwards compatibility - new options should
903         *not* have aliases!
905         """
906         if description or not self.section_descriptions.has_key(section):
907             self.section_descriptions[section] = description
908         for option_def in options:
909             klass = option_def[0]
910             args = option_def[1:]
911             option = klass(self, section, *args)
912             self.add_option(option)
914     def add_option(self, option):
915         """Adopt a new Option object"""
916         _section = option.section
917         _name = option.setting
918         if _section not in self.sections:
919             self.sections.append(_section)
920         _options = self._get_section_options(_section)
921         if _name not in _options:
922             _options.append(_name)
923         # (section, name) key is used for writing .ini file
924         self.options[(_section, _name)] = option
925         # make the option known under all of its A.K.A.s
926         for _name in option.aliases:
927             self.options[_name] = option
929     def update_option(self, name, klass,
930         default=NODEFAULT, description=None
931     ):
932         """Override behaviour of early created option.
934         Parameters:
935             name:
936                 option name
937             klass:
938                 one of the Option classes
939             default:
940                 optional default value for the option
941             description:
942                 optional new description for the option
944         Conversion from current option value to new class value
945         is done via string representation.
947         This method may be used to attach some brains
948         to options autocreated by UserConfig.
950         """
951         # fetch current option
952         option = self._get_option(name)
953         # compute constructor parameters
954         if default is NODEFAULT:
955             default = option.default
956         if description is None:
957             description = option.description
958         value = option.value2str(current=1)
959         # resurrect the option
960         option = klass(self, option.section, option.setting,
961             default=default, description=description)
962         # apply the value
963         option.set(value)
964         # incorporate new option
965         del self[name]
966         self.add_option(option)
968     def reset(self):
969         """Set all options to their default values"""
970         for _option in self.items():
971             _option.reset()
973     # Meant for commandline tools.
974     # Allows automatic creation of configuration files like this:
975     #  roundup-server -p 8017 -u roundup --save-config
976     def getopt(self, args, short_options="", long_options=(),
977         config_load_options=("C", "config"), **options
978     ):
979         """Apply options specified in command line arguments.
981         Parameters:
982             args:
983                 command line to parse (sys.argv[1:])
984             short_options:
985                 optional string of letters for command line options
986                 that are not config options
987             long_options:
988                 optional list of names for long options
989                 that are not config options
990             config_load_options:
991                 two-element sequence (letter, long_option) defining
992                 the options for config file.  If unset, don't load
993                 config file; otherwise config file is read prior
994                 to applying other options.  Short option letter
995                 must not have a colon and long_option name must
996                 not have an equal sign or '--' prefix.
997             options:
998                 mapping from option names to command line option specs.
999                 e.g. server_port="p:", server_user="u:"
1000                 Names are forced to lower case for commandline parsing
1001                 (long options) and to upper case to find config options.
1002                 Command line options accepting no value are assumed
1003                 to be binary and receive value 'yes'.
1005         Return value: same as for python standard getopt(), except that
1006         processed options are removed from returned option list.
1008         """
1009         # take a copy of long_options
1010         long_options = list(long_options)
1011         # build option lists
1012         cfg_names = {}
1013         booleans = []
1014         for (name, letter) in options.items():
1015             cfg_name = name.upper()
1016             short_opt = "-" + letter[0]
1017             name = name.lower().replace("_", "-")
1018             cfg_names.update({short_opt: cfg_name, "--" + name: cfg_name})
1020             short_options += letter
1021             if letter[-1] == ":":
1022                 long_options.append(name + "=")
1023             else:
1024                 booleans.append(short_opt)
1025                 long_options.append(name)
1027         if config_load_options:
1028             short_options += config_load_options[0] + ":"
1029             long_options.append(config_load_options[1] + "=")
1030             # compute names that will be searched in getopt return value
1031             config_load_options = (
1032                 "-" + config_load_options[0],
1033                 "--" + config_load_options[1],
1034             )
1035         # parse command line arguments
1036         optlist, args = getopt.getopt(args, short_options, long_options)
1037         # load config file if requested
1038         if config_load_options:
1039             for option in optlist:
1040                 if option[0] in config_load_options:
1041                     self.load_ini(option[1])
1042                     optlist.remove(option)
1043                     break
1044         # apply options
1045         extra_options = []
1046         for (opt, arg) in optlist:
1047             if (opt in booleans): # and not arg
1048                 arg = "yes"
1049             try:
1050                 name = cfg_names[opt]
1051             except KeyError:
1052                 extra_options.append((opt, arg))
1053             else:
1054                 self[name] = arg
1055         return (extra_options, args)
1057     # option and section locators (used in option access methods)
1059     def _get_option(self, name):
1060         try:
1061             return self.options[name]
1062         except KeyError:
1063             raise InvalidOptionError(name)
1065     def _get_section_options(self, name):
1066         return self.section_options.setdefault(name, [])
1068     def _get_unset_options(self):
1069         """Return options that need manual adjustments
1071         Return value is a dictionary where keys are section
1072         names and values are lists of option names as they
1073         appear in the config file.
1075         """
1076         need_set = {}
1077         for option in self.items():
1078             if not option.isset():
1079                 need_set.setdefault(option.section, []).append(option.setting)
1080         return need_set
1082     def _adjust_options(self, config):
1083         """Load ad-hoc option definitions from ConfigParser instance."""
1084         pass
1086     def _get_name(self):
1087         """Return the service name for config file heading"""
1088         return ""
1090     # file operations
1092     def load_ini(self, config_path, defaults=None):
1093         """Set options from config.ini file in given home_dir
1095         Parameters:
1096             config_path:
1097                 directory or file name of the config file.
1098                 If config_path is a directory name, use default
1099                 base name of the config file
1100             defaults:
1101                 optional dictionary of defaults for ConfigParser
1103         Note: if home_dir does not contain config.ini file,
1104         no error is raised.  Config will be reset to defaults.
1106         """
1107         if os.path.isdir(config_path):
1108             home_dir = config_path
1109             config_path = os.path.join(config_path, self.INI_FILE)
1110         else:
1111             home_dir = os.path.dirname(config_path)
1112         # parse the file
1113         config_defaults = {"HOME": home_dir}
1114         if defaults:
1115             config_defaults.update(defaults)
1116         config = ConfigParser.ConfigParser(config_defaults)
1117         config.read([config_path])
1118         # .ini file loaded ok.
1119         self.HOME = home_dir
1120         self.filepath = config_path
1121         self._adjust_options(config)
1122         # set the options, starting from HOME
1123         self.reset()
1124         for option in self.items():
1125             option.load_ini(config)
1127     def load(self, home_dir):
1128         """Load configuration settings from home_dir"""
1129         self.load_ini(home_dir)
1131     def save(self, ini_file=None):
1132         """Write current configuration to .ini file
1134         'ini_file' argument, if passed, must be valid full path
1135         to the file to write.  If omitted, default file in current
1136         HOME is created.
1138         If the file to write already exists, it is saved with '.bak'
1139         extension.
1141         """
1142         if ini_file is None:
1143             ini_file = self.filepath
1144         _tmp_file = os.path.splitext(ini_file)[0]
1145         _bak_file = _tmp_file + ".bak"
1146         _tmp_file = _tmp_file + ".tmp"
1147         _fp = file(_tmp_file, "wt")
1148         _fp.write("# %s configuration file\n" % self._get_name())
1149         _fp.write("# Autogenerated at %s\n" % time.asctime())
1150         need_set = self._get_unset_options()
1151         if need_set:
1152             _fp.write("\n# WARNING! Following options need adjustments:\n")
1153             for section, options in need_set.items():
1154                 _fp.write("#  [%s]: %s\n" % (section, ", ".join(options)))
1155         for section in self.sections:
1156             comment = self.section_descriptions.get(section, None)
1157             if comment:
1158                 _fp.write("\n# ".join([""] + comment.split("\n")) +"\n")
1159             else:
1160                 # no section comment - just leave a blank line between sections
1161                 _fp.write("\n")
1162             _fp.write("[%s]\n" % section)
1163             for option in self._get_section_options(section):
1164                 _fp.write("\n" + self.options[(section, option)].format())
1165         _fp.close()
1166         if os.access(ini_file, os.F_OK):
1167             if os.access(_bak_file, os.F_OK):
1168                 os.remove(_bak_file)
1169             os.rename(ini_file, _bak_file)
1170         os.rename(_tmp_file, ini_file)
1172     # container emulation
1174     def __len__(self):
1175         return len(self.items())
1177     def __getitem__(self, name):
1178         if name == "HOME":
1179             return self.HOME
1180         else:
1181             return self._get_option(name).get()
1183     def __setitem__(self, name, value):
1184         if name == "HOME":
1185             self.HOME = value
1186         else:
1187             self._get_option(name).set(value)
1189     def __delitem__(self, name):
1190         _option = self._get_option(name)
1191         _section = _option.section
1192         _name = _option.setting
1193         self._get_section_options(_section).remove(_name)
1194         del self.options[(_section, _name)]
1195         for _alias in _option.aliases:
1196             del self.options[_alias]
1198     def items(self):
1199         """Return the list of Option objects, in .ini file order
1201         Note that HOME is not included in this list
1202         because it is builtin pseudo-option, not a real Option
1203         object loaded from or saved to .ini file.
1205         """
1206         return [self.options[(_section, _name)]
1207             for _section in self.sections
1208             for _name in self._get_section_options(_section)
1209         ]
1211     def keys(self):
1212         """Return the list of "canonical" names of the options
1214         Unlike .items(), this list also includes HOME
1216         """
1217         return ["HOME"] + [_option.name for _option in self.items()]
1219     # .values() is not implemented because i am not sure what should be
1220     # the values returned from this method: Option instances or config values?
1222     # attribute emulation
1224     def __setattr__(self, name, value):
1225         if self.__dict__.has_key(name) or hasattr(self.__class__, name):
1226             self.__dict__[name] = value
1227         else:
1228             self._get_option(name).set(value)
1230     # Note: __getattr__ is not symmetric to __setattr__:
1231     #   self.__dict__ lookup is done before calling this method
1232     def __getattr__(self, name):
1233         return self[name]
1235 class UserConfig(Config):
1237     """Configuration for user extensions.
1239     Instances of this class have no predefined configuration layout.
1240     Options are created on the fly for each setting present in the
1241     config file.
1243     """
1245     def _adjust_options(self, config):
1246         # config defaults appear in all sections.
1247         # we'll need to filter them out.
1248         defaults = config.defaults().keys()
1249         # see what options are already defined and add missing ones
1250         preset = [(option.section, option.setting) for option in self.items()]
1251         for section in config.sections():
1252             for name in config.options(section):
1253                 if ((section, name) not in preset) \
1254                 and (name not in defaults):
1255                     self.add_option(Option(self, section, name))
1257 class CoreConfig(Config):
1259     """Roundup instance configuration.
1261     Core config has a predefined layout (see the SETTINGS structure),
1262     supports loading of old-style pythonic configurations and holds
1263     three additional attributes:
1264         logging:
1265             instance logging engine, from standard python logging module
1266             or minimalistic logger implemented in Roundup
1267         detectors:
1268             user-defined configuration for detectors
1269         ext:
1270             user-defined configuration for extensions
1272     """
1274     # module name for old style configuration
1275     PYCONFIG = "config"
1276     # user configs
1277     ext = None
1278     detectors = None
1280     def __init__(self, home_dir=None, settings={}):
1281         Config.__init__(self, home_dir, layout=SETTINGS, settings=settings)
1282         # load the config if home_dir given
1283         if home_dir is None:
1284             self.init_logging()
1286     def copy(self):
1287         new = CoreConfig()
1288         new.sections = list(self.sections)
1289         new.section_descriptions = dict(self.section_descriptions)
1290         new.section_options = dict(self.section_options)
1291         new.options = dict(self.options)
1292         return new
1294     def _get_unset_options(self):
1295         need_set = Config._get_unset_options(self)
1296         # remove MAIL_PASSWORD if MAIL_USER is empty
1297         if "password" in need_set.get("mail", []):
1298             if not self["MAIL_USERNAME"]:
1299                 settings = need_set["mail"]
1300                 settings.remove("password")
1301                 if not settings:
1302                     del need_set["mail"]
1303         return need_set
1305     def _get_name(self):
1306         return self["TRACKER_NAME"]
1308     def reset(self):
1309         Config.reset(self)
1310         if self.ext:
1311             self.ext.reset()
1312         if self.detectors:
1313             self.detectors.reset()
1314         self.init_logging()
1316     def init_logging(self):
1317         _file = self["LOGGING_CONFIG"]
1318         if _file and os.path.isfile(_file):
1319             logging.config.fileConfig(_file)
1320             return
1322         _file = self["LOGGING_FILENAME"]
1323         # set file & level on the roundup logger
1324         logger = logging.getLogger('roundup')
1325         if _file:
1326             hdlr = logging.FileHandler(_file)
1327         else:
1328             hdlr = logging.StreamHandler(sys.stdout)
1329         formatter = logging.Formatter(
1330             '%(asctime)s %(levelname)s %(message)s')
1331         hdlr.setFormatter(formatter)
1332         # no logging API to remove all existing handlers!?!
1333         for h in logger.handlers:
1334             h.close()
1335             logger.removeHandler(hdlr)
1336         logger.handlers = [hdlr]
1337         logger.setLevel(logging._levelNames[self["LOGGING_LEVEL"] or "ERROR"])
1339     def load(self, home_dir):
1340         """Load configuration from path designated by home_dir argument"""
1341         if os.path.isfile(os.path.join(home_dir, self.INI_FILE)):
1342             self.load_ini(home_dir)
1343         else:
1344             self.load_pyconfig(home_dir)
1345         self.init_logging()
1346         self.ext = UserConfig(os.path.join(home_dir, "extensions"))
1347         self.detectors = UserConfig(os.path.join(home_dir, "detectors"))
1349     def load_ini(self, home_dir, defaults=None):
1350         """Set options from config.ini file in given home_dir directory"""
1351         config_defaults = {"TRACKER_HOME": home_dir}
1352         if defaults:
1353             config_defaults.update(defaults)
1354         Config.load_ini(self, home_dir, config_defaults)
1356     def load_pyconfig(self, home_dir):
1357         """Set options from config.py file in given home_dir directory"""
1358         # try to locate and import the module
1359         _mod_fp = None
1360         try:
1361             try:
1362                 _module = imp.find_module(self.PYCONFIG, [home_dir])
1363                 _mod_fp = _module[0]
1364                 _config = imp.load_module(self.PYCONFIG, *_module)
1365             except ImportError:
1366                 raise NoConfigError(home_dir)
1367         finally:
1368             if _mod_fp is not None:
1369                 _mod_fp.close()
1370         # module loaded ok.  set the options, starting from HOME
1371         self.reset()
1372         self.HOME = home_dir
1373         for _option in self.items():
1374             _option.load_pyconfig(_config)
1375         # backward compatibility:
1376         # SMTP login parameters were specified as a tuple in old style configs
1377         # convert them to new plain string options
1378         _mailuser = getattr(_config, "MAILUSER", ())
1379         if len(_mailuser) > 0:
1380             self.MAIL_USERNAME = _mailuser[0]
1381         if len(_mailuser) > 1:
1382             self.MAIL_PASSWORD = _mailuser[1]
1384     # in this config, HOME is also known as TRACKER_HOME
1385     def __getitem__(self, name):
1386         if name == "TRACKER_HOME":
1387             return self.HOME
1388         else:
1389             return Config.__getitem__(self, name)
1391     def __setitem__(self, name, value):
1392         if name == "TRACKER_HOME":
1393             self.HOME = value
1394         else:
1395             self._get_option(name).set(value)
1397     def __setattr__(self, name, value):
1398         if name == "TRACKER_HOME":
1399             self.__dict__["HOME"] = value
1400         else:
1401             Config.__setattr__(self, name, value)
1403 # vim: set et sts=4 sw=4 :