Repository: sbidoul/pip-deepfreeze (language: python)

Issue:
Default extras
Developers typically need to remember to use `pip-df sync -x test,dev`. This is not optimal.

Two possibilities:
- sync install all extras by default
- configure default extras in `pyproject.toml`, in a `tool.pip-deepfreeze` section.


Discussion / hints:
> Developers typically need to remember to use `pip-df sync -x test,dev`. This is not optimal.
> 
> Two possibilities:
> 
> * sync install all extras by default
> * configure default extras in `pyproject.toml`, in a `tool.pip-deepfreeze` section.

I would say a section for pip deepfreeze could be the best way as pip will just look at normal section by default, but when you are in developement (and run pip-deepfreeze), you normally have all extras installed.

Fix patch:
diff --git a/README.rst b/README.rst
index b0df35c..baec703 100644
--- a/README.rst
+++ b/README.rst
@@ -360,6 +360,15 @@ pip-df tree
 
      --help               Show this message and exit.
 
+Configuration
+-------------
+
+Some options can be provided in a `[tool.pip-deepfreeze]` section of your
+``pyproject.toml`` file. The following options are supported:
+
+- `sync.extras`: provide a default value to the ``--extras`` option of the
+  ``sync`` command.
+
 Other tools
 -----------
 
diff --git a/news/83.feature b/news/83.feature
new file mode 100644
index 0000000..8e9569c
--- /dev/null
+++ b/news/83.feature
@@ -0,0 +1,1 @@
+Read some configuration defaults from ``pyproject.toml``.
diff --git a/src/pip_deepfreeze/__main__.py b/src/pip_deepfreeze/__main__.py
index e8ace9c..b560d99 100644
--- a/src/pip_deepfreeze/__main__.py
+++ b/src/pip_deepfreeze/__main__.py
@@ -1,9 +1,11 @@
 import shutil
 from pathlib import Path
+from typing import Any
 
 import typer
 from packaging.utils import canonicalize_name
 
+from .pyproject_toml import load_pyproject_toml
 from .sanity import check_env
 from .sync import sync as sync_operation
 from .tree import tree as tree_operation
@@ -92,6 +94,13 @@ def tree(
     )
 
 
+def project_root_callback(ctx: typer.Context, param: Any, value: Path) -> Path:
+    pyproject_toml = load_pyproject_toml(value)
+    if pyproject_toml:
+        ctx.default_map = pyproject_toml.get("tool", {}).get("pip-deepfreeze", {})
+    return value
+
+
 @app.callback()
 def callback(
     ctx: typer.Context,
@@ -110,6 +119,9 @@ def callback(
         ".",
         "--project-root",
         "-r",
+        # Process this parameter first so we can load default values from pyproject.toml
+        is_eager=True,
+        callback=project_root_callback,
         exists=True,
         dir_okay=True,
         file_okay=False,
diff --git a/src/pip_deepfreeze/project_name.py b/src/pip_deepfreeze/project_name.py
index 45ce221..abf92ef 100644
--- a/src/pip_deepfreeze/project_name.py
+++ b/src/pip_deepfreeze/project_name.py
@@ -9,21 +9,19 @@
 from functools import lru_cache
 from pathlib import Path
 from tempfile import TemporaryDirectory
-from typing import Any, MutableMapping, Optional
+from typing import Optional
 
-import toml
 from packaging.utils import canonicalize_name
 
 from .compat import NormalizedName
+from .pyproject_toml import PyProjectToml, load_pyproject_toml
 from .utils import check_call, check_output, log_info
 
-PyProjectToml = MutableMapping[str, Any]
-
 
 @lru_cache(maxsize=1)
 def get_project_name(python: str, project_root: Path) -> NormalizedName:
     log_info("Getting project name..", nl=False)
-    pyproject_toml = _load_pyproject_toml(project_root)
+    pyproject_toml = load_pyproject_toml(project_root)
     name = (
         get_project_name_from_pyproject_toml_pep621(pyproject_toml)
         or get_project_name_from_setup_cfg(project_root, pyproject_toml)
@@ -34,14 +32,6 @@ def get_project_name(python: str, project_root: Path) -> NormalizedName:
     return canonicalize_name(name)
 
 
-def _load_pyproject_toml(project_root: Path) -> Optional[PyProjectToml]:
-    log_info(".", nl=False)
-    pyproject_toml_path = project_root / "pyproject.toml"
-    if not pyproject_toml_path.is_file():
-        return None
-    return toml.loads(pyproject_toml_path.read_text())
-
-
 def _get_build_backend(pyproject_toml: Optional[PyProjectToml]) -> Optional[str]:
     if not pyproject_toml:
         return None
diff --git a/src/pip_deepfreeze/pyproject_toml.py b/src/pip_deepfreeze/pyproject_toml.py
new file mode 100644
index 0000000..cc88cc8
--- /dev/null
+++ b/src/pip_deepfreeze/pyproject_toml.py
@@ -0,0 +1,16 @@
+from pathlib import Path
+from typing import Any, MutableMapping, Optional
+
+import toml
+
+from .utils import log_info
+
+PyProjectToml = MutableMapping[str, Any]
+
+
+def load_pyproject_toml(project_root: Path) -> Optional[PyProjectToml]:
+    log_info(".", nl=False)
+    pyproject_toml_path = project_root / "pyproject.toml"
+    if not pyproject_toml_path.is_file():
+        return None
+    return toml.loads(pyproject_toml_path.read_text())


Test patch:
diff --git a/tests/test_project_name.py b/tests/test_project_name.py
index 31cef90..6d16047 100644
--- a/tests/test_project_name.py
+++ b/tests/test_project_name.py
@@ -2,31 +2,31 @@
 import textwrap
 
 from pip_deepfreeze.project_name import (
-    _load_pyproject_toml,
     get_project_name,
     get_project_name_from_pep517,
     get_project_name_from_pyproject_toml_flit,
     get_project_name_from_pyproject_toml_pep621,
     get_project_name_from_setup_cfg,
+    load_pyproject_toml,
 )
 
 
 def test_project_name_from_setup_cfg(tmp_path):
     (tmp_path / "setup.cfg").write_text("[metadata]\nname = theproject")
     assert (
-        get_project_name_from_setup_cfg(tmp_path, _load_pyproject_toml(tmp_path))
+        get_project_name_from_setup_cfg(tmp_path, load_pyproject_toml(tmp_path))
         == "theproject"
     )
     assert get_project_name(sys.executable, tmp_path) == "theproject"
     (tmp_path / "pyproject.toml").touch()
     assert (
-        get_project_name_from_setup_cfg(tmp_path, _load_pyproject_toml(tmp_path))
+        get_project_name_from_setup_cfg(tmp_path, load_pyproject_toml(tmp_path))
         == "theproject"
     )
     assert get_project_name(sys.executable, tmp_path) == "theproject"
     (tmp_path / "pyproject.toml").write_text("[build-system]")
     assert (
-        get_project_name_from_setup_cfg(tmp_path, _load_pyproject_toml(tmp_path))
+        get_project_name_from_setup_cfg(tmp_path, load_pyproject_toml(tmp_path))
         == "theproject"
     )
     assert get_project_name(sys.executable, tmp_path) == "theproject"
@@ -34,12 +34,12 @@ def test_project_name_from_setup_cfg(tmp_path):
         '[build-system]\nbuild-backend="setuptools.build_meta"'
     )
     assert (
-        get_project_name_from_setup_cfg(tmp_path, _load_pyproject_toml(tmp_path))
+        get_project_name_from_setup_cfg(tmp_path, load_pyproject_toml(tmp_path))
         == "theproject"
     )
     assert get_project_name(sys.executable, tmp_path) == "theproject"
     (tmp_path / "setup.cfg").write_text("[metadata]")
-    assert not get_project_name_from_setup_cfg(tmp_path, _load_pyproject_toml(tmp_path))
+    assert not get_project_name_from_setup_cfg(tmp_path, load_pyproject_toml(tmp_path))
 
 
 def test_get_project_name_from_pyproject_toml_flit(tmp_path):
@@ -55,7 +55,7 @@ def test_get_project_name_from_pyproject_toml_flit(tmp_path):
         )
     )
     assert (
-        get_project_name_from_pyproject_toml_flit(_load_pyproject_toml(tmp_path))
+        get_project_name_from_pyproject_toml_flit(load_pyproject_toml(tmp_path))
         == "theproject"
     )
     assert get_project_name(sys.executable, tmp_path) == "theproject"
@@ -72,7 +72,7 @@ def test_get_project_name_from_pyproject_toml_flit_no_module(tmp_path):
             """
         )
     )
-    assert not get_project_name_from_pyproject_toml_flit(_load_pyproject_toml(tmp_path))
+    assert not get_project_name_from_pyproject_toml_flit(load_pyproject_toml(tmp_path))
 
 
 def test_get_project_name_from_pyproject_toml_pep621(tmp_path):
@@ -88,7 +88,7 @@ def test_get_project_name_from_pyproject_toml_pep621(tmp_path):
         )
     )
     assert (
-        get_project_name_from_pyproject_toml_pep621(_load_pyproject_toml(tmp_path))
+        get_project_name_from_pyproject_toml_pep621(load_pyproject_toml(tmp_path))
         == "theproject"
     )
     assert get_project_name(sys.executable, tmp_path) == "theproject"
@@ -106,7 +106,7 @@ def test_get_project_name_from_pyproject_toml_pep621_no_project(tmp_path):
         )
     )
     assert not get_project_name_from_pyproject_toml_pep621(
-        _load_pyproject_toml(tmp_path)
+        load_pyproject_toml(tmp_path)
     )
 
 
@@ -120,7 +120,7 @@ def test_get_project_name_from_pyproject_toml_pep621_no_build_system(tmp_path):
         )
     )
     assert (
-        get_project_name_from_pyproject_toml_pep621(_load_pyproject_toml(tmp_path))
+        get_project_name_from_pyproject_toml_pep621(load_pyproject_toml(tmp_path))
         == "theproject"
     )
     assert get_project_name(sys.executable, tmp_path) == "theproject"
diff --git a/tests/test_pyproject_options.py b/tests/test_pyproject_options.py
new file mode 100644
index 0000000..c59a4ef
--- /dev/null
+++ b/tests/test_pyproject_options.py
@@ -0,0 +1,62 @@
+import textwrap
+from pathlib import Path
+from unittest.mock import create_autospec
+
+from pytest import MonkeyPatch
+from typer.testing import CliRunner
+
+from pip_deepfreeze.__main__ import MainOptions, app
+from pip_deepfreeze.sync import sync
+
+
+def test_options_loaded_from_pyproject_toml_project_root(
+    tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+    """Test that options are loaded from pyproject.toml, when --project-root is used."""
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        textwrap.dedent(
+            """\
+            [project]
+            name = "test"
+            version = "1.0"
+
+            [tool.pip-deepfreeze.sync]
+            extras = "a,b"
+            """
+        )
+    )
+    sync_operation_mock = create_autospec(sync)
+    monkeypatch.setattr("pip_deepfreeze.__main__.sync_operation", sync_operation_mock)
+    runner = CliRunner(mix_stderr=False)
+    result = runner.invoke(app, ["-r", tmp_path, "sync"], obj=MainOptions())
+    assert result.exit_code == 0
+    # TODO when dropping python 3.7, use call_args.kwargs
+    assert sync_operation_mock.call_args[1]["extras"] == ["a", "b"]
+
+
+def test_options_loaded_from_pyproject_toml_cwd(
+    tmp_path: Path, monkeypatch: MonkeyPatch
+) -> None:
+    """Test that options are loaded from pyproject.toml in cwd."""
+    pyproject_toml = tmp_path / "pyproject.toml"
+    pyproject_toml.write_text(
+        textwrap.dedent(
+            """\
+            [project]
+            name = "test"
+            version = "1.0"
+
+            [tool.pip-deepfreeze.sync]
+            extras = "a,b"
+            """
+        )
+    )
+    sync_operation_mock = create_autospec(sync)
+    monkeypatch.setattr("pip_deepfreeze.__main__.sync_operation", sync_operation_mock)
+    monkeypatch.chdir(tmp_path)
+    runner = CliRunner(mix_stderr=False)
+    result = runner.invoke(app, ["sync"], obj=MainOptions())
+    assert result.exit_code == 0
+    # TODO when dropping python 3.7, use call_args.kwargs
+    assert sync_operation_mock.call_args[1]["extras"] == ["a", "b"]
diff --git a/tests/test_sync.py b/tests/test_sync.py
index 09760c3..208a72d 100644
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -69,7 +69,7 @@ def test_sync_no_in_req(virtualenv_python, tmp_path):
     )
 
 
-def test_python_not_found(tmp_path):
+def test_python_not_found():
     runner = CliRunner()
     result = runner.invoke(app, ["--python", "this-is-not-a-python", "sync"])
     assert result.exit_code != 0