Skip to content

Commit e2d89f5

Browse files
Add IniConfig.parse() classmethod to fix inline comment handling
Fixes #55 - Inline comments were incorrectly included in parsed values The bug: Inline comments (# or ;) were being included as part of values instead of being stripped, inconsistent with how section comments are handled. Example of the bug: name = value # comment Result was: "value # comment" (incorrect) Should be: "value" (correct) Changes: - Add IniConfig.parse() classmethod with strip_inline_comments parameter - Default: strip_inline_comments=True (correct behavior - strips comments) - Can set strip_inline_comments=False if old buggy behavior needed - IniConfig() constructor preserves old behavior for backward compatibility (calls parse_ini_data with strip_inline_comments=False) - Add parse_ini_data() helper in _parse.py to avoid code duplication - Update _parseline() to support strip_inline_comments parameter - Add comprehensive tests for both correct and legacy behavior Backward compatibility: Existing code using IniConfig() continues to work unchanged. Users should migrate to IniConfig.parse() for correct behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 57b7ed9 commit e2d89f5

File tree

4 files changed

+199
-27
lines changed

4 files changed

+199
-27
lines changed

CHANGELOG

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
2.3.0
2+
=====
3+
4+
* add IniConfig.parse() classmethod with strip_inline_comments parameter (fixes #55)
5+
- by default (strip_inline_comments=True), inline comments are properly stripped from values
6+
- set strip_inline_comments=False to preserve old behavior if needed
7+
* IniConfig() constructor maintains backward compatibility (does not strip inline comments)
8+
* users should migrate to IniConfig.parse() for correct comment handling
9+
110
2.2.0
211
=====
312

src/iniconfig/__init__.py

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -102,27 +102,61 @@ def __init__(
102102
with open(self.path, encoding=encoding) as fp:
103103
data = fp.read()
104104

105-
tokens = _parse.parse_lines(self.path, data.splitlines(True))
106-
107-
self._sources = {}
108-
sections_data: dict[str, dict[str, str]]
109-
self.sections = sections_data = {}
110-
111-
for lineno, section, name, value in tokens:
112-
if section is None:
113-
raise ParseError(self.path, lineno, "no section header defined")
114-
self._sources[section, name] = lineno
115-
if name is None:
116-
if section in self.sections:
117-
raise ParseError(
118-
self.path, lineno, f"duplicate section {section!r}"
119-
)
120-
sections_data[section] = {}
121-
else:
122-
if name in self.sections[section]:
123-
raise ParseError(self.path, lineno, f"duplicate name {name!r}")
124-
assert value is not None
125-
sections_data[section][name] = value
105+
# Use old behavior (no stripping) for backward compatibility
106+
sections_data, sources = _parse.parse_ini_data(
107+
self.path, data, strip_inline_comments=False
108+
)
109+
110+
self._sources = sources
111+
self.sections = sections_data
112+
113+
@classmethod
114+
def parse(
115+
cls,
116+
path: str | os.PathLike[str],
117+
data: str | None = None,
118+
encoding: str = "utf-8",
119+
*,
120+
strip_inline_comments: bool = True,
121+
) -> "IniConfig":
122+
"""Parse an INI file.
123+
124+
Args:
125+
path: Path to the INI file (used for error messages)
126+
data: Optional INI content as string. If None, reads from path.
127+
encoding: Encoding to use when reading the file (default: utf-8)
128+
strip_inline_comments: Whether to strip inline comments from values
129+
(default: True). When True, comments starting with # or ; are
130+
removed from values, matching the behavior for section comments.
131+
132+
Returns:
133+
IniConfig instance with parsed configuration
134+
135+
Example:
136+
# With comment stripping (default):
137+
config = IniConfig.parse("setup.cfg")
138+
# value = "foo" instead of "foo # comment"
139+
140+
# Without comment stripping (old behavior):
141+
config = IniConfig.parse("setup.cfg", strip_inline_comments=False)
142+
# value = "foo # comment"
143+
"""
144+
fspath = os.fspath(path)
145+
146+
if data is None:
147+
with open(fspath, encoding=encoding) as fp:
148+
data = fp.read()
149+
150+
sections_data, sources = _parse.parse_ini_data(
151+
fspath, data, strip_inline_comments=strip_inline_comments
152+
)
153+
154+
# Create instance directly without calling __init__
155+
instance = cls.__new__(cls)
156+
object.__setattr__(instance, "path", fspath)
157+
object.__setattr__(instance, "sections", sections_data)
158+
object.__setattr__(instance, "_sources", sources)
159+
return instance
126160

127161
def lineof(self, section: str, name: str | None = None) -> int | None:
128162
lineno = self._sources.get((section, name))

src/iniconfig/_parse.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Mapping
12
from typing import NamedTuple
23

34
from .exceptions import ParseError
@@ -12,11 +13,55 @@ class ParsedLine(NamedTuple):
1213
value: str | None
1314

1415

15-
def parse_lines(path: str, line_iter: list[str]) -> list[ParsedLine]:
16+
def parse_ini_data(
17+
path: str,
18+
data: str,
19+
*,
20+
strip_inline_comments: bool,
21+
) -> tuple[Mapping[str, Mapping[str, str]], Mapping[tuple[str, str | None], int]]:
22+
"""Parse INI data and return sections and sources mappings.
23+
24+
Args:
25+
path: Path for error messages
26+
data: INI content as string
27+
strip_inline_comments: Whether to strip inline comments from values
28+
29+
Returns:
30+
Tuple of (sections_data, sources) where:
31+
- sections_data: mapping of section -> {name -> value}
32+
- sources: mapping of (section, name) -> line number
33+
"""
34+
tokens = parse_lines(
35+
path, data.splitlines(True), strip_inline_comments=strip_inline_comments
36+
)
37+
38+
sources: dict[tuple[str, str | None], int] = {}
39+
sections_data: dict[str, dict[str, str]] = {}
40+
41+
for lineno, section, name, value in tokens:
42+
if section is None:
43+
raise ParseError(path, lineno, "no section header defined")
44+
sources[section, name] = lineno
45+
if name is None:
46+
if section in sections_data:
47+
raise ParseError(path, lineno, f"duplicate section {section!r}")
48+
sections_data[section] = {}
49+
else:
50+
if name in sections_data[section]:
51+
raise ParseError(path, lineno, f"duplicate name {name!r}")
52+
assert value is not None
53+
sections_data[section][name] = value
54+
55+
return sections_data, sources
56+
57+
58+
def parse_lines(
59+
path: str, line_iter: list[str], *, strip_inline_comments: bool = False
60+
) -> list[ParsedLine]:
1661
result: list[ParsedLine] = []
1762
section = None
1863
for lineno, line in enumerate(line_iter):
19-
name, data = _parseline(path, line, lineno)
64+
name, data = _parseline(path, line, lineno, strip_inline_comments)
2065
# new value
2166
if name is not None and data is not None:
2267
result.append(ParsedLine(lineno, section, name, data))
@@ -42,7 +87,9 @@ def parse_lines(path: str, line_iter: list[str]) -> list[ParsedLine]:
4287
return result
4388

4489

45-
def _parseline(path: str, line: str, lineno: int) -> tuple[str | None, str | None]:
90+
def _parseline(
91+
path: str, line: str, lineno: int, strip_inline_comments: bool
92+
) -> tuple[str | None, str | None]:
4693
# blank lines
4794
if iscommentline(line):
4895
line = ""
@@ -69,10 +116,20 @@ def _parseline(path: str, line: str, lineno: int) -> tuple[str | None, str | Non
69116
name, value = line.split(":", 1)
70117
except ValueError:
71118
raise ParseError(path, lineno, f"unexpected line: {line!r}") from None
72-
return name.strip(), value.strip()
119+
value = value.strip()
120+
# Strip inline comments from values if requested (issue #55)
121+
if strip_inline_comments:
122+
for c in COMMENTCHARS:
123+
value = value.split(c)[0].rstrip()
124+
return name.strip(), value
73125
# continuation
74126
else:
75-
return None, line.strip()
127+
line = line.strip()
128+
# Strip inline comments from continuations if requested (issue #55)
129+
if strip_inline_comments:
130+
for c in COMMENTCHARS:
131+
line = line.split(c)[0].rstrip()
132+
return None, line
76133

77134

78135
def iscommentline(line: str) -> bool:

testing/test_iniconfig.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def test_iniconfig_from_file(tmp_path: Path) -> None:
125125
config = IniConfig(str(path), "[diff]")
126126
assert list(config.sections) == ["diff"]
127127
with pytest.raises(TypeError):
128-
IniConfig(data=path.read_text()) # type: ignore
128+
IniConfig(data=path.read_text()) # type: ignore[call-arg]
129129

130130

131131
def test_iniconfig_section_first() -> None:
@@ -304,3 +304,75 @@ def test_api_import() -> None:
304304
)
305305
def test_iscommentline_true(line: str) -> None:
306306
assert iscommentline(line)
307+
308+
309+
def test_parse_strips_inline_comments() -> None:
310+
"""Test that IniConfig.parse() strips inline comments from values by default."""
311+
config = IniConfig.parse(
312+
"test.ini",
313+
data=dedent(
314+
"""
315+
[section1]
316+
name1 = value1 # this is a comment
317+
name2 = value2 ; this is also a comment
318+
name3 = value3# no space before comment
319+
list = a, b, c # some items
320+
"""
321+
),
322+
)
323+
assert config["section1"]["name1"] == "value1"
324+
assert config["section1"]["name2"] == "value2"
325+
assert config["section1"]["name3"] == "value3"
326+
assert config["section1"]["list"] == "a, b, c"
327+
328+
329+
def test_parse_strips_inline_comments_from_continuations() -> None:
330+
"""Test that inline comments are stripped from continuation lines."""
331+
config = IniConfig.parse(
332+
"test.ini",
333+
data=dedent(
334+
"""
335+
[section]
336+
names =
337+
Alice # first person
338+
Bob ; second person
339+
Charlie
340+
"""
341+
),
342+
)
343+
assert config["section"]["names"] == "Alice\nBob\nCharlie"
344+
345+
346+
def test_parse_preserves_inline_comments_when_disabled() -> None:
347+
"""Test that IniConfig.parse(strip_inline_comments=False) preserves comments."""
348+
config = IniConfig.parse(
349+
"test.ini",
350+
data=dedent(
351+
"""
352+
[section1]
353+
name1 = value1 # this is a comment
354+
name2 = value2 ; this is also a comment
355+
list = a, b, c # some items
356+
"""
357+
),
358+
strip_inline_comments=False,
359+
)
360+
assert config["section1"]["name1"] == "value1 # this is a comment"
361+
assert config["section1"]["name2"] == "value2 ; this is also a comment"
362+
assert config["section1"]["list"] == "a, b, c # some items"
363+
364+
365+
def test_constructor_preserves_inline_comments_for_backward_compatibility() -> None:
366+
"""Test that IniConfig() constructor preserves old behavior (no stripping)."""
367+
config = IniConfig(
368+
"test.ini",
369+
data=dedent(
370+
"""
371+
[section1]
372+
name1 = value1 # this is a comment
373+
name2 = value2 ; this is also a comment
374+
"""
375+
),
376+
)
377+
assert config["section1"]["name1"] == "value1 # this is a comment"
378+
assert config["section1"]["name2"] == "value2 ; this is also a comment"

0 commit comments

Comments
 (0)