Repository: pypa/build

Issue:
Double requirement error if build-backend missing
I have the following pyproject.toml:

```toml
[build-system]
requires = ["wheel", "setuptools>=42", "setuptools_scm[toml]>=3.4"]
```

Build fails with:

```
Looking in links: /tmp/tmpu9cyr1oc
Processing /tmp/tmpu9cyr1oc/setuptools-47.1.0-py3-none-any.whl
Processing /tmp/tmpu9cyr1oc/pip-20.1.1-py2.py3-none-any.whl
Installing collected packages: setuptools, pip
Successfully installed pip setuptools
ERROR: Double requirement given: setuptools>=42 (from -r /tmp/build-reqs-akrau6fv.txt (line 2)) (already in setuptools>=40.8.0 (from -r /tmp/build-reqs-akrau6fv.txt (line 1)), name='setuptools')
WARNING: You are using pip version 20.1.1; however, version 20.2.3 is available.
You should consider upgrading via the '/opt/hostedtoolcache/Python/3.8.5/x64/bin/python -m pip install --upgrade pip' command.
Traceback (most recent call last):
  File "/opt/hostedtoolcache/Python/3.8.5/x64/lib/python3.8/runpy.py", line 194, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/opt/hostedtoolcache/Python/3.8.5/x64/lib/python3.8/runpy.py", line 87, in _run_code
    exec(code, run_globals)
  File "/opt/hostedtoolcache/Python/3.8.5/x64/lib/python3.8/site-packages/build/__main__.py", line 176, in <module>
    main(sys.argv[1:], 'python -m build')
  File "/opt/hostedtoolcache/Python/3.8.5/x64/lib/python3.8/site-packages/build/__main__.py", line 168, in main
    build(args.srcdir, args.outdir, distributions, config_settings, not args.no_isolation, args.skip_dependencies)
  File "/opt/hostedtoolcache/Python/3.8.5/x64/lib/python3.8/site-packages/build/__main__.py", line 80, in build
    _build_in_isolated_env(builder, outdir, distributions)
  File "/opt/hostedtoolcache/Python/3.8.5/x64/lib/python3.8/site-packages/build/__main__.py", line 45, in _build_in_isolated_env
    env.install(builder.build_dependencies)
  File "/opt/hostedtoolcache/Python/3.8.5/x64/lib/python3.8/site-packages/build/env.py", line 176, in install
    subprocess.check_call(cmd)
  File "/opt/hostedtoolcache/Python/3.8.5/x64/lib/python3.8/subprocess.py", line 364, in check_call
    raise CalledProcessError(retcode, cmd)
subprocess.CalledProcessError: Command '['/opt/hostedtoolcache/Python/3.8.5/x64/bin/python', '-m', 'pip', 'install', '--prefix', '/tmp/build-env-6o04yjl_', '-r', '/tmp/build-reqs-akrau6fv.txt']' returned non-zero exit status 1.
```

However, there is a missing line - and adding it fixed the problem:

```toml
build-backend = "setuptools.build_meta"
```

a) Is it possible to improve the error message and b) should this error be happening anyway? It seems to be trying to recursively produce all the requirements manually and put then into a requirements.txt-like file, which then collide?

Discussion:

Patch:
diff --git a/src/build/__init__.py b/src/build/__init__.py
index 6a0a32f..fcdb925 100644
--- a/src/build/__init__.py
+++ b/src/build/__init__.py
@@ -133,26 +133,31 @@ class ProjectBuilder(object):
 
         try:
             with open(spec_file) as f:
-                self._spec = toml.load(f)
+                spec = toml.load(f)
         except FileNotFoundError:
-            self._spec = {}
+            spec = {}
         except PermissionError as e:
             raise BuildException("{}: '{}' ".format(e.strerror, e.filename))
         except toml.decoder.TomlDecodeError as e:
-            raise BuildException('Failed to parse pyproject.toml: {} '.format(e))
-
-        _find_typo(self._spec, 'build-system')
-        self._build_system = self._spec.get('build-system', _DEFAULT_BACKEND)
-
-        if 'build-backend' not in self._build_system:
-            _find_typo(self._build_system, 'build-backend')
-            _find_typo(self._build_system, 'requires')
-            self._build_system['build-backend'] = _DEFAULT_BACKEND['build-backend']
-            self._build_system['requires'] = self._build_system.get('requires', []) + _DEFAULT_BACKEND['requires']
-
-        if 'requires' not in self._build_system:
-            raise BuildException("Missing 'build-system.requires' in pyproject.toml")
-
+            raise BuildException('Failed to parse {}: {} '.format(spec_file, e))
+
+        build_system = spec.get('build-system')
+        # if pyproject.toml is missing (per PEP 517) or [build-system] is missing (pep PEP 518),
+        # use default values.
+        if build_system is None:
+            _find_typo(spec, 'build-system')
+            build_system = _DEFAULT_BACKEND
+        # if [build-system] is present, it must have a ``requires`` field (per PEP 518).
+        elif 'requires' not in build_system:
+            _find_typo(build_system, 'requires')
+            raise BuildException("Missing 'build-system.requires' in {}".format(spec_file))
+        # if ``build-backend`` is missing, inject the legacy setuptools backend
+        # but leave ``requires`` alone to emulate pip.
+        elif 'build-backend' not in build_system:
+            _find_typo(build_system, 'build-backend')
+            build_system['build-backend'] = _DEFAULT_BACKEND['build-backend']
+
+        self._build_system = build_system
         self._backend = self._build_system['build-backend']
 
         self._hook = pep517.wrappers.Pep517HookCaller(


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index c06d26a..fe76ba6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -118,6 +118,11 @@ def test_no_requires_path(packages_path):
     return os.path.join(packages_path, 'test-no-requires')
 
 
+@pytest.fixture
+def test_optional_hooks_path(packages_path):
+    return os.path.join(packages_path, 'test-optional-hooks')
+
+
 @pytest.fixture
 def test_typo(packages_path):
     return os.path.join(packages_path, 'test-typo')
diff --git a/tests/packages/test-optional-hooks/hookless_backend.py b/tests/packages/test-optional-hooks/hookless_backend.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/packages/test-optional-hooks/pyproject.toml b/tests/packages/test-optional-hooks/pyproject.toml
new file mode 100644
index 0000000..2796891
--- /dev/null
+++ b/tests/packages/test-optional-hooks/pyproject.toml
@@ -0,0 +1,4 @@
+[build-system]
+requires = []
+build-backend = 'hookless_backend'
+backend-path = ['.']
diff --git a/tests/test_projectbuilder.py b/tests/test_projectbuilder.py
index 1d38efa..7cbd19f 100644
--- a/tests/test_projectbuilder.py
+++ b/tests/test_projectbuilder.py
@@ -148,6 +148,13 @@ def test_get_dependencies_missing_backend(packages_path, distribution):
         builder.get_dependencies(distribution)
 
 
+@pytest.mark.parametrize('distribution', ['wheel', 'sdist'])
+def test_get_dependencies_missing_optional_hooks(test_optional_hooks_path, distribution):
+    builder = build.ProjectBuilder(test_optional_hooks_path)
+
+    assert builder.get_dependencies(distribution) == set()
+
+
 @pytest.mark.parametrize('distribution', ['wheel', 'sdist'])
 def test_build_missing_backend(packages_path, distribution, tmpdir):
     bad_backend_path = os.path.join(packages_path, 'test-bad-backend')
@@ -238,7 +245,7 @@ def test_missing_backend(mocker, test_no_backend_path):
 
     builder = build.ProjectBuilder(test_no_backend_path)
 
-    assert builder._build_system == DEFAULT_BACKEND
+    assert builder._build_system == {'requires': [], 'build-backend': DEFAULT_BACKEND['build-backend']}
 
 
 def test_missing_requires(mocker, test_no_requires_path):