Ticket #7378: config-refact.patch
| File config-refact.patch, 23.5 KB (added by cboos, 15 months ago) |
|---|
-
trac/config.py
# HG changeset patch # Parent 49cb2d5c973c6b04e2146b94beeaded7e7ca53b0 config: get rid of !ConfigParser. Instead, we use our own `Configuration._read()` which preserves the order of sections and of the entries within those sections, as found in the .ini file. The comments are also preserved, so that writing back a modified Configuration simply updates the entries in place. The other behavior changes are: - full unicode support - case sensitive sections and keys - no `%(...)s` expansion Closes #7378. diff -r 49cb2d5c973c trac/config.py
a b 14 14 15 15 from __future__ import with_statement 16 16 17 from ConfigParser import SafeConfigParser17 from StringIO import StringIO 18 18 from copy import deepcopy 19 19 import os.path 20 20 … … _TRUE_VALUES = ('yes', 'true', 'enabled' 34 34 35 35 _use_default = object() 36 36 37 def _to_utf8(basestr):38 return to_unicode(basestr).encode('utf-8')39 40 41 37 class ConfigurationError(TracError): 42 38 """Exception raised when a value in the configuration file is not valid.""" 43 39 title = N_('Configuration Error') 44 40 45 41 46 42 class Configuration(object): 47 """ Thin layer over `ConfigParser` from the Python standard library.43 """Ini file reader and writer. 48 44 49 In addition to providing some convenience methods, the class remembers 50 the last modification time of the configuration file, and reparses it 51 when the file has changed. 45 :param filename: location of the .ini file 46 :param params: global defaults that can be used in `%(name)s` 47 style parameter substitution 48 In addition to providing some convenience methods, the class 49 remembers the last modification time of the configuration file, 50 and reparses it when the file has changed. 52 51 """ 53 52 def __init__(self, filename, params={}): 54 53 self.filename = filename 55 self.parser = SafeConfigParser(params) 54 self.params = params 55 self.parsed_sections = [] 56 # parsed_sections stores sections and entries as read: 57 # [(section, [(key, value), "#...", ...]), ...] 58 self.parsed_options = {} 59 # parsed_options stores actual values: 60 # section -> {key: value} 56 61 self._old_sections = {} 57 62 self.parents = [] 58 63 self._lastmtime = 0 59 self._sections = {} 64 self._sections = {} 65 # _sections stores the Section instances 60 66 self.parse_if_needed(force=True) 61 67 68 def has_section(self, section): 69 """Return `True` if `section` was defined in the .ini file.""" 70 return section in self.parsed_options 71 62 72 def __contains__(self, name): 63 73 """Return whether the configuration contains a section of the given 64 74 name. 65 75 """ 66 return name in self.sections()76 return name and name in self.sections() 67 77 68 78 def __getitem__(self, name): 69 79 """Return the configuration section with the specified name.""" … … class Configuration(object): 183 193 options declared in components that are enabled in the given 184 194 `ComponentManager` are returned. 185 195 """ 186 sections = set( [to_unicode(s) for s in self.parser.sections()])196 sections = set(self.parsed_options.keys()) 187 197 for parent in self.parents: 188 198 sections.update(parent.sections(compmgr, defaults=False)) 189 199 if defaults: … … class Configuration(object): 197 207 198 208 (since Trac 0.11) 199 209 """ 200 section_str = _to_utf8(section) 201 if self.parser.has_section(section_str): 202 if _to_utf8(option) in self.parser.options(section_str): 203 return True 210 if option in self.parsed_options.get(section, ()): 211 return True 204 212 for parent in self.parents: 205 213 if parent.has_option(section, option, defaults=False): 206 214 return True … … class Configuration(object): 211 219 if not self.filename: 212 220 return 213 221 214 # Only save options that differ from the defaults 222 # Only save options that differ from the defaults, return 223 # either a (key, value) pair if value differs from default, 224 # or a "# key = value (where it comes from)" comment 225 def make_entry(section, key, options): 226 default = _use_default 227 info = None 228 for parent in self.parents: 229 if parent.has_option(section, key, defaults=False): 230 default = parent.get(section, key) 231 info = 'inherited from ' + parent.filename 232 break 233 if default is _use_default: 234 option = Option.registry.get((section, key)) 235 if option: 236 default = option.default 237 info = 'default' 238 if key in options: 239 current = options.pop(key) 240 if current != default: 241 return key, current 242 return '# %s = %s ; (%s)' % (key, default, info) 243 # Collect only actual entries 244 def non_default_entries(section, options): 245 entries = [] 246 for key in sorted(options): 247 entry = make_entry(section, key, options) 248 if isinstance(entry, tuple): 249 entries.append(entry) 250 return entries 251 252 parsed_options = deepcopy(self.parsed_options) 215 253 sections = [] 216 for section in self.sections(): 217 section_str = _to_utf8(section) 218 options = [] 219 for option in self[section]: 220 default_str = None 221 for parent in self.parents: 222 if parent.has_option(section, option, defaults=False): 223 default_str = _to_utf8(parent.get(section, option)) 224 break 225 option_str = _to_utf8(option) 226 current_str = False 227 if self.parser.has_option(section_str, option_str): 228 current_str = self.parser.get(section_str, option_str) 229 if current_str is not False and current_str != default_str: 230 options.append((option_str, current_str)) 231 if options: 232 sections.append((section_str, sorted(options))) 254 for section, section_content in self.parsed_sections: 255 filtered_entries = [] 256 options = parsed_options.get(section, ()) 257 for entry in section_content: 258 if isinstance(entry, tuple): 259 entry = make_entry(section, entry[0], options) 260 if not entry: 261 continue 262 # else try to see if this is a previously "commented out value" 263 # that needs to be updated in place (comment:12) 264 filtered_entries.append(entry) 265 sections.append((section, filtered_entries)) 266 if any(len(options) for options in parsed_options): 267 # add new entries to existing sections 268 for section, section_content in sections: 269 options = parsed_options.pop(section, ()) 270 new_entries = non_default_entries(section, options) 271 if new_entries: 272 section_content.extend(new_entries + ['']) 273 # add remaining sections as new sections 274 if parsed_options: 275 for section in sorted(parsed_options.keys()): 276 options = parsed_options[section] 277 entries = non_default_entries(section, options) 278 if entries: 279 sections.append((section, entries + [''])) 233 280 234 # At this point, all the strings in `sections` are UTF-8 encoded `str`235 281 try: 282 buf = StringIO() 283 284 encoding = '# -*- coding: utf-8 -*-' 285 if self.parsed_sections: 286 section, comments = self.parsed_sections[0] 287 if section is None and comments and comments[0] == encoding: 288 encoding = None 289 if encoding: 290 buf.write(encoding + '\n\n') 291 292 for section, section_content in sections: 293 if section: 294 buf.write('[%s]\n' % section) 295 for entry in section_content: 296 if isinstance(entry, tuple): 297 key, val = entry 298 if key in self[section].overridden: 299 buf.write('# %s = <inherited>\n' % key) 300 else: 301 val = val.replace(CRLF, '\n').replace('\n', '\n ') 302 buf.write('%s = %s\n' % (key, val)) 303 else: 304 buf.write(entry + '\n') 305 utf8_content = buf.getvalue().encode('utf-8') 236 306 with AtomicFile(self.filename, 'w') as fileobj: 237 fileobj.write('# -*- coding: utf-8 -*-\n\n') 238 for section, options in sections: 239 fileobj.write('[%s]\n' % section) 240 for key_str, val_str in options: 241 if to_unicode(key_str) in self[section].overridden: 242 fileobj.write('# %s = <inherited>\n' % key_str) 243 else: 244 val_str = val_str.replace(CRLF, '\n') \ 245 .replace('\n', '\n ') 246 fileobj.write('%s = %s\n' % (key_str, val_str)) 247 fileobj.write('\n') 248 self._old_sections = deepcopy(self.parser._sections) 249 except Exception: 307 fileobj.write(utf8_content) 308 ## self._old_sections = deepcopy(self.parser._sections) 309 except Exception, e: 250 310 # Revert all changes to avoid inconsistencies 251 self.parser._sections = deepcopy(self._old_sections)311 ## FIXME self.parser._sections = deepcopy(self._old_sections) 252 312 raise 253 313 254 314 def parse_if_needed(self, force=False): … … class Configuration(object): 259 319 modtime = os.path.getmtime(self.filename) 260 320 if force or modtime > self._lastmtime: 261 321 self._sections = {} 262 self.parser._sections = {}263 self. parser.read(self.filename)322 ## self.parser._sections = {} ### FIXME 323 self._read() 264 324 self._lastmtime = modtime 265 self._old_sections = deepcopy(self.parser._sections)325 ## self._old_sections = deepcopy(self.parser._sections) 266 326 changed = True 267 327 268 328 if changed: 269 329 self.parents = [] 270 i f self.parser.has_option('inherit', 'file'):271 for filename in self.parser.get('inherit', 'file').split(','):272 filename = to_unicode(filename.strip())330 inherited = self.parsed_options.get('inherit', {}).get('file') 331 if inherited: 332 for filename in (f.strip() for f in inherited.split(',')): 273 333 if not os.path.isabs(filename): 274 334 filename = os.path.join(os.path.dirname(self.filename), 275 335 filename) … … class Configuration(object): 282 342 self._cache = {} 283 343 return changed 284 344 345 def _read(self): 346 content = to_unicode(file(self.filename, 'rb').read()) 347 self.parsed_sections = [] 348 section, section_content = None, [] 349 for line in content.splitlines(): 350 lsline = line.lstrip() 351 sline = lsline.rstrip() 352 if not sline or sline[0] in '#;': 353 section_content.append(sline) 354 elif lsline != line: # multi-line value? 355 if section_content and isinstance(section_content[-1], tuple): 356 key, value = section_content[-1] 357 section_content[-1] = (key, value + '\n' + sline) 358 elif section and '=' in sline: # setting 359 key, value = sline.split('=', 1) 360 # TODO: split value at rightmost ';', then store (k,v,comment) 361 section_content.append((key.strip(), value.strip())) 362 elif sline[0] == '[' and sline[-1] == ']': 363 self.parsed_sections.append((section, section_content)) 364 section, section_content = sline[1:-1], [] 365 if section: 366 self.parsed_sections.append((section, section_content)) 367 self.parsed_options = {} 368 for section, section_content in self.parsed_sections: 369 if section: 370 options = self.parsed_options.setdefault(section, {}) 371 for key, value in (entry for entry in section_content 372 if isinstance(entry, tuple)): 373 options[key] = value 374 285 375 def touch(self): 286 376 if self.filename and os.path.isfile(self.filename) \ 287 377 and os.access(self.filename, os.W_OK): … … class Configuration(object): 295 385 """ 296 386 for section, default_options in self.defaults(compmgr).items(): 297 387 for name, value in default_options.items(): 298 if not self.parser.has_option(_to_utf8(section), 299 _to_utf8(name)): 388 if not self.has_option(section, name): 300 389 if any(parent[section].contains(name, defaults=False) 301 390 for parent in self.parents): 302 391 value = None … … class Section(object): 308 397 309 398 Objects of this class should not be instantiated directly. 310 399 """ 311 __slots__ = ['config', 'name', 'overridden', '_cache']400 __slots__ = ('config', 'name', 'overridden', '_cache') 312 401 313 402 def __init__(self, config, name): 314 403 self.config = config … … class Section(object): 317 406 self._cache = {} 318 407 319 408 def contains(self, key, defaults=True): 320 if self.config. parser.has_option(_to_utf8(self.name), _to_utf8(key)):409 if self.config.has_option(self.name, key): 321 410 return True 322 411 for parent in self.config.parents: 323 412 if parent[self.name].contains(key, defaults=False): 324 413 return True 325 return defaults and Option.registry.has_key((self.name, key))414 return defaults and (self.name, key) in Option.registry 326 415 327 416 __contains__ = contains 328 417 … … class Section(object): 333 422 components that are enabled in the given `ComponentManager`. 334 423 """ 335 424 options = set() 336 name_str = _to_utf8(self.name) 337 if self.config.parser.has_section(name_str): 338 for option_str in self.config.parser.options(name_str): 339 option = to_unicode(option_str) 425 poptions = self.config.parsed_options.get(self.name) 426 if poptions: 427 for option in poptions.keys(): 340 428 options.add(option.lower()) 341 429 yield option 342 430 for parent in self.config.parents: … … class Section(object): 363 451 cached = self._cache.get(key, _use_default) 364 452 if cached is not _use_default: 365 453 return cached 366 name_str = _to_utf8(self.name) 367 key_str = _to_utf8(key) 368 if self.config.parser.has_option(name_str, key_str): 369 value = self.config.parser.get(name_str, key_str) 454 options = self.config.parsed_options.get(self.name, ()) 455 if key in options: 456 value = options[key] 370 457 else: 371 458 for parent in self.config.parents: 372 459 value = parent[self.name].get(key, _use_default) … … class Section(object): 382 469 return default 383 470 if not value: 384 471 value = u'' 385 elif isinstance(value, basestring):386 value = to_unicode(value)387 472 self._cache[key] = value 388 473 return value 389 474 … … class Section(object): 480 565 481 566 def set(self, key, value): 482 567 """Change a configuration value. 483 568 569 :param key: setting key 570 :param value: setting value; if `None`, the value for this setting 571 will be the inherited one 572 :return: the value, or `''` if `None` was given. 573 484 574 These changes are not persistent unless saved with `save()`. 485 575 """ 486 576 self._cache.pop(key, None) 487 name_str = _to_utf8(self.name)488 key_str = _to_utf8(key)489 if not self.config.parser.has_section(name_str):490 self.config.parser.add_section(name_str)491 577 if value is None: 492 578 self.overridden[key] = True 493 value_str = '' 494 else: 495 value_str = _to_utf8(value) 496 return self.config.parser.set(name_str, key_str, value_str) 579 value = '' 580 self.config.parsed_options.setdefault(self.name, {})[key] = value 581 return value 497 582 498 583 def remove(self, key): 499 584 """Delete a key from this section. 500 585 501 586 Like for `set()`, the changes won't persist until `save()` gets called. 502 587 """ 503 name_str = _to_utf8(self.name)504 if self.config.parser.has_section(name_str):588 options = self.config.parsed_options.get(self.name) 589 if options: 505 590 self._cache.pop(key, None) 506 self.config.parser.remove_option(_to_utf8(self.name), _to_utf8(key))591 options.pop(key, None) 507 592 508 593 509 594 def _get_registry(cls, compmgr=None): … … class ChoiceOption(Option): 645 730 """ 646 731 647 732 def __init__(self, section, name, choices, doc=''): 648 Option.__init__(self, section, name, _to_utf8(choices[0]), doc)649 self.choices = set( _to_utf8(choice).strip() for choice in choices)733 Option.__init__(self, section, name, to_unicode(choices[0]), doc) 734 self.choices = set(to_unicode(choice).strip() for choice in choices) 650 735 651 736 def accessor(self, section, name, default): 652 737 value = section.get(name, default) -
trac/tests/config.py
diff -r 49cb2d5c973c trac/tests/config.py
a b class ConfigurationTestCase(unittest.Tes 35 35 36 36 def tearDown(self): 37 37 Option.registry = self._orig_registry 38 os.remove(self.filename) 38 if os.path.isfile(self.filename): 39 os.remove(self.filename) 39 40 40 41 def _read(self): 41 42 return Configuration(self.filename) … … class ConfigurationTestCase(unittest.Tes 218 219 def test_set_and_save(self): 219 220 config = self._read() 220 221 config.set('b', u'öption0', 'y') 221 config.set(u'aä', 'öption0', 'x')222 config.set('aä', 'option2', "Voilà l'été") # UTF-8222 config.set(u'aä', u'öption0', 'x') 223 # config.set('aä', 'option2', "Voilà l'été") # UTF-8 no longer legit 223 224 config.set(u'aä', 'option1', u"Voilà l'été") # unicode 224 225 # Note: the following would depend on the locale.getpreferredencoding() 225 226 # config.set('a', 'option3', "Voil\xe0 l'\xe9t\xe9") # latin-1 226 227 self.assertEquals('x', config.get(u'aä', u'öption0')) 227 228 self.assertEquals(u"Voilà l'été", config.get(u'aä', 'option1')) 228 self.assertEquals(u"Voilà l'été", config.get(u'aä', 'option2'))229 #self.assertEquals(u"Voilà l'été", config.get(u'aä', 'option2')) 229 230 config.save() 230 231 231 232 configfile = open(self.filename, 'r') … … class ConfigurationTestCase(unittest.Tes 233 234 '\n', 234 235 '[aä]\n', 235 236 "option1 = Voilà l'été\n", 236 "option2 = Voilà l'été\n",237 #"option2 = Voilà l'été\n", 237 238 'öption0 = x\n', 238 239 # "option3 = Voilà l'été\n", 239 240 '\n', … … class ConfigurationTestCase(unittest.Tes 245 246 config2 = Configuration(self.filename) 246 247 self.assertEquals('x', config2.get(u'aä', u'öption0')) 247 248 self.assertEquals(u"Voilà l'été", config2.get(u'aä', 'option1')) 248 self.assertEquals(u"Voilà l'été", config2.get(u'aä', 'option2'))249 #self.assertEquals(u"Voilà l'été", config2.get(u'aä', 'option2')) 249 250 # self.assertEquals(u"Voilà l'été", config2.get('a', 'option3')) 250 251 251 252 def test_set_and_save_inherit(self): 252 253 def testcb(): 253 254 config = self._read() 254 config.set('a', 'option2', "Voilà l'été") # UTF-8255 #config.set('a', 'option2', "Voilà l'été") # UTF-8 255 256 config.set('a', 'option1', u"Voilà l'été") # unicode 256 257 self.assertEquals('x', config.get('a', 'option')) 257 258 self.assertEquals(u"Voilà l'été", config.get('a', 'option1')) 258 self.assertEquals(u"Voilà l'été", config.get('a', 'option2'))259 #self.assertEquals(u"Voilà l'été", config.get('a', 'option2')) 259 260 config.save() 260 261 261 262 configfile = open(self.filename, 'r') 262 263 self.assertEquals(['# -*- coding: utf-8 -*-\n', 263 264 '\n', 265 '[inherit]\n', 266 "file = trac-site.ini\n", 264 267 '[a]\n', 265 268 "option1 = Voilà l'été\n", 266 "option2 = Voilà l'été\n", 267 '\n', 268 '[inherit]\n', 269 "file = trac-site.ini\n", 269 #"option2 = Voilà l'été\n", 270 270 '\n'], 271 271 configfile.readlines()) 272 272 configfile.close() 273 273 config2 = Configuration(self.filename) 274 274 self.assertEquals('x', config2.get('a', 'option')) 275 275 self.assertEquals(u"Voilà l'été", config2.get('a', 'option1')) 276 self.assertEquals(u"Voilà l'été", config2.get('a', 'option2'))276 #self.assertEquals(u"Voilà l'été", config2.get('a', 'option2')) 277 277 self._test_with_inherit(testcb) 278 278 279 279 def test_simple_remove(self): … … class ConfigurationTestCase(unittest.Tes 335 335 self.assertEquals((u'öption', 'x'), iter(config.options(u'ä')).next()) 336 336 self.assertEquals(('option', 'y'), iter(config.options('b')).next()) 337 337 self.assertRaises(StopIteration, iter(config.options('c')).next) 338 self.assertEquals(u'öption', iter(config[ 'ä']).next())338 self.assertEquals(u'öption', iter(config[u'ä']).next()) 339 339 340 340 class Foo(object): 341 341 option_a = Option(u'ä', u'öption2', 'c') … … class ConfigurationTestCase(unittest.Tes 424 424 self.assertEqual(os.path.join(base, 'site4'), 425 425 config.getpath('c', 'path4', 'site4')) 426 426 finally: 427 os.remove(site2) 428 os.rmdir(os.path.dirname(site2)) 427 if os.path.isfile(site2): 428 os.remove(site2) 429 os.rmdir(os.path.dirname(site2)) 429 430 finally: 430 os.remove(site1) 431 os.rmdir(os.path.dirname(site1)) 431 if os.path.isfile(site1): 432 os.remove(site1) 433 os.rmdir(os.path.dirname(site1)) 432 434 433 435 def _test_with_inherit(self, testcb): 434 436 sitename = os.path.join(tempfile.gettempdir(), 'trac-site.ini') … … class ConfigurationTestCase(unittest.Tes 439 441 self._write(['[inherit]', 'file = trac-site.ini']) 440 442 testcb() 441 443 finally: 442 os.remove(sitename) 444 if os.path.isfile(sitename): 445 os.remove(sitename) 443 446 444 447 445 448 def suite():
