Repository: mgedmin/check-manifest

Issue:
Does not work with setup.py-less packaging?
I use setuptools with declarative config (i.e. defined in [`setup.cfg`](https://setuptools.readthedocs.io/en/latest/setuptools.html#configuring-setup-using-setup-cfg-files)) and build system in `pyproject.toml`:

```
[build-system]
requires = [
    "setuptools >= 40.6.0",
    "wheel",
]
build-backend = "setuptools.build_meta"
```

This works fine to build wheels and sdist using the PEP 517 hooks ([here](https://github.com/pypa/setuptools/blob/v41.5.0/setuptools/build_meta.py#L252-L253)).

But check-manifest doesn't recognize this as valid Python project.

```
$ check-manifest
This is not a Python project (no setup.py).
```

Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index c68615b..1f50f51 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,7 +5,10 @@ Changelog
 0.41 (unreleased)
 -----------------
 
-- Nothing changed yet.
+- Support `PEP 517`_, i.e. packages using pyproject.toml instead of a setup.py
+  (`#105 <https://github.com/mgedmin/check-manifest/issue/105>`_).
+
+.. _PEP 517: https://www.python.org/dev/peps/pep-0517/
 
 
 0.40 (2019-10-15)
diff --git a/check_manifest.py b/check_manifest.py
index 0038476..46203a7 100755
--- a/check_manifest.py
+++ b/check_manifest.py
@@ -827,9 +827,12 @@ def is_package(source_tree='.'):
     """Is the directory the root of a Python package?
 
     Note: the term "package" here refers to a collection of files
-    with a setup.py, not to a directory with an __init__.py.
+    with a setup.py/pyproject.toml, not to a directory with an __init__.py.
     """
-    return os.path.exists(os.path.join(source_tree, 'setup.py'))
+    return (
+        os.path.exists(os.path.join(source_tree, 'setup.py'))
+        or os.path.exists(os.path.join(source_tree, 'pyproject.toml'))
+    )
 
 
 def extract_version_from_filename(filename):
@@ -840,6 +843,23 @@ def extract_version_from_filename(filename):
     return filename.partition('-')[2]
 
 
+def build_sdist(tempdir, python=sys.executable):
+    """Build a source distribution in a temporary directory.
+
+    Should be run with the current working directory inside the Python package
+    you want to build.
+    """
+    if os.path.exists('pyproject.toml'):
+        # I could do this in-process with
+        #   import pep517.envbuild
+        #   pep517.envbuild.build_sdist('.', tempdir)
+        # but then it would print a bunch of things to stdout and I'd have to
+        # worry about exceptions
+        run([python, '-m', 'pep517.build', '--source', '-o', tempdir, '.'])
+    else:
+        run([python, 'setup.py', 'sdist', '-d', tempdir])
+
+
 def check_manifest(source_tree='.', create=False, update=False,
                    python=sys.executable):
     """Compare a generated source distribution with list of files in a VCS.
@@ -851,7 +871,8 @@ def check_manifest(source_tree='.', create=False, update=False,
         python = os.path.abspath(python)
     with cd(source_tree):
         if not is_package():
-            raise Failure('This is not a Python project (no setup.py).')
+            raise Failure(
+                'This is not a Python project (no setup.py/pyproject.toml).')
         read_config()
         read_manifest()
         info_begin("listing source files under version control")
@@ -862,7 +883,7 @@ def check_manifest(source_tree='.', create=False, update=False,
             raise Failure('There are no files added to version control!')
         info_begin("building an sdist")
         with mkdtemp('-sdist') as tempdir:
-            run([python, 'setup.py', 'sdist', '-d', tempdir])
+            build_sdist(tempdir, python=python)
             sdist_filename = get_one_file_in(tempdir)
             info_continue(": %s" % os.path.basename(sdist_filename))
             sdist_files = sorted(normalize_names(strip_sdist_extras(
@@ -877,24 +898,19 @@ def check_manifest(source_tree='.', create=False, update=False,
         info_begin("copying source files to a temporary directory")
         with mkdtemp('-sources') as tempsourcedir:
             copy_files(existing_source_files, tempsourcedir)
-            if os.path.exists('MANIFEST.in') and 'MANIFEST.in' not in source_files:
-                # See https://github.com/mgedmin/check-manifest/issues/7
-                # if do this, we will emit a warning about MANIFEST.in not
-                # being in source control, if we don't do this, the user
-                # gets confused about their new manifest rules being
-                # ignored.
-                copy_files(['MANIFEST.in'], tempsourcedir)
-            if 'setup.py' not in source_files:
-                # See https://github.com/mgedmin/check-manifest/issues/46
-                # if do this, we will emit a warning about setup.py not
-                # being in source control, if we don't do this, the user
-                # gets a scary error
-                copy_files(['setup.py'], tempsourcedir)
+            for filename in 'MANIFEST.in', 'setup.py', 'pyproject.toml':
+                if filename not in source_files and os.path.exists(filename):
+                    # See https://github.com/mgedmin/check-manifest/issues/7
+                    # and https://github.com/mgedmin/check-manifest/issues/46:
+                    # if we do this, the user gets a warning about files
+                    # missing from source control; if we don't do this,
+                    # things get very confusing for the user!
+                    copy_files([filename], tempsourcedir)
             info_begin("building a clean sdist")
             with cd(tempsourcedir):
                 with mkdtemp('-sdist') as tempdir:
                     os.environ['SETUPTOOLS_SCM_PRETEND_VERSION'] = version
-                    run([python, 'setup.py', 'sdist', '-d', tempdir])
+                    build_sdist(tempdir, python=python)
                     sdist_filename = get_one_file_in(tempdir)
                     info_continue(": %s" % os.path.basename(sdist_filename))
                     clean_sdist_files = sorted(normalize_names(strip_sdist_extras(
diff --git a/setup.py b/setup.py
index ac1af10..cfefae9 100755
--- a/setup.py
+++ b/setup.py
@@ -58,7 +58,7 @@ setup(
     zip_safe=False,
     test_suite='tests.test_suite',
     python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*",
-    install_requires=['toml'],
+    install_requires=['toml', 'pep517'],
     extras_require={
         'test': ['mock'],
     },


Test patch:
diff --git a/tests.py b/tests.py
index 8832ce8..b760687 100644
--- a/tests.py
+++ b/tests.py
@@ -452,9 +452,11 @@ class Tests(unittest.TestCase):
     def test_is_package(self):
         from check_manifest import is_package
         j = os.path.join
-        with mock.patch('os.path.exists', lambda fn: fn == j('a', 'setup.py')):
+        exists = {j('a', 'setup.py'), j('c', 'pyproject.toml')}
+        with mock.patch('os.path.exists', lambda fn: fn in exists):
             self.assertTrue(is_package('a'))
             self.assertFalse(is_package('b'))
+            self.assertTrue(is_package('c'))
 
     def test_extract_version_from_filename(self):
         from check_manifest import extract_version_from_filename as e
@@ -593,6 +595,24 @@ class Tests(unittest.TestCase):
             "%s, line 2: continuation line immediately precedes end-of-file" % filename,
         ])
 
+    def test_build_sdist(self):
+        from check_manifest import build_sdist, cd, get_one_file_in
+        src_dir = self.make_temp_dir()
+        filename = os.path.join(src_dir, 'pyproject.toml')
+        self.create_file(filename, textwrap.dedent('''
+            [build-system]
+            requires = [
+                "setuptools >= 40.6.0",
+                "wheel",
+            ]
+            build-backend = "setuptools.build_meta"
+        '''))
+        out_dir = self.make_temp_dir()
+        python = os.path.abspath(sys.executable)
+        with cd(src_dir):
+            build_sdist(out_dir, python=python)
+        self.assertTrue(get_one_file_in(out_dir))
+
 
 class TestConfiguration(unittest.TestCase):
 
@@ -1386,8 +1406,9 @@ class TestCheckManifest(unittest.TestCase):
         from check_manifest import check_manifest, Failure
         with self.assertRaises(Failure) as cm:
             check_manifest()
-        self.assertEqual(str(cm.exception),
-                         "This is not a Python project (no setup.py).")
+        self.assertEqual(
+            str(cm.exception),
+            "This is not a Python project (no setup.py/pyproject.toml).")
 
     def test_forgot_to_git_add_anything(self):
         from check_manifest import check_manifest, Failure