Repository: conan-io/conan

Issue:
[question] Build log file generation in recipe
### What is your question?

Hello everyone,

I searched for that topic, but I couldn't find any result here.
Is it somehow feasible to generate a log file from the build in the build function of the Conan recipe? My personal attempts here weren't successfull, but I am also not the most skilled Python developer. The idea would be to generate a log file during the `conan create .` so that the log can be archived as part of the package itself.
Does anyone have any ideas here?

BR,
Karsten

### Have you read the CONTRIBUTING guide?

- [X] I've read the CONTRIBUTING guide


Discussion:
Hi @KarstenErnert thanks a lot for your question.

Conan 2 recently released a metadata feature which aims to solve cases just like this one. [This blog post](https://blog.conan.io/2023/10/24/Conan-launches-metadata-files.html) goes into some detail about using in for different usecases, I suggest you read it - feel free to ask any question if needed after you've done so, we're happy to help where needed :) 
Hi @RubenRBS  and thanks for the reply,

the article shows nicely how to work with logs, but my problem is rather, that I would like to dump the cmake output into a text file while building. I tried it with redirecting the output stream for the `cmake.build()` but that didn't work.

I think you can use extra arguments to redirect the stdout:

```python
    def build(self):
        cmake = CMake(self)
        cmake.configure(cli_args=[">", "build.log"])
        cmake.build(cli_args=[">>", "build.log"])
```
That will create the "build.log" files from the commands output.
In any case, we might want to improve the UX for logging build logs into files, even if that works, wdyt @RubenRBS ?
Yeah, I think this would synergize pretty nice with the metadata, let's try to push this for some of the next releases if time allows :)
Hi @KarstenErnert we're going to implement an extra argument in the CMake integration to allow for direct stdout redirection to a file, it should be available for the next 2.0.15 Conan release :)
(So it will look something like `cmake.build(redirect_stdout="build.log")`)

Resolving patch:
diff --git a/conan/tools/cmake/cmake.py b/conan/tools/cmake/cmake.py
--- a/conan/tools/cmake/cmake.py
+++ b/conan/tools/cmake/cmake.py
@@ -51,7 +51,8 @@ def __init__(self, conanfile):
 
         self._cmake_program = conanfile.conf.get("tools.cmake:cmake_program", default="cmake")
 
-    def configure(self, variables=None, build_script_folder=None, cli_args=None):
+    def configure(self, variables=None, build_script_folder=None, cli_args=None,
+                  stdout=None, stderr=None):
         """
 
         Reads the ``CMakePresets.json`` file generated by the
@@ -73,6 +74,8 @@ def configure(self, variables=None, build_script_folder=None, cli_args=None):
         :param build_script_folder: Path to the CMakeLists.txt in case it is not in the declared
                                     ``self.folders.source`` at the ``layout()`` method.
         :param cli_args: List of extra arguments provided when calling to CMake.
+        :param stdout: Use it to redirect stdout to this stream
+        :param stderr: Use it to redirect stderr to this stream
         """
         self._conanfile.output.info("Running CMake.configure()")
         cmakelist_folder = self._conanfile.source_folder
@@ -112,9 +115,10 @@ def configure(self, variables=None, build_script_folder=None, cli_args=None):
 
         command = " ".join(arg_list)
         with chdir(self, build_folder):
-            self._conanfile.run(command)
+            self._conanfile.run(command, stdout=stdout, stderr=stderr)
 
-    def _build(self, build_type=None, target=None, cli_args=None, build_tool_args=None, env=""):
+    def _build(self, build_type=None, target=None, cli_args=None, build_tool_args=None, env="",
+               stdout=None, stderr=None):
         bf = self._conanfile.build_folder
         is_multi = is_multi_configuration(self._generator)
         if build_type and not is_multi:
@@ -146,27 +150,30 @@ def _build(self, build_type=None, target=None, cli_args=None, build_tool_args=No
         arg_list = ['"{}"'.format(bf), build_config, cmd_args_to_string(args)]
         arg_list = " ".join(filter(None, arg_list))
         command = "%s --build %s" % (self._cmake_program, arg_list)
-        self._conanfile.run(command, env=env)
+        self._conanfile.run(command, env=env, stdout=stdout, stderr=stderr)
 
-    def build(self, build_type=None, target=None, cli_args=None, build_tool_args=None):
+    def build(self, build_type=None, target=None, cli_args=None, build_tool_args=None,
+              stdout=None, stderr=None):
         """
 
         :param build_type: Use it only to override the value defined in the ``settings.build_type``
                            for a multi-configuration generator (e.g. Visual Studio, XCode).
                            This value will be ignored for single-configuration generators, they will
                            use the one defined in the toolchain file during the install step.
-        :param target: The name of a single build target as a string, or names of multiple build targets in a 
+        :param target: The name of a single build target as a string, or names of multiple build targets in a
                        list of strings to be passed to the ``--target`` argument.
         :param cli_args: A list of arguments ``[arg1, arg2, ...]`` that will be passed to the
                         ``cmake --build ... arg1 arg2`` command directly.
         :param build_tool_args: A list of arguments ``[barg1, barg2, ...]`` for the underlying
                                 build system that will be passed to the command
                                 line after the ``--`` indicator: ``cmake --build ... -- barg1 barg2``
+        :param stdout: Use it to redirect stdout to this stream
+        :param stderr: Use it to redirect stderr to this stream
         """
         self._conanfile.output.info("Running CMake.build()")
-        self._build(build_type, target, cli_args, build_tool_args)
+        self._build(build_type, target, cli_args, build_tool_args, stdout=stdout, stderr=stderr)
 
-    def install(self, build_type=None, component=None, cli_args=None):
+    def install(self, build_type=None, component=None, cli_args=None, stdout=None, stderr=None):
         """
         Equivalent to run ``cmake --build . --target=install``
 
@@ -178,6 +185,8 @@ def install(self, build_type=None, component=None, cli_args=None):
         :param cli_args: A list of arguments ``[arg1, arg2, ...]`` for the underlying
                          build system that will be passed to the command line:
                          ``cmake --install ... arg1 arg2``
+        :param stdout: Use it to redirect stdout to this stream
+        :param stderr: Use it to redirect stderr to this stream
         """
         self._conanfile.output.info("Running CMake.install()")
         mkdir(self._conanfile, self._conanfile.package_folder)
@@ -206,9 +215,10 @@ def install(self, build_type=None, component=None, cli_args=None):
 
         arg_list = " ".join(filter(None, arg_list))
         command = "%s %s" % (self._cmake_program, arg_list)
-        self._conanfile.run(command)
+        self._conanfile.run(command, stdout=stdout, stderr=stderr)
 
-    def test(self, build_type=None, target=None, cli_args=None, build_tool_args=None, env=""):
+    def test(self, build_type=None, target=None, cli_args=None, build_tool_args=None, env="",
+             stdout=None, stderr=None):
         """
         Equivalent to running cmake --build . --target=RUN_TESTS.
 
@@ -219,6 +229,8 @@ def test(self, build_type=None, target=None, cli_args=None, build_tool_args=None
         :param target: Name of the build target to run, by default ``RUN_TESTS`` or ``test``
         :param cli_args: Same as above ``build()``
         :param build_tool_args: Same as above ``build()``
+        :param stdout: Use it to redirect stdout to this stream
+        :param stderr: Use it to redirect stderr to this stream
         """
         if self._conanfile.conf.get("tools.build:skip_test", check_type=bool):
             return
@@ -231,7 +243,7 @@ def test(self, build_type=None, target=None, cli_args=None, build_tool_args=None
         # The default for ``test()`` is both the buildenv and the runenv
         env = ["conanbuild", "conanrun"] if env == "" else env
         self._build(build_type=build_type, target=target, cli_args=cli_args,
-                    build_tool_args=build_tool_args, env=env)
+                    build_tool_args=build_tool_args, env=env, stdout=stdout, stderr=stderr)
 
     @property
     def _compilation_verbosity_arg(self):
@@ -254,3 +266,4 @@ def _cmake_log_levels_args(self):
         if log_level == "quiet":
             log_level = "error"
         return ["--loglevel=" + log_level.upper()] if log_level is not None else []
+


Test patch:
diff --git a/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py b/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py
--- a/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py
+++ b/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py
@@ -244,8 +244,8 @@ def build(self):
 
 @pytest.mark.tool("cmake")
 def test_cmaketoolchain_no_warnings():
-    """Make sure unitialized variables do not cause any warnings, passing -Werror=dev
-    and --wanr-unitialized, calling "cmake" with conan_toolchain.cmake used to fail
+    """Make sure uninitialized variables do not cause any warnings, passing -Werror=dev
+    and --warn-uninitialized, calling "cmake" with conan_toolchain.cmake used to fail
     """
     # Issue https://github.com/conan-io/conan/issues/10288
     client = TestClient()
@@ -1353,3 +1353,74 @@ def build(self):
                  "CMakeLists.txt": cmake})
     client.run("create . -pr=myprofile")
     assert "-- MYVAR1 MYVALUE1!!" in client.out
+
+
+@pytest.mark.tool("cmake", "3.19")
+def test_redirect_stdout():
+    client = TestClient()
+    conanfile = textwrap.dedent("""
+    import os
+    from io import StringIO
+    from conan import ConanFile
+    from conan.tools.cmake import CMake, CMakeToolchain
+    from conan.tools.cmake import cmake_layout
+
+    class Pkg(ConanFile):
+        name = "foo"
+        version = "1.0"
+        settings = "os", "arch", "build_type", "compiler"
+        generators = "CMakeToolchain"
+        exports_sources = "CMakeLists.txt", "main.cpp"
+
+        def build(self):
+            cmake = CMake(self)
+
+            config_stdout, config_stderr = StringIO(), StringIO()
+            cmake.configure(stdout=config_stdout, stderr=config_stderr)
+            self.output.info(f"Configure stdout: '{config_stdout.getvalue()}'")
+            self.output.info(f"Configure stderr: '{config_stderr.getvalue()}'")
+
+            build_stdout, build_stderr = StringIO(), StringIO()
+            cmake.build(stdout=build_stdout, stderr=build_stderr)
+            self.output.info(f"Build stdout: '{build_stdout.getvalue()}'")
+            self.output.info(f"Build stderr: '{build_stderr.getvalue()}'")
+
+            test_stdout, test_stderr = StringIO(), StringIO()
+            cmake.test(stdout=test_stdout, stderr=test_stderr)
+            self.output.info(f"Test stdout: '{test_stdout.getvalue()}'")
+            self.output.info(f"Test stderr: '{test_stderr.getvalue()}'")
+
+        def package(self):
+            cmake = CMake(self)
+            install_stdout, install_stderr = StringIO(), StringIO()
+            cmake.install(stdout=install_stdout, stderr=install_stderr)
+            self.output.info(f"Install stdout: '{install_stdout.getvalue()}'")
+            self.output.info(f"Install stderr: '{install_stderr.getvalue()}'")
+
+
+    """)
+    client.save({"conanfile.py": conanfile,
+                 "CMakeLists.txt": 'project(foo)\nadd_executable(mylib main.cpp)\ninclude(CTest)',
+                 "main.cpp": "int main() {return 0;}"})
+    client.run("create .")
+
+    # Ensure the output is not unexpectedly empty
+    assert re.search("Configure stdout: '[^']", client.out)
+    assert re.search("Configure stderr: '[^']", client.out)
+
+    assert re.search("Build stdout: '[^']", client.out)
+    assert re.search("Build stderr: ''", client.out)
+
+    assert re.search("Test stdout: '[^']", client.out)
+
+    # Empty on Windows
+    if platform.system() == "Windows":
+        assert re.search("Test stderr: ''", client.out)
+    else:
+        assert re.search("Test stderr: '[^']", client.out)
+
+    if platform.system() == "Windows":
+        assert re.search("Install stdout: ''", client.out)
+    else:
+        assert re.search("Install stdout: '[^']", client.out)
+    assert re.search("Install stderr: ''", client.out)
diff --git a/conans/test/integration/configuration/conf/test_conf_profile.py b/conans/test/integration/configuration/conf/test_conf_profile.py
--- a/conans/test/integration/configuration/conf/test_conf_profile.py
+++ b/conans/test/integration/configuration/conf/test_conf_profile.py
@@ -18,7 +18,7 @@ class Pkg(ConanFile):
             settings = "os", "arch", "compiler", "build_type"
             generators = "CMakeToolchain"
 
-            def run(self, cmd, env=None):  # INTERCEPTOR of running
+            def run(self, cmd, env=None, **kwargs):  # INTERCEPTOR of running
                 self.output.info("RECIPE-RUN: {}".format(cmd))
 
             def build(self):
diff --git a/conans/test/unittests/tools/cmake/test_cmake_presets_definitions.py b/conans/test/unittests/tools/cmake/test_cmake_presets_definitions.py
--- a/conans/test/unittests/tools/cmake/test_cmake_presets_definitions.py
+++ b/conans/test/unittests/tools/cmake/test_cmake_presets_definitions.py
@@ -42,7 +42,7 @@ def test_cmake_cmake_program(conanfile):
 
 
 def test_cmake_make_program(conanfile):
-    def run(command):
+    def run(command, **kwargs):
         assert '-DCMAKE_MAKE_PROGRAM="C:/mymake.exe"' in command
 
     conanfile.run = run