Repository: ManimCommunity/manim
Pull request: Implement Logging of Terminal Output to a .log file.
Essentially what the title says.

You will be able to use the `--log_to_file` flag to log `rich`'s terminal output to the default log file location, and use `--log_dir <directory>` to pass a custom directory for logs via the command line.

Default values for both `log_to_file` and `log_dir` have been made in `defaults.cfg`, and these are used as the defaults.

`log_to_file` defaults to False, and `defaults.cfg`defaults to `./logs/`

This PR should close #138 .

Problem statement:
Add save logs option
Add an option to save logs to an external file. Like `save_logs` parameter which can be passed along with CLI. It could help in many ways and would help beginners to get help 🆘.

### My ideas
It can easily be done by adding a file handler along with Rich handler but making it optional is a big thing.

Hints from issue discussion:
I like this. This could also be easily implemented after #98

Commit message: Ignored Line Numbers during test.

Added some documentation for why word-for-word comparisons are not used.

Patch to review:
diff --git a/manim/default.cfg b/manim/default.cfg
index 9cf64ede13..75e380871b 100644
--- a/manim/default.cfg
+++ b/manim/default.cfg
@@ -48,6 +48,9 @@ output_file =
 # --leave_progress_bars
 leave_progress_bars = False
 
+# --log_to_file
+log_to_file = False
+
 # -c, --color
 background_color = BLACK
 
@@ -67,6 +70,9 @@ upto_animation_number = -1
 # --media_dir
 media_dir = ./media
 
+# --log_dir
+log_dir = %(media_dir)s/logs
+
 # # --video_dir
 # video_dir = %(MEDIA_DIR)s/videos
 
diff --git a/manim/logger.py b/manim/logger.py
index 4ac8b8d8a3..7e21e868d1 100644
--- a/manim/logger.py
+++ b/manim/logger.py
@@ -40,9 +40,9 @@ def parse_theme(fp):
 config_parser, successfully_read_files = config_items[1], config_items[-1]
 try:
     customTheme = parse_theme(successfully_read_files)
-    console = Console(theme=customTheme)
+    console = Console(theme=customTheme,record=True)
 except KeyError:
-    console = Console()
+    console = Console(record=True)
     printf(
         "[logging.level.warning]No cfg file found, creating one in "
         + successfully_read_files[0]
diff --git a/manim/scene/scene_file_writer.py b/manim/scene/scene_file_writer.py
index aaea1d2f1f..c031637097 100644
--- a/manim/scene/scene_file_writer.py
+++ b/manim/scene/scene_file_writer.py
@@ -10,7 +10,7 @@
 
 from ..constants import FFMPEG_BIN, GIF_FILE_EXTENSION
 from ..config import file_writer_config
-from ..logger import logger
+from ..logger import logger,console
 from ..utils.config_ops import digest_config
 from ..utils.file_ops import guarantee_existence
 from ..utils.file_ops import add_extension_if_not_present
@@ -529,3 +529,13 @@ def print_file_ready_message(self, file_path):
         Prints the "File Ready" message to STDOUT.
         """
         logger.info("\nFile ready at {}\n".format(file_path))
+
+        if file_writer_config["log_to_file"]:
+            self.write_log()
+
+    def write_log(self):
+            log_file_path=os.path.join(
+                file_writer_config["log_dir"],f"{self.get_default_scene_name()}.log")
+            with open(log_file_path,"w") as logfile:
+                logfile.write(console.export_text())
+            logger.info("Log written to {}\n".format(log_file_path))
diff --git a/manim/utils/config_utils.py b/manim/utils/config_utils.py
index a6c7fb1875..359acc10de 100644
--- a/manim/utils/config_utils.py
+++ b/manim/utils/config_utils.py
@@ -48,13 +48,14 @@ def _parse_file_writer_config(config_parser, args):
         "save_pngs",
         "save_as_gif",
         "write_all",
+        'log_to_file'
     ]:
         attr = getattr(args, boolean_opt)
         fw_config[boolean_opt] = (
             default.getboolean(boolean_opt) if attr is None else attr
         )
     # for str_opt in ['media_dir', 'video_dir', 'tex_dir', 'text_dir']:
-    for str_opt in ["media_dir"]:
+    for str_opt in ["media_dir","log_dir"]:
         attr = getattr(args, str_opt)
         fw_config[str_opt] = default[str_opt] if attr is None else attr
     dir_names = {"video_dir": "videos", "tex_dir": "Tex", "text_dir": "texts"}
@@ -218,6 +219,12 @@ def _parse_cli(arg_list, input=True):
         help="Save the video as gif",
     )
 
+    parser.add_argument(
+        "--log_to_file",
+        action="store_const",
+        const=True,
+        help="Log terminal output to file.")
+
     # The default value of the following is set in manim.cfg
     parser.add_argument(
         "-c", "--color", help="Background color",
@@ -228,6 +235,12 @@ def _parse_cli(arg_list, input=True):
     parser.add_argument(
         "--media_dir", help="directory to write media",
     )
+
+    parser.add_argument(
+        "--log_dir",
+        help="directory to write log files to",
+    )
+
     # video_group = parser.add_mutually_exclusive_group()
     # video_group.add_argument(
     #     "--video_dir",
@@ -324,10 +337,14 @@ def _init_dirs(config):
         config["video_dir"],
         config["tex_dir"],
         config["text_dir"],
+        config["log_dir"],
     ]:
         if not os.path.exists(folder):
-            os.makedirs(folder)
-
+            # If log_to_file is False, ignore log_dir
+            if folder is config["log_dir"] and (not config["log_to_file"]):
+                pass
+            else:
+                os.makedirs(folder)
 
 def _from_command_line():
     """Determine if manim was called from the command line."""
diff --git a/tests/test_logging/test_logging.py b/tests/test_logging/test_logging.py
new file mode 100644
index 0000000000..a203440444
--- /dev/null
+++ b/tests/test_logging/test_logging.py
@@ -0,0 +1,44 @@
+import subprocess
+import os
+import sys
+from shutil import rmtree
+import pytest
+import re
+
+def capture(command,instream=None):
+    proc = subprocess.Popen(command,
+                            stdout=subprocess.PIPE,
+                            stderr=subprocess.PIPE,
+                            stdin=instream
+                            )
+    out, err = proc.communicate()
+    return out, err, proc.returncode
+
+
+def test_logging_to_file(python_version):
+    """Test logging Terminal output to a log file.
+    `rich` formats it's output based on the size of the terminal it is outputting to.
+    As such, since there is no way to obtain the terminal size of the testing device
+    before running the test, this test employs a workaround where instead of the exact
+    text of the log (which can differ in whitespace and truncation) only the constant
+    parts, such as keywords, are compared.
+    """
+    path_basic_scene = os.path.join("tests", "tests_data", "basic_scenes.py")
+    expected=['INFO', 'Read', 'configuration', 'files:', 'config.py:', 'INFO',
+        'scene_file_writer.py:', 'File', 'ready', 'at']
+    path_output = os.path.join("tests_cache", "media_temp")
+    command = [python_version, "-m", "manim", path_basic_scene,
+               "SquareToCircle", "-l", "--log_to_file", "--log_dir",os.path.join(path_output,"logs"), "--media_dir", path_output]
+    out, err, exitcode = capture(command)
+    log_file_path=os.path.join(path_output, "logs", "SquareToCircle.log")
+    assert exitcode == 0, err
+    assert os.path.exists(log_file_path), err
+    if sys.platform.startswith("win32") or sys.platform.startswith("cygwin"):
+        enc="Windows-1252"
+    else:
+        enc="utf-8"
+    with open(log_file_path,encoding=enc) as logfile:
+        logs=logfile.read().split()
+    logs=[e for e in logs if not any(x in e for x in ["\\","/",".mp4","[","]"])]
+    logs=[re.sub('[0-9]', '', i) for i in logs]
+    assert logs==expected, err


Code review comments:
diff_hunk: @@ -529,3 +529,13 @@ def print_file_ready_message(self, file_path):
         Prints the "File Ready" message to STDOUT.
         """
         logger.info("\nFile ready at {}\n".format(file_path))
+
+        if file_writer_config["log_to_file"]:
+            self.write_log()
+
+    def write_log(self):
+            log_file_path=os.path.join(
path: manim/scene/scene_file_writer.py
text: @user1:
Issue: this looks like it has two levels of indentation when it only needs one.