Repository: conan-io/conan
Pull request: File tools at "conan." namespace
Changelog: omit
Docs: omit
Close https://github.com/conan-io/conan/issues/10502


Problem statement:
[feature] Move file tools to "conan"
There are still some "file" related tools not migrated to "conan".
- Some of them are probably overacting regarding the encoding, probably in some situations, inherited from python2. As we really don't know what is py2 legacy and what is necessary, try to simplify is something looks weird or too much, maybe always, all, utf-8?
- Some others maybe are not very used, take a look at conan-center and if they are not used better to postpone and migrate later.

Hints from issue discussion:
I'll keep updating this comment:

**Not moved, not very used** Add to migration guide
- human_size
- untargz (only as internal usage of unzip)
- check_with_algorithm_sum (only as internal usage of sha1, md5, sha256)
- replace_path_in_file (There is some usage in c3i but probably could be omitted)
- replace_prefix_in_pc_file
- remove_files_by_mask: Rename to **remove_files(pattern, folder)**
- unix2dos
- dos2unix
- which (shutil.which)

**To be moved**
- unzip
- check_sha1
- check_md5
- check_sha256
- replace_in_file (Review encodings argument)
- collect_libs

Commit message: encoding argument, default utf-8

Patch to review:
diff --git a/conan/tools/files/__init__.py b/conan/tools/files/__init__.py
index 7a84ecda977..3ac35bd45aa 100644
--- a/conan/tools/files/__init__.py
+++ b/conan/tools/files/__init__.py
@@ -1,5 +1,7 @@
 from conan.tools.files.files import load, save, mkdir, ftp_download, download, get, rename, \
-    load_toolchain_args, save_toolchain_args, chdir
+    load_toolchain_args, save_toolchain_args, chdir, unzip, replace_in_file, collect_libs, \
+    check_md5, check_sha1, check_sha256
+
 from conan.tools.files.patches import patch, apply_conandata_patches
 from conan.tools.files.cpp_package import CppPackage
 from conan.tools.files.packager import AutoPackager
diff --git a/conan/tools/files/files.py b/conan/tools/files/files.py
index 5d14c8c8015..cb40f41d584 100644
--- a/conan/tools/files/files.py
+++ b/conan/tools/files/files.py
@@ -1,26 +1,28 @@
 import configparser
 import errno
+import gzip
+import hashlib
 import os
 import platform
 import subprocess
+import sys
 from contextlib import contextmanager
+from fnmatch import fnmatch
 
 from conan.tools import CONAN_TOOLCHAIN_ARGS_FILE, CONAN_TOOLCHAIN_ARGS_SECTION
 from conans.client.downloaders.download import run_downloader
-from conans.client.tools.files import unzip, which
+from conans.client.tools.files import which
 from conans.errors import ConanException
-from conans.util.files import decode_text, to_file_bytes
 
 
-def load(conanfile, path, binary=False, encoding="auto"):
+def load(conanfile, path, encoding="utf-8"):
     """ Loads a file content """
     with open(path, 'rb') as handle:
         tmp = handle.read()
-        # TODO: Get rid of encoding auto-detection
-        return tmp if binary else decode_text(tmp, encoding)
+        return tmp.decode(encoding)
 
 
-def save(conanfile, path, content, append=False):
+def save(conanfile, path, content, append=False, encoding="utf-8"):
     if append:
         mode = "ab"
         try:
@@ -41,7 +43,9 @@ def save(conanfile, path, content, append=False):
                 raise
 
     with open(path, mode) as handle:
-        handle.write(to_file_bytes(content, encoding="utf-8"))
+        if not isinstance(content, bytes):
+            content = bytes(content, encoding=encoding)
+        handle.write(content)
 
 
 def mkdir(conanfile, path):
@@ -57,7 +61,6 @@ def get(conanfile, url, md5='', sha1='', sha256='', destination=".", filename=""
     """ high level downloader + unzipper + (optional hash checker) + delete temporary zip
     """
 
-    output = conanfile.output
     if not filename:  # deduce filename from the URL
         url_base = url[0] if isinstance(url, (list, tuple)) else url
         if "?" in url_base or "=" in url_base:
@@ -68,8 +71,8 @@ def get(conanfile, url, md5='', sha1='', sha256='', destination=".", filename=""
     download(conanfile, url, filename, verify=verify,
              retry=retry, retry_wait=retry_wait, auth=auth, headers=headers,
              md5=md5, sha1=sha1, sha256=sha256)
-    unzip(filename, destination=destination, keep_permissions=keep_permissions, pattern=pattern,
-          output=output, strip_root=strip_root)
+    unzip(conanfile, filename, destination=destination, keep_permissions=keep_permissions,
+          pattern=pattern, strip_root=strip_root)
     os.unlink(filename)
 
 
@@ -242,3 +245,245 @@ def chdir(conanfile, newdir):
         yield
     finally:
         os.chdir(old_path)
+
+
+def unzip(conanfile, filename, destination=".", keep_permissions=False, pattern=None,
+          strip_root=False):
+    """
+    Unzip a zipped file
+    :param filename: Path to the zip file
+    :param destination: Destination folder (or file for .gz files)
+    :param keep_permissions: Keep the zip permissions. WARNING: Can be
+    dangerous if the zip was not created in a NIX system, the bits could
+    produce undefined permission schema. Use this option only if you are sure
+    that the zip was created correctly.
+    :param pattern: Extract only paths matching the pattern. This should be a
+    Unix shell-style wildcard, see fnmatch documentation for more details.
+    :param flat: If all the contents are in a single dir, flat that directory.
+    :return:
+    """
+
+    output = conanfile.output
+    if (filename.endswith(".tar.gz") or filename.endswith(".tgz") or
+            filename.endswith(".tbz2") or filename.endswith(".tar.bz2") or
+            filename.endswith(".tar")):
+        return untargz(filename, destination, pattern, strip_root)
+    if filename.endswith(".gz"):
+        with gzip.open(filename, 'rb') as f:
+            file_content = f.read()
+        target_name = filename[:-3] if destination == "." else destination
+        save(conanfile, target_name, file_content)
+        return
+    if filename.endswith(".tar.xz") or filename.endswith(".txz"):
+        return untargz(filename, destination, pattern, strip_root)
+
+    import zipfile
+    full_path = os.path.normpath(os.path.join(os.getcwd(), destination))
+
+    if hasattr(sys.stdout, "isatty") and sys.stdout.isatty():
+        def print_progress(the_size, uncomp_size):
+            the_size = (the_size * 100.0 / uncomp_size) if uncomp_size != 0 else 0
+            txt_msg = "Unzipping %d %%"
+            if the_size > print_progress.last_size + 1:
+                output.rewrite_line(txt_msg % the_size)
+                print_progress.last_size = the_size
+                if int(the_size) == 99:
+                    output.rewrite_line(txt_msg % 100)
+    else:
+        def print_progress(_, __):
+            pass
+
+    with zipfile.ZipFile(filename, "r") as z:
+        zip_info = z.infolist()
+        if pattern:
+            zip_info = [zi for zi in zip_info if fnmatch(zi.filename, pattern)]
+        if strip_root:
+            names = [n.replace("\\", "/") for n in z.namelist()]
+            common_folder = os.path.commonprefix(names).split("/", 1)[0]
+            if not common_folder and len(names) > 1:
+                raise ConanException("The zip file contains more than 1 folder in the root")
+            if len(names) == 1 and len(names[0].split("/", 1)) == 1:
+                raise ConanException("The zip file contains a file in the root")
+            # Remove the directory entry if present
+            # Note: The "zip" format contains the "/" at the end if it is a directory
+            zip_info = [m for m in zip_info if m.filename != (common_folder + "/")]
+            for member in zip_info:
+                name = member.filename.replace("\\", "/")
+                member.filename = name.split("/", 1)[1]
+
+        uncompress_size = sum((file_.file_size for file_ in zip_info))
+        if uncompress_size > 100000:
+            output.info("Unzipping %s, this can take a while" % _human_size(uncompress_size))
+        else:
+            output.info("Unzipping %s" % _human_size(uncompress_size))
+        extracted_size = 0
+
+        print_progress.last_size = -1
+        if platform.system() == "Windows":
+            for file_ in zip_info:
+                extracted_size += file_.file_size
+                print_progress(extracted_size, uncompress_size)
+                try:
+                    z.extract(file_, full_path)
+                except Exception as e:
+                    output.error("Error extract %s\n%s" % (file_.filename, str(e)))
+        else:  # duplicated for, to avoid a platform check for each zipped file
+            for file_ in zip_info:
+                extracted_size += file_.file_size
+                print_progress(extracted_size, uncompress_size)
+                try:
+                    z.extract(file_, full_path)
+                    if keep_permissions:
+                        # Could be dangerous if the ZIP has been created in a non nix system
+                        # https://bugs.python.org/issue15795
+                        perm = file_.external_attr >> 16 & 0xFFF
+                        os.chmod(os.path.join(full_path, file_.filename), perm)
+                except Exception as e:
+                    output.error("Error extract %s\n%s" % (file_.filename, str(e)))
+        output.writeln("")
+
+
+def untargz(filename, destination=".", pattern=None, strip_root=False):
+    # NOT EXPOSED at `conan.tools.files` but used in tests
+    import tarfile
+    with tarfile.TarFile.open(filename, 'r:*') as tarredgzippedFile:
+        if not pattern and not strip_root:
+            tarredgzippedFile.extractall(destination)
+        else:
+            members = tarredgzippedFile.getmembers()
+
+            if strip_root:
+                names = [n.replace("\\", "/") for n in tarredgzippedFile.getnames()]
+                common_folder = os.path.commonprefix(names).split("/", 1)[0]
+                if not common_folder and len(names) > 1:
+                    raise ConanException("The tgz file contains more than 1 folder in the root")
+                if len(names) == 1 and len(names[0].split("/", 1)) == 1:
+                    raise ConanException("The tgz file contains a file in the root")
+                # Remove the directory entry if present
+                members = [m for m in members if m.name != common_folder]
+                for member in members:
+                    name = member.name.replace("\\", "/")
+                    member.name = name.split("/", 1)[1]
+                    member.path = member.name
+            if pattern:
+                members = list(filter(lambda m: fnmatch(m.name, pattern),
+                                      tarredgzippedFile.getmembers()))
+            tarredgzippedFile.extractall(destination, members=members)
+
+
+def _human_size(size_bytes):
+    """
+    format a size in bytes into a 'human' file size, e.g. B, KB, MB, GB, TB, PB
+    Note that bytes will be reported in whole numbers but KB and above will have
+    greater precision.  e.g. 43 B, 443 KB, 4.3 MB, 4.43 GB, etc
+    """
+    UNIT_SIZE = 1000.0
+
+    suffixes_table = [('B', 0), ('KB', 1), ('MB', 1), ('GB', 2), ('TB', 2), ('PB', 2)]
+
+    num = float(size_bytes)
+    the_precision = None
+    the_suffix = None
+    for suffix, precision in suffixes_table:
+        the_precision = precision
+        the_suffix = suffix
+        if num < UNIT_SIZE:
+            break
+        num /= UNIT_SIZE
+
+    if the_precision == 0:
+        formatted_size = "%d" % num
+    else:
+        formatted_size = str(round(num, ndigits=the_precision))
+
+    return "%s%s" % (formatted_size, the_suffix)
+
+
+def check_sha1(conanfile, file_path, signature):
+    _check_with_algorithm_sum("sha1", file_path, signature)
+
+
+def check_md5(conanfile, file_path, signature):
+    _check_with_algorithm_sum("md5", file_path, signature)
+
+
+def check_sha256(conanfile, file_path, signature):
+    _check_with_algorithm_sum("sha256", file_path, signature)
+
+
+def _check_with_algorithm_sum(algorithm_name, file_path, signature):
+    real_signature = _generic_algorithm_sum(file_path, algorithm_name)
+    if real_signature != signature.lower():
+        raise ConanException("%s signature failed for '%s' file. \n"
+                             " Provided signature: %s  \n"
+                             " Computed signature: %s" % (algorithm_name,
+                                                          os.path.basename(file_path),
+                                                          signature,
+                                                          real_signature))
+
+
+def _generic_algorithm_sum(file_path, algorithm_name):
+
+    with open(file_path, 'rb') as fh:
+        try:
+            m = hashlib.new(algorithm_name)
+        except ValueError:  # FIPS error https://github.com/conan-io/conan/issues/7800
+            m = hashlib.new(algorithm_name, usedforsecurity=False)
+        while True:
+            data = fh.read(8192)
+            if not data:
+                break
+            m.update(data)
+        return m.hexdigest()
+
+
+def replace_in_file(conanfile, file_path, search, replace, strict=True, encoding="utf-8"):
+    """
+    :param conanfile: Conanfile instance
+    :param file_path: Path to the file
+    :param search: Pattern to search
+    :param replace: string to replace the matches
+    :param strict: Raise in case "search" is not found in the file contents
+    :return:
+    """
+    output = conanfile.output
+    content = load(conanfile, file_path, encoding=encoding)
+    if -1 == content.find(search):
+        message = "replace_in_file didn't find pattern '%s' in '%s' file." % (search, file_path)
+        if strict:
+            raise ConanException(message)
+        else:
+            output.warn(message)
+            return False
+    content = content.replace(search, replace)
+    save(conanfile, file_path, content, encoding=encoding)
+
+
+def collect_libs(conanfile, folder=None):
+    if not conanfile.package_folder:
+        return []
+    if folder:
+        lib_folders = [os.path.join(conanfile.package_folder, folder)]
+    else:
+        lib_folders = [os.path.join(conanfile.package_folder, folder)
+                       for folder in conanfile.cpp_info.libdirs]
+    result = []
+    for lib_folder in lib_folders:
+        if not os.path.exists(lib_folder):
+            conanfile.output.warn("Lib folder doesn't exist, can't collect libraries: "
+                                  "{0}".format(lib_folder))
+            continue
+        files = os.listdir(lib_folder)
+        for f in files:
+            name, ext = os.path.splitext(f)
+            if ext in (".so", ".lib", ".a", ".dylib", ".bc"):
+                if ext != ".lib" and name.startswith("lib"):
+                    name = name[3:]
+                if name in result:
+                    conanfile.output.warn("Library '%s' was either already found in a previous "
+                                          "'conanfile.cpp_info.libdirs' folder or appears several "
+                                          "times with a different file extension" % name)
+                else:
+                    result.append(name)
+    result.sort()
+    return result
diff --git a/conans/test/unittests/tools/files/checksums_test.py b/conans/test/unittests/tools/files/checksums_test.py
new file mode 100644
index 00000000000..2ca666d9c92
--- /dev/null
+++ b/conans/test/unittests/tools/files/checksums_test.py
@@ -0,0 +1,16 @@
+import os
+
+from conan.tools.files import check_md5, check_sha1, check_sha256
+from conans.test.utils.test_files import temp_folder
+from conans.tools import save
+
+
+def test_checksums():
+    tmp = temp_folder()
+    file_path = os.path.join(tmp, "foo.txt")
+    save(os.path.join(tmp, "foo.txt"), "contents")
+
+    check_md5(None, file_path, "98bf7d8c15784f0a3d63204441e1e2aa")
+    check_sha1(None, file_path, "4a756ca07e9487f482465a99e8286abc86ba4dc7")
+    check_sha256(None, file_path, "d1b2a59fbea7e20077af9f91b27e95e865061b270be03ff539ab3b73587882e8")
+
diff --git a/conans/test/unittests/tools/files/collect_lib_test.py b/conans/test/unittests/tools/files/collect_lib_test.py
new file mode 100644
index 00000000000..45e4504f189
--- /dev/null
+++ b/conans/test/unittests/tools/files/collect_lib_test.py
@@ -0,0 +1,72 @@
+import os
+
+from conan.tools.files import check_md5, check_sha1, check_sha256, collect_libs
+from conans.model.build_info import CppInfo
+from conans.test.utils.mocks import ConanFileMock
+from conans.test.utils.test_files import temp_folder
+from conans.tools import save
+
+
+def test_collect_libs():
+    conanfile = ConanFileMock()
+    conanfile.cpp_info = CppInfo(conanfile.name, "")
+    # Without package_folder
+    result = collect_libs(conanfile)
+    assert [] == result
+
+    # Default behavior
+    conanfile.folders.set_base_package(temp_folder())
+    mylib_path = os.path.join(conanfile.package_folder, "lib", "mylib.lib")
+    save(mylib_path, "")
+
+    result = collect_libs(conanfile)
+    assert ["mylib"] == result
+
+    # Custom folder
+    customlib_path = os.path.join(conanfile.package_folder, "custom_folder", "customlib.lib")
+    save(customlib_path, "")
+    result = collect_libs(conanfile, folder="custom_folder")
+    assert ["customlib"] == result
+
+    # Custom folder doesn't exist
+    result = collect_libs(conanfile, folder="fake_folder")
+    assert [] == result
+    assert "Lib folder doesn't exist, can't collect libraries:" in conanfile.output
+
+    # Use cpp_info.libdirs
+    conanfile.cpp_info.libdirs = ["lib", "custom_folder"]
+    result = collect_libs(conanfile)
+    assert ["customlib", "mylib"] == result
+
+    # Custom folder with multiple libdirs should only collect from custom folder
+    assert ["lib", "custom_folder"] == conanfile.cpp_info.libdirs
+    result = collect_libs(conanfile, folder="custom_folder")
+    assert ["customlib"] == result
+
+    # Warn same lib different folders
+    conanfile = ConanFileMock()
+    conanfile.folders.set_base_package(temp_folder())
+    conanfile.cpp_info = CppInfo(conanfile.name, "")
+    custom_mylib_path = os.path.join(conanfile.package_folder, "custom_folder", "mylib.lib")
+    lib_mylib_path = os.path.join(conanfile.package_folder, "lib", "mylib.lib")
+    save(custom_mylib_path, "")
+    save(lib_mylib_path, "")
+    conanfile.cpp_info.libdirs = ["lib", "custom_folder"]
+    result = collect_libs(conanfile)
+    assert ["mylib"] == result
+    assert "Library 'mylib' was either already found in a previous "\
+           "'conanfile.cpp_info.libdirs' folder or appears several times with a "\
+           "different file extension" in conanfile.output
+
+    # Warn lib folder does not exist with correct result
+    conanfile = ConanFileMock()
+    conanfile.folders.set_base_package(temp_folder())
+    conanfile.cpp_info = CppInfo(conanfile.name, "")
+    lib_mylib_path = os.path.join(conanfile.package_folder, "lib", "mylib.lib")
+    save(lib_mylib_path, "")
+    no_folder_path = os.path.join(conanfile.package_folder, "no_folder")
+    conanfile.cpp_info.libdirs = ["no_folder", "lib"]  # 'no_folder' does NOT exist
+    result = collect_libs(conanfile)
+    assert ["mylib"] == result
+    assert "WARN: Lib folder doesn't exist, can't collect libraries: %s" % no_folder_path \
+           in conanfile.output
diff --git a/conans/test/unittests/tools/files/test_file_read_and_write.py b/conans/test/unittests/tools/files/test_file_read_and_write.py
new file mode 100644
index 00000000000..1bf1edf37da
--- /dev/null
+++ b/conans/test/unittests/tools/files/test_file_read_and_write.py
@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+
+
+import os
+
+import pytest
+import six
+
+from conan.tools.files import replace_in_file, save, load
+from conans.test.utils.mocks import MockConanfile
+from conans.test.utils.test_files import temp_folder
+
+
+@pytest.mark.skipif(six.PY2, reason="only Py3")
+def test_save_and_load_encoding():
+    conanfile = MockConanfile({})
+    tmp = temp_folder()
+    file_path = os.path.join(tmp, "file.txt")
+
+    # By default utf-8 is used
+    save(conanfile, file_path, "你很重，伙計")
+    contents = load(conanfile, file_path)
+    assert isinstance(contents, str)
+    assert contents == "你很重，伙計"
+
+    # But you can save bytes directly with an encoding
+    save(conanfile, file_path, bytes("你很重，伙計", "utf-16"))
+    contents = load(conanfile, file_path, encoding="utf-16")
+    assert contents == "你很重，伙計"
+
+    # And you can specify different encoding
+    save(conanfile, file_path, "你很重，伙計", encoding="utf-16")
+    contents = load(conanfile, file_path, encoding="utf-16")
+    assert contents == "你很重，伙計"
+
+    save(conanfile, file_path, "regular contents")
+    contents = load(conanfile, file_path)
+    assert contents == "regular contents"
+
+
+@pytest.mark.skipif(six.PY2, reason="only Py3")
+def test_replace_in_file():
+    conanfile = MockConanfile({})
+    tmp = temp_folder()
+    file_path = os.path.join(tmp, "file.txt")
+
+    # By default utf-8 is used
+    save(conanfile, file_path, "你很重，伙計")
+    replace_in_file(conanfile, file_path, "重", "0")
+    contents = load(conanfile, file_path)
+    assert contents == "你很0，伙計"
+
+    # Replacing with other encodings is also possible
+    save(conanfile, file_path, "Ö¼", encoding="cp1252")
+    replace_in_file(conanfile, file_path, "¼", "0", encoding="cp1252")
+    contents = load(conanfile, file_path, encoding="cp1252")
+    assert contents == "Ö0"
+
+    save(conanfile, file_path, bytes("Ö¼", "ISO-8859-1"))
+    replace_in_file(conanfile, file_path, "¼", "0", encoding="ISO-8859-1")
+    contents = load(conanfile, file_path, encoding="ISO-8859-1")
+    assert contents == "Ö0"
+
+    # Replacing utf-16 is also possible but using "utf-16LE" (without BOM) to search and replace
+    # otherwise the "search" string is not found because it contains also a BOM (header)
+    save(conanfile, file_path, bytes("你很重，伙計", "utf-16"))
+    replace_in_file(conanfile, file_path, "重", "0", encoding="utf-16")
+    contents = load(conanfile, file_path, encoding="utf-16")
+    assert contents == "你很0，伙計"
diff --git a/conans/test/unittests/tools/files/test_zipping.py b/conans/test/unittests/tools/files/test_zipping.py
new file mode 100644
index 00000000000..d4f27f4f51b
--- /dev/null
+++ b/conans/test/unittests/tools/files/test_zipping.py
@@ -0,0 +1,31 @@
+import os
+import zipfile
+from os.path import basename
+
+import pytest
+
+from conan.tools.files import unzip
+from conans.test.utils.mocks import MockConanfile
+from conans.test.utils.test_files import temp_folder
+from conans.tools import save
+
+
+def test_impossible_to_import_untargz():
+    with pytest.raises(ImportError) as exc:
+        from conan.tools.files import untargz
+
+
+def test_unzip():
+    tmp_dir = temp_folder()
+    file_path = os.path.join(tmp_dir, "foo.txt")
+    save(os.path.join(tmp_dir, "foo.txt"), "bar")
+    zf = zipfile.ZipFile(os.path.join(tmp_dir, 'zipfile.zip'), mode='w')
+    zf.write(file_path, basename(file_path))
+    zf.close()
+
+    conanfile = MockConanfile({})
+
+    # Unzip and check permissions are kept
+    dest_dir = temp_folder()
+    unzip(conanfile, os.path.join(tmp_dir, 'zipfile.zip'), dest_dir)
+    assert os.path.exists(os.path.join(dest_dir, "foo.txt"))


Code review comments:
diff_hunk: @@ -1,5 +1,7 @@
 from conan.tools.files.files import load, save, mkdir, ftp_download, download, get, rename, \
-    load_toolchain_args, save_toolchain_args, chdir
+    load_toolchain_args, save_toolchain_args, chdir, unzip, replace_in_file, collect_libs, \
path: conan/tools/files/__init__.py
text: @user1:
I'd say lets keep ``load_toolchain_args`` and ``save_toolchain_args`` a implementation detail of the current integrations. Users can save any file they want and recover it easily in ``build()``, no need to standardize.

I am considering exploring the idea of moving to a json compatible with CMakePresets.json, and getting the information from there, from recent feedback it seems doable and it will be much better integrated with IDEs and other stuff

@author:
Sure, I just kept them because they were already there.

diff_hunk: @@ -0,0 +1,16 @@
+import os
+
+from conan.tools.files import check_md5, check_sha1, check_sha256
+from conans.test.utils.test_files import temp_folder
+from conans.tools import save
path: conans/test/unittests/tools/files/checksums_test.py
text: @user1:
Better not use ``from conans.tools import save`` for tests, as it will break when merged to ``develop2``. Better use ``conans.utils.files.save``

diff_hunk: @@ -1,26 +1,28 @@
 import configparser
 import errno
+import gzip
+import hashlib
 import os
 import platform
 import subprocess
+import sys
 from contextlib import contextmanager
+from fnmatch import fnmatch
 
 from conan.tools import CONAN_TOOLCHAIN_ARGS_FILE, CONAN_TOOLCHAIN_ARGS_SECTION
 from conans.client.downloaders.download import run_downloader
-from conans.client.tools.files import unzip, which
+from conans.client.tools.files import which
path: conan/tools/files/files.py
text: @user1:
which in Conan 2.0 is going to be just ``shutil.which()``. Do we still want a wrapper? (Maybe necessary for Py2 only?)

@author:
I assume, the new tools at "conan" will be python3 (because conan2 is python3) so I'll remove the import (I'll keep the implementation to not break) and use `shutil.which()` here.