Repository: conan-io/conan
Pull request: Add `redirect_stdout` to CMake integration methods
Changelog: Feature: Add `redirect_stdout` to CMake integration methods.
Docs: Omit

This PR focuses on adding the functionality to the CMake integrations only. We're happy to make them available in other integrations if there're requests for it from users

Closes #15108 

Problem statement:
[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

Hints from issue 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")`)

Commit message: Update conans/test/functional/toolchains/cmake/test_cmake_toolchain.py

Co-authored-by: Carlos Zoido <mrgalleta@gmail.com>

Patch to review:
diff --git a/conan/tools/cmake/cmake.py b/conan/tools/cmake/cmake.py
index a49ae2d01df..31e50ae7fe7 100644
--- 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 []
+
diff --git a/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py b/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py
index 3e8a3ddf417..5208dcd493f 100644
--- 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,54 @@ def build(self):
                  "CMakeLists.txt": cmake})
     client.run("create . -pr=myprofile")
     assert "-- MYVAR1 MYVALUE1!!" in client.out
+
+
+@pytest.mark.tool("cmake")
+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()}")
+
+        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)',
+                 "main.cpp": "int main() {return 0;}"})
+    client.run("create .")
+    assert "Configure stdout: -- Using Conan toolchain" in client.out
+    assert "Configure stderr: CMake Warning" in client.out
+    assert "Build stdout: -- Using Conan toolchain" in client.out
+    assert "Build stderr: CMake Warning" in client.out
+    assert "Install stdout: -- Install configuration" in client.out
+    assert "Install stderr: \n" in client.out
diff --git a/conans/test/unittests/tools/cmake/test_cmake_presets_definitions.py b/conans/test/unittests/tools/cmake/test_cmake_presets_definitions.py
index 4c07830d162..b33bebb519e 100644
--- 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
diff --git a/conans/test/utils/tools.py b/conans/test/utils/tools.py
index 700224c16f4..2122ecc9295 100644
--- a/conans/test/utils/tools.py
+++ b/conans/test/utils/tools.py
@@ -774,6 +774,10 @@ def assert_listed_binary(self, requires, build=False, test=False, test_package=F
             else:
                 raise AssertionError(f"Cant find {r}-{kind} in {reqs}")
 
+    def created_build_folder(self, ref):
+        build_folder = re.search(r"{}: Build folder (.*)".format(str(ref)), str(self.out)).group(1)
+        return build_folder.replace("\\", "/")
+
     def created_test_build_folder(self, ref):
         build_folder = re.search(r"{} \(test package\): Test package build: (.*)".format(str(ref)),
                                  str(self.out)).group(1)


Code review comments:
diff_hunk: @@ -774,6 +774,10 @@ def assert_listed_binary(self, requires, build=False, test=False, test_package=F
             else:
                 raise AssertionError(f"Cant find {r}-{kind} in {reqs}")
 
+    def created_build_folder(self, ref):
path: conans/test/utils/tools.py
text: @user1:
Not really used in the PR?

@author:
Oops leftover from previous iteration, good catch