Repository: codespell-project/codespell

Issue:
Splitting multiple 'skip' paths onto indented config file lines behaves differently than when on a single line
The INI file format claims to support an option value that is split across multiple lines, if the lines after the first are indented. However, I see different behavior with the two settings

```
skip=*.adoc,./build_tests/*,
```

```
skip=*.adoc,
    ./build_tests/*,
```

The first works (skips a file with misspellings under build_tests/BAD), the second fails (reports the misspelling). Am I missing something about the INI file format that would allow this to work?

Attaching a zipfile with this minimal example.

[codespell-config.zip](https://github.com/codespell-project/codespell/files/14926060/codespell-config.zip)




Discussion:
As far as I know, INI files lack a unified standard, and we rely on a Python library to read them. I'll have a look though,  it might be just a matter of passing an option to that library.

~~Meanwhile, where exactly have you read the claim to support an option value that is split across multiple lines?~~

From the [configparser documentation](https://docs.python.org/3/library/configparser.html#supported-ini-file-structure):
> Values can also span multiple lines, as long as they are indented deeper than the first line of the value.
In the multi-line case, [configparser.ConfigParser.read](https://docs.python.org/3/library/configparser.html#configparser.ConfigParser.read) keeps the newline. After adding this debug line:
```python
    print(config["codespell"]["skip"])
```
right after:
https://github.com/codespell-project/codespell/blob/69c0f002e9e43ed92f31fa19e41bde5a96b065a2/codespell_lib/_codespell.py#L649
I see a comma-separated list of directories in the single-line case:
```
*.adoc,./build_tests/*,
```
while in the multi-line case, the newline is kept in addition to the commas:
```
*.adoc,
./build_tests
```

We need to discard the newline at some point.

Resolving patch:
diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py
--- a/codespell_lib/_codespell.py
+++ b/codespell_lib/_codespell.py
@@ -160,18 +160,10 @@ class QuietLevels:
 
 
 class GlobMatch:
-    def __init__(self, pattern: Optional[str]) -> None:
-        self.pattern_list: Optional[List[str]]
-        if pattern:
-            # Pattern might be a list of comma-delimited strings
-            self.pattern_list = ",".join(pattern).split(",")
-        else:
-            self.pattern_list = None
+    def __init__(self, pattern: List[str]) -> None:
+        self.pattern_list: List[str] = pattern
 
     def match(self, filename: str) -> bool:
-        if self.pattern_list is None:
-            return False
-
         return any(fnmatch.fnmatch(filename, p) for p in self.pattern_list)
 
 
@@ -1109,6 +1101,20 @@ def parse_file(
     return bad_count
 
 
+def flatten_clean_comma_separated_arguments(
+    arguments: Iterable[str],
+) -> List[str]:
+    """
+    >>> flatten_clean_comma_separated_arguments(["a, b ,\n c, d,", "e"])
+    ['a', 'b', 'c', 'd', 'e']
+    >>> flatten_clean_comma_separated_arguments([])
+    []
+    """
+    return [
+        item.strip() for argument in arguments for item in argument.split(",") if item
+    ]
+
+
 def _script_main() -> int:
     """Wrap to main() for setuptools."""
     return main(*sys.argv[1:])
@@ -1256,7 +1262,9 @@ def main(*args: str) -> int:
 
     file_opener = FileOpener(options.hard_encoding_detection, options.quiet_level)
 
-    glob_match = GlobMatch(options.skip)
+    glob_match = GlobMatch(
+        flatten_clean_comma_separated_arguments(options.skip) if options.skip else []
+    )
     try:
         glob_match.match("/random/path")  # does not need a real path
     except re.error:


Test patch:
diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py
--- a/codespell_lib/tests/test_basic.py
+++ b/codespell_lib/tests/test_basic.py
@@ -573,6 +573,8 @@ def test_ignore(
     (subdir / "bad.txt").write_text("abandonned")
     assert cs.main(tmp_path) == 2
     assert cs.main("--skip=bad*", tmp_path) == 0
+    assert cs.main("--skip=whatever.txt,bad*,whatelse.txt", tmp_path) == 0
+    assert cs.main("--skip=whatever.txt,\n bad* ,", tmp_path) == 0
     assert cs.main("--skip=*ignoredir*", tmp_path) == 1
     assert cs.main("--skip=ignoredir", tmp_path) == 1
     assert cs.main("--skip=*ignoredir/bad*", tmp_path) == 1
@@ -1213,7 +1215,7 @@ def test_ill_formed_ini_config_file(
     assert "ill-formed config file" in stderr
 
 
-@pytest.mark.parametrize("kind", ["cfg", "toml", "toml_list"])
+@pytest.mark.parametrize("kind", ["cfg", "cfg_multiline", "toml", "toml_list"])
 def test_config_toml(
     tmp_path: Path,
     capsys: pytest.CaptureFixture[str],
@@ -1235,44 +1237,47 @@ def test_config_toml(
     assert "bad.txt" in stdout
     assert "abandonned.txt" in stdout
 
-    if kind == "cfg":
+    if kind.startswith("cfg"):
         conffile = tmp_path / "setup.cfg"
         args = ("--config", conffile)
-        conffile.write_text(
-            """\
+        if kind == "cfg":
+            text = """\
 [codespell]
 skip = bad.txt, whatever.txt
 count =
 """
-        )
-    elif kind == "toml":
-        assert kind == "toml"
+        else:
+            assert kind == "cfg_multiline"
+            text = """\
+[codespell]
+skip = whatever.txt,
+   bad.txt ,
+   ,
+
+count =
+"""
+        conffile.write_text(text)
+    else:
         if sys.version_info < (3, 11):
             pytest.importorskip("tomli")
         tomlfile = tmp_path / "pyproject.toml"
         args = ("--toml", tomlfile)
-        tomlfile.write_text(
-            """\
+        if kind == "toml":
+            text = """\
 [tool.codespell]
 skip = 'bad.txt,whatever.txt'
 check-filenames = false
 count = true
 """
-        )
-    else:
-        assert kind == "toml_list"
-        if sys.version_info < (3, 11):
-            pytest.importorskip("tomli")
-        tomlfile = tmp_path / "pyproject.toml"
-        args = ("--toml", tomlfile)
-        tomlfile.write_text(
-            """\
+        else:
+            assert kind == "toml_list"
+            text = """\
 [tool.codespell]
 skip = ['bad.txt', 'whatever.txt']
 check-filenames = false
 count = true
 """
-        )
+        tomlfile.write_text(text)
 
     # Should pass when skipping bad.txt or abandonned.txt
     result = cs.main(d, *args, std=True)