├── LICENSE ├── hello.ini ├── inifile.py ├── setup.py └── test.py /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 by Armin Ronacher. 2 | 3 | Some rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following 14 | disclaimer in the documentation and/or other materials provided 15 | with the distribution. 16 | 17 | * The names of the contributors may not be used to endorse or 18 | promote products derived from this software without specific 19 | prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 24 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 25 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 26 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 27 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 29 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 30 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 | -------------------------------------------------------------------------------- /hello.ini: -------------------------------------------------------------------------------- 1 | [foo] 2 | value = 42 3 | bar = "testing more stuff" 4 | mep = aha! 5 | mup = aha! 6 | 7 | [bar] 8 | x = 23 9 | -------------------------------------------------------------------------------- /inifile.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import uuid 4 | import errno 5 | import tempfile 6 | from collections import OrderedDict 7 | try: 8 | from collections.abc import MutableMapping 9 | except ImportError: 10 | from collections import MutableMapping 11 | 12 | 13 | PY2 = sys.version_info[0] == 2 14 | WIN = sys.platform.startswith('win') 15 | 16 | 17 | if PY2: 18 | text_type = unicode 19 | string_types = (str, unicode) 20 | integer_types = (int, long) 21 | iteritems = lambda x: x.iteritems() 22 | exec('def reraise(tp, value, tb=None):\n raise tp, value, tb') 23 | else: 24 | text_type = str 25 | string_types = (str,) 26 | integer_types = (int,) 27 | iteritems = lambda x: iter(x.items()) 28 | def reraise(tp, value, tb=None): 29 | if value.__traceback__ is not tb: 30 | raise value.with_traceback(tb) 31 | raise value 32 | 33 | 34 | def _posixify(name): 35 | return '-'.join(name.split()).lower() 36 | 37 | 38 | def iter_from_file(f, encoding=None): 39 | if encoding is None: 40 | encoding = 'utf-8-sig' 41 | return (x.decode(encoding, 'replace') for x in f) 42 | 43 | 44 | def get_app_dir(app_name, roaming=True, force_posix=False): 45 | r"""Returns the config folder for the application. The default behavior 46 | is to return whatever is most appropriate for the operating system. 47 | 48 | To give you an idea, for an app called ``"Foo Bar"``, something like 49 | the following folders could be returned: 50 | 51 | Mac OS X: 52 | ``~/Library/Application Support/Foo Bar`` 53 | Mac OS X (POSIX): 54 | ``~/.foo-bar`` 55 | Unix: 56 | ``~/.config/foo-bar`` 57 | Unix (POSIX): 58 | ``~/.foo-bar`` 59 | Win XP (roaming): 60 | ``C:\Documents and Settings\\Local Settings\Application Data\Foo Bar`` 61 | Win XP (not roaming): 62 | ``C:\Documents and Settings\\Application Data\Foo Bar`` 63 | Win 7 (roaming): 64 | ``C:\Users\\AppData\Roaming\Foo Bar`` 65 | Win 7 (not roaming): 66 | ``C:\Users\\AppData\Local\Foo Bar`` 67 | 68 | :param app_name: the application name. This should be properly capitalized 69 | and can contain whitespace. 70 | :param roaming: controls if the folder should be roaming or not on Windows. 71 | Has no affect otherwise. 72 | :param force_posix: if this is set to `True` then on any POSIX system the 73 | folder will be stored in the home folder with a leading 74 | dot instead of the XDG config home or darwin's 75 | application support folder. 76 | """ 77 | if WIN: 78 | key = roaming and 'APPDATA' or 'LOCALAPPDATA' 79 | folder = os.environ.get(key) 80 | if folder is None: 81 | folder = os.path.expanduser('~') 82 | return os.path.join(folder, app_name) 83 | if force_posix: 84 | return os.path.join(os.path.expanduser('~/.' + _posixify(app_name))) 85 | if sys.platform == 'darwin': 86 | return os.path.join(os.path.expanduser( 87 | '~/Library/Application Support'), app_name) 88 | return os.path.join( 89 | os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config')), 90 | _posixify(app_name)) 91 | 92 | 93 | class Dialect(object): 94 | """This class allows customizing the dialect of the ini file. The 95 | default configuration is a compromise between the general Windows 96 | format and what's common on Unix systems. 97 | 98 | Example dialect config:: 99 | 100 | unix_dialect = Dialect( 101 | kv_sep=': ', 102 | quotes=("'",), 103 | comments=('#',), 104 | ) 105 | 106 | :param ns_sep: the namespace separator. This character is used to 107 | create hierarchical structures in sections and also 108 | placed between section and field. 109 | :param kv_sep: the separator to be placed between key and value. For 110 | parsing whitespace is automatically removed. 111 | :param quotes: a list of quote characters supported for strings. The 112 | leftmost one is automatically used for serialization, 113 | the others are supported for deserialization. 114 | :param true: strings that should be considered boolean true. 115 | :param false: strings that should be considered boolean false. 116 | :param comments: comment start markers. 117 | :param allow_escaping: enables or disables backslash escapes. 118 | :param linesep: a specific line separator to use other than the 119 | operating system's default. 120 | """ 121 | 122 | def __init__(self, ns_sep='.', kv_sep=' = ', quotes=('"', "'"), 123 | true=('true', 'yes', '1'), false=('false', 'no', '0'), 124 | comments=('#', ';'), allow_escaping=True, linesep=None): 125 | self.ns_sep = ns_sep 126 | self.kv_sep = kv_sep 127 | self.plain_kv_sep = kv_sep.strip() 128 | self.quotes = quotes 129 | self.true = true 130 | self.false = false 131 | self.comments = comments 132 | self.allow_escaping = allow_escaping 133 | self.linesep = linesep 134 | 135 | def get_actual_linesep(self): 136 | if self.linesep is None: 137 | return os.linesep 138 | return self.linesep 139 | 140 | def get_strippable_lineseps(self): 141 | if self.linesep is None or self.linesep in '\r\n': 142 | return '\r\n' 143 | return self.linesep 144 | 145 | def kv_serialize(self, key, val): 146 | if val is None: 147 | return None 148 | if self.quotes and val.split() != [val]: 149 | q = self.quotes[0] 150 | if self.allow_escaping: 151 | val = self.escape(val, q) 152 | val = '%s%s%s' % (q, val, q) 153 | return '%s%s%s' % (key, self.kv_sep, val) 154 | 155 | def escape(self, value, quote=None): 156 | value = value \ 157 | .replace('\\', '\\\\') \ 158 | .replace('\n', '\\n') \ 159 | .replace('\r', '\\r') \ 160 | .replace('\t', '\\t') 161 | for q in self.quotes: 162 | if q != quote: 163 | value = value.replace(q, '\\' + q) 164 | return value 165 | 166 | def unescape(self, value): 167 | value = value \ 168 | .replace('\\n', '\n') \ 169 | .replace('\\r', '\r') \ 170 | .replace('\\t', '\t') \ 171 | .replace('\\"', '"') 172 | for q in self.quotes: 173 | value = value.replace('\\' + q, q) 174 | return value 175 | 176 | def to_string(self, value): 177 | if value is True: 178 | return self.true[0] 179 | if value is False: 180 | return self.false[0] 181 | if isinstance(value, integer_types) or isinstance(value, float): 182 | return text_type(value) 183 | if not isinstance(value, string_types): 184 | raise TypeError('Cannot set value of this type') 185 | return text_type(value) 186 | 187 | def dict_from_iterable(self, iterable): 188 | """Builds a mapping of values out of an iterable of lines.""" 189 | mapping = OrderedDict() 190 | for token, _, data in self.tokenize(iterable): 191 | if token == 'KV': 192 | section, key, value = data 193 | mapping[self.ns_sep.join(section + (key,))] = value 194 | return mapping 195 | 196 | def tokenize(self, iterable): 197 | """Tokenizes an iterable of lines.""" 198 | section = () 199 | 200 | line_strip = self.get_strippable_lineseps() 201 | 202 | for line in iterable: 203 | line = line.rstrip(line_strip) 204 | if not line.strip(): 205 | yield 'EMPTY', line, None 206 | elif line.lstrip()[:1] in self.comments: 207 | yield 'COMMENT', line, None 208 | elif line[:1] == '[' and line[-1:] == ']': 209 | section = tuple(line[1:-1].strip().split(self.ns_sep)) 210 | yield 'SECTION', line, section 211 | elif self.plain_kv_sep in line: 212 | key, value = line.split(self.plain_kv_sep, 1) 213 | value = value.strip() 214 | if value[:1] in self.quotes and value[:1] == value[-1:]: 215 | value = value[1:-1] 216 | if self.allow_escaping: 217 | value = self.unescape(value) 218 | yield 'KV', line, (section, key.strip(), value) 219 | 220 | def update_tokens(self, old_tokens, changes): 221 | """Given the tokens returned from :meth:`tokenize` and a dictionary 222 | of new values (or `None` for values to be deleted) returns a new 223 | list of tokens that should be written back to a file. 224 | """ 225 | new_tokens = [] 226 | section_ends = {None: 0} 227 | pending_changes = dict(changes) 228 | 229 | for token, line, data in old_tokens: 230 | if token == 'KV': 231 | section, key, value = data 232 | k = self.ns_sep.join(section + (key,)) 233 | if k in pending_changes: 234 | value = pending_changes.pop(k) 235 | line = self.kv_serialize(key, value) 236 | data = (section, key, value) 237 | section_ends[self.ns_sep.join(section)] = len(new_tokens) 238 | elif token == 'SECTION': 239 | section_ends[self.ns_sep.join(data)] = len(new_tokens) 240 | new_tokens.append((token, line, data)) 241 | 242 | pending_by_sec = {} 243 | for key, value in sorted(pending_changes.items()): 244 | section, local_key = key.rsplit(self.ns_sep, 1) 245 | pending_by_sec.setdefault(section, []).append((local_key, value)) 246 | 247 | if pending_by_sec: 248 | section_ends_r = dict((v, k) for k, v in section_ends.items()) 249 | final_lines = [] 250 | 251 | for idx, (token, line, data) in enumerate(new_tokens): 252 | final_lines.append((token, line, data)) 253 | section = section_ends_r.get(idx) 254 | if section is not None and section in pending_by_sec: 255 | for local_key, value in pending_by_sec.pop(section): 256 | final_lines.append(( 257 | 'KV', 258 | self.kv_serialize(local_key, value), 259 | (section, local_key, value), 260 | )) 261 | 262 | for section, items in sorted(pending_by_sec.items()): 263 | if final_lines: 264 | final_lines.append(('EMPTY', u'', None)) 265 | final_lines.append(('SECTION', u'[%s]' % section, section)) 266 | for local_key, value in items: 267 | final_lines.append(( 268 | 'KV', 269 | self.kv_serialize(local_key, value), 270 | (section, local_key, value), 271 | )) 272 | 273 | new_tokens = final_lines 274 | 275 | return [x for x in new_tokens if x[1] is not None] 276 | 277 | 278 | default_dialect = Dialect() 279 | 280 | 281 | class IniData(MutableMapping): 282 | """This object behaves similar to a dictionary but it tracks 283 | modifications properly so that it can later write them back to an INI 284 | file with the help of the ini dialect, without destroying ordering or 285 | comments. 286 | 287 | This is rarely used directly, instead the :class:`IniFile` is normally 288 | used. 289 | 290 | This generally works similar to a dictionary and exposes the same 291 | basic API. 292 | """ 293 | 294 | def __init__(self, mapping=None, dialect=None): 295 | if dialect is None: 296 | dialect = default_dialect 297 | self.dialect = dialect 298 | if mapping is None: 299 | mapping = {} 300 | self._primary = mapping 301 | self._changes = {} 302 | 303 | @property 304 | def is_dirty(self): 305 | """This is true if the data was modified.""" 306 | return bool(self._changes) 307 | 308 | def get_updated_lines(self, line_iter=None): 309 | """Reconciles the updates in the ini data with the iterator of 310 | lines from the source file and returns a list of the new lines 311 | as they should be written into the file. 312 | """ 313 | return self.dialect.update_tokens(line_iter or (), self._changes) 314 | 315 | def discard(self): 316 | """Discards all local modifications in the ini data.""" 317 | self._changes.clear() 318 | 319 | def rollover(self): 320 | """Rolls all local modifications to the primary data. After this 321 | modifications are no longer tracked and `get_updated_lines` will 322 | not return them. 323 | """ 324 | self._primary = OrderedDict(self.iteritems()) 325 | self.discard() 326 | 327 | def to_dict(self): 328 | """Returns the current ini data as dictionary.""" 329 | return dict(self.iteritems()) 330 | 331 | def __len__(self): 332 | rv = len(self._primary) 333 | for key, value in iteritems(self._changes): 334 | if key in self._primary and value is not None: 335 | rv += 1 336 | return rv 337 | 338 | def get(self, name, default=None): 339 | """Return a value for a key or return a default if the key does 340 | not exist. 341 | """ 342 | try: 343 | return self[name] 344 | except KeyError: 345 | return default 346 | 347 | def get_ascii(self, name, default=None): 348 | """This returns a value for a key for as long as the value fits 349 | into ASCII. Otherwise (or if the key does not exist) the default 350 | is returned. This is especially useful on Python 2 when working 351 | with some APIs that do not support unicode. 352 | """ 353 | try: 354 | rv = self[name] 355 | try: 356 | rv.encode('ascii') 357 | except UnicodeError: 358 | raise KeyError() 359 | if PY2: 360 | rv = str(rv) 361 | return rv 362 | except KeyError: 363 | return default 364 | 365 | def get_bool(self, name, default=False): 366 | """Returns a value as boolean. What constitutes as a valid boolean 367 | value depends on the dialect. 368 | """ 369 | try: 370 | rv = self[name].lower() 371 | if rv in self.dialect.true: 372 | return True 373 | if rv in self.dialect.false: 374 | return False 375 | raise KeyError() 376 | except KeyError: 377 | return default 378 | 379 | def get_int(self, name, default=None): 380 | """Returns a value as integer.""" 381 | try: 382 | return int(self[name]) 383 | except (ValueError, KeyError): 384 | return default 385 | 386 | def get_float(self, name, default=None): 387 | """Returns a value as float.""" 388 | try: 389 | return float(self[name]) 390 | except (ValueError, KeyError): 391 | return default 392 | 393 | def get_uuid(self, name, default=None): 394 | """Returns a value as uuid.""" 395 | try: 396 | return uuid.UUID(self[name]) 397 | except Exception: 398 | return default 399 | 400 | def itersections(self): 401 | """Iterates over the sections of the sections of the ini.""" 402 | seen = set() 403 | sep = self.dialect.ns_sep 404 | for key in self: 405 | if sep in key: 406 | section = key.rsplit(sep, 1)[0] 407 | if section not in seen: 408 | seen.add(section) 409 | yield section 410 | 411 | if PY2: 412 | def sections(self): 413 | """Returns a list of the sections in the ini file.""" 414 | return list(self.itersections()) 415 | else: 416 | sections = itersections 417 | 418 | def iteritems(self): 419 | for key in self._primary: 420 | try: 421 | yield key, self[key] 422 | except LookupError: 423 | pass 424 | for key in self._changes: 425 | if key not in self._primary: 426 | try: 427 | yield key, self[key] 428 | except LookupError: 429 | pass 430 | 431 | def iterkeys(self): 432 | for key, _ in self.iteritems(): 433 | yield key 434 | 435 | def itervalues(self): 436 | for _, value in self.iteritems(): 437 | yield value 438 | 439 | __iter__ = iterkeys 440 | 441 | if PY2: 442 | def keys(self): 443 | return list(self.iterkeys()) 444 | 445 | def values(self): 446 | return list(self.iterkeys()) 447 | 448 | def items(self): 449 | return list(self.iteritems()) 450 | else: 451 | keys = iterkeys 452 | values = itervalues 453 | items = iteritems 454 | 455 | def section_as_dict(self, section): 456 | rv = {} 457 | prefix = section + '.' 458 | for key, value in self.iteritems(): 459 | if key.startswith(prefix): 460 | rv[key[len(prefix):]] = value 461 | return rv 462 | 463 | def __getitem__(self, name): 464 | if name in self._changes: 465 | rv = self._changes[name] 466 | if rv is None: 467 | raise KeyError(name) 468 | return rv 469 | return self._primary[name] 470 | 471 | def __setitem__(self, name, value): 472 | self._changes[name] = self.dialect.to_string(value) 473 | 474 | def __delitem__(self, name): 475 | self._changes[name] = None 476 | 477 | 478 | class IniFile(IniData): 479 | """This class implements simplified read and write access to INI files 480 | in a way that preserves the original files as good as possible. Unlike 481 | a regular INI serializer it only overwrites the lines that were modified. 482 | 483 | Example usage:: 484 | 485 | ifile = IniFile('myfile.ini') 486 | ifile['ui.username'] = 'john_doe' 487 | ifile.save() 488 | 489 | The ini file exposes unicode strings but utility methods are provided 490 | for common type conversion. The default namespace separator is a dot 491 | (``.``). 492 | 493 | The format of the file can be configured by providing a custom 494 | :class:`Dialect` instance to the constructor. 495 | """ 496 | 497 | def __init__(self, filename, encoding=None, dialect=None): 498 | if dialect is None: 499 | dialect = default_dialect 500 | self.filename = os.path.abspath(filename) 501 | self.encoding = encoding 502 | 503 | try: 504 | with open(filename, 'rb') as f: 505 | mapping = dialect.dict_from_iterable( 506 | iter_from_file(f, self.encoding)) 507 | is_new = False 508 | except IOError as e: 509 | if e.errno != errno.ENOENT: 510 | raise 511 | is_new = True 512 | mapping = OrderedDict() 513 | 514 | IniData.__init__(self, mapping, dialect) 515 | 516 | #: If this is `true` the file did not exist yet (it is new). This 517 | #: can be used to fill it with some defaults. 518 | self.is_new = is_new 519 | 520 | def save(self, create_folder=False): 521 | """Saves all modifications back to the file. By default the folder 522 | in which the file is placed needs to exist. 523 | """ 524 | # No modifications means no write. 525 | if not self.is_dirty: 526 | return 527 | 528 | enc = self.encoding 529 | if enc is None: 530 | enc = 'utf-8' 531 | linesep = self.dialect.get_actual_linesep() 532 | 533 | if create_folder: 534 | folder = os.path.dirname(self.filename) 535 | try: 536 | os.makedirs(folder) 537 | except OSError: 538 | pass 539 | 540 | try: 541 | with open(self.filename, 'rb') as f: 542 | old_tokens = list(self.dialect.tokenize( 543 | iter_from_file(f, self.encoding))) 544 | except IOError: 545 | old_tokens = [] 546 | 547 | fd, tmp_filename = tempfile.mkstemp( 548 | dir=os.path.dirname(self.filename), prefix='.__atomic-write') 549 | try: 550 | with os.fdopen(fd, 'wb') as f: 551 | new_tokens = self.get_updated_lines(old_tokens) 552 | for _, line, _ in new_tokens: 553 | f.write((line + linesep).encode(enc)) 554 | except: 555 | exc_info = sys.exc_info() 556 | try: 557 | os.remove(tmp_filename) 558 | except OSError: 559 | pass 560 | reraise(*exc_info) 561 | 562 | if hasattr(os, 'replace'): 563 | os.replace(tmp_filename, self.filename) 564 | else: 565 | try: 566 | os.rename(tmp_filename, self.filename) 567 | except OSError: 568 | if os.name == 'nt': 569 | os.remove(self.filename) 570 | os.rename(tmp_filename, self.filename) 571 | else: 572 | raise 573 | self.rollover() 574 | self.is_new = False 575 | 576 | 577 | class AppIniFile(IniFile): 578 | """This works exactly the same as :class:`IniFile` but the ini files 579 | are placed by default in an application config directory. This uses 580 | the function :func:`get_app_dir` internally to calculate the path 581 | to it. Also by default the :meth:`~IniFile.save` method will create 582 | the folder if it did not exist yet. 583 | 584 | Example:: 585 | 586 | from inifile import AppIniFile 587 | 588 | config = AppIniFile('My App', 'my_config.ini') 589 | config['ui.user_colors'] = True 590 | config['ui.colorscheme'] = 'tango' 591 | config.save() 592 | """ 593 | 594 | def __init__(self, app_name, filename, roaming=True, force_posix=False, 595 | encoding=None, dialect=None): 596 | app_dir = get_app_dir(app_name, roaming=roaming, 597 | force_posix=force_posix) 598 | IniFile.__init__(self, os.path.join(app_dir, filename), 599 | encoding=encoding, dialect=dialect) 600 | 601 | def save(self, create_folder=True): 602 | return IniFile.save(self, create_folder=create_folder) 603 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='inifile', 5 | license='BSD', 6 | author='Armin Ronacher', 7 | author_email='armin.ronacher@active-4.com', 8 | description='A small INI library for Python.', 9 | version='0.4.1', 10 | py_modules=['inifile'], 11 | ) 12 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | from inifile import IniFile 2 | 3 | i = IniFile('hello.ini') 4 | i['foo.bar'] = 'testing more stuff' 5 | i['foo.mup'] = 'aha!' 6 | print i.get_updated_lines() 7 | i.save() 8 | --------------------------------------------------------------------------------