__init__.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. """brain-dead simple parser for ini-style files.
  2. (C) Ronny Pfannschmidt, Holger Krekel -- MIT licensed
  3. """
  4. import os
  5. from collections.abc import Callable
  6. from collections.abc import Iterator
  7. from collections.abc import Mapping
  8. from typing import Final
  9. from typing import TypeVar
  10. from typing import overload
  11. __all__ = ["IniConfig", "ParseError", "COMMENTCHARS", "iscommentline"]
  12. from . import _parse
  13. from ._parse import COMMENTCHARS
  14. from ._parse import iscommentline
  15. from .exceptions import ParseError
  16. _D = TypeVar("_D")
  17. _T = TypeVar("_T")
  18. class SectionWrapper:
  19. config: Final["IniConfig"]
  20. name: Final[str]
  21. def __init__(self, config: "IniConfig", name: str) -> None:
  22. self.config = config
  23. self.name = name
  24. def lineof(self, name: str) -> int | None:
  25. return self.config.lineof(self.name, name)
  26. @overload
  27. def get(self, key: str) -> str | None: ...
  28. @overload
  29. def get(
  30. self,
  31. key: str,
  32. convert: Callable[[str], _T],
  33. ) -> _T | None: ...
  34. @overload
  35. def get(
  36. self,
  37. key: str,
  38. default: None,
  39. convert: Callable[[str], _T],
  40. ) -> _T | None: ...
  41. @overload
  42. def get(self, key: str, default: _D, convert: None = None) -> str | _D: ...
  43. @overload
  44. def get(
  45. self,
  46. key: str,
  47. default: _D,
  48. convert: Callable[[str], _T],
  49. ) -> _T | _D: ...
  50. # TODO: investigate possible mypy bug wrt matching the passed over data
  51. def get( # type: ignore [misc]
  52. self,
  53. key: str,
  54. default: _D | None = None,
  55. convert: Callable[[str], _T] | None = None,
  56. ) -> _D | _T | str | None:
  57. return self.config.get(self.name, key, convert=convert, default=default)
  58. def __getitem__(self, key: str) -> str:
  59. return self.config.sections[self.name][key]
  60. def __iter__(self) -> Iterator[str]:
  61. section: Mapping[str, str] = self.config.sections.get(self.name, {})
  62. def lineof(key: str) -> int:
  63. return self.config.lineof(self.name, key) # type: ignore[return-value]
  64. yield from sorted(section, key=lineof)
  65. def items(self) -> Iterator[tuple[str, str]]:
  66. for name in self:
  67. yield name, self[name]
  68. class IniConfig:
  69. path: Final[str]
  70. sections: Final[Mapping[str, Mapping[str, str]]]
  71. _sources: Final[Mapping[tuple[str, str | None], int]]
  72. def __init__(
  73. self,
  74. path: str | os.PathLike[str],
  75. data: str | None = None,
  76. encoding: str = "utf-8",
  77. *,
  78. _sections: Mapping[str, Mapping[str, str]] | None = None,
  79. _sources: Mapping[tuple[str, str | None], int] | None = None,
  80. ) -> None:
  81. self.path = os.fspath(path)
  82. # Determine sections and sources
  83. if _sections is not None and _sources is not None:
  84. # Use provided pre-parsed data (called from parse())
  85. sections_data = _sections
  86. sources = _sources
  87. else:
  88. # Parse the data (backward compatible path)
  89. if data is None:
  90. with open(self.path, encoding=encoding) as fp:
  91. data = fp.read()
  92. # Use old behavior (no stripping) for backward compatibility
  93. sections_data, sources = _parse.parse_ini_data(
  94. self.path, data, strip_inline_comments=False
  95. )
  96. # Assign once to Final attributes
  97. self._sources = sources
  98. self.sections = sections_data
  99. @classmethod
  100. def parse(
  101. cls,
  102. path: str | os.PathLike[str],
  103. data: str | None = None,
  104. encoding: str = "utf-8",
  105. *,
  106. strip_inline_comments: bool = True,
  107. strip_section_whitespace: bool = False,
  108. ) -> "IniConfig":
  109. """Parse an INI file.
  110. Args:
  111. path: Path to the INI file (used for error messages)
  112. data: Optional INI content as string. If None, reads from path.
  113. encoding: Encoding to use when reading the file (default: utf-8)
  114. strip_inline_comments: Whether to strip inline comments from values
  115. (default: True). When True, comments starting with # or ; are
  116. removed from values, matching the behavior for section comments.
  117. strip_section_whitespace: Whether to strip whitespace from section and key names
  118. (default: False). When True, strips Unicode whitespace from section and key names,
  119. addressing issue #4. When False, preserves existing behavior for backward compatibility.
  120. Returns:
  121. IniConfig instance with parsed configuration
  122. Example:
  123. # With comment stripping (default):
  124. config = IniConfig.parse("setup.cfg")
  125. # value = "foo" instead of "foo # comment"
  126. # Without comment stripping (old behavior):
  127. config = IniConfig.parse("setup.cfg", strip_inline_comments=False)
  128. # value = "foo # comment"
  129. # With section name stripping (opt-in for issue #4):
  130. config = IniConfig.parse("setup.cfg", strip_section_whitespace=True)
  131. # section names and keys have Unicode whitespace stripped
  132. """
  133. fspath = os.fspath(path)
  134. if data is None:
  135. with open(fspath, encoding=encoding) as fp:
  136. data = fp.read()
  137. sections_data, sources = _parse.parse_ini_data(
  138. fspath,
  139. data,
  140. strip_inline_comments=strip_inline_comments,
  141. strip_section_whitespace=strip_section_whitespace,
  142. )
  143. # Call constructor with pre-parsed sections and sources
  144. return cls(path=fspath, _sections=sections_data, _sources=sources)
  145. def lineof(self, section: str, name: str | None = None) -> int | None:
  146. lineno = self._sources.get((section, name))
  147. return None if lineno is None else lineno + 1
  148. @overload
  149. def get(
  150. self,
  151. section: str,
  152. name: str,
  153. ) -> str | None: ...
  154. @overload
  155. def get(
  156. self,
  157. section: str,
  158. name: str,
  159. convert: Callable[[str], _T],
  160. ) -> _T | None: ...
  161. @overload
  162. def get(
  163. self,
  164. section: str,
  165. name: str,
  166. default: None,
  167. convert: Callable[[str], _T],
  168. ) -> _T | None: ...
  169. @overload
  170. def get(
  171. self, section: str, name: str, default: _D, convert: None = None
  172. ) -> str | _D: ...
  173. @overload
  174. def get(
  175. self,
  176. section: str,
  177. name: str,
  178. default: _D,
  179. convert: Callable[[str], _T],
  180. ) -> _T | _D: ...
  181. def get( # type: ignore
  182. self,
  183. section: str,
  184. name: str,
  185. default: _D | None = None,
  186. convert: Callable[[str], _T] | None = None,
  187. ) -> _D | _T | str | None:
  188. try:
  189. value: str = self.sections[section][name]
  190. except KeyError:
  191. return default
  192. else:
  193. if convert is not None:
  194. return convert(value)
  195. else:
  196. return value
  197. def __getitem__(self, name: str) -> SectionWrapper:
  198. if name not in self.sections:
  199. raise KeyError(name)
  200. return SectionWrapper(self, name)
  201. def __iter__(self) -> Iterator[SectionWrapper]:
  202. for name in sorted(self.sections, key=self.lineof): # type: ignore
  203. yield SectionWrapper(self, name)
  204. def __contains__(self, arg: str) -> bool:
  205. return arg in self.sections