Repository: facebookincubator/ptr

Issue:
Support pyproject.toml cleanly
## How is `ptr` not doing what you expect?
If a project is using pyproject.toml investigate the best way to incorporate some `ptr` configuration

## What is your suggestion to make this better?
- Either see if the a section can be added to the .toml file and we parse that 
  - Would prefer if we don't need a third party library dependecy (can live with it if we do)
- Or if we just document we need a `setup.cfg|.py` along side the toml for ptr to find and use

Discussion:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7b2fbac..a05aa64 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,7 +27,7 @@ jobs:
 
     - name: Run Unitests via coverage
       run: |
-        python -m pip install coverage
+        python -m pip install . coverage
         coverage run ptr_tests.py -v
 
     - name: Show coverage
diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml
index 3c0515e..e640ae8 100644
--- a/.github/workflows/fuzz.yml
+++ b/.github/workflows/fuzz.yml
@@ -28,7 +28,7 @@ jobs:
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip setuptools
-          python -m pip install coverage hypothesis
+          python -m pip install . coverage hypothesis
 
       - name: Run fuzz tests
         run: |
diff --git a/README.md b/README.md
index d0cc036..1dcec1a 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,9 @@ Python Test Runner (ptr) was born to run tests in an opinionated way, within arb
 - `ptr` itself uses `ptr` to run its tests 👌🏼
 - `ptr` is supported and tested on *Linux*, *MacOS* + *Windows* Operating Systems
 
-By adding `ptr` configuration to your `setup.cfg` or `setup.py` you can have `ptr` perform the following, per test suite, in parallel:
+By adding `ptr` configuration to your either of your `pyproject.toml`, `setup.cfg` or `setup.py` you can have `ptr` perform the following,
+per test suite, in parallel:
+
 - run your test suite
 - check and enforce coverage requirements (via [coverage](https://pypi.org/project/coverage/)),
 - format code (via [black](https://pypi.org/project/black/))
@@ -36,7 +38,7 @@ ptr
 I'm glad you ask. Under the covers `ptr` performs:
 - Recursively searches for `setup.(cfg|py)` files from `BASE_DIR` (defaults to your "current working directory" (CWD))
    - [AST](https://docs.python.org/3/library/ast.html) parses out the config for each `setup.py` test requirements
-   - If a `setup.cfg` exists, load via configparser and prefer if a `[ptr]` section exists
+   - If a `pyproject.toml` or `setup.cfg` exists, load via configparser/tomli and prefer if a `[ptr]` section exists
 - Creates a [Python Virtual Environment](https://docs.python.org/3/tutorial/venv.html) (*OPTIONALLY* pointed at an internal PyPI mirror)
 - Runs `ATONCE` tests suites in parallel (i.e. per setup.(cfg|ptr))
 - All steps will be run for each suite and ONLY *FAILED* runs will have output written to stdout
@@ -129,11 +131,19 @@ ptr_params = {
 }
 ```
 
+### `pyproject.toml`
+
+This is per project in your repository and if exists is preferred over `setup.py` and `setup.cfg`.
+
+Please refer to [`pyproject.toml`](http://github.com/facebookincubator/ptr/blob/master/pyproject.toml)
+for the options available + format.
+
 ### `setup.cfg`
 
 This is per project in your repository and if exists is preferred over `setup.py`.
 
-Please refer to [`setup.cfg.sample`](http://github.com/facebookincubator/ptr/blob/master/setup.cfg.sample) for the options available + format.
+Please refer to [`setup.cfg.sample`](http://github.com/facebookincubator/ptr/blob/master/setup.cfg.sample)
+for the options available + format.
 
 ### mypy Specifics
 
diff --git a/ptr.py b/ptr.py
index 8471b59..aa7fb00 100755
--- a/ptr.py
+++ b/ptr.py
@@ -27,6 +27,18 @@ from tempfile import gettempdir
 from time import time
 from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Set, Tuple, Union
 
+# Support pyproject.toml
+# In >= 3.11 we can remove this import dance
+if sys.version_info >= (3, 11):  # pragma: no cover
+    try:
+        import tomllib
+    except ImportError:
+        # Help users on older alphas
+        import tomli as tomllib
+else:
+    # pyre-ignore: Undefined import [21]
+    import tomli as tomllib
+
 
 LOG = logging.getLogger(__name__)
 MACOSX = system() == "Darwin"
@@ -34,6 +46,7 @@ MACOSX = system() == "Darwin"
 # Using 3.8 rather than 3.7 due to subprocess.exec in < 3.8 only support main loop
 # https://bugs.python.org/issue35621
 PY_38_OR_GREATER = sys.version_info >= (3, 8)
+PYPROJECT_TOML = "pyproject.toml"
 WINDOWS = system() == "Windows"
 # Windows needs to use a ProactorEventLoop for subprocesses
 # Need to use sys.platform for mypy to understand
@@ -412,8 +425,11 @@ def _get_test_modules(
     test_modules: Dict[Path, Dict] = {}
     for setup_py in all_setup_pys:
         disabled_err_msg = f"Not running {setup_py} as ptr is disabled via config"
-        # If a setup.cfg exists lets prefer it, if there is a [ptr] section
-        ptr_params = parse_setup_cfg(setup_py)
+        # If a pyproject.toml or setup.cfg exists lets prefer them
+        # Only if there is a [ptr] section
+        ptr_params = parse_pyproject_toml(setup_py)
+        if not ptr_params:
+            ptr_params = parse_setup_cfg(setup_py)
         if not ptr_params:
             ptr_params = _parse_setup_params(setup_py)
 
@@ -856,6 +872,25 @@ def find_setup_pys(
     return setup_pys
 
 
+def parse_pyproject_toml(
+    setup_py: Path, tool_section: str = "tool", ptr_section: str = "ptr"
+) -> Dict[str, Any]:
+    ptr_params: Dict[str, Any] = {}
+    pyproject_toml_path = setup_py.parent / PYPROJECT_TOML
+    if not pyproject_toml_path.exists():
+        return ptr_params
+
+    with pyproject_toml_path.open("rb") as f:
+        pyproject_toml = tomllib.load(f)
+    ptr_params = pyproject_toml.get(tool_section, {}).get(ptr_section, {})
+
+    if not ptr_params:
+        LOG.info(f"{pyproject_toml} does not have a tool.ptr section")
+        return ptr_params
+
+    return ptr_params
+
+
 def parse_setup_cfg(setup_py: Path) -> Dict[str, Any]:
     req_cov_key_strip = "required_coverage_"
     ptr_params: Dict[str, Any] = {}
diff --git a/pyproject.toml b/pyproject.toml
index 00135a8..48bc58e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,16 @@
 [build-system]
 requires = ["setuptools>=43.0.0"]
 build-backend = "setuptools.build_meta"
+
+[tool.ptr]
+disabled = true
+entry_point_module = "ptr"
+test_suite = "ptr_tests"
+test_suite_timeout = 120
+required_coverage = { 'ptr.py' = 84, TOTAL = 88 }
+run_usort = true
+run_black = true
+run_mypy = true
+run_flake8 = true
+run_pylint = true
+run_pyre = true
diff --git a/setup.py b/setup.py
index 2f0849b..bbdae33 100644
--- a/setup.py
+++ b/setup.py
@@ -70,7 +70,7 @@ setup(
         "Programming Language :: Python :: 3.10",
     ],
     python_requires=">=3.7",
-    install_requires=None,
+    install_requires=["tomli>=1.1.0; python_full_version < '3.11.0a7'"],
     entry_points={"console_scripts": ["ptr = ptr:main"]},
     test_suite=ptr_params["test_suite"],
 )


Test patch:
diff --git a/.github/workflows/ci_latest.yml b/.github/workflows/ci_latest.yml
index 9e3e259..6a6f868 100644
--- a/.github/workflows/ci_latest.yml
+++ b/.github/workflows/ci_latest.yml
@@ -27,7 +27,7 @@ jobs:
 
       - name: Run Unitests via coverage
         run: |
-          python -m pip install coverage
+          python -m pip install . coverage
           coverage run ptr_tests.py -v
 
       - name: Show coverage
diff --git a/ptr_tests.py b/ptr_tests.py
index dded9a4..054f3fb 100644
--- a/ptr_tests.py
+++ b/ptr_tests.py
@@ -374,10 +374,17 @@ class TestPtr(unittest.TestCase):
             lib_path.mkdir()
             self.assertIsNone(ptr._get_site_packages_path(lib_path.parent))
 
+    # Patch parsing except setup.py to keep coverage up
+    @patch("ptr.parse_setup_cfg")
+    @patch("ptr.parse_pyproject_toml")
     @patch("ptr.print")  # noqa
-    def test_get_test_modules(self, mock_print: Mock) -> None:
+    def test_get_test_modules(
+        self, mock_print: Mock, mock_pyproject: Mock, mock_setup_cfg: Mock
+    ) -> None:
+        mock_pyproject.return_value = {}
+        mock_setup_cfg.return_value = {}
         base_path = Path(__file__).parent
-        stats = defaultdict(int)  # type: Dict[str, int]
+        stats: Dict[str, int] = defaultdict(int)
         test_modules = ptr._get_test_modules(base_path, stats, True, True)
         self.assertEqual(
             test_modules[base_path / "setup.py"],
@@ -425,6 +432,23 @@ class TestPtr(unittest.TestCase):
             with self.assertRaises(SystemExit):
                 ptr.main()
 
+    def test_parse_pyproject_toml(self) -> None:
+        tmp_dir = Path(gettempdir())
+        pyproject_toml = tmp_dir / ptr.PYPROJECT_TOML
+        setup_py = tmp_dir / "setup.py"
+
+        with pyproject_toml.open("w", encoding=FILE_ENCODING) as pcp:
+            pcp.write(ptr_tests_fixtures.SAMPLE_PYPROJECT)
+
+        # No pyproject.toml file exists
+        self.assertEqual(ptr.parse_pyproject_toml(setup_py.parent), {})
+        # No ptr section
+        self.assertEqual(ptr.parse_pyproject_toml(setup_py, "not_a_tool"), {})
+        # Everything works
+        self.assertEqual(
+            ptr.parse_pyproject_toml(setup_py), ptr_tests_fixtures.EXPECTED_TEST_PARAMS
+        )
+
     def test_parse_setup_cfg(self) -> None:
         tmp_dir = Path(gettempdir())
         setup_cfg = tmp_dir / "setup.cfg"
diff --git a/ptr_tests_fixtures.py b/ptr_tests_fixtures.py
index 2640fb0..b67eda1 100644
--- a/ptr_tests_fixtures.py
+++ b/ptr_tests_fixtures.py
@@ -211,6 +211,21 @@ setup(
 )
 """
 
+SAMPLE_PYPROJECT = """\
+[tool.ptr]
+disabled = true
+entry_point_module = "ptr"
+test_suite = "ptr_tests"
+test_suite_timeout = 120
+required_coverage = { 'ptr.py' = 84, TOTAL = 88 }
+run_usort = true
+run_black = true
+run_mypy = true
+run_flake8 = true
+run_pylint = true
+run_pyre = true
+"""
+
 # Disabled is set as we --run-disabled the run in CI
 SAMPLE_SETUP_CFG = """\
 [ptr]