This commit is contained in:
Iliyan Angelov
2025-11-23 18:59:18 +02:00
parent be07802066
commit 627959f52b
1840 changed files with 236564 additions and 3475 deletions

View File

@@ -0,0 +1,249 @@
"""brain-dead simple parser for ini-style files.
(C) Ronny Pfannschmidt, Holger Krekel -- MIT licensed
"""
import os
from collections.abc import Callable
from collections.abc import Iterator
from collections.abc import Mapping
from typing import Final
from typing import TypeVar
from typing import overload
__all__ = ["IniConfig", "ParseError", "COMMENTCHARS", "iscommentline"]
from . import _parse
from ._parse import COMMENTCHARS
from ._parse import iscommentline
from .exceptions import ParseError
_D = TypeVar("_D")
_T = TypeVar("_T")
class SectionWrapper:
config: Final["IniConfig"]
name: Final[str]
def __init__(self, config: "IniConfig", name: str) -> None:
self.config = config
self.name = name
def lineof(self, name: str) -> int | None:
return self.config.lineof(self.name, name)
@overload
def get(self, key: str) -> str | None: ...
@overload
def get(
self,
key: str,
convert: Callable[[str], _T],
) -> _T | None: ...
@overload
def get(
self,
key: str,
default: None,
convert: Callable[[str], _T],
) -> _T | None: ...
@overload
def get(self, key: str, default: _D, convert: None = None) -> str | _D: ...
@overload
def get(
self,
key: str,
default: _D,
convert: Callable[[str], _T],
) -> _T | _D: ...
# TODO: investigate possible mypy bug wrt matching the passed over data
def get( # type: ignore [misc]
self,
key: str,
default: _D | None = None,
convert: Callable[[str], _T] | None = None,
) -> _D | _T | str | None:
return self.config.get(self.name, key, convert=convert, default=default)
def __getitem__(self, key: str) -> str:
return self.config.sections[self.name][key]
def __iter__(self) -> Iterator[str]:
section: Mapping[str, str] = self.config.sections.get(self.name, {})
def lineof(key: str) -> int:
return self.config.lineof(self.name, key) # type: ignore[return-value]
yield from sorted(section, key=lineof)
def items(self) -> Iterator[tuple[str, str]]:
for name in self:
yield name, self[name]
class IniConfig:
path: Final[str]
sections: Final[Mapping[str, Mapping[str, str]]]
_sources: Final[Mapping[tuple[str, str | None], int]]
def __init__(
self,
path: str | os.PathLike[str],
data: str | None = None,
encoding: str = "utf-8",
*,
_sections: Mapping[str, Mapping[str, str]] | None = None,
_sources: Mapping[tuple[str, str | None], int] | None = None,
) -> None:
self.path = os.fspath(path)
# Determine sections and sources
if _sections is not None and _sources is not None:
# Use provided pre-parsed data (called from parse())
sections_data = _sections
sources = _sources
else:
# Parse the data (backward compatible path)
if data is None:
with open(self.path, encoding=encoding) as fp:
data = fp.read()
# Use old behavior (no stripping) for backward compatibility
sections_data, sources = _parse.parse_ini_data(
self.path, data, strip_inline_comments=False
)
# Assign once to Final attributes
self._sources = sources
self.sections = sections_data
@classmethod
def parse(
cls,
path: str | os.PathLike[str],
data: str | None = None,
encoding: str = "utf-8",
*,
strip_inline_comments: bool = True,
strip_section_whitespace: bool = False,
) -> "IniConfig":
"""Parse an INI file.
Args:
path: Path to the INI file (used for error messages)
data: Optional INI content as string. If None, reads from path.
encoding: Encoding to use when reading the file (default: utf-8)
strip_inline_comments: Whether to strip inline comments from values
(default: True). When True, comments starting with # or ; are
removed from values, matching the behavior for section comments.
strip_section_whitespace: Whether to strip whitespace from section and key names
(default: False). When True, strips Unicode whitespace from section and key names,
addressing issue #4. When False, preserves existing behavior for backward compatibility.
Returns:
IniConfig instance with parsed configuration
Example:
# With comment stripping (default):
config = IniConfig.parse("setup.cfg")
# value = "foo" instead of "foo # comment"
# Without comment stripping (old behavior):
config = IniConfig.parse("setup.cfg", strip_inline_comments=False)
# value = "foo # comment"
# With section name stripping (opt-in for issue #4):
config = IniConfig.parse("setup.cfg", strip_section_whitespace=True)
# section names and keys have Unicode whitespace stripped
"""
fspath = os.fspath(path)
if data is None:
with open(fspath, encoding=encoding) as fp:
data = fp.read()
sections_data, sources = _parse.parse_ini_data(
fspath,
data,
strip_inline_comments=strip_inline_comments,
strip_section_whitespace=strip_section_whitespace,
)
# Call constructor with pre-parsed sections and sources
return cls(path=fspath, _sections=sections_data, _sources=sources)
def lineof(self, section: str, name: str | None = None) -> int | None:
lineno = self._sources.get((section, name))
return None if lineno is None else lineno + 1
@overload
def get(
self,
section: str,
name: str,
) -> str | None: ...
@overload
def get(
self,
section: str,
name: str,
convert: Callable[[str], _T],
) -> _T | None: ...
@overload
def get(
self,
section: str,
name: str,
default: None,
convert: Callable[[str], _T],
) -> _T | None: ...
@overload
def get(
self, section: str, name: str, default: _D, convert: None = None
) -> str | _D: ...
@overload
def get(
self,
section: str,
name: str,
default: _D,
convert: Callable[[str], _T],
) -> _T | _D: ...
def get( # type: ignore
self,
section: str,
name: str,
default: _D | None = None,
convert: Callable[[str], _T] | None = None,
) -> _D | _T | str | None:
try:
value: str = self.sections[section][name]
except KeyError:
return default
else:
if convert is not None:
return convert(value)
else:
return value
def __getitem__(self, name: str) -> SectionWrapper:
if name not in self.sections:
raise KeyError(name)
return SectionWrapper(self, name)
def __iter__(self) -> Iterator[SectionWrapper]:
for name in sorted(self.sections, key=self.lineof): # type: ignore
yield SectionWrapper(self, name)
def __contains__(self, arg: str) -> bool:
return arg in self.sections

View File

@@ -0,0 +1,163 @@
from collections.abc import Mapping
from typing import NamedTuple
from .exceptions import ParseError
COMMENTCHARS = "#;"
class ParsedLine(NamedTuple):
lineno: int
section: str | None
name: str | None
value: str | None
def parse_ini_data(
path: str,
data: str,
*,
strip_inline_comments: bool,
strip_section_whitespace: bool = False,
) -> tuple[Mapping[str, Mapping[str, str]], Mapping[tuple[str, str | None], int]]:
"""Parse INI data and return sections and sources mappings.
Args:
path: Path for error messages
data: INI content as string
strip_inline_comments: Whether to strip inline comments from values
strip_section_whitespace: Whether to strip whitespace from section and key names
(default: False). When True, addresses issue #4 by stripping Unicode whitespace.
Returns:
Tuple of (sections_data, sources) where:
- sections_data: mapping of section -> {name -> value}
- sources: mapping of (section, name) -> line number
"""
tokens = parse_lines(
path,
data.splitlines(True),
strip_inline_comments=strip_inline_comments,
strip_section_whitespace=strip_section_whitespace,
)
sources: dict[tuple[str, str | None], int] = {}
sections_data: dict[str, dict[str, str]] = {}
for lineno, section, name, value in tokens:
if section is None:
raise ParseError(path, lineno, "no section header defined")
sources[section, name] = lineno
if name is None:
if section in sections_data:
raise ParseError(path, lineno, f"duplicate section {section!r}")
sections_data[section] = {}
else:
if name in sections_data[section]:
raise ParseError(path, lineno, f"duplicate name {name!r}")
assert value is not None
sections_data[section][name] = value
return sections_data, sources
def parse_lines(
path: str,
line_iter: list[str],
*,
strip_inline_comments: bool = False,
strip_section_whitespace: bool = False,
) -> list[ParsedLine]:
result: list[ParsedLine] = []
section = None
for lineno, line in enumerate(line_iter):
name, data = _parseline(
path, line, lineno, strip_inline_comments, strip_section_whitespace
)
# new value
if name is not None and data is not None:
result.append(ParsedLine(lineno, section, name, data))
# new section
elif name is not None and data is None:
if not name:
raise ParseError(path, lineno, "empty section name")
section = name
result.append(ParsedLine(lineno, section, None, None))
# continuation
elif name is None and data is not None:
if not result:
raise ParseError(path, lineno, "unexpected value continuation")
last = result.pop()
if last.name is None:
raise ParseError(path, lineno, "unexpected value continuation")
if last.value:
last = last._replace(value=f"{last.value}\n{data}")
else:
last = last._replace(value=data)
result.append(last)
return result
def _parseline(
path: str,
line: str,
lineno: int,
strip_inline_comments: bool,
strip_section_whitespace: bool,
) -> tuple[str | None, str | None]:
# blank lines
if iscommentline(line):
line = ""
else:
line = line.rstrip()
if not line:
return None, None
# section
if line[0] == "[":
realline = line
for c in COMMENTCHARS:
line = line.split(c)[0].rstrip()
if line[-1] == "]":
section_name = line[1:-1]
# Optionally strip whitespace from section name (issue #4)
if strip_section_whitespace:
section_name = section_name.strip()
return section_name, None
return None, realline.strip()
# value
elif not line[0].isspace():
try:
name, value = line.split("=", 1)
if ":" in name:
raise ValueError()
except ValueError:
try:
name, value = line.split(":", 1)
except ValueError:
raise ParseError(path, lineno, f"unexpected line: {line!r}") from None
# Strip key name (always for backward compatibility, optionally with unicode awareness)
key_name = name.strip()
# Strip value
value = value.strip()
# Strip inline comments from values if requested (issue #55)
if strip_inline_comments:
for c in COMMENTCHARS:
value = value.split(c)[0].rstrip()
return key_name, value
# continuation
else:
line = line.strip()
# Strip inline comments from continuations if requested (issue #55)
if strip_inline_comments:
for c in COMMENTCHARS:
line = line.split(c)[0].rstrip()
return None, line
def iscommentline(line: str) -> bool:
c = line.lstrip()[:1]
return c in COMMENTCHARS

View File

@@ -0,0 +1,34 @@
# file generated by setuptools-scm
# don't change, don't track in version control
__all__ = [
"__version__",
"__version_tuple__",
"version",
"version_tuple",
"__commit_id__",
"commit_id",
]
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Tuple
from typing import Union
VERSION_TUPLE = Tuple[Union[int, str], ...]
COMMIT_ID = Union[str, None]
else:
VERSION_TUPLE = object
COMMIT_ID = object
version: str
__version__: str
__version_tuple__: VERSION_TUPLE
version_tuple: VERSION_TUPLE
commit_id: COMMIT_ID
__commit_id__: COMMIT_ID
__version__ = version = '2.3.0'
__version_tuple__ = version_tuple = (2, 3, 0)
__commit_id__ = commit_id = None

View File

@@ -0,0 +1,16 @@
from typing import Final
class ParseError(Exception):
path: Final[str]
lineno: Final[int]
msg: Final[str]
def __init__(self, path: str, lineno: int, msg: str) -> None:
super().__init__(path, lineno, msg)
self.path = path
self.lineno = lineno
self.msg = msg
def __str__(self) -> str:
return f"{self.path}:{self.lineno + 1}: {self.msg}"