Commit | Line | Data |
---|---|---|
c638d827 CR |
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" | |
6 | ||
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 | |
16 | ||
17 | import roundup.date | |
18 | ||
19 | # XXX i don't think this module needs string translation, does it? | |
20 | ||
21 | ### Exceptions | |
22 | ||
23 | class ConfigurationError(Exception): | |
24 | pass | |
25 | ||
26 | class NoConfigError(ConfigurationError): | |
27 | ||
28 | """Raised when configuration loading fails | |
29 | ||
30 | Constructor parameters: path to the directory that was used as HOME | |
31 | ||
32 | """ | |
33 | ||
34 | def __str__(self): | |
35 | return "No valid configuration files found in directory %s" \ | |
36 | % self.args[0] | |
37 | ||
38 | class InvalidOptionError(ConfigurationError, KeyError, AttributeError): | |
39 | ||
40 | """Attempted access to non-existing configuration option | |
41 | ||
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). | |
46 | ||
47 | Constructor parameter: option name | |
48 | ||
49 | """ | |
50 | ||
51 | def __str__(self): | |
52 | return "Unsupported configuration option: %s" % self.args[0] | |
53 | ||
54 | class OptionValueError(ConfigurationError, ValueError): | |
55 | ||
56 | """Raised upon attempt to assign an invalid value to config option | |
57 | ||
58 | Constructor parameters: Option instance, offending value | |
59 | and optional info string. | |
60 | ||
61 | """ | |
62 | ||
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 | |
70 | ||
71 | class OptionUnsetError(ConfigurationError): | |
72 | ||
73 | """Raised when no Option value is available - neither set, nor default | |
74 | ||
75 | Constructor parameters: Option instance. | |
76 | ||
77 | """ | |
78 | ||
79 | def __str__(self): | |
80 | return "%s is not set and has no default" % self.args[0].name | |
81 | ||
82 | class UnsetDefaultValue: | |
83 | ||
84 | """Special object meaning that default value for Option is not specified""" | |
85 | ||
86 | def __str__(self): | |
87 | return "NO DEFAULT" | |
88 | ||
89 | NODEFAULT = UnsetDefaultValue() | |
90 | ||
91 | ### Option classes | |
92 | ||
93 | class Option: | |
94 | ||
95 | """Single configuration option. | |
96 | ||
97 | Options have following attributes: | |
98 | ||
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. | |
119 | ||
120 | The name and aliases are forced to be uppercase. | |
121 | The setting name is forced to lowercase. | |
122 | ||
123 | """ | |
124 | ||
125 | class_description = None | |
126 | ||
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 | |
150 | ||
151 | def str2value(self, value): | |
152 | """Return 'value' argument converted to internal representation""" | |
153 | return value | |
154 | ||
155 | def _value2str(self, value): | |
156 | """Return 'value' argument converted to external representation | |
157 | ||
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(). | |
161 | ||
162 | """ | |
163 | return str(value) | |
164 | ||
165 | def value2str(self, value=NODEFAULT, current=0): | |
166 | """Return 'value' argument converted to external representation | |
167 | ||
168 | If 'current' is True, use current option value. | |
169 | ||
170 | """ | |
171 | if current: | |
172 | value = self._value | |
173 | if value is NODEFAULT: | |
174 | return str(value) | |
175 | else: | |
176 | return self._value2str(value) | |
177 | ||
178 | def get(self): | |
179 | """Return current option value""" | |
180 | if self._value is NODEFAULT: | |
181 | raise OptionUnsetError(self) | |
182 | return self._value | |
183 | ||
184 | def set(self, value): | |
185 | """Update the value""" | |
186 | self._value = self.str2value(value) | |
187 | ||
188 | def reset(self): | |
189 | """Reset the value to default""" | |
190 | self._value = self._default_value | |
191 | ||
192 | def isdefault(self): | |
193 | """Return True if current value is the default one""" | |
194 | return self._value == self._default_value | |
195 | ||
196 | def isset(self): | |
197 | """Return True if the value is available (either set or default)""" | |
198 | return self._value != NODEFAULT | |
199 | ||
200 | def __str__(self): | |
201 | return self.value2str(self._value) | |
202 | ||
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 | } | |
214 | ||
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 | |
235 | ||
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)) | |
240 | ||
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 | |
247 | ||
248 | class BooleanOption(Option): | |
249 | ||
250 | """Boolean option: yes or no""" | |
251 | ||
252 | class_description = "Allowed values: yes, no" | |
253 | ||
254 | def _value2str(self, value): | |
255 | if value: | |
256 | return "yes" | |
257 | else: | |
258 | return "no" | |
259 | ||
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 | |
272 | ||
273 | class WordListOption(Option): | |
274 | ||
275 | """List of strings""" | |
276 | ||
277 | class_description = "Allowed values: comma-separated list of words" | |
278 | ||
279 | def _value2str(self, value): | |
280 | return ','.join(value) | |
281 | ||
282 | def str2value(self, value): | |
283 | return value.split(',') | |
284 | ||
285 | class RunDetectorOption(Option): | |
286 | ||
287 | """When a detector is run: always, never or for new items only""" | |
288 | ||
289 | class_description = "Allowed values: yes, no, new" | |
290 | ||
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) | |
297 | ||
298 | class MailAddressOption(Option): | |
299 | ||
300 | """Email address | |
301 | ||
302 | Email addresses may be either fully qualified or local. | |
303 | In the latter case MAIL_DOMAIN is automatically added. | |
304 | ||
305 | """ | |
306 | ||
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 | |
312 | ||
313 | class FilePathOption(Option): | |
314 | ||
315 | """File or directory path name | |
316 | ||
317 | Paths may be either absolute or relative to the HOME. | |
318 | ||
319 | """ | |
320 | ||
321 | class_description = "The path may be either absolute or relative\n" \ | |
322 | "to the directory containig this config file." | |
323 | ||
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 | |
329 | ||
330 | class FloatNumberOption(Option): | |
331 | ||
332 | """Floating point numbers""" | |
333 | ||
334 | def str2value(self, value): | |
335 | try: | |
336 | return float(value) | |
337 | except ValueError: | |
338 | raise OptionValueError(self, value, | |
339 | "Floating point number required") | |
340 | ||
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 | |
347 | ||
348 | class IntegerNumberOption(Option): | |
349 | ||
350 | """Integer numbers""" | |
351 | ||
352 | def str2value(self, value): | |
353 | try: | |
354 | return int(value) | |
355 | except ValueError: | |
356 | raise OptionValueError(self, value, "Integer number required") | |
357 | ||
358 | class OctalNumberOption(Option): | |
359 | ||
360 | """Octal Integer numbers""" | |
361 | ||
362 | def str2value(self, value): | |
363 | try: | |
364 | return int(value, 8) | |
365 | except ValueError: | |
366 | raise OptionValueError(self, value, "Octal Integer number required") | |
367 | ||
368 | def _value2str(self, value): | |
369 | return oct(value) | |
370 | ||
371 | class NullableOption(Option): | |
372 | ||
373 | """Option that is set to None if its string value is one of NULL strings | |
374 | ||
375 | Default nullable strings list contains empty string only. | |
376 | There is constructor parameter allowing to specify different nullables. | |
377 | ||
378 | Conversion to external representation returns the first of the NULL | |
379 | strings list when the value is None. | |
380 | ||
381 | """ | |
382 | ||
383 | NULL_STRINGS = ("",) | |
384 | ||
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) | |
392 | ||
393 | def str2value(self, value): | |
394 | if value in self.null_strings: | |
395 | return None | |
396 | else: | |
397 | return value | |
398 | ||
399 | def _value2str(self, value): | |
400 | if value is None: | |
401 | return self.null_strings[0] | |
402 | else: | |
403 | return value | |
404 | ||
405 | class NullableFilePathOption(NullableOption, FilePathOption): | |
406 | ||
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) | |
411 | ||
412 | class TimezoneOption(Option): | |
413 | ||
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." | |
419 | ||
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 | |
427 | ||
428 | class RegExpOption(Option): | |
429 | ||
430 | """Regular Expression option (value is Regular Expression Object)""" | |
431 | ||
432 | class_description = "Value is Python Regular Expression (UTF8-encoded)." | |
433 | ||
434 | RE_TYPE = type(re.compile("")) | |
435 | ||
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) | |
443 | ||
444 | def _value2str(self, value): | |
445 | assert isinstance(value, self.RE_TYPE) | |
446 | return value.pattern | |
447 | ||
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) | |
458 | ||
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 | )), | |
583 | ("rdbms", ( | |
584 | (Option, 'name', 'roundup', | |
585 | "Name of the database to use.", | |
586 | ['MYSQL_DBNAME']), | |
587 | (NullableOption, 'host', 'localhost', | |
588 | "Database server host.", | |
589 | ['MYSQL_DBHOST']), | |
590 | (NullableOption, 'port', '', | |
591 | "TCP port number of the database server.\n" | |
592 | "Postgresql usually resides on port 5432 (if any),\n" | |
593 | "for MySQL default port number is 3306.\n" | |
594 | "Leave this option empty to use backend default"), | |
595 | (NullableOption, 'user', 'roundup', | |
596 | "Database user name that Roundup should use.", | |
597 | ['MYSQL_DBUSER']), | |
598 | (NullableOption, 'password', 'roundup', | |
599 | "Database user password.", | |
600 | ['MYSQL_DBPASSWORD']), | |
601 | (NullableOption, 'read_default_file', '~/.my.cnf', | |
602 | "Name of the MySQL defaults file.\n" | |
603 | "Only used in MySQL connections."), | |
604 | (NullableOption, 'read_default_group', 'roundup', | |
605 | "Name of the group to use in the MySQL defaults file (.my.cnf).\n" | |
606 | "Only used in MySQL connections."), | |
607 | (IntegerNumberOption, 'cache_size', '100', | |
608 | "Size of the node cache (in elements)"), | |
609 | ), "Settings in this section are used" | |
610 | " by RDBMS backends only" | |
611 | ), | |
612 | ("logging", ( | |
613 | (FilePathOption, "config", "", | |
614 | "Path to configuration file for standard Python logging module.\n" | |
615 | "If this option is set, logging configuration is loaded\n" | |
616 | "from specified file; options 'filename' and 'level'\n" | |
617 | "in this section are ignored."), | |
618 | (FilePathOption, "filename", "", | |
619 | "Log file name for minimal logging facility built into Roundup.\n" | |
620 | "If no file name specified, log messages are written on stderr.\n" | |
621 | "If above 'config' option is set, this option has no effect."), | |
622 | (Option, "level", "ERROR", | |
623 | "Minimal severity level of messages written to log file.\n" | |
624 | "If above 'config' option is set, this option has no effect.\n" | |
625 | "Allowed values: DEBUG, INFO, WARNING, ERROR"), | |
626 | )), | |
627 | ("mail", ( | |
628 | (Option, "domain", NODEFAULT, | |
629 | "The email domain that admin_email, issue_tracker and\n" | |
630 | "dispatcher_email belong to.\n" | |
631 | "This domain is added to those config items if they don't\n" | |
632 | "explicitly include a domain.\n" | |
633 | "Do not include the '@' symbol."), | |
634 | (Option, "host", NODEFAULT, | |
635 | "SMTP mail host that roundup will use to send mail", | |
636 | ["MAILHOST"],), | |
637 | (Option, "username", "", "SMTP login name.\n" | |
638 | "Set this if your mail host requires authenticated access.\n" | |
639 | "If username is not empty, password (below) MUST be set!"), | |
640 | (Option, "password", NODEFAULT, "SMTP login password.\n" | |
641 | "Set this if your mail host requires authenticated access."), | |
642 | (IntegerNumberOption, "port", smtplib.SMTP_PORT, | |
643 | "Default port to send SMTP on.\n" | |
644 | "Set this if your mail server runs on a different port."), | |
645 | (NullableOption, "local_hostname", '', | |
646 | "The local hostname to use during SMTP transmission.\n" | |
647 | "Set this if your mail server requires something specific."), | |
648 | (BooleanOption, "tls", "no", | |
649 | "If your SMTP mail host provides or requires TLS\n" | |
650 | "(Transport Layer Security) then set this option to 'yes'."), | |
651 | (NullableFilePathOption, "tls_keyfile", "", | |
652 | "If TLS is used, you may set this option to the name\n" | |
653 | "of a PEM formatted file that contains your private key."), | |
654 | (NullableFilePathOption, "tls_certfile", "", | |
655 | "If TLS is used, you may set this option to the name\n" | |
656 | "of a PEM formatted certificate chain file."), | |
657 | (Option, "charset", "utf-8", | |
658 | "Character set to encode email headers with.\n" | |
659 | "We use utf-8 by default, as it's the most flexible.\n" | |
660 | "Some mail readers (eg. Eudora) can't cope with that,\n" | |
661 | "so you might need to specify a more limited character set\n" | |
662 | "(eg. iso-8859-1).", | |
663 | ["EMAIL_CHARSET"]), | |
664 | (FilePathOption, "debug", "", | |
665 | "Setting this option makes Roundup to write all outgoing email\n" | |
666 | "messages to this file *instead* of sending them.\n" | |
667 | "This option has the same effect as environment variable" | |
668 | " SENDMAILDEBUG.\nEnvironment variable takes precedence."), | |
669 | (BooleanOption, "add_authorinfo", "yes", | |
670 | "Add a line with author information at top of all messages\n" | |
671 | "sent by roundup"), | |
672 | (BooleanOption, "add_authoremail", "yes", | |
673 | "Add the mail address of the author to the author information at\n" | |
674 | "the top of all messages.\n" | |
675 | "If this is false but add_authorinfo is true, only the name\n" | |
676 | "of the actor is added which protects the mail address of the\n" | |
677 | "actor from being exposed at mail archives, etc."), | |
678 | ), "Outgoing email options.\nUsed for nozy messages and approval requests"), | |
679 | ("mailgw", ( | |
680 | (BooleanOption, "keep_quoted_text", "yes", | |
681 | "Keep email citations when accepting messages.\n" | |
682 | "Setting this to \"no\" strips out \"quoted\" text" | |
683 | " from the message.\n" | |
684 | "Signatures are also stripped.", | |
685 | ["EMAIL_KEEP_QUOTED_TEXT"]), | |
686 | (BooleanOption, "leave_body_unchanged", "no", | |
687 | "Preserve the email body as is - that is,\n" | |
688 | "keep the citations _and_ signatures.", | |
689 | ["EMAIL_LEAVE_BODY_UNCHANGED"]), | |
690 | (Option, "default_class", "issue", | |
691 | "Default class to use in the mailgw\n" | |
692 | "if one isn't supplied in email subjects.\n" | |
693 | "To disable, leave the value blank.", | |
694 | ["MAIL_DEFAULT_CLASS"]), | |
695 | (NullableOption, "language", "", | |
696 | "Default locale name for the tracker mail gateway.\n" | |
697 | "If this option is not set, mail gateway will use\n" | |
698 | "the language of the tracker instance."), | |
699 | (Option, "subject_prefix_parsing", "strict", | |
700 | "Controls the parsing of the [prefix] on subject\n" | |
701 | "lines in incoming emails. \"strict\" will return an\n" | |
702 | "error to the sender if the [prefix] is not recognised.\n" | |
703 | "\"loose\" will attempt to parse the [prefix] but just\n" | |
704 | "pass it through as part of the issue title if not\n" | |
705 | "recognised. \"none\" will always pass any [prefix]\n" | |
706 | "through as part of the issue title."), | |
707 | (Option, "subject_suffix_parsing", "strict", | |
708 | "Controls the parsing of the [suffix] on subject\n" | |
709 | "lines in incoming emails. \"strict\" will return an\n" | |
710 | "error to the sender if the [suffix] is not recognised.\n" | |
711 | "\"loose\" will attempt to parse the [suffix] but just\n" | |
712 | "pass it through as part of the issue title if not\n" | |
713 | "recognised. \"none\" will always pass any [suffix]\n" | |
714 | "through as part of the issue title."), | |
715 | (Option, "subject_suffix_delimiters", "[]", | |
716 | "Defines the brackets used for delimiting the prefix and \n" | |
717 | 'suffix in a subject line. The presence of "suffix" in\n' | |
718 | "the config option name is a historical artifact and may\n" | |
719 | "be ignored."), | |
720 | (Option, "subject_content_match", "always", | |
721 | "Controls matching of the incoming email subject line\n" | |
722 | "against issue titles in the case where there is no\n" | |
723 | "designator [prefix]. \"never\" turns off matching.\n" | |
724 | "\"creation + interval\" or \"activity + interval\"\n" | |
725 | "will match an issue for the interval after the issue's\n" | |
726 | "creation or last activity. The interval is a standard\n" | |
727 | "Roundup interval."), | |
728 | (RegExpOption, "refwd_re", "(\s*\W?\s*(fw|fwd|re|aw|sv|ang)\W)+", | |
729 | "Regular expression matching a single reply or forward\n" | |
730 | "prefix prepended by the mailer. This is explicitly\n" | |
731 | "stripped from the subject during parsing."), | |
732 | (RegExpOption, "origmsg_re", | |
733 | "^[>|\s]*-----\s?Original Message\s?-----$", | |
734 | "Regular expression matching start of an original message\n" | |
735 | "if quoted the in body."), | |
736 | (RegExpOption, "sign_re", "^[>|\s]*-- ?$", | |
737 | "Regular expression matching the start of a signature\n" | |
738 | "in the message body."), | |
739 | (RegExpOption, "eol_re", r"[\r\n]+", | |
740 | "Regular expression matching end of line."), | |
741 | (RegExpOption, "blankline_re", r"[\r\n]+\s*[\r\n]+", | |
742 | "Regular expression matching a blank line."), | |
743 | (BooleanOption, "ignore_alternatives", "no", | |
744 | "When parsing incoming mails, roundup uses the first\n" | |
745 | "text/plain part it finds. If this part is inside a\n" | |
746 | "multipart/alternative, and this option is set, all other\n" | |
747 | "parts of the multipart/alternative are ignored. The default\n" | |
748 | "is to keep all parts and attach them to the issue."), | |
749 | ), "Roundup Mail Gateway options"), | |
750 | ("pgp", ( | |
751 | (BooleanOption, "enable", "no", | |
752 | "Enable PGP processing. Requires pyme."), | |
753 | (NullableOption, "roles", "", | |
754 | "If specified, a comma-separated list of roles to perform\n" | |
755 | "PGP processing on. If not specified, it happens for all\n" | |
756 | "users."), | |
757 | (NullableOption, "homedir", "", | |
758 | "Location of PGP directory. Defaults to $HOME/.gnupg if\n" | |
759 | "not specified."), | |
760 | ), "OpenPGP mail processing options"), | |
761 | ("nosy", ( | |
762 | (RunDetectorOption, "messages_to_author", "no", | |
763 | "Send nosy messages to the author of the message.", | |
764 | ["MESSAGES_TO_AUTHOR"]), | |
765 | (Option, "signature_position", "bottom", | |
766 | "Where to place the email signature.\n" | |
767 | "Allowed values: top, bottom, none", | |
768 | ["EMAIL_SIGNATURE_POSITION"]), | |
769 | (RunDetectorOption, "add_author", "new", | |
770 | "Does the author of a message get placed on the nosy list\n" | |
771 | "automatically? If 'new' is used, then the author will\n" | |
772 | "only be added when a message creates a new issue.\n" | |
773 | "If 'yes', then the author will be added on followups too.\n" | |
774 | "If 'no', they're never added to the nosy.\n", | |
775 | ["ADD_AUTHOR_TO_NOSY"]), | |
776 | (RunDetectorOption, "add_recipients", "new", | |
777 | "Do the recipients (To:, Cc:) of a message get placed on the\n" | |
778 | "nosy list? If 'new' is used, then the recipients will\n" | |
779 | "only be added when a message creates a new issue.\n" | |
780 | "If 'yes', then the recipients will be added on followups too.\n" | |
781 | "If 'no', they're never added to the nosy.\n", | |
782 | ["ADD_RECIPIENTS_TO_NOSY"]), | |
783 | (Option, "email_sending", "single", | |
784 | "Controls the email sending from the nosy reactor. If\n" | |
785 | "\"multiple\" then a separate email is sent to each\n" | |
786 | "recipient. If \"single\" then a single email is sent with\n" | |
787 | "each recipient as a CC address."), | |
788 | (IntegerNumberOption, "max_attachment_size", sys.maxint, | |
789 | "Attachments larger than the given number of bytes\n" | |
790 | "won't be attached to nosy mails. They will be replaced by\n" | |
791 | "a link to the tracker's download page for the file.") | |
792 | ), "Nosy messages sending"), | |
793 | ) | |
794 | ||
795 | ### Configuration classes | |
796 | ||
797 | class Config: | |
798 | ||
799 | """Base class for configuration objects. | |
800 | ||
801 | Configuration options may be accessed as attributes or items | |
802 | of instances of this class. All option names are uppercased. | |
803 | ||
804 | """ | |
805 | ||
806 | # Config file name | |
807 | INI_FILE = "config.ini" | |
808 | ||
809 | # Object attributes that should not be taken as common configuration | |
810 | # options in __setattr__ (most of them are initialized in constructor): | |
811 | # builtin pseudo-option - package home directory | |
812 | HOME = "." | |
813 | # names of .ini file sections, in order | |
814 | sections = None | |
815 | # section comments | |
816 | section_descriptions = None | |
817 | # lists of option names for each section, in order | |
818 | section_options = None | |
819 | # mapping from option names and aliases to Option instances | |
820 | options = None | |
821 | # actual name of the config file. set on load. | |
822 | filepath = os.path.join(HOME, INI_FILE) | |
823 | ||
824 | def __init__(self, config_path=None, layout=None, settings={}): | |
825 | """Initialize confing instance | |
826 | ||
827 | Parameters: | |
828 | config_path: | |
829 | optional directory or file name of the config file. | |
830 | If passed, load the config after processing layout (if any). | |
831 | If config_path is a directory name, use default base name | |
832 | of the config file. | |
833 | layout: | |
834 | optional configuration layout, a sequence of | |
835 | section definitions suitable for .add_section() | |
836 | settings: | |
837 | optional setting overrides (dictionary). | |
838 | The overrides are applied after loading config file. | |
839 | ||
840 | """ | |
841 | # initialize option containers: | |
842 | self.sections = [] | |
843 | self.section_descriptions = {} | |
844 | self.section_options = {} | |
845 | self.options = {} | |
846 | # add options from the layout structure | |
847 | if layout: | |
848 | for section in layout: | |
849 | self.add_section(*section) | |
850 | if config_path is not None: | |
851 | self.load(config_path) | |
852 | for (name, value) in settings.items(): | |
853 | self[name.upper()] = value | |
854 | ||
855 | def add_section(self, section, options, description=None): | |
856 | """Define new config section | |
857 | ||
858 | Parameters: | |
859 | section - name of the config.ini section | |
860 | options - a sequence of Option definitions. | |
861 | Each Option definition is a sequence | |
862 | containing class object and constructor | |
863 | parameters, starting from the setting name: | |
864 | setting, default, [description, [aliases]] | |
865 | description - optional section comment | |
866 | ||
867 | Note: aliases should only exist in historical options | |
868 | for backwards compatibility - new options should | |
869 | *not* have aliases! | |
870 | ||
871 | """ | |
872 | if description or not self.section_descriptions.has_key(section): | |
873 | self.section_descriptions[section] = description | |
874 | for option_def in options: | |
875 | klass = option_def[0] | |
876 | args = option_def[1:] | |
877 | option = klass(self, section, *args) | |
878 | self.add_option(option) | |
879 | ||
880 | def add_option(self, option): | |
881 | """Adopt a new Option object""" | |
882 | _section = option.section | |
883 | _name = option.setting | |
884 | if _section not in self.sections: | |
885 | self.sections.append(_section) | |
886 | _options = self._get_section_options(_section) | |
887 | if _name not in _options: | |
888 | _options.append(_name) | |
889 | # (section, name) key is used for writing .ini file | |
890 | self.options[(_section, _name)] = option | |
891 | # make the option known under all of its A.K.A.s | |
892 | for _name in option.aliases: | |
893 | self.options[_name] = option | |
894 | ||
895 | def update_option(self, name, klass, | |
896 | default=NODEFAULT, description=None | |
897 | ): | |
898 | """Override behaviour of early created option. | |
899 | ||
900 | Parameters: | |
901 | name: | |
902 | option name | |
903 | klass: | |
904 | one of the Option classes | |
905 | default: | |
906 | optional default value for the option | |
907 | description: | |
908 | optional new description for the option | |
909 | ||
910 | Conversion from current option value to new class value | |
911 | is done via string representation. | |
912 | ||
913 | This method may be used to attach some brains | |
914 | to options autocreated by UserConfig. | |
915 | ||
916 | """ | |
917 | # fetch current option | |
918 | option = self._get_option(name) | |
919 | # compute constructor parameters | |
920 | if default is NODEFAULT: | |
921 | default = option.default | |
922 | if description is None: | |
923 | description = option.description | |
924 | value = option.value2str(current=1) | |
925 | # resurrect the option | |
926 | option = klass(self, option.section, option.setting, | |
927 | default=default, description=description) | |
928 | # apply the value | |
929 | option.set(value) | |
930 | # incorporate new option | |
931 | del self[name] | |
932 | self.add_option(option) | |
933 | ||
934 | def reset(self): | |
935 | """Set all options to their default values""" | |
936 | for _option in self.items(): | |
937 | _option.reset() | |
938 | ||
939 | # Meant for commandline tools. | |
940 | # Allows automatic creation of configuration files like this: | |
941 | # roundup-server -p 8017 -u roundup --save-config | |
942 | def getopt(self, args, short_options="", long_options=(), | |
943 | config_load_options=("C", "config"), **options | |
944 | ): | |
945 | """Apply options specified in command line arguments. | |
946 | ||
947 | Parameters: | |
948 | args: | |
949 | command line to parse (sys.argv[1:]) | |
950 | short_options: | |
951 | optional string of letters for command line options | |
952 | that are not config options | |
953 | long_options: | |
954 | optional list of names for long options | |
955 | that are not config options | |
956 | config_load_options: | |
957 | two-element sequence (letter, long_option) defining | |
958 | the options for config file. If unset, don't load | |
959 | config file; otherwise config file is read prior | |
960 | to applying other options. Short option letter | |
961 | must not have a colon and long_option name must | |
962 | not have an equal sign or '--' prefix. | |
963 | options: | |
964 | mapping from option names to command line option specs. | |
965 | e.g. server_port="p:", server_user="u:" | |
966 | Names are forced to lower case for commandline parsing | |
967 | (long options) and to upper case to find config options. | |
968 | Command line options accepting no value are assumed | |
969 | to be binary and receive value 'yes'. | |
970 | ||
971 | Return value: same as for python standard getopt(), except that | |
972 | processed options are removed from returned option list. | |
973 | ||
974 | """ | |
975 | # take a copy of long_options | |
976 | long_options = list(long_options) | |
977 | # build option lists | |
978 | cfg_names = {} | |
979 | booleans = [] | |
980 | for (name, letter) in options.items(): | |
981 | cfg_name = name.upper() | |
982 | short_opt = "-" + letter[0] | |
983 | name = name.lower().replace("_", "-") | |
984 | cfg_names.update({short_opt: cfg_name, "--" + name: cfg_name}) | |
985 | ||
986 | short_options += letter | |
987 | if letter[-1] == ":": | |
988 | long_options.append(name + "=") | |
989 | else: | |
990 | booleans.append(short_opt) | |
991 | long_options.append(name) | |
992 | ||
993 | if config_load_options: | |
994 | short_options += config_load_options[0] + ":" | |
995 | long_options.append(config_load_options[1] + "=") | |
996 | # compute names that will be searched in getopt return value | |
997 | config_load_options = ( | |
998 | "-" + config_load_options[0], | |
999 | "--" + config_load_options[1], | |
1000 | ) | |
1001 | # parse command line arguments | |
1002 | optlist, args = getopt.getopt(args, short_options, long_options) | |
1003 | # load config file if requested | |
1004 | if config_load_options: | |
1005 | for option in optlist: | |
1006 | if option[0] in config_load_options: | |
1007 | self.load_ini(option[1]) | |
1008 | optlist.remove(option) | |
1009 | break | |
1010 | # apply options | |
1011 | extra_options = [] | |
1012 | for (opt, arg) in optlist: | |
1013 | if (opt in booleans): # and not arg | |
1014 | arg = "yes" | |
1015 | try: | |
1016 | name = cfg_names[opt] | |
1017 | except KeyError: | |
1018 | extra_options.append((opt, arg)) | |
1019 | else: | |
1020 | self[name] = arg | |
1021 | return (extra_options, args) | |
1022 | ||
1023 | # option and section locators (used in option access methods) | |
1024 | ||
1025 | def _get_option(self, name): | |
1026 | try: | |
1027 | return self.options[name] | |
1028 | except KeyError: | |
1029 | raise InvalidOptionError(name) | |
1030 | ||
1031 | def _get_section_options(self, name): | |
1032 | return self.section_options.setdefault(name, []) | |
1033 | ||
1034 | def _get_unset_options(self): | |
1035 | """Return options that need manual adjustments | |
1036 | ||
1037 | Return value is a dictionary where keys are section | |
1038 | names and values are lists of option names as they | |
1039 | appear in the config file. | |
1040 | ||
1041 | """ | |
1042 | need_set = {} | |
1043 | for option in self.items(): | |
1044 | if not option.isset(): | |
1045 | need_set.setdefault(option.section, []).append(option.setting) | |
1046 | return need_set | |
1047 | ||
1048 | def _adjust_options(self, config): | |
1049 | """Load ad-hoc option definitions from ConfigParser instance.""" | |
1050 | pass | |
1051 | ||
1052 | def _get_name(self): | |
1053 | """Return the service name for config file heading""" | |
1054 | return "" | |
1055 | ||
1056 | # file operations | |
1057 | ||
1058 | def load_ini(self, config_path, defaults=None): | |
1059 | """Set options from config.ini file in given home_dir | |
1060 | ||
1061 | Parameters: | |
1062 | config_path: | |
1063 | directory or file name of the config file. | |
1064 | If config_path is a directory name, use default | |
1065 | base name of the config file | |
1066 | defaults: | |
1067 | optional dictionary of defaults for ConfigParser | |
1068 | ||
1069 | Note: if home_dir does not contain config.ini file, | |
1070 | no error is raised. Config will be reset to defaults. | |
1071 | ||
1072 | """ | |
1073 | if os.path.isdir(config_path): | |
1074 | home_dir = config_path | |
1075 | config_path = os.path.join(config_path, self.INI_FILE) | |
1076 | else: | |
1077 | home_dir = os.path.dirname(config_path) | |
1078 | # parse the file | |
1079 | config_defaults = {"HOME": home_dir} | |
1080 | if defaults: | |
1081 | config_defaults.update(defaults) | |
1082 | config = ConfigParser.ConfigParser(config_defaults) | |
1083 | config.read([config_path]) | |
1084 | # .ini file loaded ok. | |
1085 | self.HOME = home_dir | |
1086 | self.filepath = config_path | |
1087 | self._adjust_options(config) | |
1088 | # set the options, starting from HOME | |
1089 | self.reset() | |
1090 | for option in self.items(): | |
1091 | option.load_ini(config) | |
1092 | ||
1093 | def load(self, home_dir): | |
1094 | """Load configuration settings from home_dir""" | |
1095 | self.load_ini(home_dir) | |
1096 | ||
1097 | def save(self, ini_file=None): | |
1098 | """Write current configuration to .ini file | |
1099 | ||
1100 | 'ini_file' argument, if passed, must be valid full path | |
1101 | to the file to write. If omitted, default file in current | |
1102 | HOME is created. | |
1103 | ||
1104 | If the file to write already exists, it is saved with '.bak' | |
1105 | extension. | |
1106 | ||
1107 | """ | |
1108 | if ini_file is None: | |
1109 | ini_file = self.filepath | |
1110 | _tmp_file = os.path.splitext(ini_file)[0] | |
1111 | _bak_file = _tmp_file + ".bak" | |
1112 | _tmp_file = _tmp_file + ".tmp" | |
1113 | _fp = file(_tmp_file, "wt") | |
1114 | _fp.write("# %s configuration file\n" % self._get_name()) | |
1115 | _fp.write("# Autogenerated at %s\n" % time.asctime()) | |
1116 | need_set = self._get_unset_options() | |
1117 | if need_set: | |
1118 | _fp.write("\n# WARNING! Following options need adjustments:\n") | |
1119 | for section, options in need_set.items(): | |
1120 | _fp.write("# [%s]: %s\n" % (section, ", ".join(options))) | |
1121 | for section in self.sections: | |
1122 | comment = self.section_descriptions.get(section, None) | |
1123 | if comment: | |
1124 | _fp.write("\n# ".join([""] + comment.split("\n")) +"\n") | |
1125 | else: | |
1126 | # no section comment - just leave a blank line between sections | |
1127 | _fp.write("\n") | |
1128 | _fp.write("[%s]\n" % section) | |
1129 | for option in self._get_section_options(section): | |
1130 | _fp.write("\n" + self.options[(section, option)].format()) | |
1131 | _fp.close() | |
1132 | if os.access(ini_file, os.F_OK): | |
1133 | if os.access(_bak_file, os.F_OK): | |
1134 | os.remove(_bak_file) | |
1135 | os.rename(ini_file, _bak_file) | |
1136 | os.rename(_tmp_file, ini_file) | |
1137 | ||
1138 | # container emulation | |
1139 | ||
1140 | def __len__(self): | |
1141 | return len(self.items()) | |
1142 | ||
1143 | def __getitem__(self, name): | |
1144 | if name == "HOME": | |
1145 | return self.HOME | |
1146 | else: | |
1147 | return self._get_option(name).get() | |
1148 | ||
1149 | def __setitem__(self, name, value): | |
1150 | if name == "HOME": | |
1151 | self.HOME = value | |
1152 | else: | |
1153 | self._get_option(name).set(value) | |
1154 | ||
1155 | def __delitem__(self, name): | |
1156 | _option = self._get_option(name) | |
1157 | _section = _option.section | |
1158 | _name = _option.setting | |
1159 | self._get_section_options(_section).remove(_name) | |
1160 | del self.options[(_section, _name)] | |
1161 | for _alias in _option.aliases: | |
1162 | del self.options[_alias] | |
1163 | ||
1164 | def items(self): | |
1165 | """Return the list of Option objects, in .ini file order | |
1166 | ||
1167 | Note that HOME is not included in this list | |
1168 | because it is builtin pseudo-option, not a real Option | |
1169 | object loaded from or saved to .ini file. | |
1170 | ||
1171 | """ | |
1172 | return [self.options[(_section, _name)] | |
1173 | for _section in self.sections | |
1174 | for _name in self._get_section_options(_section) | |
1175 | ] | |
1176 | ||
1177 | def keys(self): | |
1178 | """Return the list of "canonical" names of the options | |
1179 | ||
1180 | Unlike .items(), this list also includes HOME | |
1181 | ||
1182 | """ | |
1183 | return ["HOME"] + [_option.name for _option in self.items()] | |
1184 | ||
1185 | # .values() is not implemented because i am not sure what should be | |
1186 | # the values returned from this method: Option instances or config values? | |
1187 | ||
1188 | # attribute emulation | |
1189 | ||
1190 | def __setattr__(self, name, value): | |
1191 | if self.__dict__.has_key(name) or hasattr(self.__class__, name): | |
1192 | self.__dict__[name] = value | |
1193 | else: | |
1194 | self._get_option(name).set(value) | |
1195 | ||
1196 | # Note: __getattr__ is not symmetric to __setattr__: | |
1197 | # self.__dict__ lookup is done before calling this method | |
1198 | def __getattr__(self, name): | |
1199 | return self[name] | |
1200 | ||
1201 | class UserConfig(Config): | |
1202 | ||
1203 | """Configuration for user extensions. | |
1204 | ||
1205 | Instances of this class have no predefined configuration layout. | |
1206 | Options are created on the fly for each setting present in the | |
1207 | config file. | |
1208 | ||
1209 | """ | |
1210 | ||
1211 | def _adjust_options(self, config): | |
1212 | # config defaults appear in all sections. | |
1213 | # we'll need to filter them out. | |
1214 | defaults = config.defaults().keys() | |
1215 | # see what options are already defined and add missing ones | |
1216 | preset = [(option.section, option.setting) for option in self.items()] | |
1217 | for section in config.sections(): | |
1218 | for name in config.options(section): | |
1219 | if ((section, name) not in preset) \ | |
1220 | and (name not in defaults): | |
1221 | self.add_option(Option(self, section, name)) | |
1222 | ||
1223 | class CoreConfig(Config): | |
1224 | ||
1225 | """Roundup instance configuration. | |
1226 | ||
1227 | Core config has a predefined layout (see the SETTINGS structure), | |
1228 | supports loading of old-style pythonic configurations and holds | |
1229 | three additional attributes: | |
1230 | logging: | |
1231 | instance logging engine, from standard python logging module | |
1232 | or minimalistic logger implemented in Roundup | |
1233 | detectors: | |
1234 | user-defined configuration for detectors | |
1235 | ext: | |
1236 | user-defined configuration for extensions | |
1237 | ||
1238 | """ | |
1239 | ||
1240 | # module name for old style configuration | |
1241 | PYCONFIG = "config" | |
1242 | # user configs | |
1243 | ext = None | |
1244 | detectors = None | |
1245 | ||
1246 | def __init__(self, home_dir=None, settings={}): | |
1247 | Config.__init__(self, home_dir, layout=SETTINGS, settings=settings) | |
1248 | # load the config if home_dir given | |
1249 | if home_dir is None: | |
1250 | self.init_logging() | |
1251 | ||
1252 | def copy(self): | |
1253 | new = CoreConfig() | |
1254 | new.sections = list(self.sections) | |
1255 | new.section_descriptions = dict(self.section_descriptions) | |
1256 | new.section_options = dict(self.section_options) | |
1257 | new.options = dict(self.options) | |
1258 | return new | |
1259 | ||
1260 | def _get_unset_options(self): | |
1261 | need_set = Config._get_unset_options(self) | |
1262 | # remove MAIL_PASSWORD if MAIL_USER is empty | |
1263 | if "password" in need_set.get("mail", []): | |
1264 | if not self["MAIL_USERNAME"]: | |
1265 | settings = need_set["mail"] | |
1266 | settings.remove("password") | |
1267 | if not settings: | |
1268 | del need_set["mail"] | |
1269 | return need_set | |
1270 | ||
1271 | def _get_name(self): | |
1272 | return self["TRACKER_NAME"] | |
1273 | ||
1274 | def reset(self): | |
1275 | Config.reset(self) | |
1276 | if self.ext: | |
1277 | self.ext.reset() | |
1278 | if self.detectors: | |
1279 | self.detectors.reset() | |
1280 | self.init_logging() | |
1281 | ||
1282 | def init_logging(self): | |
1283 | _file = self["LOGGING_CONFIG"] | |
1284 | if _file and os.path.isfile(_file): | |
1285 | logging.config.fileConfig(_file) | |
1286 | return | |
1287 | ||
1288 | _file = self["LOGGING_FILENAME"] | |
1289 | # set file & level on the root logger | |
1290 | logger = logging.getLogger() | |
1291 | if _file: | |
1292 | hdlr = logging.FileHandler(_file) | |
1293 | else: | |
1294 | hdlr = logging.StreamHandler(sys.stdout) | |
1295 | formatter = logging.Formatter( | |
1296 | '%(asctime)s %(levelname)s %(message)s') | |
1297 | hdlr.setFormatter(formatter) | |
1298 | # no logging API to remove all existing handlers!?! | |
1299 | for h in logger.handlers: | |
1300 | h.close() | |
1301 | logger.removeHandler(hdlr) | |
1302 | logger.handlers = [hdlr] | |
1303 | logger.setLevel(logging._levelNames[self["LOGGING_LEVEL"] or "ERROR"]) | |
1304 | ||
1305 | def load(self, home_dir): | |
1306 | """Load configuration from path designated by home_dir argument""" | |
1307 | if os.path.isfile(os.path.join(home_dir, self.INI_FILE)): | |
1308 | self.load_ini(home_dir) | |
1309 | else: | |
1310 | self.load_pyconfig(home_dir) | |
1311 | self.init_logging() | |
1312 | self.ext = UserConfig(os.path.join(home_dir, "extensions")) | |
1313 | self.detectors = UserConfig(os.path.join(home_dir, "detectors")) | |
1314 | ||
1315 | def load_ini(self, home_dir, defaults=None): | |
1316 | """Set options from config.ini file in given home_dir directory""" | |
1317 | config_defaults = {"TRACKER_HOME": home_dir} | |
1318 | if defaults: | |
1319 | config_defaults.update(defaults) | |
1320 | Config.load_ini(self, home_dir, config_defaults) | |
1321 | ||
1322 | def load_pyconfig(self, home_dir): | |
1323 | """Set options from config.py file in given home_dir directory""" | |
1324 | # try to locate and import the module | |
1325 | _mod_fp = None | |
1326 | try: | |
1327 | try: | |
1328 | _module = imp.find_module(self.PYCONFIG, [home_dir]) | |
1329 | _mod_fp = _module[0] | |
1330 | _config = imp.load_module(self.PYCONFIG, *_module) | |
1331 | except ImportError: | |
1332 | raise NoConfigError(home_dir) | |
1333 | finally: | |
1334 | if _mod_fp is not None: | |
1335 | _mod_fp.close() | |
1336 | # module loaded ok. set the options, starting from HOME | |
1337 | self.reset() | |
1338 | self.HOME = home_dir | |
1339 | for _option in self.items(): | |
1340 | _option.load_pyconfig(_config) | |
1341 | # backward compatibility: | |
1342 | # SMTP login parameters were specified as a tuple in old style configs | |
1343 | # convert them to new plain string options | |
1344 | _mailuser = getattr(_config, "MAILUSER", ()) | |
1345 | if len(_mailuser) > 0: | |
1346 | self.MAIL_USERNAME = _mailuser[0] | |
1347 | if len(_mailuser) > 1: | |
1348 | self.MAIL_PASSWORD = _mailuser[1] | |
1349 | ||
1350 | # in this config, HOME is also known as TRACKER_HOME | |
1351 | def __getitem__(self, name): | |
1352 | if name == "TRACKER_HOME": | |
1353 | return self.HOME | |
1354 | else: | |
1355 | return Config.__getitem__(self, name) | |
1356 | ||
1357 | def __setitem__(self, name, value): | |
1358 | if name == "TRACKER_HOME": | |
1359 | self.HOME = value | |
1360 | else: | |
1361 | self._get_option(name).set(value) | |
1362 | ||
1363 | def __setattr__(self, name, value): | |
1364 | if name == "TRACKER_HOME": | |
1365 | self.__dict__["HOME"] = value | |
1366 | else: | |
1367 | Config.__setattr__(self, name, value) | |
1368 | ||
1369 | # vim: set et sts=4 sw=4 : |