Skip to content

Commit 2d20dba

Browse files
feat(#44): Support multiline config value for INI config (#45)
Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com>
1 parent 922e708 commit 2d20dba

File tree

2 files changed

+155
-8
lines changed

2 files changed

+155
-8
lines changed

src/nitpick/plugins/ini.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import copy
66
from configparser import ConfigParser, DuplicateOptionError, Error, MissingSectionHeaderError, ParsingError
77
from io import StringIO
8-
from typing import TYPE_CHECKING, Any, ClassVar, Iterator
8+
from typing import TYPE_CHECKING, Any, ClassVar, Iterable, Iterator
99

1010
import dictdiffer
1111
from configupdater import ConfigUpdater, Space
@@ -67,9 +67,17 @@ def post_init(self):
6767
self.updater = ConfigUpdater()
6868
self.comma_separated_values = set(self.comma_separated_values_dict.get(self.filename, []))
6969

70-
if not self.needs_top_section:
71-
return
7270
if all(isinstance(v, dict) for v in self.expected_config.values()):
71+
for section in self.expected_config:
72+
for key, value in self.expected_config[section].items():
73+
if self._is_multiline_value(section, key, value):
74+
# Convert the value to a string that's compatible with ConfigUpdater
75+
# Remove the indent to be diff-able later with values in self.updater,
76+
# which was read from existing config
77+
lines = [line.strip() for line in value.strip().split("\n")]
78+
self.expected_config[section][key] = self._get_configupdater_values(lines, indent="")
79+
return
80+
if not self.needs_top_section:
7381
return
7482

7583
new_config = {TOP_SECTION: {}}
@@ -134,6 +142,12 @@ def get_missing_output(self) -> str:
134142
parser = ConfigParser()
135143
for section in sorted(missing, key=lambda s: "0" if s == TOP_SECTION else f"1{s}"):
136144
expected_config: dict = self.expected_config[section]
145+
for k, v in expected_config.items(): # pylint: disable=invalid-name
146+
if self._is_multiline_value(section, k, v):
147+
# Convert the value to a string that's compatible with ConfigUpdater
148+
lines = [line.strip() for line in v.strip().split("\n")]
149+
expected_config[k] = self._get_configupdater_values(lines)
150+
137151
if self.autofix:
138152
if self.updater.last_block:
139153
self.updater.last_block.add_after.space(1)
@@ -143,6 +157,22 @@ def get_missing_output(self) -> str:
143157
parser[section] = expected_config
144158
return self.contents_without_top_section(self.get_example_cfg(parser))
145159

160+
def _is_multiline_value(self, section: str, key: str, value: int | str) -> bool:
161+
"""Check if the value is a multiline value."""
162+
return f"{section}.{key}" not in self.comma_separated_values and "\n" in str(value)
163+
164+
@staticmethod
165+
def _get_configupdater_values(values: Iterable[str], separator="\n", indent=4 * " "):
166+
"""Convert a list of values to a string compatible with ConfigUpdater.
167+
168+
This is similar to the ConfigUpdater's set_values() method
169+
"""
170+
values = list(values).copy()
171+
if "\n" in separator:
172+
values = ["", *values]
173+
separator = separator + indent
174+
return separator.join(values)
175+
146176
# TODO: refactor: convert the contents to dict (with IniConfig().sections?) and mimic other plugins doing dict diffs
147177
def enforce_rules(self) -> Iterator[Fuss]:
148178
"""Enforce rules on missing sections and missing key/value pairs in an INI file."""
@@ -323,13 +353,28 @@ def compare_different_keys(self, section, key, raw_actual: Any, raw_expected: An
323353
actual = str(raw_actual).lower()
324354
expected = str(raw_expected).lower()
325355
else:
356+
if self._is_multiline_value(section, key, raw_expected):
357+
# Find missing lines that should be in the expected value
358+
actual_lines = [line.strip() for line in raw_actual.strip().split("\n")]
359+
expected_lines = [line.strip() for line in raw_expected.strip().split("\n")]
360+
missing_lines = set(expected_lines) - set(actual_lines)
361+
# expected value should be the actual lines plus the missing lines
362+
raw_expected = raw_actual
363+
for line in missing_lines:
364+
raw_expected += "\n" + line
365+
326366
actual = raw_actual
327367
expected = raw_expected
328368
if actual == expected:
329369
return
330370

331371
if self.autofix:
332-
self.updater[section][key].value = expected
372+
if self._is_multiline_value(section, key, expected):
373+
# Handle multiline config using ConfigUpdater's set_values()
374+
expected_lines = [line.strip() for line in expected.strip().split("\n")]
375+
self.updater[section][key].set_values(expected_lines)
376+
else:
377+
self.updater[section][key].value = expected
333378
self.dirty = True
334379
if section == TOP_SECTION:
335380
yield self.reporter.make_fuss(

tests/test_ini.py

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -635,10 +635,7 @@ def test_comma_separated_values_in_multiline_config_value_should_be_enforced_if_
635635
".flake8",
636636
Violations.MISSING_OPTION.code,
637637
": section [flake8] has some missing key/value pairs. Use this:",
638-
"[flake8]\n"
639-
"per-file-ignores = \n"
640-
"\t tests/*.py:WPS116,WPS118\n"
641-
"\t tests_2/*.py:WPS116,WPS118,WPS218",
638+
"[flake8]\nper-file-ignores = \n\t tests/*.py:WPS116,WPS118\n\t tests_2/*.py:WPS116,WPS118,WPS218",
642639
)
643640
).assert_file_contents(
644641
".flake8",
@@ -689,3 +686,108 @@ def test_comma_separated_values_in_multiline_config_value_should_be_without_viol
689686
tests_2/*.py:WPS116,WPS118,WPS218
690687
""",
691688
)
689+
690+
691+
def test_multiline_config_value_should_be_enforced_if_some_lines_are_missing(tmp_path):
692+
"""Target multiline config value is missing some lines."""
693+
ProjectMock(tmp_path).save_file(
694+
".coveragerc",
695+
"""
696+
[report]
697+
exclude_also =
698+
pragma: no cover
699+
def __repr__
700+
""",
701+
).style(
702+
"""
703+
[".coveragerc".report]
704+
exclude_also = \"\"\"
705+
def __repr__
706+
def __str__
707+
\"\"\"
708+
"""
709+
).api_check_then_fix(
710+
Fuss(
711+
True,
712+
".coveragerc",
713+
Violations.OPTION_HAS_DIFFERENT_VALUE.code,
714+
": [report]exclude_also is \npragma: no cover\ndef __repr__ but it should be like this:",
715+
"[report]\nexclude_also = \npragma: no cover\ndef __repr__\ndef __str__",
716+
)
717+
).assert_file_contents(
718+
".coveragerc",
719+
"""
720+
[report]
721+
exclude_also =
722+
pragma: no cover
723+
def __repr__
724+
def __str__
725+
""",
726+
)
727+
728+
729+
def test_multiline_config_value_should_be_enforced_if_missing_entirely(tmp_path):
730+
"""Target multiline config is missing entirely."""
731+
ProjectMock(tmp_path).save_file(
732+
".coveragerc",
733+
"""
734+
[run]
735+
relative_files = True
736+
""",
737+
).style(
738+
"""
739+
[".coveragerc".report]
740+
exclude_also = \"\"\"
741+
pragma: no cover
742+
def __repr__
743+
\"\"\"
744+
"""
745+
).api_check_then_fix(
746+
Fuss(
747+
True,
748+
".coveragerc",
749+
Violations.MISSING_SECTIONS.code,
750+
" has some missing sections. Use this:",
751+
"[report]\nexclude_also = \n\t pragma: no cover\n\t def __repr__",
752+
)
753+
).assert_file_contents(
754+
".coveragerc",
755+
"""
756+
[run]
757+
relative_files = True
758+
759+
[report]
760+
exclude_also =
761+
pragma: no cover
762+
def __repr__
763+
""",
764+
)
765+
766+
767+
def test_multiline_config_value_should_be_without_violation_if_no_changes(tmp_path):
768+
"""Target multiline config is unchanged."""
769+
ProjectMock(tmp_path).save_file(
770+
".coveragerc",
771+
"""
772+
[report]
773+
exclude_also =
774+
pragma: no cover
775+
def __repr__
776+
""",
777+
).style(
778+
"""
779+
[".coveragerc".report]
780+
exclude_also = \"\"\"
781+
pragma: no cover
782+
def __repr__
783+
\"\"\"
784+
"""
785+
).api_check_then_fix().assert_file_contents(
786+
".coveragerc",
787+
"""
788+
[report]
789+
exclude_also =
790+
pragma: no cover
791+
def __repr__
792+
""",
793+
)

0 commit comments

Comments
 (0)