Repository: conan-io/conan

Issue:
[feature] Adding support to exclude pattern when removing files with Conan tools
### What is your suggestion?

Greetings!

Following the logic behind of [conan.tools.files.copy](https://docs.conan.io/2/reference/tools/files/basic.html#conan-tools-files-copy), users can exclude an specific pattern of file, avoid extra logic in the recipe to copy or not files.

The same could be added to [conan.tools.files.rm](https://docs.conan.io/2/reference/tools/files/basic.html#conan-tools-files-rm) too.

The scenario is when you have mixed files in the same folder, but only want to remove part of them. Real example, exclude Tools installed in bindir, but keep .dll installed at same place.

Currently we have the Geographiclib in ConanCenterIndex under to this specific case, we need to exclude tools after installing (there is an option for tools, and upstream recommends customizing the install, not the building step), but preserving .dll files when having it.

### Have you read the CONTRIBUTING guide?

- [ ] I've read the CONTRIBUTING guide

Discussion:

Patch:
diff --git a/conan/api/subapi/install.py b/conan/api/subapi/install.py
index 6fd61d179..0fb5e1108 100644
--- a/conan/api/subapi/install.py
+++ b/conan/api/subapi/install.py
@@ -36,7 +36,8 @@ class InstallAPI:
         installer.install_system_requires(graph, only_info)
 
     def install_sources(self, graph, remotes):
-        """ Install sources for dependency graph
+        """ Install sources for dependency graph of packages to BUILD or packages that match
+        tools.build:download_source conf
         :param remotes:
         :param graph: Dependency graph to install packages for
         """
diff --git a/conan/tools/files/files.py b/conan/tools/files/files.py
index 73f0d6d82..661b88db3 100644
--- a/conan/tools/files/files.py
+++ b/conan/tools/files/files.py
@@ -66,7 +66,7 @@ def rmdir(conanfile, path):
     _internal_rmdir(path)
 
 
-def rm(conanfile, pattern, folder, recursive=False):
+def rm(conanfile, pattern, folder, recursive=False, excludes=None):
     """
     Utility functions to remove files matching a ``pattern`` in a ``folder``.
 
@@ -74,10 +74,17 @@ def rm(conanfile, pattern, folder, recursive=False):
     :param pattern: Pattern that the files to be removed have to match (fnmatch).
     :param folder: Folder to search/remove the files.
     :param recursive: If ``recursive`` is specified it will search in the subfolders.
+    :param excludes: (Optional, defaulted to None) A tuple/list of fnmatch patterns or even a
+                     single one to be excluded from the remove pattern.
     """
+    if excludes and not isinstance(excludes, (tuple, list)):
+        excludes = (excludes,)
+    elif not excludes:
+        excludes = []
+
     for root, _, filenames in os.walk(folder):
         for filename in filenames:
-            if fnmatch(filename, pattern):
+            if fnmatch(filename, pattern) and not any(fnmatch(filename, it) for it in excludes):
                 fullname = os.path.join(root, filename)
                 os.unlink(fullname)
         if not recursive:
diff --git a/conans/client/installer.py b/conans/client/installer.py
index 5f62d1527..ce4755728 100644
--- a/conans/client/installer.py
+++ b/conans/client/installer.py
@@ -176,11 +176,11 @@ class BinaryInstaller:
         self._hook_manager = app.hook_manager
         self._global_conf = global_conf
 
-    def _install_source(self, node, remotes):
+    def _install_source(self, node, remotes, need_conf=False):
         conanfile = node.conanfile
         download_source = conanfile.conf.get("tools.build:download_source", check_type=bool)
 
-        if not download_source and node.binary != BINARY_BUILD:
+        if not download_source and (need_conf or node.binary != BINARY_BUILD):
             return
 
         conanfile = node.conanfile
@@ -235,7 +235,7 @@ class BinaryInstaller:
         for level in install_order:
             for install_reference in level:
                 for package in install_reference.packages.values():
-                    self._install_source(package.nodes[0], remotes)
+                    self._install_source(package.nodes[0], remotes, need_conf=True)
 
     def install(self, deps_graph, remotes, install_order=None):
         assert not deps_graph.error, "This graph cannot be installed: {}".format(deps_graph)


Test patch:
diff --git a/test/integration/command/test_forced_download_source.py b/test/integration/command/test_forced_download_source.py
index dfe814815..cc06ceae6 100644
--- a/test/integration/command/test_forced_download_source.py
+++ b/test/integration/command/test_forced_download_source.py
@@ -44,6 +44,9 @@ def test_install(client):
 def test_info(client):
     client.run("graph info --requires=dep/0.1")
     assert "RUNNING SOURCE" not in client.out
+    # Even if the package is to be built, it shouldn't download sources unless the conf is defined
+    client.run("graph info --requires=dep/0.1 --build=dep*")
+    assert "RUNNING SOURCE" not in client.out
     client.run("graph info --requires=dep/0.1 -c tools.build:download_source=True")
     assert "RUNNING SOURCE" in client.out
     client.run("graph info --requires=dep/0.1 -c tools.build:download_source=True")
diff --git a/test/unittests/tools/files/test_rm.py b/test/unittests/tools/files/test_rm.py
index 75f32fa84..b569ac331 100644
--- a/test/unittests/tools/files/test_rm.py
+++ b/test/unittests/tools/files/test_rm.py
@@ -1,4 +1,5 @@
 import os
+import pytest
 
 # Check it is importable from tools
 from conan.tools.files import rm, chdir
@@ -63,3 +64,46 @@ def test_remove_files_by_mask_non_recursively():
 
     assert os.path.exists(os.path.join(tmpdir, "1.txt"))
     assert os.path.exists(os.path.join(tmpdir, "subdir", "2.txt"))
+
+
+@pytest.mark.parametrize("recursive", [False, True])
+@pytest.mark.parametrize("results", [
+    ["*.dll", ("foo.dll",)],
+    [("*.dll",), ("foo.dll",)],
+    [["*.dll"], ("foo.dll",)],
+    [("*.dll", "*.lib"), ("foo.dll", "foo.dll.lib")],
+])
+def test_exclude_pattern_from_remove_list(recursive, results):
+    """ conan.tools.files.rm should not remove files that match the pattern but are excluded
+        by the excludes parameter.
+        It should obey the recursive parameter, only excluding the files in the root folder in case
+        it is False.
+    """
+    excludes, expected_files = results
+    temporary_folder = temp_folder()
+    with chdir(None, temporary_folder):
+        os.makedirs("subdir")
+
+    save_files(temporary_folder, {
+        "1.txt": "",
+        "1.pdb": "",
+        "1.pdb1": "",
+        "foo.dll": "",
+        "foo.dll.lib": "",
+        os.path.join("subdir", "2.txt"): "",
+        os.path.join("subdir", "2.pdb"): "",
+        os.path.join("subdir", "foo.dll"): "",
+        os.path.join("subdir", "foo.dll.lib"): "",
+        os.path.join("subdir", "2.pdb1"): ""})
+
+    rm(None, "*", temporary_folder, excludes=excludes, recursive=recursive)
+
+    for it in expected_files:
+        assert os.path.exists(os.path.join(temporary_folder, it))
+    assert not os.path.exists(os.path.join(temporary_folder, "1.pdb"))
+
+    # Check the recursive parameter and subfolder
+    condition = (lambda x: not x) if recursive else (lambda x: x)
+    assert condition(os.path.exists(os.path.join(temporary_folder, "subdir", "2.pdb")))
+    for it in expected_files:
+        assert os.path.exists(os.path.join(temporary_folder, "subdir", it))