Repository: beeware/briefcase

Issue:
Write logs to a subdirectory
**Is your feature request related to a problem? Please describe.**
As discussed in #760, we currently put log files directly in the top-level project folder. However, after a few failed runs this starts to get cluttered and obscure the view of the other files. So the user will probably delete the logs to get them out of the way, potentially losing useful information.

**Describe the solution you'd like**
How about writing the logs to a "logs" subdirectory of the project folder? Since this is a directory the user won't constantly be navigating through, they'll be under no pressure to delete the logs prematurely.

This directory should also be added to the default .gitignore file. `briefcase.*.log` is currently in the *top-level* .gitignore file in https://github.com/beeware/briefcase-template, but not in the .gitignore file within the template itself, which I guess is a mistake.

Discussion:

Patch:
diff --git a/changes/883.feature.rst b/changes/883.feature.rst
new file mode 100644
index 00000000..4a402432
--- /dev/null
+++ b/changes/883.feature.rst
@@ -0,0 +1,1 @@
+Briefcase log files are now stored in the ``logs`` subdirectory and only when the current directory is a Briefcase project.
diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py
index a0ccb173..e0cbbffa 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -619,7 +619,9 @@ a custom location for Briefcase's tools.
 
         except FileNotFoundError as e:
             raise BriefcaseConfigError(
-                f"""Configuration file not found. Did you run briefcase in the directory that contains {filename!r}?"""
+                f"""Configuration file not found.
+
+Did you run Briefcase in a project directory that contains {filename.name!r}?"""
             ) from e
 
     def update_cookiecutter_cache(self, template: str, branch="master"):
@@ -696,7 +698,7 @@ a custom location for Briefcase's tools.
         return cached_template
 
     def generate_template(self, template, branch, output_path, extra_context):
-        """Ensure the named template is up to date for the given branch, and
+        """Ensure the named template is up-to-date for the given branch, and
         roll out that template.
 
         :param template: The template URL or path to generate
diff --git a/src/briefcase/console.py b/src/briefcase/console.py
index eb063e17..2ec4aae2 100644
--- a/src/briefcase/console.py
+++ b/src/briefcase/console.py
@@ -121,6 +121,8 @@ class Log:
     DEBUG = 2
     # printed at the beginning of all debug output
     DEBUG_PREFACE = ">>> "
+    # subdirectory of command.base_path to store log files
+    LOG_DIR = "logs"
 
     def __init__(self, printer=Printer(), verbosity=1):
         self.print = printer
@@ -128,6 +130,8 @@ class Log:
         self.verbosity = verbosity
         # --log flag to force logfile creation
         self.save_log = False
+        # flag set by exceptions to skip writing the log; save_log takes precedence.
+        self.skip_log = False
         # Rich stacktrace of exception for logging to file
         self.stacktrace = None
         # functions to run for additional logging if creating a logfile
@@ -202,7 +206,12 @@ class Log:
 
     def capture_stacktrace(self):
         """Preserve Rich stacktrace from exception while in except block."""
-        self.stacktrace = Traceback.extract(*sys.exc_info(), show_locals=True)
+        exc_info = sys.exc_info()
+        try:
+            self.skip_log = exc_info[1].skip_logfile
+        except AttributeError:
+            pass
+        self.stacktrace = Traceback.extract(*exc_info, show_locals=True)
 
     def add_log_file_extra(self, func):
         """Register a function to be called in the event that a log file is
@@ -215,15 +224,18 @@ class Log:
 
     def save_log_to_file(self, command):
         """Save the current application log to file."""
-        # only save the log if a command ran and it errored or --log was specified
-        if command is None or (not self.stacktrace and not self.save_log):
+        # A log file is always written if a Command ran and `--log` was provided.
+        # If `--log` was not provided, then the log is written when an exception
+        # occurred and the exception was not explicitly configured to skip the log.
+        if not (command and (self.save_log or (self.stacktrace and not self.skip_log))):
             return
 
         with command.input.wait_bar("Saving log...", transient=True):
             self.print.to_console()
             log_filename = f"briefcase.{datetime.now().strftime('%Y_%m_%d-%H_%M_%S')}.{command.command}.log"
-            log_filepath = command.base_path / log_filename
+            log_filepath = command.base_path / self.LOG_DIR / log_filename
             try:
+                log_filepath.parent.mkdir(parents=True, exist_ok=True)
                 with open(
                     log_filepath, "w", encoding="utf-8", errors="backslashreplace"
                 ) as log_file:
diff --git a/src/briefcase/exceptions.py b/src/briefcase/exceptions.py
index 6f29a248..d2505d72 100644
--- a/src/briefcase/exceptions.py
+++ b/src/briefcase/exceptions.py
@@ -1,6 +1,7 @@
 class BriefcaseError(Exception):
-    def __init__(self, error_code):
+    def __init__(self, error_code, skip_logfile=False):
         self.error_code = error_code
+        self.skip_logfile = skip_logfile
 
 
 class HelpText(BriefcaseError):
@@ -10,7 +11,7 @@ class HelpText(BriefcaseError):
 
 class InfoHelpText(HelpText):
     def __init__(self, msg):
-        super().__init__(0)
+        super().__init__(error_code=0, skip_logfile=True)
         self.msg = msg
 
     def __str__(self):
@@ -19,7 +20,7 @@ class InfoHelpText(HelpText):
 
 class NoCommandError(HelpText):
     def __init__(self, msg):
-        super().__init__(-10)
+        super().__init__(error_code=-10, skip_logfile=True)
         self.msg = msg
 
     def __str__(self):
@@ -39,7 +40,7 @@ class ShowOutputFormats(InfoHelpText):
 
 class InvalidFormatError(BriefcaseError):
     def __init__(self, requested, choices):
-        super().__init__(-21)
+        super().__init__(error_code=-21, skip_logfile=True)
         self.requested = requested
         self.choices = choices
 
@@ -50,7 +51,7 @@ class InvalidFormatError(BriefcaseError):
 
 class UnsupportedCommandError(BriefcaseError):
     def __init__(self, platform, output_format, command):
-        super().__init__(-30)
+        super().__init__(error_code=-30, skip_logfile=True)
         self.platform = platform
         self.output_format = output_format
         self.command = command
@@ -64,7 +65,7 @@ class UnsupportedCommandError(BriefcaseError):
 
 class BriefcaseConfigError(BriefcaseError):
     def __init__(self, msg):
-        super().__init__(100)
+        super().__init__(error_code=100, skip_logfile=True)
         self.msg = msg
 
     def __str__(self):
@@ -73,7 +74,7 @@ class BriefcaseConfigError(BriefcaseError):
 
 class BriefcaseCommandError(BriefcaseError):
     def __init__(self, msg):
-        super().__init__(200)
+        super().__init__(error_code=200)
         self.msg = msg
 
     def __str__(self):


Test patch:
diff --git a/tests/console/test_Log.py b/tests/console/test_Log.py
index 9660f78b..99db294b 100644
--- a/tests/console/test_Log.py
+++ b/tests/console/test_Log.py
@@ -7,6 +7,7 @@ from rich.traceback import Trace
 
 import briefcase
 from briefcase.console import Log
+from briefcase.exceptions import BriefcaseError
 
 TRACEBACK_HEADER = "Traceback (most recent call last)"
 EXTRA_HEADER = "Extra information:"
@@ -25,11 +26,30 @@ def now(monkeypatch):
 def test_capture_stacktrace():
     """capture_stacktrace sets Log.stacktrace."""
     logger = Log()
+    assert logger.skip_log is False
+
     try:
         1 / 0
     except ZeroDivisionError:
         logger.capture_stacktrace()
+
+    assert isinstance(logger.stacktrace, Trace)
+    assert logger.skip_log is False
+
+
+@pytest.mark.parametrize("skip_logfile", [True, False])
+def test_capture_stacktrace_for_briefcaseerror(skip_logfile):
+    """skip_log is updated for BriefcaseError exceptions."""
+    logger = Log()
+    assert logger.skip_log is False
+
+    try:
+        raise BriefcaseError(error_code=542, skip_logfile=skip_logfile)
+    except BriefcaseError:
+        logger.capture_stacktrace()
+
     assert isinstance(logger.stacktrace, Trace)
+    assert logger.skip_log is skip_logfile
 
 
 def test_save_log_to_file_do_not_log():
@@ -77,7 +97,7 @@ def test_save_log_to_file_no_exception(tmp_path, now):
 
     logger.save_log_to_file(command=command)
 
-    log_filepath = tmp_path / "briefcase.2022_06_25-16_12_29.dev.log"
+    log_filepath = tmp_path / logger.LOG_DIR / "briefcase.2022_06_25-16_12_29.dev.log"
 
     assert log_filepath.exists()
     with open(log_filepath, encoding="utf-8") as log:
@@ -119,7 +139,7 @@ def test_save_log_to_file_with_exception(tmp_path, now):
         logger.capture_stacktrace()
     logger.save_log_to_file(command=command)
 
-    log_filepath = tmp_path / "briefcase.2022_06_25-16_12_29.dev.log"
+    log_filepath = tmp_path / logger.LOG_DIR / "briefcase.2022_06_25-16_12_29.dev.log"
 
     log_filepath.exists()
     with open(log_filepath, encoding="utf-8") as log:
@@ -151,7 +171,7 @@ def test_save_log_to_file_extra(tmp_path, now):
     for extra in [extra1, extra2, extra3]:
         logger.add_log_file_extra(extra)
     logger.save_log_to_file(command=command)
-    log_filepath = tmp_path / "briefcase.2022_06_25-16_12_29.dev.log"
+    log_filepath = tmp_path / logger.LOG_DIR / "briefcase.2022_06_25-16_12_29.dev.log"
     with open(log_filepath, encoding="utf-8") as log:
         log_contents = log.read()
 
@@ -180,7 +200,7 @@ def test_save_log_to_file_extra_interrupted(tmp_path, now):
     with pytest.raises(KeyboardInterrupt):
         logger.save_log_to_file(command=command)
     extra2.assert_not_called()
-    log_filepath = tmp_path / "briefcase.2022_06_25-16_12_29.dev.log"
+    log_filepath = tmp_path / logger.LOG_DIR / "briefcase.2022_06_25-16_12_29.dev.log"
     assert log_filepath.stat().st_size == 0
 
 
diff --git a/tests/test_mainline.py b/tests/test_mainline.py
index b287b7aa..d2ea8ba9 100644
--- a/tests/test_mainline.py
+++ b/tests/test_mainline.py
@@ -5,6 +5,7 @@ import pytest
 
 from briefcase.__main__ import main
 from briefcase.commands.create import CreateCommand
+from briefcase.console import Log
 
 from .utils import create_file
 
@@ -34,7 +35,7 @@ sources = ["myapp"]
 
 
 def test_help(monkeypatch, tmp_path, capsys):
-    "Briefcase can output help."
+    """Briefcase can output help."""
     # Set the test command line
     monkeypatch.setattr(sys, "argv", ["briefcase", "--help"])
 
@@ -47,11 +48,11 @@ def test_help(monkeypatch, tmp_path, capsys):
     )
 
     # No log file was written
-    assert len(list(tmp_path.glob("briefcase.*.log"))) == 0
+    assert len(list(tmp_path.glob(f"{Log.LOG_DIR}/briefcase.*.log"))) == 0
 
 
 def test_command(monkeypatch, tmp_path, capsys):
-    "A command can be successful"
+    """A command can be successful."""
     # Monkeypatch cwd() to use a test folder.
     monkeypatch.setattr(Path, "cwd", lambda: tmp_path)
 
@@ -75,18 +76,18 @@ def test_command(monkeypatch, tmp_path, capsys):
     assert output.startswith("\nGenerating a new application 'Hello World'\n")
 
     # No log file was written
-    assert len(list(tmp_path.glob("briefcase.*.log"))) == 0
+    assert len(list(tmp_path.glob(f"{Log.LOG_DIR}/briefcase.*.log"))) == 0
 
 
 def test_command_error(monkeypatch, tmp_path, capsys):
-    "A command can raise a known error"
+    """A command can raise a known error."""
     # Monkeypatch cwd() to use a test folder.
     monkeypatch.setattr(Path, "cwd", lambda: tmp_path)
 
     # Set the test command line
     monkeypatch.setattr(sys, "argv", ["briefcase", "create"])
 
-    # Help has a return code of -10
+    # BriefcaseConfigError has a return code of 100
     assert main() == 100
 
     output = capsys.readouterr().out
@@ -94,12 +95,12 @@ def test_command_error(monkeypatch, tmp_path, capsys):
         "\nBriefcase configuration error: Configuration file not found."
     )
 
-    # A log file was written
-    assert len(list(tmp_path.glob("briefcase.*.create.log"))) == 1
+    # Log files are not created for BriefcaseConfigError errors
+    assert len(list(tmp_path.glob(f"{Log.LOG_DIR}/briefcase.*.create.log"))) == 0
 
 
 def test_unknown_command_error(monkeypatch, pyproject_toml, capsys):
-    "A command can raise an unknown error"
+    """A command can raise an unknown error."""
     monkeypatch.setattr(sys, "argv", ["briefcase", "create"])
 
     # Monkeypatch an error into the create command
@@ -116,7 +117,7 @@ def test_unknown_command_error(monkeypatch, pyproject_toml, capsys):
 
 
 def test_interrupted_command(monkeypatch, pyproject_toml, tmp_path, capsys):
-    "A command can be interrupted"
+    """A command can be interrupted."""
     monkeypatch.setattr(sys, "argv", ["briefcase", "create"])
 
     # Monkeypatch a keyboard interrupt into the create command
@@ -134,11 +135,11 @@ def test_interrupted_command(monkeypatch, pyproject_toml, tmp_path, capsys):
     assert "\nAborted by user.\n" in output
 
     # No log file was written
-    assert len(list(tmp_path.glob("briefcase.*.create.log"))) == 0
+    assert len(list(tmp_path.glob(f"{Log.LOG_DIR}/briefcase.*.create.log"))) == 0
 
 
 def test_interrupted_command_with_log(monkeypatch, pyproject_toml, tmp_path, capsys):
-    "A log can be generated when a command is interrupted"
+    """A log can be generated when a command is interrupted."""
     monkeypatch.setattr(sys, "argv", ["briefcase", "create", "--log"])
 
     # Monkeypatch a keyboard interrupt into the create command
@@ -156,4 +157,4 @@ def test_interrupted_command_with_log(monkeypatch, pyproject_toml, tmp_path, cap
     assert "\nAborted by user.\n" in output
 
     # A log file was written
-    assert len(list(tmp_path.glob("briefcase.*.create.log"))) == 1
+    assert len(list(tmp_path.glob(f"{Log.LOG_DIR}/briefcase.*.create.log"))) == 1