Repository: pypa/setuptools_scm

Issue:
Using 'root' in `pyproject.toml` file raises a TypeError when invoked from `__main__.py`
Hello,

I'm using the following versions

    setuptools          50.3.2
    setuptools-scm      6.4.2

I've a `pyproject.toml` file which setuptools_scm section is

       [tool.setuptools_scm]                                                                                                                                                                                                            
       write_to = "_version.txt"                                                                                                                                                                                                  
       root = "../.."                                                                                                                                                                                                                   
       relative_to = "__file__" 

When used with `python3 -m build` it works. However,  when I run just tool to see the output version, I get the following

    $ python -m setuptools_scm       
    Traceback (most recent call last):
      File "<some-path>/lib/python3.7/runpy.py", line 193, in _run_module_as_main
        "__main__", mod_spec)
      File "<some-path>/lib/python3.7/runpy.py", line 85, in _run_code
        exec(code, run_globals)
      File "<some-path>/lib/python3.7/site-packages/setuptools_scm/__main__.py", line 83, in <module>
        main()
      File "<some-path>/lib/python3.7/site-packages/setuptools_scm/__main__.py", line 18, in main
        config = Configuration.from_file(pyproject, root=root)
      File "<some-path>/lib/python3.7/site-packages/setuptools_scm/config.py", line 216, in from_file
        return cls(dist_name=dist_name, **section, **kwargs)
    TypeError: type object got multiple values for keyword argument 'root'

The variable "root" get defined twice, first by `__main__.py` when setting the default 'root' or the one from the command line (`--root`); and the second time by the user (me) when I add 'root' to my `pyproject.toml` file

Comments:

Patch:
diff --git a/src/setuptools_scm/_cli.py b/src/setuptools_scm/_cli.py
index 88dd6d0..4883c4f 100644
--- a/src/setuptools_scm/_cli.py
+++ b/src/setuptools_scm/_cli.py
@@ -10,15 +10,15 @@ from setuptools_scm.discover import walk_potential_roots
 from setuptools_scm.integration import find_files
 
 
-def main() -> None:
-    opts = _get_cli_opts()
-    root = opts.root or "."
+def main(args: list[str] | None = None) -> None:
+    opts = _get_cli_opts(args)
+    inferred_root: str = opts.root or "."
 
-    pyproject = opts.config or _find_pyproject(root)
+    pyproject = opts.config or _find_pyproject(inferred_root)
 
     try:
-        root = opts.root or os.path.relpath(os.path.dirname(pyproject))
-        config = Configuration.from_file(pyproject, root=root)
+
+        config = Configuration.from_file(pyproject, root=opts.root)
     except (LookupError, FileNotFoundError) as ex:
         # no pyproject.toml OR no [tool.setuptools_scm]
         print(
@@ -27,10 +27,11 @@ def main() -> None:
             f" Reason: {ex}.",
             file=sys.stderr,
         )
-        config = Configuration(root=root)
+        config = Configuration(inferred_root)
 
     version = _get_version(config)
-    assert version is not None
+    if version is None:
+        raise SystemExit("ERROR: no version found for", opts)
     if opts.strip_dev:
         version = version.partition(".dev")[0]
     print(version)
@@ -40,7 +41,7 @@ def main() -> None:
             print(fname)
 
 
-def _get_cli_opts() -> argparse.Namespace:
+def _get_cli_opts(args: list[str] | None) -> argparse.Namespace:
     prog = "python -m setuptools_scm"
     desc = "Print project version according to SCM metadata"
     parser = argparse.ArgumentParser(prog, description=desc)
@@ -68,7 +69,7 @@ def _get_cli_opts() -> argparse.Namespace:
     # We avoid `metavar` to prevent printing repetitive information
     desc = "List files managed by the SCM"
     sub.add_parser("ls", help=desc[0].lower() + desc[1:], description=desc)
-    return parser.parse_args()
+    return parser.parse_args(args)
 
 
 def _find_pyproject(parent: str) -> str:
diff --git a/src/setuptools_scm/config.py b/src/setuptools_scm/config.py
index f73b905..fee652c 100644
--- a/src/setuptools_scm/config.py
+++ b/src/setuptools_scm/config.py
@@ -24,6 +24,7 @@ if TYPE_CHECKING:
 DEFAULT_TAG_REGEX = r"^(?:[\w-]+-)?(?P<version>[vV]?\d+(?:\.\d+){0,2}[^\+]*)(?:\+.*)?$"
 DEFAULT_VERSION_SCHEME = "guess-next-dev"
 DEFAULT_LOCAL_SCHEME = "node-and-date"
+_ROOT = "root"
 
 
 def _check_tag_regex(value: str | Pattern[str] | None) -> Pattern[str]:
@@ -213,6 +214,7 @@ class Configuration:
 
         with open(name, encoding="UTF-8") as strm:
             data = strm.read()
+
         defn = _load_toml(data)
         try:
             section = defn.get("tool", {})["setuptools_scm"]
@@ -220,6 +222,21 @@ class Configuration:
             raise LookupError(
                 f"{name} does not contain a tool.setuptools_scm section"
             ) from e
+
+        project = defn.get("project", {})
+        dist_name = cls._cleanup_from_file_args_data(
+            project, dist_name, kwargs, section
+        )
+        return cls(dist_name=dist_name, relative_to=name, **section, **kwargs)
+
+    @staticmethod
+    def _cleanup_from_file_args_data(
+        project: dict[str, Any],
+        dist_name: str | None,
+        kwargs: dict[str, Any],
+        section: dict[str, Any],
+    ) -> str | None:
+        """drops problematic details and figures the distribution name"""
         if "dist_name" in section:
             if dist_name is None:
                 dist_name = section.pop("dist_name")
@@ -227,13 +244,21 @@ class Configuration:
                 assert dist_name == section["dist_name"]
                 del section["dist_name"]
         if dist_name is None:
-            if "project" in defn:
-                # minimal pep 621 support for figuring the pretend keys
-                dist_name = defn["project"].get("name")
+            # minimal pep 621 support for figuring the pretend keys
+            dist_name = project.get("name")
         if dist_name is None:
             dist_name = _read_dist_name_from_setup_cfg()
-
-        return cls(dist_name=dist_name, **section, **kwargs)
+        if _ROOT in kwargs:
+            if kwargs[_ROOT] is None:
+                kwargs.pop(_ROOT, None)
+            elif _ROOT in section:
+                if section[_ROOT] != kwargs[_ROOT]:
+                    warnings.warn(
+                        f"root {section[_ROOT]} is overridden"
+                        f" by the cli arg {kwargs[_ROOT]}"
+                    )
+                section.pop("root", None)
+        return dist_name
 
 
 def _read_dist_name_from_setup_cfg() -> str | None:


Test patch:
diff --git a/testing/conftest.py b/testing/conftest.py
index c881042..d29b5dd 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -3,10 +3,10 @@ from __future__ import annotations
 import os
 from pathlib import Path
 from typing import Any
-from typing import Generator
 
 import pytest
 
+import setuptools_scm.utils
 from .wd_wrapper import WorkDir
 
 
@@ -39,13 +39,25 @@ def pytest_addoption(parser: Any) -> None:
     )
 
 
-@pytest.fixture(autouse=True)
-def debug_mode() -> Generator[None, None, None]:
-    from setuptools_scm import utils
+class DebugMode:
+    def __init__(self, monkeypatch: pytest.MonkeyPatch):
+        self.__monkeypatch = monkeypatch
+        self.__module = setuptools_scm.utils
+
+    __monkeypatch: pytest.MonkeyPatch
+
+    def enable(self) -> None:
+        self.__monkeypatch.setattr(self.__module, "DEBUG", True)
 
-    utils.DEBUG = True
-    yield
-    utils.DEBUG = False
+    def disable(self) -> None:
+        self.__monkeypatch.setattr(self.__module, "DEBUG", False)
+
+
+@pytest.fixture(autouse=True)
+def debug_mode(monkeypatch: pytest.MonkeyPatch) -> DebugMode:
+    debug_mode = DebugMode(monkeypatch)
+    debug_mode.enable()
+    return debug_mode
 
 
 @pytest.fixture
diff --git a/testing/test_cli.py b/testing/test_cli.py
new file mode 100644
index 0000000..0198111
--- /dev/null
+++ b/testing/test_cli.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import io
+from contextlib import redirect_stdout
+
+import pytest
+
+from .conftest import DebugMode
+from .test_git import wd as wd_fixture  # NOQA evil fixture reuse
+from .wd_wrapper import WorkDir
+from setuptools_scm._cli import main
+
+
+PYPROJECT_TOML = "pyproject.toml"
+PYPROJECT_SIMPLE = "[tool.setuptools_scm]"
+PYPROJECT_ROOT = '[tool.setuptools_scm]\nroot=".."'
+
+
+def get_output(args: list[str]) -> str:
+
+    with redirect_stdout(io.StringIO()) as out:
+        main(args)
+    return out.getvalue()
+
+
+def test_cli_find_pyproject(
+    wd: WorkDir, monkeypatch: pytest.MonkeyPatch, debug_mode: DebugMode
+) -> None:
+    debug_mode.disable()
+    wd.commit_testfile()
+    wd.write(PYPROJECT_TOML, PYPROJECT_SIMPLE)
+    monkeypatch.chdir(wd.cwd)
+
+    out = get_output([])
+    assert out.startswith("0.1.dev1+")
+
+    with pytest.raises(SystemExit, match="no version found for"):
+        get_output(["--root=.."])
+
+    wd.write(PYPROJECT_TOML, PYPROJECT_ROOT)
+    with pytest.raises(SystemExit, match="no version found for"):
+        print(get_output(["-c", PYPROJECT_TOML]))
+
+    with pytest.raises(SystemExit, match="no version found for"):
+
+        get_output(["-c", PYPROJECT_TOML, "--root=.."])
+
+    with pytest.warns(UserWarning, match="root .. is overridden by the cli arg ."):
+        out = get_output(["-c", PYPROJECT_TOML, "--root=."])
+    assert out.startswith("0.1.dev1+")
diff --git a/testing/test_git.py b/testing/test_git.py
index cb54f61..6663527 100644
--- a/testing/test_git.py
+++ b/testing/test_git.py
@@ -15,6 +15,7 @@ from unittest.mock import patch
 
 import pytest
 
+from .conftest import DebugMode
 from .wd_wrapper import WorkDir
 from setuptools_scm import Configuration
 from setuptools_scm import format_version
@@ -31,14 +32,16 @@ pytestmark = pytest.mark.skipif(
 )
 
 
-@pytest.fixture
-def wd(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> WorkDir:
+@pytest.fixture(name="wd")
+def wd(wd: WorkDir, monkeypatch: pytest.MonkeyPatch, debug_mode: DebugMode) -> WorkDir:
+    debug_mode.disable()
     monkeypatch.delenv("HOME", raising=False)
     wd("git init")
     wd("git config user.email test@example.com")
     wd('git config user.name "a test"')
     wd.add_command = "git add ."
     wd.commit_command = "git commit -m test-{reason}"
+    debug_mode.enable()
     return wd