Repository: conan-io/conan
Pull request #15906 (2024-03-20 15:27:27)

Add copytree_compat method
Changelog: Fix: Add `copytree_compat` method for compatibility with Python>=3.12 after distutils removal.
Docs: omit




Patch:
diff --git a/conans/__init__.py b/conans/__init__.py
index 2d76b5ed9..3c3e43ea5 100644
--- a/conans/__init__.py
+++ b/conans/__init__.py
@@ -2,4 +2,4 @@ CHECKSUM_DEPLOY = "checksum_deploy"  # Only when v2
 REVISIONS = "revisions"  # Only when enabled in config, not by default look at server_launcher.py
 OAUTH_TOKEN = "oauth_token"
 
-__version__ = '2.2.0'
+__version__ = '2.2.1'
diff --git a/conans/client/rest_client_local_recipe_index.py b/conans/client/rest_client_local_recipe_index.py
index a8a2ecb0c..5aa92690f 100644
--- a/conans/client/rest_client_local_recipe_index.py
+++ b/conans/client/rest_client_local_recipe_index.py
@@ -1,7 +1,6 @@
 import os
 import sys
 import textwrap
-from distutils.dir_util import copy_tree
 from fnmatch import fnmatch
 from io import StringIO
 
@@ -14,7 +13,7 @@ from conans.client.cmd.export import cmd_export
 from conans.errors import ConanException, PackageNotFoundException, RecipeNotFoundException
 from conans.model.conf import ConfDefinition
 from conans.model.recipe_ref import RecipeReference
-from conans.util.files import load, save, rmdir
+from conans.util.files import load, save, rmdir, copytree_compat
 
 
 def add_local_recipes_index_remote(conan_api, remote):
@@ -160,7 +159,7 @@ class RestApiClientLocalRecipesIndex:
     def _copy_files(source_folder, dest_folder):
         if not os.path.exists(source_folder):
             return {}
-        copy_tree(source_folder, dest_folder)
+        copytree_compat(source_folder, dest_folder)
         ret = {}
         for root, _, _files in os.walk(dest_folder):
             for _f in _files:
diff --git a/conans/util/files.py b/conans/util/files.py
index b35bb7244..9003077ed 100644
--- a/conans/util/files.py
+++ b/conans/util/files.py
@@ -5,6 +5,7 @@ import os
 import platform
 import shutil
 import stat
+import sys
 import tarfile
 import time
 
@@ -373,3 +374,12 @@ def human_size(size_bytes):
         formatted_size = str(round(num, ndigits=the_precision))
 
     return "%s%s" % (formatted_size, the_suffix)
+
+
+# FIXME: completely remove disutils once we don't support <3.8 any more
+def copytree_compat(source_folder, dest_folder):
+    if sys.version_info >= (3, 8):
+        shutil.copytree(source_folder, dest_folder, dirs_exist_ok=True)
+    else:
+        from distutils.dir_util import copy_tree
+        copy_tree(source_folder, dest_folder)


Test patch:
diff --git a/.ci/jenkins/testsv2.jenkins b/.ci/jenkins/testsv2.jenkins
index d95f3082a..3a304e3b4 100644
--- a/.ci/jenkins/testsv2.jenkins
+++ b/.ci/jenkins/testsv2.jenkins
@@ -17,6 +17,9 @@ String getTempFolder(String nodeName) {
 List<Map> getConfigurations(String moduleName, String branchName, String jobName) {
     // TODO: handle revision cases
     def configs = []
+    if (moduleName=="conans/test/unittests" || moduleName=="conans/test/integration") {
+        configs.add([node: "Linux", pyvers: ["PY312"]])
+    }
     if (branchName =~ /(^PR-.*)/) {
         configs.add([node: "Linux", pyvers: ["PY36"]])
         configs.add([node: "Windows", pyvers: ["PY36"]])
@@ -89,6 +92,7 @@ private Closure runTests(String nodeName, String pythonVersion, String module) {
                 if (nodeName=="Linux") {
                     try {
                         def dockerImage = getDockerImage(module)
+                        docker.image(dockerImage).pull()
                         docker.image(dockerImage).inside("--entrypoint=") {
 
                             // we only test scons in Linux
diff --git a/conans/test/unittests/util/files/test_copy_compat.py b/conans/test/unittests/util/files/test_copy_compat.py
new file mode 100644
index 000000000..084facd86
--- /dev/null
+++ b/conans/test/unittests/util/files/test_copy_compat.py
@@ -0,0 +1,20 @@
+import pytest
+
+from conans.util.files import copytree_compat
+
+
+@pytest.fixture
+def source_dest_folders(tmp_path):
+    source_folder = tmp_path / "source"
+    dest_folder = tmp_path / "dest"
+    source_folder.mkdir()
+    dest_folder.mkdir()
+    test_file = source_folder / "test_file.txt"
+    test_file.write_text("Test content")
+    return source_folder, dest_folder
+
+
+def test_copytree_compat(source_dest_folders):
+    source_folder, dest_folder = source_dest_folders
+    copytree_compat(str(source_folder), str(dest_folder))
+    assert (dest_folder / "test_file.txt").exists()