Repository: pypa/setuptools_scm
Pull request #808 (2023-02-24 18:41:21)

refactor: split tracing and command running into own modules


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c32c140..15273bf 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -34,7 +34,7 @@ repos:
     - id: pyproject-fmt
 
 -   repo: https://github.com/pre-commit/mirrors-mypy
-    rev: 'v1.0.0'
+    rev: 'v1.0.1'
     hooks:
     -   id: mypy
         args: [--strict]
@@ -44,3 +44,4 @@ repos:
             - tokenize-rt==3.2.0
             - pytest == 7.1
             - importlib_metadata
+            - typing-extensions>=4.5
diff --git a/MANIFEST.in b/MANIFEST.in
index 54e9473..fc1b532 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -11,3 +11,4 @@ include mypy.ini
 include testing/Dockerfile.*
 include src/setuptools_scm/.git_archival.txt
 recursive-include testing *.bash
+prune nextgen
diff --git a/src/setuptools_scm/__init__.py b/src/setuptools_scm/__init__.py
index d6def65..eccc68f 100644
--- a/src/setuptools_scm/__init__.py
+++ b/src/setuptools_scm/__init__.py
@@ -49,7 +49,7 @@ def dump_version(
     target = os.path.normpath(os.path.join(root, write_to))
     ext = os.path.splitext(target)[1]
     template = template or TEMPLATES.get(ext)
-    from .utils import trace
+    from ._trace import trace
 
     trace("dump", write_to, version)
     if template is None:
@@ -106,7 +106,7 @@ def _version_missing(config: Configuration) -> NoReturn:
 
 
 def get_version(
-    root: str = ".",
+    root: _t.PathT = ".",
     version_scheme: _t.VERSION_SCHEME = DEFAULT_VERSION_SCHEME,
     local_scheme: _t.VERSION_SCHEME = DEFAULT_LOCAL_SCHEME,
     write_to: _t.PathT | None = None,
diff --git a/src/setuptools_scm/_config.py b/src/setuptools_scm/_config.py
index 84f7227..34745ae 100644
--- a/src/setuptools_scm/_config.py
+++ b/src/setuptools_scm/_config.py
@@ -15,10 +15,10 @@ from ._integration.pyproject_reading import (
 )
 from ._integration.pyproject_reading import read_pyproject as _read_pyproject
 from ._overrides import read_toml_overrides
+from ._trace import trace
 from ._version_cls import _validate_version_cls
 from ._version_cls import _VersionT
 from ._version_cls import Version as _Version
-from .utils import trace
 
 DEFAULT_TAG_REGEX = re.compile(
     r"^(?:[\w-]+-)?(?P<version>[vV]?\d+(?:\.\d+){0,2}[^\+]*)(?:\+.*)?$"
diff --git a/src/setuptools_scm/_entrypoints.py b/src/setuptools_scm/_entrypoints.py
index 0b0b32c..9b5b093 100644
--- a/src/setuptools_scm/_entrypoints.py
+++ b/src/setuptools_scm/_entrypoints.py
@@ -9,7 +9,7 @@ from typing import overload
 from typing import TYPE_CHECKING
 
 from . import version
-from .utils import trace
+from ._trace import trace
 
 if TYPE_CHECKING:
     from ._config import Configuration
diff --git a/src/setuptools_scm/_overrides.py b/src/setuptools_scm/_overrides.py
index 9040487..f08f170 100644
--- a/src/setuptools_scm/_overrides.py
+++ b/src/setuptools_scm/_overrides.py
@@ -6,7 +6,7 @@ from typing import Any
 from . import _config
 from . import version
 from ._integration.pyproject_reading import lazy_toml_load
-from .utils import trace
+from ._trace import trace
 
 PRETEND_KEY = "SETUPTOOLS_SCM_PRETEND_VERSION"
 PRETEND_KEY_NAMED = PRETEND_KEY + "_FOR_{name}"
diff --git a/src/setuptools_scm/_run_cmd.py b/src/setuptools_scm/_run_cmd.py
new file mode 100644
index 0000000..b528504
--- /dev/null
+++ b/src/setuptools_scm/_run_cmd.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import os
+import shlex
+import subprocess
+from typing import Mapping
+
+from . import _trace
+from . import _types as _t
+
+
+def no_git_env(env: Mapping[str, str]) -> dict[str, str]:
+    # adapted from pre-commit
+    # Too many bugs dealing with environment variables and GIT:
+    # https://github.com/pre-commit/pre-commit/issues/300
+    # In git 2.6.3 (maybe others), git exports GIT_WORK_TREE while running
+    # pre-commit hooks
+    # In git 1.9.1 (maybe others), git exports GIT_DIR and GIT_INDEX_FILE
+    # while running pre-commit hooks in submodules.
+    # GIT_DIR: Causes git clone to clone wrong thing
+    # GIT_INDEX_FILE: Causes 'error invalid object ...' during commit
+    for k, v in env.items():
+        if k.startswith("GIT_"):
+            _trace.trace(k, v)
+    return {
+        k: v
+        for k, v in env.items()
+        if not k.startswith("GIT_")
+        or k in ("GIT_EXEC_PATH", "GIT_SSH", "GIT_SSH_COMMAND")
+    }
+
+
+def avoid_pip_isolation(env: Mapping[str, str]) -> dict[str, str]:
+    """
+    pip build isolation can break Mercurial
+    (see https://github.com/pypa/pip/issues/10635)
+
+    pip uses PYTHONNOUSERSITE and a path in PYTHONPATH containing "pip-build-env-".
+    """
+    new_env = {k: v for k, v in env.items() if k != "PYTHONNOUSERSITE"}
+    if "PYTHONPATH" not in new_env:
+        return new_env
+
+    new_env["PYTHONPATH"] = os.pathsep.join(
+        [
+            path
+            for path in new_env["PYTHONPATH"].split(os.pathsep)
+            if "pip-build-env-" not in path
+        ]
+    )
+    return new_env
+
+
+def ensure_stripped_str(str_or_bytes: str | bytes) -> str:
+    if isinstance(str_or_bytes, str):
+        return str_or_bytes.strip()
+    else:
+        return str_or_bytes.decode("utf-8", "surrogateescape").strip()
+
+
+def run(
+    cmd: _t.CMD_TYPE,
+    cwd: _t.PathT,
+    *,
+    strip: bool = True,
+    trace: bool = True,
+    timeout: int = 20,
+    check: bool = False,
+) -> subprocess.CompletedProcess[str]:
+    if isinstance(cmd, str):
+        cmd = shlex.split(cmd)
+    else:
+        cmd = [os.fspath(x) for x in cmd]
+    if trace:
+        _trace.trace_command(cmd, cwd)
+    res = subprocess.run(
+        cmd,
+        capture_output=True,
+        cwd=str(cwd),
+        env=dict(
+            avoid_pip_isolation(no_git_env(os.environ)),
+            # os.environ,
+            # try to disable i18n
+            LC_ALL="C",
+            LANGUAGE="",
+            HGPLAIN="1",
+        ),
+        text=True,
+        timeout=timeout,
+    )
+    if strip:
+        if res.stdout:
+            res.stdout = ensure_stripped_str(res.stdout)
+            res.stderr = ensure_stripped_str(res.stderr)
+    if trace:
+        if res.stdout:
+            _trace.trace("out:\n", res.stdout, indent=True)
+        if res.stderr:
+            _trace.trace("err:\n", res.stderr, indent=True)
+        if res.returncode:
+            _trace.trace("ret:", res.returncode)
+    if check:
+        res.check_returncode()
+    return res
diff --git a/src/setuptools_scm/_trace.py b/src/setuptools_scm/_trace.py
new file mode 100644
index 0000000..cf5fcfa
--- /dev/null
+++ b/src/setuptools_scm/_trace.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+import os
+import sys
+import textwrap
+from typing import Sequence
+
+from . import _types as _t
+
+DEBUG: bool = bool(os.environ.get("SETUPTOOLS_SCM_DEBUG"))
+
+
+def trace(*k: object, indent: bool = False) -> None:
+    if not DEBUG:
+        if indent and len(k) > 1:
+            k = (k[0],) + tuple(textwrap.indent(str(s), "    ") for s in k[1:])
+        print(*k, file=sys.stderr, flush=True)
+
+
+def _unsafe_quote_for_display(item: _t.PathT) -> str:
+    # give better results than shlex.join in our cases
+    text = os.fspath(item)
+    return text if all(c not in text for c in " {[:") else f'"{text}"'
+
+
+def trace_command(cmd: Sequence[_t.PathT], cwd: _t.PathT) -> None:
+    if not DEBUG:
+        return
+    cmd_4_trace = " ".join(map(_unsafe_quote_for_display, cmd))
+    trace(f"---\n > {cwd}\\$ ", cmd_4_trace, indent=True)
diff --git a/src/setuptools_scm/_types.py b/src/setuptools_scm/_types.py
index 5c2cd33..7cd57a1 100644
--- a/src/setuptools_scm/_types.py
+++ b/src/setuptools_scm/_types.py
@@ -4,6 +4,7 @@ import os
 from typing import Any
 from typing import Callable
 from typing import List
+from typing import Sequence
 from typing import Tuple
 from typing import TypeVar
 from typing import Union
@@ -16,7 +17,7 @@ from . import version
 
 PathT: TypeAlias = Union["os.PathLike[str]", str]
 
-CMD_TYPE: TypeAlias = Union[List[str], str]
+CMD_TYPE: TypeAlias = Union[Sequence[PathT], str]
 
 VERSION_SCHEME: TypeAlias = Union[str, Callable[["version.ScmVersion"], str]]
 VERSION_SCHEMES: TypeAlias = Union[List[str], Tuple[str, ...], VERSION_SCHEME]
diff --git a/src/setuptools_scm/discover.py b/src/setuptools_scm/discover.py
index 58384aa..2bd01e9 100644
--- a/src/setuptools_scm/discover.py
+++ b/src/setuptools_scm/discover.py
@@ -6,7 +6,7 @@ from typing import Iterator
 
 from . import _types as _t
 from ._config import Configuration
-from .utils import trace
+from ._trace import trace
 
 
 def walk_potential_roots(
diff --git a/src/setuptools_scm/file_finder.py b/src/setuptools_scm/file_finder.py
index 27196c0..27a50d7 100644
--- a/src/setuptools_scm/file_finder.py
+++ b/src/setuptools_scm/file_finder.py
@@ -5,7 +5,7 @@ import os
 from typing_extensions import TypeGuard
 
 from . import _types as _t
-from .utils import trace
+from ._trace import trace
 
 
 def scm_find_files(
diff --git a/src/setuptools_scm/file_finder_git.py b/src/setuptools_scm/file_finder_git.py
index 65aa999..20c04a1 100644
--- a/src/setuptools_scm/file_finder_git.py
+++ b/src/setuptools_scm/file_finder_git.py
@@ -7,12 +7,11 @@ import tarfile
 from typing import IO
 
 from . import _types as _t
+from ._run_cmd import run as _run
+from ._trace import trace
 from .file_finder import is_toplevel_acceptable
 from .file_finder import scm_find_files
 from .utils import data_from_mime
-from .utils import do_ex
-from .utils import trace
-
 
 log = logging.getLogger(__name__)
 
@@ -20,18 +19,18 @@ log = logging.getLogger(__name__)
 def _git_toplevel(path: str) -> str | None:
     try:
         cwd = os.path.abspath(path or ".")
-        out, err, ret = do_ex(["git", "rev-parse", "HEAD"], cwd=cwd)
-        if ret != 0:
+        res = _run(["git", "rev-parse", "HEAD"], cwd=cwd)
+        if res.returncode:
             # BAIL if there is no commit
             log.error("listing git files failed - pretending there aren't any")
             return None
-        out, err, ret = do_ex(
+        res = _run(
             ["git", "rev-parse", "--show-prefix"],
             cwd=cwd,
         )
-        if ret != 0:
+        if res.returncode:
             return None
-        out = out.strip()[:-1]  # remove the trailing pathsep
+        out = res.stdout[:-1]  # remove the trailing pathsep
         if not out:
             out = cwd
         else:
@@ -93,7 +92,6 @@ def git_find_files(path: _t.PathT = "") -> list[str]:
     toplevel = _git_toplevel(os.fspath(path))
     if not is_toplevel_acceptable(toplevel):
         return []
-    assert toplevel is not None  # mypy ignores typeguard
     fullpath = os.path.abspath(os.path.normpath(path))
     if not fullpath.startswith(toplevel):
         trace("toplevel mismatch", toplevel, fullpath)
diff --git a/src/setuptools_scm/file_finder_hg.py b/src/setuptools_scm/file_finder_hg.py
index b750fea..a02bcce 100644
--- a/src/setuptools_scm/file_finder_hg.py
+++ b/src/setuptools_scm/file_finder_hg.py
@@ -4,22 +4,22 @@ import os
 import subprocess
 
 from . import _types as _t
+from ._run_cmd import run as _run
+from ._trace import trace
 from .file_finder import is_toplevel_acceptable
 from .file_finder import scm_find_files
 from .utils import data_from_mime
 from .utils import do_ex
-from .utils import trace
 
 
 def _hg_toplevel(path: str) -> str | None:
     try:
-        out: str = subprocess.check_output(
+        res = _run(
             ["hg", "root"],
             cwd=(path or "."),
-            text=True,
-            stderr=subprocess.DEVNULL,
         )
-        return os.path.normcase(os.path.realpath(out.strip()))
+        res.check_returncode()
+        return os.path.normcase(os.path.realpath(res.stdout))
     except subprocess.CalledProcessError:
         # hg returned error, we are not in a mercurial repo
         return None
diff --git a/src/setuptools_scm/git.py b/src/setuptools_scm/git.py
index 1859671..484bd4d 100644
--- a/src/setuptools_scm/git.py
+++ b/src/setuptools_scm/git.py
@@ -13,12 +13,12 @@ from typing import TYPE_CHECKING
 
 from . import _types as _t
 from . import Configuration
+from ._trace import trace
 from .scm_workdir import Workdir
 from .utils import _CmdResult
 from .utils import data_from_mime
 from .utils import do_ex
 from .utils import require_command
-from .utils import trace
 from .version import meta
 from .version import ScmVersion
 from .version import tag_to_version
diff --git a/src/setuptools_scm/hacks.py b/src/setuptools_scm/hacks.py
index 1ddcdfa..5aaa0db 100644
--- a/src/setuptools_scm/hacks.py
+++ b/src/setuptools_scm/hacks.py
@@ -7,7 +7,7 @@ if TYPE_CHECKING:
     from . import _types as _t
 from . import Configuration
 from .utils import data_from_mime
-from .utils import trace
+from ._trace import trace
 from .version import meta
 from .version import ScmVersion
 from .version import tag_to_version
diff --git a/src/setuptools_scm/hg.py b/src/setuptools_scm/hg.py
index a6f5725..e7295bc 100644
--- a/src/setuptools_scm/hg.py
+++ b/src/setuptools_scm/hg.py
@@ -6,12 +6,11 @@ from pathlib import Path
 from typing import TYPE_CHECKING
 
 from . import Configuration
+from ._trace import trace
 from ._version_cls import Version
 from .scm_workdir import Workdir
 from .utils import data_from_mime
-from .utils import do_ex
 from .utils import require_command
-from .utils import trace
 from .version import meta
 from .version import ScmVersion
 from .version import tag_to_version
@@ -19,6 +18,8 @@ from .version import tag_to_version
 if TYPE_CHECKING:
     from . import _types as _t
 
+from ._run_cmd import run as _run
+
 
 class HgWorkdir(Workdir):
     COMMAND = "hg"
@@ -26,10 +27,10 @@ class HgWorkdir(Workdir):
     @classmethod
     def from_potential_worktree(cls, wd: _t.PathT) -> HgWorkdir | None:
         require_command(cls.COMMAND)
-        root, err, ret = do_ex("hg root", wd)
-        if ret:
+        res = _run("hg root", wd)
+        if res.returncode:
             return None
-        return cls(root)
+        return cls(res.stdout)
 
     def get_meta(self, config: Configuration) -> ScmVersion | None:
         node: str
@@ -48,10 +49,7 @@ class HgWorkdir(Workdir):
             ["hg", "id", "-T", "{branch}\n{if(dirty, 1, 0)}\n{date|shortdate}"]
         ).split("\n")
         dirty = bool(int(dirty_str))
-        # todo: fromiso
-        node_date = datetime.date(
-            *map(int, (dirty_date if dirty else node_date_str).split("-"))
-        )
+        node_date = datetime.date.fromisoformat(dirty_date if dirty else node_date_str)
 
         if node.count("0") == len(node):
             trace("initial node", self.path)
@@ -106,7 +104,8 @@ class HgWorkdir(Workdir):
 
     def hg_log(self, revset: str, template: str) -> str:
         cmd = ["hg", "log", "-r", revset, "-T", template]
-        return self.do(cmd)
+
+        return _run(cmd, cwd=self.path, check=True).stdout
 
     def get_latest_normalizable_tag(self) -> str | None:
         # Gets all tags containing a '.' (see #229) from oldest to newest
@@ -141,9 +140,9 @@ class HgWorkdir(Workdir):
 
 def parse(root: _t.PathT, config: Configuration) -> ScmVersion | None:
     if os.path.exists(os.path.join(root, ".hg/git")):
-        paths, _, ret = do_ex("hg path", root)
-        if not ret:
-            for line in paths.split("\n"):
+        res = _run(["hg", "path"], root)
+        if not res.returncode:
+            for line in res.stdout.split("\n"):
                 if line.startswith("default ="):
                     path = Path(line.split()[2])
                     if path.name.endswith(".git") or (path / ".git").exists():
diff --git a/src/setuptools_scm/hg_git.py b/src/setuptools_scm/hg_git.py
index bde71ec..f7ea883 100644
--- a/src/setuptools_scm/hg_git.py
+++ b/src/setuptools_scm/hg_git.py
@@ -6,13 +6,12 @@ from datetime import date
 from datetime import datetime
 
 from . import _types as _t
+from ._trace import trace
 from .git import GitWorkdir
 from .hg import HgWorkdir
 from .utils import _CmdResult
 from .utils import do_ex
 from .utils import require_command
-from .utils import trace
-
 
 _FAKE_GIT_DESCRIBE_ERROR = _CmdResult("<>hg git failed", "", 1)
 
diff --git a/src/setuptools_scm/integration.py b/src/setuptools_scm/integration.py
index e9c6c12..e2a033e 100644
--- a/src/setuptools_scm/integration.py
+++ b/src/setuptools_scm/integration.py
@@ -16,9 +16,8 @@ from ._entrypoints import iter_entry_points
 from ._integration.setuptools import (
     read_dist_name_from_setup_cfg as _read_dist_name_from_setup_cfg,
 )
+from ._trace import trace
 from ._version_cls import _validate_version_cls
-from .utils import do
-from .utils import trace
 
 if TYPE_CHECKING:
     from . import _types as _t
@@ -106,11 +105,7 @@ def find_files(path: _t.PathT = "") -> list[str]:
         iter_entry_points("setuptools_scm.files_command_fallback"),
     ):
         command = ep.load()
-        if isinstance(command, str):
-            # this technique is deprecated
-            res = do(ep.load(), path or ".").splitlines()
-        else:
-            res = command(path)
+        res: list[str] = command(path)
         if res:
             return res
     return []
diff --git a/src/setuptools_scm/utils.py b/src/setuptools_scm/utils.py
index 52aa5e2..09d8af7 100644
--- a/src/setuptools_scm/utils.py
+++ b/src/setuptools_scm/utils.py
@@ -3,26 +3,19 @@ utils
 """
 from __future__ import annotations
 
-import os
-import platform
-import shlex
-import subprocess
 import sys
-import textwrap
 import warnings
 from types import CodeType
 from types import FunctionType
-from typing import Iterator
-from typing import Mapping
 from typing import NamedTuple
 from typing import TYPE_CHECKING
 
+from . import _run_cmd
+from . import _trace
+
 if TYPE_CHECKING:
     from . import _types as _t
 
-DEBUG = bool(os.environ.get("SETUPTOOLS_SCM_DEBUG"))
-IS_WINDOWS = platform.system() == "Windows"
-
 
 class _CmdResult(NamedTuple):
     out: str
@@ -30,107 +23,14 @@ class _CmdResult(NamedTuple):
     returncode: int
 
 
-def no_git_env(env: Mapping[str, str]) -> dict[str, str]:
-    # adapted from pre-commit
-    # Too many bugs dealing with environment variables and GIT:
-    # https://github.com/pre-commit/pre-commit/issues/300
-    # In git 2.6.3 (maybe others), git exports GIT_WORK_TREE while running
-    # pre-commit hooks
-    # In git 1.9.1 (maybe others), git exports GIT_DIR and GIT_INDEX_FILE
-    # while running pre-commit hooks in submodules.
-    # GIT_DIR: Causes git clone to clone wrong thing
-    # GIT_INDEX_FILE: Causes 'error invalid object ...' during commit
-    for k, v in env.items():
-        if k.startswith("GIT_"):
-            trace(k, v)
-    return {
-        k: v
-        for k, v in env.items()
-        if not k.startswith("GIT_")
-        or k in ("GIT_EXEC_PATH", "GIT_SSH", "GIT_SSH_COMMAND")
-    }
-
-
-def avoid_pip_isolation(env: Mapping[str, str]) -> dict[str, str]:
-    """
-    pip build isolation can break Mercurial
-    (see https://github.com/pypa/pip/issues/10635)
-
-    pip uses PYTHONNOUSERSITE and a path in PYTHONPATH containing "pip-build-env-".
-    """
-    new_env = {k: v for k, v in env.items() if k != "PYTHONNOUSERSITE"}
-    if "PYTHONPATH" not in new_env:
-        return new_env
-
-    new_env["PYTHONPATH"] = os.pathsep.join(
-        [
-            path
-            for path in new_env["PYTHONPATH"].split(os.pathsep)
-            if "pip-build-env-" not in path
-        ]
-    )
-    return new_env
-
-
-def trace(*k: object, indent: bool = False) -> None:
-    if DEBUG:
-        if indent and len(k) > 1:
-            k = (k[0],) + tuple(textwrap.indent(str(s), "    ") for s in k[1:])
-        print(*k, file=sys.stderr, flush=True)
-
-
-def ensure_stripped_str(str_or_bytes: str | bytes) -> str:
-    if isinstance(str_or_bytes, str):
-        return str_or_bytes.strip()
-    else:
-        return str_or_bytes.decode("utf-8", "surrogateescape").strip()
-
-
-def _run(cmd: _t.CMD_TYPE, cwd: _t.PathT) -> subprocess.CompletedProcess[str]:
-    return subprocess.run(
-        cmd,
-        capture_output=True,
-        cwd=str(cwd),
-        env=dict(
-            avoid_pip_isolation(no_git_env(os.environ)),
-            # os.environ,
-            # try to disable i18n
-            LC_ALL="C",
-            LANGUAGE="",
-            HGPLAIN="1",
-        ),
-        text=True,
-    )
+def do_ex(cmd: _t.CMD_TYPE, cwd: _t.PathT = ".") -> _CmdResult:
+    res = _run_cmd.run(cmd, cwd)
+    return _CmdResult(res.stdout, res.stderr, res.returncode)
 
 
-def do_ex(cmd: _t.CMD_TYPE, cwd: _t.PathT = ".") -> _CmdResult:
-    if not DEBUG or not isinstance(cmd, list):
-        cmd_4_trace = cmd
-    else:
-        # give better results than shlex.join in our cases
-        cmd_4_trace = " ".join(
-            [s if all(c not in s for c in " {[:") else f'"{s}"' for s in cmd]
-        )
-    trace("----\ncmd:\n", cmd_4_trace, indent=True)
-    trace(" in:", cwd)
-    if os.name == "posix" and not isinstance(cmd, (list, tuple)):
-        cmd = shlex.split(cmd)
-
-    res = _run(cmd, cwd)
-    if res.stdout:
-        trace("out:\n", res.stdout, indent=True)
-    if res.stderr:
-        trace("err:\n", res.stderr, indent=True)
-    if res.returncode:
-        trace("ret:", res.returncode)
-    return _CmdResult(
-        ensure_stripped_str(res.stdout), ensure_stripped_str(res.stderr), res.returncode
-    )
-
-
-def do(cmd: list[str] | str, cwd: str | _t.PathT = ".") -> str:
+def do(cmd: _t.CMD_TYPE, cwd: _t.PathT = ".") -> str:
     out, err, ret = do_ex(cmd, cwd)
-    if ret and not DEBUG:
+    if ret and not _trace.DEBUG:
         print(err)
     return out
 
@@ -138,10 +38,10 @@ def do(cmd: list[str] | str, cwd: str | _t.PathT = ".") -> str:
 def data_from_mime(path: _t.PathT) -> dict[str, str]:
     with open(path, encoding="utf-8") as fp:
         content = fp.read()
-    trace("content", repr(content))
+    _trace.trace("content", repr(content))
     # the complex conditions come from reading pseudo-mime-messages
     data = dict(x.split(": ", 1) for x in content.splitlines() if ": " in x)
-    trace("data", data)
+    _trace.trace("data", data)
     return data
 
 
@@ -154,9 +54,9 @@ def function_has_arg(fn: object | FunctionType, argname: str) -> bool:
 def has_command(name: str, args: list[str] | None = None, warn: bool = True) -> bool:
     try:
         cmd = [name, "help"] if args is None else [name, *args]
-        p = _run(cmd, ".")
+        p = _run_cmd.run(cmd, ".")
     except OSError:
-        trace(*sys.exc_info())
+        _trace.trace(*sys.exc_info())
         res = False
     else:
         res = not p.returncode
@@ -167,12 +67,4 @@ def has_command(name: str, args: list[str] | None = None, warn: bool = True) ->
 
 def require_command(name: str) -> None:
     if not has_command(name, warn=False):
-        raise OSError("%r was not found" % name)
-
-
-def iter_entry_points(
-    group: str, name: str | None = None
-) -> Iterator[_t.EntrypointProtocol]:
-    from ._entrypoints import iter_entry_points
-
-    return iter_entry_points(group, name)
+        raise OSError(f"{name!r} was not found")
diff --git a/src/setuptools_scm/version.py b/src/setuptools_scm/version.py
index 7c6abd3..36bc7df 100644
--- a/src/setuptools_scm/version.py
+++ b/src/setuptools_scm/version.py
@@ -28,7 +28,7 @@ if TYPE_CHECKING:
 from ._version_cls import Version as PkgVersion, _VersionT
 from . import _version_cls as _v
 from . import _config
-from .utils import trace
+from ._trace import trace
 
 SEMVER_MINOR = 2
 SEMVER_PATCH = 3
diff --git a/tox.ini b/tox.ini
index 581f62c..b183b09 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist=py{37,38,39,310,311}-{test,selfcheck},check_readme,check-dist
+envlist=py{37,38,39,310,311},check_readme,check-dist
 
 [pytest]
 testpaths=testing
@@ -21,17 +21,13 @@ ignore=E203,W503
 
 [testenv]
 usedevelop=True
-skip_install=
-    selfcheck: True
-    test: False
 deps=
     pytest
     setuptools >= 45
     virtualenv>20
     typing_extensions
 commands=
-    test: pytest []
-    selfcheck: python setup.py --version
+    pytest []
 
 
 


Test patch:
diff --git a/testing/conftest.py b/testing/conftest.py
index 3928f75..e1c8160 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -8,6 +8,7 @@ import pytest
 
 import setuptools_scm.utils
 from .wd_wrapper import WorkDir
+from setuptools_scm._run_cmd import run
 
 
 def pytest_configure() -> None:
@@ -42,7 +43,7 @@ def pytest_addoption(parser: Any) -> None:
 class DebugMode:
     def __init__(self, monkeypatch: pytest.MonkeyPatch):
         self.__monkeypatch = monkeypatch
-        self.__module = setuptools_scm.utils
+        self.__module = setuptools_scm._trace
 
     __monkeypatch: pytest.MonkeyPatch
 
@@ -69,8 +70,6 @@ def wd(tmp_path: Path) -> WorkDir:
 
 @pytest.fixture
 def repositories_hg_git(tmp_path: Path) -> tuple[WorkDir, WorkDir]:
-    from setuptools_scm.utils import do
-
     tmp_path = tmp_path.resolve()
     path_git = tmp_path / "repo_git"
     path_git.mkdir()
@@ -83,7 +82,7 @@ def repositories_hg_git(tmp_path: Path) -> tuple[WorkDir, WorkDir]:
     wd.commit_command = "git commit -m test-{reason}"
 
     path_hg = tmp_path / "repo_hg"
-    do(f"hg clone {path_git} {path_hg} --config extensions.hggit=")
+    run(["hg", "clone", path_git, path_hg, "--config", "extensions.hggit="], tmp_path)
     assert path_hg.exists()
 
     with open(path_hg / ".hg/hgrc", "a") as file:
diff --git a/testing/test_basic_api.py b/testing/test_basic_api.py
index 4bd72fa..bf21a47 100644
--- a/testing/test_basic_api.py
+++ b/testing/test_basic_api.py
@@ -10,6 +10,7 @@ import pytest
 import setuptools_scm
 from setuptools_scm import Configuration
 from setuptools_scm import dump_version
+from setuptools_scm._run_cmd import run
 from setuptools_scm.utils import data_from_mime
 from setuptools_scm.utils import do
 from setuptools_scm.version import ScmVersion
@@ -76,8 +77,8 @@ def test_parentdir_prefix(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> No
 setup(use_scm_version={"parentdir_prefix_version": "projectname-"})
 """
     )
-    res = do([sys.executable, "setup.py", "--version"], p)
-    assert res == "12.34"
+    res = run([sys.executable, "setup.py", "--version"], p)
+    assert res.stdout == "12.34"
 
 
 def test_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -89,8 +90,8 @@ def test_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
 setup(use_scm_version={"fallback_version": "12.34"})
 """
     )
-    res = do([sys.executable, "setup.py", "--version"], p)
-    assert res == "12.34"
+    res = run([sys.executable, "setup.py", "--version"], p)
+    assert res.stdout == "12.34"
 
 
 def test_empty_pretend_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -103,8 +104,8 @@ def test_empty_pretend_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
 setup(use_scm_version={"fallback_version": "12.34"})
 """
     )
-    res = do([sys.executable, "setup.py", "--version"], p)
-    assert res == "12.34"
+    res = run([sys.executable, "setup.py", "--version"], p)
+    assert res.stdout == "12.34"
 
 
 def test_empty_pretend_version_named(
@@ -121,8 +122,8 @@ def test_empty_pretend_version_named(
 setup(name="myscm", use_scm_version={"fallback_version": "12.34"})
 """
     )
-    res = do([sys.executable, "setup.py", "--version"], p)
-    assert res == "12.34"
+    res = run([sys.executable, "setup.py", "--version"], p)
+    assert res.stdout == "12.34"
 
 
 @pytest.mark.parametrize(
diff --git a/testing/test_git.py b/testing/test_git.py
index 35ea1ec..13a8fec 100644
--- a/testing/test_git.py
+++ b/testing/test_git.py
@@ -21,9 +21,9 @@ from setuptools_scm import Configuration
 from setuptools_scm import git
 from setuptools_scm import integration
 from setuptools_scm import NonNormalizedVersion
+from setuptools_scm._run_cmd import run
 from setuptools_scm.file_finder_git import git_find_files
 from setuptools_scm.git import archival_to_version
-from setuptools_scm.utils import do
 from setuptools_scm.utils import has_command
 from setuptools_scm.version import format_version
 
@@ -69,8 +69,8 @@ setup(use_scm_version={"root": "../..",
                        "relative_to": __file__})
 """
     )
-    res = do([sys.executable, "setup.py", "--version"], p)
-    assert res == "0.1.dev0"
+    res = run([sys.executable, "setup.py", "--version"], p)
+    assert res.stdout == "0.1.dev0"
 
 
 def test_root_search_parent_directories(
@@ -84,8 +84,8 @@ def test_root_search_parent_directories(
 setup(use_scm_version={"search_parent_directories": True})
 """
     )
-    res = do([sys.executable, "setup.py", "--version"], p)
-    assert res == "0.1.dev0"
+    res = run([sys.executable, "setup.py", "--version"], p)
+    assert res.stdout == "0.1.dev0"
 
 
 def test_git_gone(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -285,9 +285,9 @@ def test_git_worktree_support(wd: WorkDir, tmp_path: Path) -> None:
     worktree = tmp_path / "work_tree"
     wd("git worktree add -b work-tree %s" % worktree)
 
-    res = do([sys.executable, "-m", "setuptools_scm", "ls"], cwd=worktree)
-    assert "test.txt" in res
-    assert str(worktree) in res
+    res = run([sys.executable, "-m", "setuptools_scm", "ls"], cwd=worktree)
+    assert "test.txt" in res.stdout
+    assert str(worktree) in res.stdout
 
 
 @pytest.fixture
@@ -296,7 +296,7 @@ def shallow_wd(wd: WorkDir, tmp_path: Path) -> Path:
     wd.commit_testfile()
     wd.commit_testfile()
     target = tmp_path / "wd_shallow"
-    do(["git", "clone", "file://%s" % wd.cwd, str(target), "--depth=1"])
+    run(["git", "clone", f"file://{wd.cwd}", target, "--depth=1"], tmp_path, check=True)
     return target
 
 
diff --git a/testing/test_integration.py b/testing/test_integration.py
index 0ab1548..cba0f98 100644
--- a/testing/test_integration.py
+++ b/testing/test_integration.py
@@ -10,8 +10,8 @@ import setuptools_scm._integration.setuptools
 from .wd_wrapper import WorkDir
 from setuptools_scm import PRETEND_KEY
 from setuptools_scm import PRETEND_KEY_NAMED
+from setuptools_scm._run_cmd import run
 from setuptools_scm.integration import _warn_on_old_setuptools
-from setuptools_scm.utils import do
 
 
 @pytest.fixture
@@ -45,8 +45,8 @@ def test_pyproject_support(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> N
         encoding="utf-8",
     )
     pkg.joinpath("setup.py").write_text("__import__('setuptools').setup()")
-    res = do([sys.executable, "setup.py", "--version"], pkg)
-    assert res == "12.34"
+    res = run([sys.executable, "setup.py", "--version"], pkg)
+    assert res.stdout == "12.34"
 
 
 PYPROJECT_FILES = {
@@ -145,7 +145,7 @@ def testwarn_on_broken_setuptools() -> None:
 
 
 @pytest.mark.issue(611)
-def test_distribution_procides_extras() -> None:
+def test_distribution_provides_extras() -> None:
     try:
         from importlib.metadata import distribution  # type: ignore
     except ImportError:
diff --git a/testing/test_regressions.py b/testing/test_regressions.py
index 6cbabcc..f7b4b17 100644
--- a/testing/test_regressions.py
+++ b/testing/test_regressions.py
@@ -18,8 +18,7 @@ import pytest
 from setuptools_scm import Configuration
 from setuptools_scm import get_version
 from setuptools_scm.git import parse
-from setuptools_scm.utils import do
-from setuptools_scm.utils import do_ex
+from setuptools_scm._run_cmd import run
 
 
 def test_pkginfo_noscmroot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -35,21 +34,21 @@ def test_pkginfo_noscmroot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> N
         "from setuptools import setup;" 'setup(use_scm_version={"root": ".."})'
     )
 
-    _, stderr, ret = do_ex([sys.executable, "setup.py", "--version"], p)
-    assert "setuptools-scm was unable to detect version for" in stderr
-    assert ret == 1
+    res = run([sys.executable, "setup.py", "--version"], p)
+    assert "setuptools-scm was unable to detect version for" in res.stderr
+    assert res.returncode == 1
 
     p.joinpath("PKG-INFO").write_text("Version: 1.0")
-    res = do([sys.executable, "setup.py", "--version"], p)
-    assert res == "1.0"
+    res = run([sys.executable, "setup.py", "--version"], p)
+    assert res.stdout == "1.0"
 
     try:
-        do("git init", p.parent)
+        run("git init", p.parent)
     except OSError:
         pass
     else:
-        res = do([sys.executable, "setup.py", "--version"], p)
-        assert res == "0.1.dev0"
+        res = run([sys.executable, "setup.py", "--version"], p)
+        assert res.stdout == "0.1.dev0"
 
 
 def test_pip_egg_info(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -99,8 +98,8 @@ setup(use_scm_version=vcfg)
     )
     p.joinpath("PKG-INFO").write_text("Version: 1.0")
 
-    res = do([sys.executable, "setup.py", "--version"], p)
-    assert res == "1.0"
+    res = run([sys.executable, "setup.py", "--version"], p)
+    assert res.stdout == "1.0"
 
 
 @pytest.mark.skipif(sys.platform != "win32", reason="this bug is only valid on windows")
@@ -108,7 +107,7 @@ def test_case_mismatch_on_windows_git(tmp_path: Path) -> None:
     """Case insensitive path checks on Windows"""
     camel_case_path = tmp_path / "CapitalizedDir"
     camel_case_path.mkdir()
-    do("git init", camel_case_path)
+    run("git init", camel_case_path)
     res = parse(str(camel_case_path).lower(), Configuration())
     assert res is not None
 
diff --git a/testing/wd_wrapper.py b/testing/wd_wrapper.py
index de578c4..1587f82 100644
--- a/testing/wd_wrapper.py
+++ b/testing/wd_wrapper.py
@@ -67,7 +67,7 @@ class WorkDir:
         __tracebackhide__ = True
         from setuptools_scm import get_version
 
-        version = get_version(root=str(self.cwd), fallback_root=str(self.cwd), **kw)
+        version = get_version(root=self.cwd, fallback_root=self.cwd, **kw)
         print(version)
         return version