Repository: beeware/briefcase

Issue:
Include `pyproject.toml` in the log file
### What is the problem or limitation you are having?

When the Briefcase log file doesn't provide information I'm interested in, it will likely be in the project's `pyproject.toml`. For instance, what `requires` is set to.

### Describe the solution you'd like

Append the contents of `pyproject.toml` to the log file.

An alternative to this would be dumping the `AppConfig` object.

### Describe alternatives you've considered

Continue requesting such information specifically.

### Additional context

_No response_

Discussion:

Patch:
diff --git a/changes/1674.feature.rst b/changes/1674.feature.rst
new file mode 100644
index 00000000..e03ab6cb
--- /dev/null
+++ b/changes/1674.feature.rst
@@ -0,0 +1,1 @@
+The contents of ``pyproject.toml`` is now included in the log file.
diff --git a/src/briefcase/console.py b/src/briefcase/console.py
index c05c3a0c..b2c48905 100644
--- a/src/briefcase/console.py
+++ b/src/briefcase/console.py
@@ -398,9 +398,9 @@ class Log:
                 self.warning(f"Log saved to {log_filepath}")
             self.print.to_console()
 
-    def _build_log(self, command):
+    def _build_log(self, command) -> str:
         """Accumulate all information to include in the log file."""
-        # add the exception stacktrace to end of log if one was captured
+        # Add the exception stacktraces to end of log if any were captured
         if self.stacktraces:
             # using print.log.print() instead of print.to_log() to avoid
             # timestamp and code location inclusion for the stacktrace box.
@@ -415,6 +415,7 @@ class Log:
                     new_line_start=True,
                 )
 
+        # Retrieve additional logging added by the Command
         if self.log_file_extras:
             with command.input.wait_bar(
                 "Collecting extra information for log...",
@@ -428,21 +429,30 @@ class Log:
                     except Exception:
                         self.error(traceback.format_exc())
 
-        # build log header and export buffered log from Rich
-        uname = platform.uname()
+        # Capture env vars removing any potentially sensitive information
         sanitized_env_vars = "\n".join(
             f"\t{env_var}={value if not SENSITIVE_SETTING_RE.search(env_var) else '********************'}"
             for env_var, value in sorted(command.tools.os.environ.items())
         )
+
+        # Capture pyproject.toml if one exists in the current directory
+        try:
+            with open(Path.cwd() / "pyproject.toml", encoding="utf-8") as f:
+                pyproject_toml = f.read().strip()
+        except OSError as e:
+            pyproject_toml = str(e)
+
+        # Build log with buffered log from Rich
+        uname = platform.uname()
         return (
             f"Date/Time:       {datetime.now().strftime('%Y-%m-%d %H:%M:%S %Z')}\n"
             f"Command line:    {' '.join(sys.argv)}\n"
-            f"\n"
+            "\n"
             f"OS Release:      {uname.system} {uname.release}\n"
             f"OS Version:      {uname.version}\n"
             f"Architecture:    {uname.machine}\n"
             f"Platform:        {platform.platform(aliased=True)}\n"
-            f"\n"
+            "\n"
             f"Python exe:      {sys.executable}\n"
             # replace line breaks with spaces (use chr(10) since '\n' isn't allowed in f-strings...)
             f"Python version:  {sys.version.replace(chr(10), ' ')}\n"
@@ -452,15 +462,18 @@ class Log:
             f"Virtual env:     {hasattr(sys, 'real_prefix') or sys.base_prefix != sys.prefix}\n"
             # for conda, prefix and base_prefix are likely the same but contain a conda-meta dir.
             f"Conda env:       {(Path(sys.prefix) / 'conda-meta').exists()}\n"
-            f"\n"
+            "\n"
             f"Briefcase:       {__version__}\n"
             f"Target platform: {command.platform}\n"
             f"Target format:   {command.output_format}\n"
-            f"\n"
-            f"Environment Variables:\n"
+            "\n"
+            "Environment Variables:\n"
             f"{sanitized_env_vars}\n"
-            f"\n"
-            f"Briefcase Log:\n"
+            "\n"
+            "pyproject.toml:\n"
+            f"{pyproject_toml}\n"
+            "\n"
+            "Briefcase Log:\n"
             f"{self.print.export_log()}"
         )
 


Test patch:
diff --git a/tests/console/test_Log.py b/tests/console/test_Log.py
index 3515c137..e17f00fe 100644
--- a/tests/console/test_Log.py
+++ b/tests/console/test_Log.py
@@ -2,6 +2,7 @@ import contextlib
 import datetime
 import logging
 from io import TextIOBase
+from pathlib import Path
 from unittest.mock import MagicMock, PropertyMock, call
 
 import pytest
@@ -162,7 +163,7 @@ def test_save_log_to_file_do_not_log(command):
     assert len(logger.stacktraces) == 0
 
 
-def test_save_log_to_file_no_exception(mock_now, command, tmp_path):
+def test_save_log_to_file_no_exception(mock_now, command, tmp_path, monkeypatch):
     """Log file contains everything printed to log; env vars are sanitized; no
     stacktrace if one is not captured."""
     command.tools.os.environ = {
@@ -203,13 +204,18 @@ def test_save_log_to_file_no_exception(mock_now, command, tmp_path):
         with command.tools.input.wait_bar("abort message..."):
             raise KeyboardInterrupt
 
+    project_root = tmp_path / "project_root"
+    project_root.mkdir(exist_ok=True)
+    monkeypatch.chdir(project_root)
+    with open("pyproject.toml", "w", encoding="utf-8") as f:
+        f.writelines(["[section]\n", "name = 'project'\n\n\n\n\n"])
+
     logger.save_log_to_file(command=command)
 
     log_filepath = tmp_path / "logs/briefcase.2022_06_25-16_12_29.dev.log"
 
     assert log_filepath.exists()
-    with open(log_filepath, encoding="utf-8") as log:
-        log_contents = log.read()
+    log_contents = log_filepath.read_text(encoding="utf-8")
 
     assert log_contents.startswith("Date/Time:       2022-06-25 16:12:29")
     assert "this is debug output" in log_contents
@@ -235,6 +241,7 @@ def test_save_log_to_file_no_exception(mock_now, command, tmp_path):
     assert "wait message... done" in log_contents
     assert "abort message... started" in log_contents
     assert "abort message... aborted" in log_contents
+    assert "pyproject.toml:\n[section]\nname = 'project'\n\nBriefcase" in log_contents
 
     assert TRACEBACK_HEADER not in log_contents
     assert EXTRA_HEADER not in log_contents
@@ -253,8 +260,7 @@ def test_save_log_to_file_with_exception(mock_now, command, tmp_path):
     log_filepath = tmp_path / "logs/briefcase.2022_06_25-16_12_29.dev.log"
 
     assert log_filepath.exists()
-    with open(log_filepath, encoding="utf-8") as log:
-        log_contents = log.read()
+    log_contents = log_filepath.read_text(encoding="utf-8")
 
     assert len(logger.stacktraces) == 1
     assert log_contents.startswith("Date/Time:       2022-06-25 16:12:29")
@@ -277,8 +283,7 @@ def test_save_log_to_file_with_multiple_exceptions(mock_now, command, tmp_path):
     log_filepath = tmp_path / "logs/briefcase.2022_06_25-16_12_29.dev.log"
 
     assert log_filepath.exists()
-    with open(log_filepath, encoding="utf-8") as log:
-        log_contents = log.read()
+    log_contents = log_filepath.read_text(encoding="utf-8")
 
     assert len(logger.stacktraces) == 4
     assert log_contents.startswith("Date/Time:       2022-06-25 16:12:29")
@@ -306,8 +311,7 @@ def test_save_log_to_file_extra(mock_now, command, tmp_path):
         logger.add_log_file_extra(extra)
     logger.save_log_to_file(command=command)
     log_filepath = tmp_path / "logs/briefcase.2022_06_25-16_12_29.dev.log"
-    with open(log_filepath, encoding="utf-8") as log:
-        log_contents = log.read()
+    log_contents = log_filepath.read_text(encoding="utf-8")
 
     assert EXTRA_HEADER in log_contents
     assert "Log extra 1" in log_contents
@@ -334,6 +338,25 @@ def test_save_log_to_file_extra_interrupted(mock_now, command, tmp_path):
     assert log_filepath.stat().st_size == 0
 
 
+def test_save_log_to_file_missing_pyproject(mock_now, command, tmp_path, monkeypatch):
+    """Log file contains pyproject read exception if it's missing."""
+    logger = Log()
+    logger.save_log = True
+
+    # ensure in a directory without a pyproject.toml
+    monkeypatch.chdir(tmp_path)
+    Path(tmp_path / "pyproject.toml").unlink(missing_ok=True)
+
+    logger.save_log_to_file(command=command)
+
+    log_filepath = tmp_path / "logs/briefcase.2022_06_25-16_12_29.dev.log"
+
+    assert log_filepath.exists()
+    log_contents = log_filepath.read_text(encoding="utf-8")
+
+    assert "pyproject.toml:\n[Errno 2] No such file or directory" in log_contents
+
+
 def test_save_log_to_file_fail_to_make_logs_dir(
     mock_now,
     command,