Repository: copier-org/copier (python)

Issue:
Better error message for EOFError exception
### Actual Situation

Thanks for copier!  Keep up the good work.

Related to #666.  I tried to run copier in docker and got the same `EOFError` exception. 

### Desired Situation

I'd like to see a better error message that tells the user what is wrong and how to fix it.

### Proposed solution

Yes, it is a user error now that I know what is wrong.  But, a better error message would have saved me a bit of searching and be more user friendly:

"Input required but no terminal input available.  Use "--defaults" and optionally "--data" as needed to run copier in this environment."


Pull request:
feat: raise `InteractiveSessionError` when prompting in non-interactive environment
I've improved the error handling when prompting in a non-interactive environment, which was failing with `EOFError`. Now, the new `InteractiveSessionError` exception is raised.

Resolves #1988.

Patch:
diff --git a/copier/errors.py b/copier/errors.py
index 01e6f1a..48a87fc 100644
--- a/copier/errors.py
+++ b/copier/errors.py
@@ -147,3 +147,10 @@ class MissingSettingsWarning(UserWarning, CopierWarning):
 
 class MissingFileWarning(UserWarning, CopierWarning):
     """I still couldn't find what I'm looking for."""
+
+
+class InteractiveSessionError(UserMessageError):
+    """An interactive session is required to run this program."""
+
+    def __init__(self, message: str) -> None:
+        super().__init__(f"Interactive session required: {message}")
diff --git a/copier/main.py b/copier/main.py
index 382b017..ce462f0 100644
--- a/copier/main.py
+++ b/copier/main.py
@@ -32,16 +32,16 @@ from unicodedata import normalize
 from jinja2.loaders import FileSystemLoader
 from pathspec import PathSpec
 from plumbum import ProcessExecutionError, colors
-from plumbum.cli.terminal import ask
 from plumbum.machines import local
 from pydantic import ConfigDict, PositiveInt
 from pydantic.dataclasses import dataclass
 from pydantic_core import to_jsonable_python
-from questionary import unsafe_prompt
+from questionary import confirm, unsafe_prompt
 
 from .errors import (
     CopierAnswersInterrupt,
     ExtensionNotFoundError,
+    InteractiveSessionError,
     UnsafeTemplateError,
     UserMessageError,
     YieldTagInFileError,
@@ -466,7 +466,11 @@ class Worker:
                 file_=sys.stderr,
             )
             return True
-        return bool(ask(f" Overwrite {dst_relpath}?", default=True))
+        try:
+            answer = confirm(f" Overwrite {dst_relpath}?", default=True).unsafe_ask()
+        except EOFError as err:
+            raise InteractiveSessionError("Consider using `--overwrite`") from err
+        return bool(answer)
 
     def _render_allowed(
         self,
@@ -587,10 +591,15 @@ class Worker:
                     if new_answer is MISSING:
                         raise ValueError(f'Question "{var_name}" is required')
                 else:
-                    new_answer = unsafe_prompt(
-                        [question.get_questionary_structure()],
-                        answers={question.var_name: question.get_default()},
-                    )[question.var_name]
+                    try:
+                        new_answer = unsafe_prompt(
+                            [question.get_questionary_structure()],
+                            answers={question.var_name: question.get_default()},
+                        )[question.var_name]
+                    except EOFError as err:
+                        raise InteractiveSessionError(
+                            "Use `--defaults` and/or `--data`/`--data-file`"
+                        ) from err
             except KeyboardInterrupt as err:
                 raise CopierAnswersInterrupt(
                     self.answers, question, self.template


Test patch:
diff --git a/tests/test_prompt.py b/tests/test_prompt.py
index 492f806..086f9fe 100644
--- a/tests/test_prompt.py
+++ b/tests/test_prompt.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import platform
+import subprocess
 import sys
 from pathlib import Path
 from typing import Any, Dict, List, Mapping, Protocol, Union
@@ -997,3 +999,53 @@ def test_secret_validator(
     tui.sendline(default)
     tui.expect_exact("******")
     tui.expect_exact(pexpect.EOF)
+
+
+# Related to https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1243#issuecomment-706668723)
+@pytest.mark.xfail(
+    platform.system() == "Windows",
+    reason="prompt-toolkit in subprocess call fails on Windows",
+)
+def test_interactive_session_required_for_question_prompt(
+    question_tree: QuestionTreeFixture,
+) -> None:
+    """Answering a question prompt requires an interactive session."""
+    src, dst = question_tree(type="str")
+    process = subprocess.run(
+        (*COPIER_PATH, "copy", str(src), str(dst)),
+        stdin=subprocess.PIPE,  # Prevents interactive input
+        capture_output=True,
+        timeout=10,
+    )
+    assert process.returncode == 1
+    assert (
+        b"Interactive session required: Use `--defaults` and/or `--data`/`--data-file`"
+    ) in process.stderr
+
+
+# Related to https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1243#issuecomment-706668723)
+@pytest.mark.xfail(
+    platform.system() == "Windows",
+    reason="prompt-toolkit in subprocess call fails on Windows",
+)
+def test_interactive_session_required_for_overwrite_prompt(
+    tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+    """Overwriting a file without `--overwrite` flag requires an interactive session."""
+    src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
+    build_file_tree(
+        {
+            (src / "foo.txt.jinja"): "bar",
+            (dst / "foo.txt"): "baz",
+        }
+    )
+    process = subprocess.run(
+        (*COPIER_PATH, "copy", str(src), str(dst)),
+        stdin=subprocess.PIPE,  # Prevents interactive input
+        capture_output=True,
+        timeout=10,
+    )
+    assert process.returncode == 1
+    assert (
+        b"Interactive session required: Consider using `--overwrite`" in process.stderr
+    )