Repository: pypa/setuptools_scm

Issue:
Use fallback_version if SCM isn't found
I'm in some situation (testing my package in Docker) where git isn't available, and thought I could just use `fallback_version` argument, but it turns out I still get an exception `OSError: 'git' was not found`. 

I found I can overcome this by defining the `SETUPTOOLS_SCM_PRETEND_VERSION` environment variable, but I thought it would be easier to use `fallback_version` in this case as well?

I'm not sure I understood the different cases where one should use `SETUPTOOLS_SCM_PRETEND_VERSION` or `fallback_version`?

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 70c92ee..67520e2 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -38,13 +38,13 @@ features
 * support passing log levels to SETUPTOOLS_SCM_DEBUG
 * support using rich.logging as console log handler if installed
 * fix #527: type annotation in default version template
+* fix #549: use fallbacks when scm search raises CommandNotFoundError
 
 bugfixes
 --------
 
 * fix #883: use HeadersParser to ensure only mime metadata in headers is used
 * fix #884: parse calver dates from versions with the v prefix
-* don't use a C locale without UTF-8 support, when running commands.
 
 v7.1.0
 ======
diff --git a/src/setuptools_scm/_entrypoints.py b/src/setuptools_scm/_entrypoints.py
index 8350968..e3be053 100644
--- a/src/setuptools_scm/_entrypoints.py
+++ b/src/setuptools_scm/_entrypoints.py
@@ -13,8 +13,8 @@ from . import _log
 from . import version
 
 if TYPE_CHECKING:
-    from ._config import Configuration
     from . import _types as _t
+    from ._config import Configuration, ParseFunction
 
 
 log = _log.log.getChild("entrypoints")
@@ -27,21 +27,14 @@ class EntrypointProtocol(Protocol):
         pass
 
 
-def _version_from_entrypoints(
-    config: Configuration, fallback: bool = False
+def version_from_entrypoint(
+    config: Configuration, entrypoint: str, root: _t.PathT
 ) -> version.ScmVersion | None:
-    if fallback:
-        entrypoint = "setuptools_scm.parse_scm_fallback"
-        root = config.fallback_root
-    else:
-        entrypoint = "setuptools_scm.parse_scm"
-        root = config.absolute_root
-
     from .discover import iter_matching_entrypoints
 
     log.debug("version_from_ep %s in %s", entrypoint, root)
     for ep in iter_matching_entrypoints(root, entrypoint, config):
-        fn = ep.load()
+        fn: ParseFunction = ep.load()
         maybe_version: version.ScmVersion | None = fn(root, config=config)
         log.debug("%s found %r", ep, maybe_version)
         if maybe_version is not None:
diff --git a/src/setuptools_scm/_get_version.py b/src/setuptools_scm/_get_version.py
index a1afe2d..d2fe9e1 100644
--- a/src/setuptools_scm/_get_version.py
+++ b/src/setuptools_scm/_get_version.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import logging
 import re
 import warnings
 from pathlib import Path
@@ -8,30 +9,41 @@ from typing import NoReturn
 from typing import Pattern
 
 from . import _config
+from . import _entrypoints
+from . import _run_cmd
 from . import _types as _t
 from ._config import Configuration
-from ._entrypoints import _version_from_entrypoints
 from ._overrides import _read_pretended_version_for
 from ._version_cls import _validate_version_cls
 from .version import format_version as _format_version
 from .version import ScmVersion
 
+_log = logging.getLogger(__name__)
+
 
 def parse_scm_version(config: Configuration) -> ScmVersion | None:
-    if config.parse is not None:
-        parse_result = config.parse(config.absolute_root, config=config)
-        if parse_result is not None and not isinstance(parse_result, ScmVersion):
-            raise TypeError(
-                f"version parse result was {str!r}\n"
-                "please return a parsed version (ScmVersion)"
-            )
-        return parse_result
-    else:
-        return _version_from_entrypoints(config)
+    try:
+        if config.parse is not None:
+            parse_result = config.parse(config.absolute_root, config=config)
+            if parse_result is not None and not isinstance(parse_result, ScmVersion):
+                raise TypeError(
+                    f"version parse result was {str!r}\n"
+                    "please return a parsed version (ScmVersion)"
+                )
+            return parse_result
+        else:
+            entrypoint = "setuptools_scm.parse_scm"
+            root = config.absolute_root
+            return _entrypoints.version_from_entrypoint(config, entrypoint, root)
+    except _run_cmd.CommandNotFoundError as e:
+        _log.exception("command %s not found while parsing the scm, using fallbacks", e)
+        return None
 
 
 def parse_fallback_version(config: Configuration) -> ScmVersion | None:
-    return _version_from_entrypoints(config, fallback=True)
+    entrypoint = "setuptools_scm.parse_scm_fallback"
+    root = config.fallback_root
+    return _entrypoints.version_from_entrypoint(config, entrypoint, root)
 
 
 def _do_parse(config: Configuration) -> ScmVersion | None:
diff --git a/src/setuptools_scm/_run_cmd.py b/src/setuptools_scm/_run_cmd.py
index 3c938d8..20dbb25 100644
--- a/src/setuptools_scm/_run_cmd.py
+++ b/src/setuptools_scm/_run_cmd.py
@@ -142,8 +142,8 @@ def run(
         env=dict(
             avoid_pip_isolation(no_git_env(os.environ)),
             # os.environ,
-            # try to disable i18n, but still allow UTF-8 encoded text.
-            LC_ALL="C.UTF-8",
+            # try to disable i18n
+            LC_ALL="C",
             LANGUAGE="",
             HGPLAIN="1",
         ),
@@ -189,6 +189,10 @@ def has_command(
     return res
 
 
+class CommandNotFoundError(LookupError, FileNotFoundError):
+    pass
+
+
 def require_command(name: str) -> None:
     if not has_command(name, warn=False):
-        raise OSError(f"{name!r} was not found")
+        raise CommandNotFoundError(name)


Test patch:
diff --git a/testing/test_git.py b/testing/test_git.py
index cec9afa..8bebd1e 100644
--- a/testing/test_git.py
+++ b/testing/test_git.py
@@ -24,6 +24,7 @@ from setuptools_scm import Configuration
 from setuptools_scm import git
 from setuptools_scm import NonNormalizedVersion
 from setuptools_scm._file_finders.git import git_find_files
+from setuptools_scm._run_cmd import CommandNotFoundError
 from setuptools_scm._run_cmd import CompletedProcess
 from setuptools_scm._run_cmd import has_command
 from setuptools_scm._run_cmd import run
@@ -93,8 +94,12 @@ setup(use_scm_version={"search_parent_directories": True})
 
 def test_git_gone(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None:
     monkeypatch.setenv("PATH", str(wd.cwd / "not-existing"))
-    with pytest.raises(EnvironmentError, match="'git' was not found"):
-        git.parse(str(wd.cwd), Configuration(), git.DEFAULT_DESCRIBE)
+
+    wd.write("pyproject.toml", "[tool.setuptools_scm]")
+    with pytest.raises(CommandNotFoundError, match=r"git"):
+        git.parse(wd.cwd, Configuration(), git.DEFAULT_DESCRIBE)
+
+    assert wd.get_version(fallback_version="1.0") == "1.0"
 
 
 @pytest.mark.issue("https://github.com/pypa/setuptools_scm/issues/298")
diff --git a/testing/test_mercurial.py b/testing/test_mercurial.py
index 1b35d11..3aa8594 100644
--- a/testing/test_mercurial.py
+++ b/testing/test_mercurial.py
@@ -7,6 +7,7 @@ import pytest
 
 import setuptools_scm._file_finders
 from setuptools_scm import Configuration
+from setuptools_scm._run_cmd import CommandNotFoundError
 from setuptools_scm._run_cmd import has_command
 from setuptools_scm.hg import archival_to_version
 from setuptools_scm.hg import parse
@@ -55,8 +56,11 @@ def test_archival_to_version(expected: str, data: dict[str, str]) -> None:
 def test_hg_gone(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None:
     monkeypatch.setenv("PATH", str(wd.cwd / "not-existing"))
     config = Configuration()
-    with pytest.raises(EnvironmentError, match="'hg' was not found"):
-        parse(str(wd.cwd), config=config)
+    wd.write("pyproject.toml", "[tool.setuptools_scm]")
+    with pytest.raises(CommandNotFoundError, match=r"hg"):
+        parse(wd.cwd, config=config)
+
+    assert wd.get_version(fallback_version="1.0") == "1.0"
 
 
 def test_find_files_stop_at_root_hg(