Repository: akaihola/darker

Issue:
`pyproject.toml` options with hyphens are ignored
**Describe the bug**
It seems that Darker ignores config options in `pyproject.toml` if they are spelled using hyphens instead of underscores.

**To Reproduce**
I have configuration for a Darker pre-commit hook like this:

```yaml
  - repo: https://github.com/akaihola/darker
    rev: 1.7.1
    hooks:
      - id: darker
        args: [ --skip-string-normalization ]

```

and a `pyproject.toml` like this:

```toml
[tool.darker]
line-length = 120
skip-string-normalization = true
```

However running `pre-commit` it did not honor the line length setting in `pyproject.toml`.

Reading the documentation and other bug reports here, it seems this ought to work.

Is this an issue in `pre-commit`, perhaps something to do with the fact that the pre-commit hook acts directly on the files that are part of the commit and therefore ignores the `pyproject.toml`?

Or is this a bug in Darker that I need to file there?

Changing the pre-commit hook to include the setting does seem to be a workaround. I.e.:

```yaml
  - repo: https://github.com/akaihola/darker
    rev: 1.7.1
    hooks:
      - id: darker
        args: [ --skip-string-normalization , --line-length=120 ]
```


**Expected behavior**
Darker should honor the config option `line-length = 120` in the pyproject.toml

**Environment (please complete the following information):**
 - OS: MacOS
 - Python version 3.10.12
 - Git version 2.39.2 (Apple Git-143)
 - Darker version 1.7.1
 - Black version black, 22.8.0 (compiled: yes)
 - other reformatter and linter versions ruff==0.0.272

Discussion:
akaihola: Hey @dwt would you be able to review this pull request? Can I invite you as a project collaborater and assign reviewing to you?

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 7bf6857..9f66213 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -52,6 +52,10 @@ Fixed
 - Linting fixes: Use ``stacklevel=2`` in ``warnings.warn()`` calls as suggested by
   Flake8; skip Bandit check for virtualenv creation in the GitHub Action;
   use ``ignore[method-assign]`` as suggested by Mypy.
+- Configuration options spelled with hyphens in ``pyproject.toml``
+  (e.g. ``line-length = 88``) are now supported.
+- In debug log output mode, configuration options are now always spelled with hyphens
+  instead of underscores.
 
 
 1.7.0_ - 2023-02-11
diff --git a/src/darker/config.py b/src/darker/config.py
index 940a5d9..702c1f0 100644
--- a/src/darker/config.py
+++ b/src/darker/config.py
@@ -6,7 +6,7 @@ import sys
 from argparse import ArgumentParser, Namespace
 from dataclasses import dataclass, field
 from pathlib import Path
-from typing import Iterable, List, Optional, Set, cast
+from typing import Dict, Iterable, List, Optional, Set, Union, cast
 
 import toml
 
@@ -26,6 +26,9 @@ class TomlArrayLinesEncoder(toml.TomlEncoder):  # type: ignore
         return "[{}\n]".format("".join(f"\n    {self.dump_value(item)}," for item in v))
 
 
+UnvalidatedConfig = Dict[str, Union[List[str], str, bool, int]]
+
+
 class DarkerConfig(TypedDict, total=False):
     """Dictionary representing ``[tool.darker]`` from ``pyproject.toml``"""
 
@@ -92,6 +95,40 @@ class ConfigurationError(Exception):
     """Exception class for invalid configuration values"""
 
 
+def convert_config_characters(
+    config: UnvalidatedConfig, pattern: str, replacement: str
+) -> UnvalidatedConfig:
+    """Convert a character in config keys to a different character"""
+    return {key.replace(pattern, replacement): value for key, value in config.items()}
+
+
+def convert_hyphens_to_underscores(config: UnvalidatedConfig) -> UnvalidatedConfig:
+    """Convert hyphenated config keys to underscored keys"""
+    return convert_config_characters(config, "-", "_")
+
+
+def convert_underscores_to_hyphens(config: DarkerConfig) -> UnvalidatedConfig:
+    """Convert underscores in config keys to hyphens"""
+    return convert_config_characters(cast(UnvalidatedConfig, config), "_", "-")
+
+
+def validate_config_keys(config: UnvalidatedConfig) -> None:
+    """Raise an exception if any keys in the configuration are invalid.
+
+    :param config: The configuration read from ``pyproject.toml``
+    :raises ConfigurationError: Raised if unknown options are present
+
+    """
+    if set(config).issubset(DarkerConfig.__annotations__):
+        return
+    unknown_keys = ", ".join(
+        sorted(set(config).difference(DarkerConfig.__annotations__))
+    )
+    raise ConfigurationError(
+        f"Invalid [tool.darker] keys in pyproject.toml: {unknown_keys}"
+    )
+
+
 def replace_log_level_name(config: DarkerConfig) -> None:
     """Replace numeric log level in configuration with the name of the log level"""
     if "log_level" in config:
@@ -165,7 +202,11 @@ def load_config(path: Optional[str], srcs: Iterable[str]) -> DarkerConfig:
         if not config_path.is_file():
             return {}
     pyproject_toml = toml.load(config_path)
-    config = cast(DarkerConfig, pyproject_toml.get("tool", {}).get("darker", {}) or {})
+    tool_darker_config = convert_hyphens_to_underscores(
+        pyproject_toml.get("tool", {}).get("darker", {}) or {}
+    )
+    validate_config_keys(tool_darker_config)
+    config = cast(DarkerConfig, tool_darker_config)
     replace_log_level_name(config)
     validate_config_output_mode(config)
     return config
@@ -195,7 +236,9 @@ def get_modified_config(parser: ArgumentParser, args: Namespace) -> DarkerConfig
 
 def dump_config(config: DarkerConfig) -> str:
     """Return the configuration in TOML format"""
-    dump = toml.dumps(config, encoder=TomlArrayLinesEncoder())
+    dump = toml.dumps(
+        convert_underscores_to_hyphens(config), encoder=TomlArrayLinesEncoder()
+    )
     return f"[tool.darker]\n{dump}"
 
 


Test patch:
diff --git a/src/darker/tests/test_config.py b/src/darker/tests/test_config.py
index 577e4b3..0713ef4 100644
--- a/src/darker/tests/test_config.py
+++ b/src/darker/tests/test_config.py
@@ -206,8 +206,8 @@ def test_output_mode_from_args(diff, stdout, expect):
     dict(cwd="lvl1/lvl2"),
     dict(cwd="has_git", expect={}),
     dict(cwd="has_git/lvl1", expect={}),
-    dict(cwd="has_pyp", expect={"CONFIG_PATH": "has_pyp"}),
-    dict(cwd="has_pyp/lvl1", expect={"CONFIG_PATH": "has_pyp"}),
+    dict(cwd="has_pyp", expect={"config": "has_pyp"}),
+    dict(cwd="has_pyp/lvl1", expect={"config": "has_pyp"}),
     dict(srcs=["root.py"]),
     dict(srcs=["../root.py"], cwd="lvl1"),
     dict(srcs=["../root.py"], cwd="has_git"),
@@ -222,22 +222,22 @@ def test_output_mode_from_args(diff, stdout, expect):
     dict(srcs=["pyp.py", "../lvl1/lvl1.py"], cwd="has_pyp"),
     dict(
         srcs=["has_pyp/lvl1/l1.py", "has_pyp/lvl1b/l1b.py"],
-        expect={"CONFIG_PATH": "has_pyp"},
+        expect={"config": "has_pyp"},
     ),
     dict(
         srcs=["../has_pyp/lvl1/l1.py", "../has_pyp/lvl1b/l1b.py"],
         cwd="lvl1",
-        expect={"CONFIG_PATH": "has_pyp"},
+        expect={"config": "has_pyp"},
     ),
     dict(
         srcs=["../has_pyp/lvl1/l1.py", "../has_pyp/lvl1b/l1b.py"],
         cwd="has_git",
-        expect={"CONFIG_PATH": "has_pyp"},
+        expect={"config": "has_pyp"},
     ),
     dict(
         srcs=["lvl1/l1.py", "lvl1b/l1b.py"],
         cwd="has_pyp",
-        expect={"CONFIG_PATH": "has_pyp"},
+        expect={"config": "has_pyp"},
     ),
     dict(
         srcs=["full_example/full.py"],
@@ -252,202 +252,230 @@ def test_output_mode_from_args(diff, stdout, expect):
         },
     ),
     dict(srcs=["stdout_example/dummy.py"], expect={"stdout": True}),
-    dict(confpath="c", expect={"PYP_TOML": 1}),
-    dict(confpath="c/pyproject.toml", expect={"PYP_TOML": 1}),
-    dict(cwd="lvl1", confpath="../c", expect={"PYP_TOML": 1}),
-    dict(cwd="lvl1", confpath="../c/pyproject.toml", expect={"PYP_TOML": 1}),
-    dict(cwd="lvl1/lvl2", confpath="../../c", expect={"PYP_TOML": 1}),
-    dict(cwd="lvl1/lvl2", confpath="../../c/pyproject.toml", expect={"PYP_TOML": 1}),
-    dict(cwd="has_git", confpath="../c", expect={"PYP_TOML": 1}),
-    dict(cwd="has_git", confpath="../c/pyproject.toml", expect={"PYP_TOML": 1}),
-    dict(cwd="has_git/lvl1", confpath="../../c", expect={"PYP_TOML": 1}),
-    dict(cwd="has_git/lvl1", confpath="../../c/pyproject.toml", expect={"PYP_TOML": 1}),
-    dict(cwd="has_pyp", confpath="../c", expect={"PYP_TOML": 1}),
-    dict(cwd="has_pyp", confpath="../c/pyproject.toml", expect={"PYP_TOML": 1}),
-    dict(cwd="has_pyp/lvl1", confpath="../../c", expect={"PYP_TOML": 1}),
-    dict(cwd="has_pyp/lvl1", confpath="../../c/pyproject.toml", expect={"PYP_TOML": 1}),
-    dict(srcs=["root.py"], confpath="c", expect={"PYP_TOML": 1}),
-    dict(srcs=["root.py"], confpath="c/pyproject.toml", expect={"PYP_TOML": 1}),
-    dict(srcs=["../root.py"], cwd="lvl1", confpath="../c", expect={"PYP_TOML": 1}),
+    dict(confpath="c", expect={"lint": ["PYP_TOML"]}),
+    dict(confpath="c/pyproject.toml", expect={"lint": ["PYP_TOML"]}),
+    dict(cwd="lvl1", confpath="../c", expect={"lint": ["PYP_TOML"]}),
+    dict(cwd="lvl1", confpath="../c/pyproject.toml", expect={"lint": ["PYP_TOML"]}),
+    dict(cwd="lvl1/lvl2", confpath="../../c", expect={"lint": ["PYP_TOML"]}),
+    dict(
+        cwd="lvl1/lvl2",
+        confpath="../../c/pyproject.toml",
+        expect={"lint": ["PYP_TOML"]},
+    ),
+    dict(cwd="has_git", confpath="../c", expect={"lint": ["PYP_TOML"]}),
+    dict(cwd="has_git", confpath="../c/pyproject.toml", expect={"lint": ["PYP_TOML"]}),
+    dict(cwd="has_git/lvl1", confpath="../../c", expect={"lint": ["PYP_TOML"]}),
+    dict(
+        cwd="has_git/lvl1",
+        confpath="../../c/pyproject.toml",
+        expect={"lint": ["PYP_TOML"]},
+    ),
+    dict(cwd="has_pyp", confpath="../c", expect={"lint": ["PYP_TOML"]}),
+    dict(cwd="has_pyp", confpath="../c/pyproject.toml", expect={"lint": ["PYP_TOML"]}),
+    dict(cwd="has_pyp/lvl1", confpath="../../c", expect={"lint": ["PYP_TOML"]}),
+    dict(
+        cwd="has_pyp/lvl1",
+        confpath="../../c/pyproject.toml",
+        expect={"lint": ["PYP_TOML"]},
+    ),
+    dict(srcs=["root.py"], confpath="c", expect={"lint": ["PYP_TOML"]}),
+    dict(srcs=["root.py"], confpath="c/pyproject.toml", expect={"lint": ["PYP_TOML"]}),
+    dict(
+        srcs=["../root.py"], cwd="lvl1", confpath="../c", expect={"lint": ["PYP_TOML"]}
+    ),
     dict(
         srcs=["../root.py"],
         cwd="lvl1",
         confpath="../c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
+    ),
+    dict(
+        srcs=["../root.py"],
+        cwd="has_git",
+        confpath="../c",
+        expect={"lint": ["PYP_TOML"]},
     ),
-    dict(srcs=["../root.py"], cwd="has_git", confpath="../c", expect={"PYP_TOML": 1}),
     dict(
         srcs=["../root.py"],
         cwd="has_git",
         confpath="../c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
+    ),
+    dict(
+        srcs=["../root.py"],
+        cwd="has_pyp",
+        confpath="../c",
+        expect={"lint": ["PYP_TOML"]},
     ),
-    dict(srcs=["../root.py"], cwd="has_pyp", confpath="../c", expect={"PYP_TOML": 1}),
     dict(
         srcs=["../root.py"],
         cwd="has_pyp",
         confpath="../c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
-    dict(srcs=["root.py", "lvl1/lvl1.py"], confpath="c", expect={"PYP_TOML": 1}),
+    dict(srcs=["root.py", "lvl1/lvl1.py"], confpath="c", expect={"lint": ["PYP_TOML"]}),
     dict(
         srcs=["root.py", "lvl1/lvl1.py"],
         confpath="c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../root.py", "lvl1.py"],
         cwd="lvl1",
         confpath="../c",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../root.py", "lvl1.py"],
         cwd="lvl1",
         confpath="../c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../root.py", "../lvl1/lvl1.py"],
         cwd="has_git",
         confpath="../c",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../root.py", "../lvl1/lvl1.py"],
         cwd="has_git",
         confpath="../c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../root.py", "../lvl1/lvl1.py"],
         cwd="has_pyp",
         confpath="../c",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../root.py", "../lvl1/lvl1.py"],
         cwd="has_pyp",
         confpath="../c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
+    ),
+    dict(
+        srcs=["has_pyp/pyp.py", "lvl1/lvl1.py"],
+        confpath="c",
+        expect={"lint": ["PYP_TOML"]},
     ),
-    dict(srcs=["has_pyp/pyp.py", "lvl1/lvl1.py"], confpath="c", expect={"PYP_TOML": 1}),
     dict(
         srcs=["has_pyp/pyp.py", "lvl1/lvl1.py"],
         confpath="c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../has_pyp/pyp.py", "lvl1.py"],
         cwd="lvl1",
         confpath="../c",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../has_pyp/pyp.py", "lvl1.py"],
         cwd="lvl1",
         confpath="../c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../has_pyp/pyp.py", "../lvl1/lvl1.py"],
         cwd="has_git",
         confpath="../c",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../has_pyp/pyp.py", "../lvl1/lvl1.py"],
         cwd="has_git",
         confpath="../c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["pyp.py", "../lvl1/lvl1.py"],
         cwd="has_pyp",
         confpath="../c",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["pyp.py", "../lvl1/lvl1.py"],
         cwd="has_pyp",
         confpath="../c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["has_pyp/lvl1/l1.py", "has_pyp/lvl1b/l1b.py"],
         confpath="c",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["has_pyp/lvl1/l1.py", "has_pyp/lvl1b/l1b.py"],
         confpath="c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../has_pyp/lvl1/l1.py", "../has_pyp/lvl1b/l1b.py"],
         cwd="lvl1",
         confpath="../c",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../has_pyp/lvl1/l1.py", "../has_pyp/lvl1b/l1b.py"],
         cwd="lvl1",
         confpath="../c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../has_pyp/lvl1/l1.py", "../has_pyp/lvl1b/l1b.py"],
         cwd="has_git",
         confpath="../c",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["../has_pyp/lvl1/l1.py", "../has_pyp/lvl1b/l1b.py"],
         cwd="has_git",
         confpath="../c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["lvl1/l1.py", "lvl1b/l1b.py"],
         cwd="has_pyp",
         confpath="../c",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     dict(
         srcs=["lvl1/l1.py", "lvl1b/l1b.py"],
         cwd="has_pyp",
         confpath="../c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
-    dict(srcs=["full_example/full.py"], confpath="c", expect={"PYP_TOML": 1}),
+    dict(srcs=["full_example/full.py"], confpath="c", expect={"lint": ["PYP_TOML"]}),
     dict(
         srcs=["full_example/full.py"],
         confpath="c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
-    dict(srcs=["stdout_example/dummy.py"], confpath="c", expect={"PYP_TOML": 1}),
+    dict(srcs=["stdout_example/dummy.py"], confpath="c", expect={"lint": ["PYP_TOML"]}),
     dict(
         srcs=["stdout_example/dummy.py"],
         confpath="c/pyproject.toml",
-        expect={"PYP_TOML": 1},
+        expect={"lint": ["PYP_TOML"]},
     ),
     srcs=[],
     cwd=".",
     confpath=None,
-    expect={"CONFIG_PATH": "."},
+    expect={"config": "no_pyp"},
 )
 def test_load_config(  # pylint: disable=too-many-arguments
     find_project_root_cache_clear, tmp_path, monkeypatch, srcs, cwd, confpath, expect
 ):
     """``load_config()`` finds and loads configuration based on source file paths"""
     (tmp_path / ".git").mkdir()
-    (tmp_path / "pyproject.toml").write_text('[tool.darker]\nCONFIG_PATH = "."\n')
+    (tmp_path / "pyproject.toml").write_text('[tool.darker]\nconfig = "no_pyp"\n')
     (tmp_path / "lvl1/lvl2").mkdir(parents=True)
     (tmp_path / "has_git/.git").mkdir(parents=True)
     (tmp_path / "has_git/lvl1").mkdir()
     (tmp_path / "has_pyp/lvl1").mkdir(parents=True)
     (tmp_path / "has_pyp/pyproject.toml").write_text(
-        '[tool.darker]\nCONFIG_PATH = "has_pyp"\n'
+        '[tool.darker]\nconfig = "has_pyp"\n'
     )
     (tmp_path / "full_example").mkdir()
     (tmp_path / "full_example/pyproject.toml").write_text(
@@ -476,7 +504,9 @@ def test_load_config(  # pylint: disable=too-many-arguments
         "[tool.darker]\nstdout = true\n"
     )
     (tmp_path / "c").mkdir()
-    (tmp_path / "c" / "pyproject.toml").write_text("[tool.darker]\nPYP_TOML = 1\n")
+    (tmp_path / "c" / "pyproject.toml").write_text(
+        "[tool.darker]\nlint = ['PYP_TOML']\n"
+    )
     monkeypatch.chdir(tmp_path / cwd)
 
     result = load_config(confpath, srcs)
@@ -608,7 +638,7 @@ def test_get_modified_config(args, expect):
             isort = false
             lint = [
             ]
-            log_level = "DEBUG"
+            log-level = "DEBUG"
             """
         ),
     ),