configupdater.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. """As the main entry point of the ConfigUpdater library, this module is responsible
  2. for combining the data layer provided by the :mod:`configupdater.document` module
  3. and the parsing capabilities of :mod:`configupdater.parser`.
  4. To complete the API, this module adds file handling functions, so that you can read a
  5. configuration file from the disk, change it to your liking and save the updated
  6. content.
  7. """
  8. import sys
  9. from configparser import Error
  10. from types import MappingProxyType as ReadOnlyMapping
  11. from typing import Optional, TextIO, Tuple, TypeVar
  12. if sys.version_info[:2] >= (3, 9): # pragma: no cover
  13. from collections.abc import Iterable, Mapping
  14. List = list
  15. Dict = dict
  16. else: # pragma: no cover
  17. from typing import Iterable, Mapping
  18. from .block import (
  19. AlreadyAttachedError,
  20. AssignMultilineValueError,
  21. Comment,
  22. NotAttachedError,
  23. Space,
  24. )
  25. from .document import Document
  26. from .option import NoneValueDisallowed, Option
  27. from .parser import Parser, PathLike
  28. from .section import Section
  29. __all__ = [
  30. "ConfigUpdater",
  31. "Section",
  32. "Option",
  33. "Comment",
  34. "Space",
  35. "Parser",
  36. "AssignMultilineValueError",
  37. "NoConfigFileReadError",
  38. "NoneValueDisallowed",
  39. "NotAttachedError",
  40. "AlreadyAttachedError",
  41. ]
  42. T = TypeVar("T", bound="ConfigUpdater")
  43. class NoConfigFileReadError(Error):
  44. """Raised when no configuration file was read but update requested."""
  45. def __init__(self):
  46. super().__init__("No configuration file was yet read! Use .read(...) first.")
  47. class ConfigUpdater(Document):
  48. """Tool to parse and modify existing ``cfg`` files.
  49. ConfigUpdater follows the API of ConfigParser with some differences:
  50. * inline comments are treated as part of a key's value,
  51. * only a single config file can be updated at a time,
  52. * the original case of sections and keys are kept,
  53. * control over the position of a new section/key.
  54. Following features are **deliberately not** implemented:
  55. * interpolation of values,
  56. * propagation of parameters from the default section,
  57. * conversions of values,
  58. * passing key/value-pairs with ``default`` argument,
  59. * non-strict mode allowing duplicate sections and keys.
  60. **ConfigUpdater** objects can be created by passing the same kind of arguments
  61. accepted by the :class:`Parser`. After a ConfigUpdater object is created, you can
  62. load some content into it by calling any of the ``read*`` methods
  63. (:meth:`read`, :meth:`read_file` and :meth:`read_string`).
  64. Once the content is loaded you can use the ConfigUpdater object more or less in the
  65. same way you would use a nested dictionary. Please have a look into
  66. :class:`Document` to understand the main differences.
  67. When you are done changing the configuration file, you can call :meth:`write` or
  68. :meth:`update_file` methods.
  69. """
  70. def __init__(
  71. self,
  72. allow_no_value=False,
  73. *,
  74. delimiters: Tuple[str, ...] = ("=", ":"),
  75. comment_prefixes: Tuple[str, ...] = ("#", ";"),
  76. inline_comment_prefixes: Optional[Tuple[str, ...]] = None,
  77. strict: bool = True,
  78. empty_lines_in_values: bool = True,
  79. space_around_delimiters: bool = True,
  80. ):
  81. self._parser_opts = {
  82. "allow_no_value": allow_no_value,
  83. "delimiters": delimiters,
  84. "comment_prefixes": comment_prefixes,
  85. "inline_comment_prefixes": inline_comment_prefixes,
  86. "strict": strict,
  87. "empty_lines_in_values": empty_lines_in_values,
  88. "space_around_delimiters": space_around_delimiters,
  89. }
  90. self._syntax_options = ReadOnlyMapping(self._parser_opts)
  91. self._filename: Optional[PathLike] = None
  92. super().__init__()
  93. def _instantiate_copy(self: T) -> T:
  94. """Will be called by ``Container.__deepcopy__``"""
  95. clone = self.__class__(**self._parser_opts)
  96. clone.optionxform = self.optionxform # type: ignore[method-assign]
  97. clone._filename = self._filename
  98. return clone
  99. def _parser(self, **kwargs):
  100. opts = {"optionxform": self.optionxform, **self._parser_opts, **kwargs}
  101. return Parser(**opts)
  102. @property
  103. def syntax_options(self) -> Mapping:
  104. return self._syntax_options
  105. def read(self: T, filename: PathLike, encoding: Optional[str] = None) -> T:
  106. """Read and parse a filename.
  107. Args:
  108. filename (str): path to file
  109. encoding (str): encoding of file, default None
  110. """
  111. self.clear()
  112. self._filename = filename
  113. return self._parser().read(filename, encoding, self)
  114. def read_file(self: T, f: Iterable[str], source: Optional[str] = None) -> T:
  115. """Like read() but the argument must be a file-like object.
  116. The ``f`` argument must be iterable, returning one line at a time.
  117. Optional second argument is the ``source`` specifying the name of the
  118. file being read. If not given, it is taken from f.name. If ``f`` has no
  119. ``name`` attribute, ``<???>`` is used.
  120. Args:
  121. f: file like object
  122. source (str): reference name for file object, default None
  123. """
  124. self.clear()
  125. if hasattr(f, "name"):
  126. self._filename = f.name
  127. return self._parser().read_file(f, source, self)
  128. def read_string(self: T, string: str, source="<string>") -> T:
  129. """Read configuration from a given string.
  130. Args:
  131. string (str): string containing a configuration
  132. source (str): reference name for file object, default '<string>'
  133. """
  134. self.clear()
  135. return self._parser().read_string(string, source, self)
  136. def write(self, fp: TextIO, validate: bool = True):
  137. # TODO: For Python>=3.8 instead of TextIO we can define a Writeable protocol
  138. """Write an .cfg/.ini-format representation of the configuration state.
  139. Args:
  140. fp (file-like object): open file handle
  141. validate (Boolean): validate format before writing
  142. """
  143. if validate:
  144. self.validate_format()
  145. fp.write(str(self))
  146. def update_file(self: T, validate: bool = True) -> T:
  147. """Update the read-in configuration file.
  148. Args:
  149. validate (Boolean): validate format before writing
  150. """
  151. if self._filename is None:
  152. raise NoConfigFileReadError()
  153. if validate: # validate BEFORE opening the file!
  154. self.validate_format()
  155. with open(self._filename, "w") as fb:
  156. self.write(fb, validate=False)
  157. return self
  158. def validate_format(self, **kwargs):
  159. """Given the current state of the ``ConfigUpdater`` object (e.g. after
  160. modifications), validate its INI/CFG textual representation by parsing it with
  161. :class:`~configparser.ConfigParser`.
  162. The ConfigParser object is instead with the same arguments as the original
  163. ConfigUpdater object, but the ``kwargs`` can be used to overwrite them.
  164. See :meth:`~configupdater.document.Document.validate_format`.
  165. """
  166. return super().validate_format(**{**self._parser_opts, **kwargs})