"""Access and/or modify INI files * Compatiable with ConfigParser * Preserves order of sections & options * Preserves comments/blank lines/etc * More conveninet access to data Example: >>> from io import StringIO >>> sio = StringIO('''# configure foo-application ... [foo] ... bar1 = qualia ... bar2 = 1977 ... [foo-ext] ... special = 1''') >>> cfg = INIConfig(sio) >>> print(cfg.foo.bar1) qualia >>> print(cfg['foo-ext'].special) 1 >>> cfg.foo.newopt = 'hi!' >>> cfg.baz.enabled = 0 >>> print(cfg) # configure foo-application [foo] bar1 = qualia bar2 = 1977 newopt = hi! [foo-ext] special = 1 [baz] enabled = 0 """ # An ini parser that supports ordered sections/options # Also supports updates, while preserving structure # Backward-compatible with ConfigParser import re from typing import Any, Callable, Dict, TextIO, Iterator, List, Optional, Set, Union from typing import TYPE_CHECKING from .configparser import DEFAULTSECT, ParsingError, MissingSectionHeaderError from . import config if TYPE_CHECKING: from compat import RawConfigParser class LineType: line: Optional[str] = None def __init__(self, line: Optional[str] = None) -> None: if line is not None: self.line = line.strip("\n") # Return the original line for unmodified objects # Otherwise construct using the current attribute values def __str__(self) -> str: if self.line is not None: return self.line else: return self.to_string() # If an attribute is modified after initialization # set line to None since it is no longer accurate. def __setattr__(self, name: str, value: object) -> None: if hasattr(self, name): self.__dict__["line"] = None self.__dict__[name] = value def to_string(self) -> str: # FIXME Raise NotImplementedError instead raise Exception("This method must be overridden in derived classes") class SectionLine(LineType): regex = re.compile(r"^\[" r"(?P[^]]+)" r"\]\s*" r"((?P;|#)(?P.*))?$") def __init__( self, name: str, comment: Optional[str] = None, comment_separator: Optional[str] = None, comment_offset: int = -1, line: Optional[str] = None, ) -> None: super().__init__(line) self.name: str = name self.comment: Optional[str] = comment self.comment_separator: Optional[str] = comment_separator self.comment_offset: int = comment_offset def to_string(self) -> str: out: str = "[" + self.name + "]" if self.comment is not None: # try to preserve indentation of comments out = (out + " ").ljust(self.comment_offset) out = out + self.comment_separator + self.comment return out @classmethod def parse(cls, line: str) -> Optional["SectionLine"]: m: Optional[re.Match] = cls.regex.match(line.rstrip()) if m is None: return None return cls(m.group("name"), m.group("comment"), m.group("csep"), m.start("csep"), line) class OptionLine(LineType): def __init__( self, name: str, value: object, separator: str = " = ", comment: Optional[str] = None, comment_separator: Optional[str] = None, comment_offset: int = -1, line: Optional[str] = None, ) -> None: super().__init__(line) self.name: str = name self.value: object = value self.separator: str = separator self.comment: Optional[str] = comment self.comment_separator: Optional[str] = comment_separator self.comment_offset: int = comment_offset def to_string(self) -> str: out: str = "%s%s%s" % (self.name, self.separator, self.value) if self.comment is not None: # try to preserve indentation of comments out = (out + " ").ljust(self.comment_offset) out = out + self.comment_separator + self.comment return out regex = re.compile(r"^(?P[^:=\s[][^:=]*)" r"(?P[:=]\s*)" r"(?P.*)$") @classmethod def parse(cls, line: str) -> Optional["OptionLine"]: m: Optional[re.Match] = cls.regex.match(line.rstrip()) if m is None: return None name: str = m.group("name").rstrip() value: str = m.group("value") sep: str = m.group("name")[len(name) :] + m.group("sep") # comments are not detected in the regex because # ensuring total compatibility with ConfigParser # requires that: # option = value ;comment // value=='value' # option = value;1 ;comment // value=='value;1 ;comment' # # Doing this in a regex would be complicated. I # think this is a bug. The whole issue of how to # include ';' in the value needs to be addressed. # Also, '#' doesn't mark comments in options... coff: int = value.find(";") if coff != -1 and value[coff - 1].isspace(): comment = value[coff + 1 :] csep = value[coff] value = value[:coff].rstrip() coff = m.start("value") + coff else: comment = None csep = None coff = -1 return cls(name, value, sep, comment, csep, coff, line) def change_comment_syntax(comment_chars: str = "%;#", allow_rem: bool = False) -> None: comment_chars: str = re.sub(r"([\]\-\^])", r"\\\1", comment_chars) regex: str = r"^(?P[%s]" % comment_chars if allow_rem: regex += "|[rR][eE][mM]" regex += r")(?P.*)$" CommentLine.regex = re.compile(regex) class CommentLine(LineType): regex: re.Pattern = re.compile(r"^(?P[;#]|[rR][eE][mM])" r"(?P.*)$") def __init__(self, comment: str = "", separator: str = "#", line: Optional[str] = None) -> None: super().__init__(line) self.comment: str = comment self.separator: str = separator def to_string(self) -> str: return self.separator + self.comment @classmethod def parse(cls, line: str) -> Optional["CommentLine"]: m: Optional[re.Match] = cls.regex.match(line.rstrip()) if m is None: return None return cls(m.group("comment"), m.group("csep"), line) class EmptyLine(LineType): # could make this a singleton def to_string(self) -> str: return "" value = property(lambda self: "") @classmethod def parse(cls, line: str) -> Optional["EmptyLine"]: if line.strip(): return None return cls(line) class ContinuationLine(LineType): regex: re.Pattern = re.compile(r"^\s+(?P.*)$") def __init__(self, value: str, value_offset: Optional[int] = None, line: Optional[str] = None) -> None: super().__init__(line) self.value = value if value_offset is None: value_offset = 8 self.value_offset: int = value_offset def to_string(self) -> str: return " " * self.value_offset + self.value @classmethod def parse(cls, line: str) -> Optional["ContinuationLine"]: m: Optional[re.Match] = cls.regex.match(line.rstrip()) if m is None: return None return cls(m.group("value"), m.start("value"), line) class LineContainer: def __init__(self, d: Optional[Union[List[LineType], LineType]] = None) -> None: self.contents = [] self.orgvalue: str = None if d: if isinstance(d, list): self.extend(d) else: self.add(d) def add(self, x: LineType) -> None: self.contents.append(x) def extend(self, x: List[LineType]) -> None: for i in x: self.add(i) def get_name(self) -> str: return self.contents[0].name def set_name(self, data: str) -> None: self.contents[0].name = data def get_value(self) -> str: if self.orgvalue is not None: return self.orgvalue elif len(self.contents) == 1: return self.contents[0].value else: return "\n".join([("%s" % x.value) for x in self.contents if not isinstance(x, CommentLine)]) def set_value(self, data: object) -> None: self.orgvalue = data lines: List[str] = ("%s" % data).split("\n") # If there is an existing ContinuationLine, use its offset value_offset: Optional[int] = None for v in self.contents: if isinstance(v, ContinuationLine): value_offset = v.value_offset break # Rebuild contents list, preserving initial OptionLine self.contents = self.contents[0:1] self.contents[0].value = lines[0] del lines[0] for line in lines: if line.strip(): self.add(ContinuationLine(line, value_offset)) else: self.add(EmptyLine()) def get_line_number(self) -> Optional[int]: return self.contents[0].line_number if self.contents else None name = property(get_name, set_name) value = property(get_value, set_value) line_number = property(get_line_number) def __str__(self) -> str: s: List[str] = [x.__str__() for x in self.contents] return "\n".join(s) def finditer(self, key: str) -> Iterator[Union[SectionLine, OptionLine]]: for x in self.contents[::-1]: if hasattr(x, "name") and x.name == key: yield x def find(self, key: str) -> Union[SectionLine, OptionLine]: for x in self.finditer(key): return x raise KeyError(key) def _make_xform_property(myattrname: str, srcattrname: Optional[str] = None) -> property: private_attrname: str = myattrname + "value" private_srcname: str = myattrname + "source" if srcattrname is None: srcattrname = myattrname def getfn(self) -> Callable: srcobj: Optional[object] = getattr(self, private_srcname) if srcobj is not None: return getattr(srcobj, srcattrname) else: return getattr(self, private_attrname) def setfn(self, value: Callable) -> None: srcobj: Optional[object] = getattr(self, private_srcname) if srcobj is not None: setattr(srcobj, srcattrname, value) else: setattr(self, private_attrname, value) return property(getfn, setfn) class INISection(config.ConfigNamespace): _lines: List[LineContainer] = None _options: Dict[str, object] = None _defaults: Optional["INISection"] = None _optionxformvalue: "INIConfig" = None _optionxformsource: "INIConfig" = None _compat_skip_empty_lines: Set[str] = set() def __init__( self, lineobj: LineContainer, defaults: Optional["INISection"] = None, optionxformvalue: Optional["INIConfig"] = None, optionxformsource: Optional["INIConfig"] = None, ) -> None: self._lines = [lineobj] self._defaults = defaults self._optionxformvalue = optionxformvalue self._optionxformsource = optionxformsource self._options = {} _optionxform = _make_xform_property("_optionxform") def _compat_get(self, key: str) -> str: # identical to __getitem__ except that _compat_XXX # is checked for backward-compatible handling if key == "__name__": return self._lines[-1].name if self._optionxform: key = self._optionxform(key) try: value: str = self._options[key].value del_empty: bool = key in self._compat_skip_empty_lines except KeyError: if self._defaults and key in self._defaults._options: value = self._defaults._options[key].value del_empty = key in self._defaults._compat_skip_empty_lines else: raise if del_empty: value = re.sub("\n+", "\n", value) return value def _getitem(self, key: str) -> object: if key == "__name__": return self._lines[-1].name if self._optionxform: key = self._optionxform(key) try: return self._options[key].value except KeyError: if self._defaults and key in self._defaults._options: return self._defaults._options[key].value else: raise def __setitem__(self, key: str, value: object) -> None: if self._optionxform: xkey = self._optionxform(key) else: xkey = key if xkey in self._compat_skip_empty_lines: self._compat_skip_empty_lines.remove(xkey) if xkey not in self._options: # create a dummy object - value may have multiple lines obj = LineContainer(OptionLine(key, "")) self._lines[-1].add(obj) self._options[xkey] = obj # the set_value() function in LineContainer # automatically handles multi-line values self._options[xkey].value = value def __delitem__(self, key: str) -> None: if self._optionxform: key = self._optionxform(key) if key in self._compat_skip_empty_lines: self._compat_skip_empty_lines.remove(key) for l in self._lines: remaining = [] for o in l.contents: if isinstance(o, LineContainer): n = o.name if self._optionxform: n = self._optionxform(n) if key != n: remaining.append(o) else: remaining.append(o) l.contents = remaining del self._options[key] def __iter__(self) -> Iterator[str]: d = set() for l in self._lines: for x in l.contents: if isinstance(x, LineContainer): if self._optionxform: ans = self._optionxform(x.name) else: ans = x.name if ans not in d: yield ans d.add(ans) if self._defaults: for x in self._defaults: if x not in d: yield x d.add(x) def _new_namespace(self, name): raise Exception("No sub-sections allowed", name) def make_comment(line: str) -> CommentLine: return CommentLine(line.rstrip("\n")) def readline_iterator(f: TextIO) -> Iterator[str]: """Iterate over a file by only using the file object's readline method.""" have_newline: bool = False while True: line: Optional[str] = f.readline() if not line: if have_newline: yield "" return if line.endswith("\n"): have_newline = True else: have_newline = False yield line def lower(x: str) -> str: return x.lower() class INIConfig(config.ConfigNamespace): _data: LineContainer = None _sections: Dict[str, object] = None _defaults: INISection = None _optionxformvalue: Callable = None _optionxformsource: Optional["INIConfig"] = None _sectionxformvalue: Optional["INIConfig"] = None _sectionxformsource: Optional["INIConfig"] = None _parse_exc = None _bom = False def __init__( self, fp: TextIO = None, defaults: Dict[str, object] = None, parse_exc: bool = True, optionxformvalue: Callable = lower, optionxformsource: Optional[Union["INIConfig", "RawConfigParser"]] = None, sectionxformvalue: Optional["INIConfig"] = None, sectionxformsource: Optional["INIConfig"] = None, ) -> None: self._data = LineContainer() self._parse_exc = parse_exc self._optionxformvalue = optionxformvalue self._optionxformsource = optionxformsource self._sectionxformvalue = sectionxformvalue self._sectionxformsource = sectionxformsource self._sections: Dict[str, INISection] = {} if defaults is None: defaults = {} self._defaults = INISection(LineContainer(), optionxformsource=self) for name, value in defaults.items(): self._defaults[name] = value if fp is not None: self._readfp(fp) _optionxform = _make_xform_property("_optionxform", "optionxform") _sectionxform = _make_xform_property("_sectionxform", "optionxform") def _getitem(self, key: str) -> INISection: if key == DEFAULTSECT: return self._defaults if self._sectionxform: key = self._sectionxform(key) return self._sections[key] def __setitem__(self, key: str, value: object): raise Exception("Values must be inside sections", key, value) def __delitem__(self, key: str) -> None: if self._sectionxform: key = self._sectionxform(key) for line in self._sections[key]._lines: self._data.contents.remove(line) del self._sections[key] def __iter__(self) -> Iterator[str]: d = set() d.add(DEFAULTSECT) for x in self._data.contents: if isinstance(x, LineContainer): if x.name not in d: yield x.name d.add(x.name) def _new_namespace(self, name: str) -> INISection: if self._data.contents: self._data.add(EmptyLine()) obj = LineContainer(SectionLine(name)) self._data.add(obj) if self._sectionxform: name = self._sectionxform(name) if name in self._sections: ns = self._sections[name] ns._lines.append(obj) else: ns = INISection(obj, defaults=self._defaults, optionxformsource=self) self._sections[name] = ns return ns def __str__(self) -> str: if self._bom: fmt = "\ufeff%s" else: fmt = "%s" return fmt % self._data.__str__() _line_types = [EmptyLine, CommentLine, SectionLine, OptionLine, ContinuationLine] def _parse(self, line: str) -> Any: for linetype in self._line_types: lineobj = linetype.parse(line) if lineobj: return lineobj else: # can't parse line return None def _readfp(self, fp: TextIO) -> None: cur_section = None cur_option = None cur_section_name = None cur_option_name = None pending_lines = [] pending_empty_lines = False try: fname = fp.name except AttributeError: fname = "" line_count = 0 exc = None line = None for line in readline_iterator(fp): # Check for BOM on first line if line_count == 0 and isinstance(line, str): if line[0] == "\ufeff": line = line[1:] self._bom = True line_obj = self._parse(line) line_count += 1 if not cur_section and not isinstance(line_obj, (CommentLine, EmptyLine, SectionLine)): if self._parse_exc: raise MissingSectionHeaderError(fname, line_count, line) else: line_obj = make_comment(line) if line_obj is None: if self._parse_exc: if exc is None: exc = ParsingError(fname) exc.append(line_count, line) line_obj = make_comment(line) if isinstance(line_obj, ContinuationLine): if cur_option: if pending_lines: cur_option.extend(pending_lines) pending_lines = [] if pending_empty_lines: optobj._compat_skip_empty_lines.add(cur_option_name) # noqa : F821 pending_empty_lines = False cur_option.add(line_obj) else: # illegal continuation line - convert to comment if self._parse_exc: if exc is None: exc = ParsingError(fname) exc.append(line_count, line) line_obj = make_comment(line) if isinstance(line_obj, OptionLine): if pending_lines: cur_section.extend(pending_lines) pending_lines = [] pending_empty_lines = False cur_option = LineContainer(line_obj) cur_section.add(cur_option) if self._optionxform: cur_option_name = self._optionxform(cur_option.name) else: cur_option_name = cur_option.name if cur_section_name == DEFAULTSECT: optobj = self._defaults else: optobj = self._sections[cur_section_name] optobj._options[cur_option_name] = cur_option if isinstance(line_obj, SectionLine): self._data.extend(pending_lines) pending_lines = [] pending_empty_lines = False cur_section = LineContainer(line_obj) self._data.add(cur_section) cur_option = None cur_option_name = None if cur_section.name == DEFAULTSECT: self._defaults._lines.append(cur_section) cur_section_name = DEFAULTSECT else: if self._sectionxform: cur_section_name = self._sectionxform(cur_section.name) else: cur_section_name = cur_section.name if cur_section_name not in self._sections: self._sections[cur_section_name] = INISection(cur_section, defaults=self._defaults, optionxformsource=self) else: self._sections[cur_section_name]._lines.append(cur_section) if isinstance(line_obj, (CommentLine, EmptyLine)): pending_lines.append(line_obj) if isinstance(line_obj, EmptyLine): pending_empty_lines = True if line_obj: line_obj.line_number = line_count self._data.extend(pending_lines) if line and line[-1] == "\n": self._data.add(EmptyLine()) if exc: raise exc