_parse.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. from collections.abc import Mapping
  2. from typing import NamedTuple
  3. from .exceptions import ParseError
  4. COMMENTCHARS = "#;"
  5. class ParsedLine(NamedTuple):
  6. lineno: int
  7. section: str | None
  8. name: str | None
  9. value: str | None
  10. def parse_ini_data(
  11. path: str,
  12. data: str,
  13. *,
  14. strip_inline_comments: bool,
  15. strip_section_whitespace: bool = False,
  16. ) -> tuple[Mapping[str, Mapping[str, str]], Mapping[tuple[str, str | None], int]]:
  17. """Parse INI data and return sections and sources mappings.
  18. Args:
  19. path: Path for error messages
  20. data: INI content as string
  21. strip_inline_comments: Whether to strip inline comments from values
  22. strip_section_whitespace: Whether to strip whitespace from section and key names
  23. (default: False). When True, addresses issue #4 by stripping Unicode whitespace.
  24. Returns:
  25. Tuple of (sections_data, sources) where:
  26. - sections_data: mapping of section -> {name -> value}
  27. - sources: mapping of (section, name) -> line number
  28. """
  29. tokens = parse_lines(
  30. path,
  31. data.splitlines(True),
  32. strip_inline_comments=strip_inline_comments,
  33. strip_section_whitespace=strip_section_whitespace,
  34. )
  35. sources: dict[tuple[str, str | None], int] = {}
  36. sections_data: dict[str, dict[str, str]] = {}
  37. for lineno, section, name, value in tokens:
  38. if section is None:
  39. raise ParseError(path, lineno, "no section header defined")
  40. sources[section, name] = lineno
  41. if name is None:
  42. if section in sections_data:
  43. raise ParseError(path, lineno, f"duplicate section {section!r}")
  44. sections_data[section] = {}
  45. else:
  46. if name in sections_data[section]:
  47. raise ParseError(path, lineno, f"duplicate name {name!r}")
  48. assert value is not None
  49. sections_data[section][name] = value
  50. return sections_data, sources
  51. def parse_lines(
  52. path: str,
  53. line_iter: list[str],
  54. *,
  55. strip_inline_comments: bool = False,
  56. strip_section_whitespace: bool = False,
  57. ) -> list[ParsedLine]:
  58. result: list[ParsedLine] = []
  59. section = None
  60. for lineno, line in enumerate(line_iter):
  61. name, data = _parseline(
  62. path, line, lineno, strip_inline_comments, strip_section_whitespace
  63. )
  64. # new value
  65. if name is not None and data is not None:
  66. result.append(ParsedLine(lineno, section, name, data))
  67. # new section
  68. elif name is not None and data is None:
  69. if not name:
  70. raise ParseError(path, lineno, "empty section name")
  71. section = name
  72. result.append(ParsedLine(lineno, section, None, None))
  73. # continuation
  74. elif name is None and data is not None:
  75. if not result:
  76. raise ParseError(path, lineno, "unexpected value continuation")
  77. last = result.pop()
  78. if last.name is None:
  79. raise ParseError(path, lineno, "unexpected value continuation")
  80. if last.value:
  81. last = last._replace(value=f"{last.value}\n{data}")
  82. else:
  83. last = last._replace(value=data)
  84. result.append(last)
  85. return result
  86. def _parseline(
  87. path: str,
  88. line: str,
  89. lineno: int,
  90. strip_inline_comments: bool,
  91. strip_section_whitespace: bool,
  92. ) -> tuple[str | None, str | None]:
  93. # blank lines
  94. if iscommentline(line):
  95. line = ""
  96. else:
  97. line = line.rstrip()
  98. if not line:
  99. return None, None
  100. # section
  101. if line[0] == "[":
  102. realline = line
  103. for c in COMMENTCHARS:
  104. line = line.split(c)[0].rstrip()
  105. if line[-1] == "]":
  106. section_name = line[1:-1]
  107. # Optionally strip whitespace from section name (issue #4)
  108. if strip_section_whitespace:
  109. section_name = section_name.strip()
  110. return section_name, None
  111. return None, realline.strip()
  112. # value
  113. elif not line[0].isspace():
  114. try:
  115. name, value = line.split("=", 1)
  116. if ":" in name:
  117. raise ValueError()
  118. except ValueError:
  119. try:
  120. name, value = line.split(":", 1)
  121. except ValueError:
  122. raise ParseError(path, lineno, f"unexpected line: {line!r}") from None
  123. # Strip key name (always for backward compatibility, optionally with unicode awareness)
  124. key_name = name.strip()
  125. # Strip value
  126. value = value.strip()
  127. # Strip inline comments from values if requested (issue #55)
  128. if strip_inline_comments:
  129. for c in COMMENTCHARS:
  130. value = value.split(c)[0].rstrip()
  131. return key_name, value
  132. # continuation
  133. else:
  134. line = line.strip()
  135. # Strip inline comments from continuations if requested (issue #55)
  136. if strip_inline_comments:
  137. for c in COMMENTCHARS:
  138. line = line.split(c)[0].rstrip()
  139. return None, line
  140. def iscommentline(line: str) -> bool:
  141. c = line.lstrip()[:1]
  142. return c in COMMENTCHARS