Repository: conan-io/conan
Pull request #11908 (2022-08-18 11:25:43)

Read required_conan_version in text mode before loading the recipe
Changelog: Feature: Conan will try to read the `required_conan_version` from the recipes without loading the recipe (python interpreter) to be able to check it even if there are imports broken/missing for the user conan current installed version. So Conan can fail sooner and with a meaningful error if the specified required version is not satisfied.
Docs: omit

Close https://github.com/conan-io/conan/issues/11239


Patch:
diff --git a/conans/client/loader.py b/conans/client/loader.py
index d1d89130d..804d73b50 100644
--- a/conans/client/loader.py
+++ b/conans/client/loader.py
@@ -2,6 +2,7 @@ import fnmatch
 import imp
 import inspect
 import os
+import re
 import sys
 import types
 import uuid
@@ -436,13 +437,20 @@ def _parse_conanfile(conan_file_path):
         old_modules = list(sys.modules.keys())
         with chdir(current_dir):
             old_dont_write_bytecode = sys.dont_write_bytecode
-            sys.dont_write_bytecode = True
-            loaded = imp.load_source(module_id, conan_file_path)
-            sys.dont_write_bytecode = old_dont_write_bytecode
-
-        required_conan_version = getattr(loaded, "required_conan_version", None)
-        if required_conan_version:
-            validate_conan_version(required_conan_version)
+            try:
+                sys.dont_write_bytecode = True
+                # FIXME: imp is deprecated in favour of implib
+                loaded = imp.load_source(module_id, conan_file_path)
+                sys.dont_write_bytecode = old_dont_write_bytecode
+            except ImportError:
+                version_txt = _get_required_conan_version_without_loading(conan_file_path)
+                if version_txt:
+                    validate_conan_version(version_txt)
+                raise
+
+            required_conan_version = getattr(loaded, "required_conan_version", None)
+            if required_conan_version:
+                validate_conan_version(required_conan_version)
 
         # These lines are necessary, otherwise local conanfile imports with same name
         # collide, but no error, and overwrite other packages imports!!
@@ -475,3 +483,20 @@ def _parse_conanfile(conan_file_path):
         sys.path.pop(0)
 
     return loaded, module_id
+
+
+def _get_required_conan_version_without_loading(conan_file_path):
+    # First, try to detect the required_conan_version in "text" mode
+    # https://github.com/conan-io/conan/issues/11239
+    contents = load(conan_file_path)
+
+    txt_version = None
+
+    try:
+        found = re.search(r"required_conan_version\s*=\s*(.*)", contents)
+        if found:
+            txt_version = found.group(1).replace('"', "")
+    except:
+        pass
+
+    return txt_version
diff --git a/conans/model/manifest.py b/conans/model/manifest.py
index d323df770..1b260beaf 100644
--- a/conans/model/manifest.py
+++ b/conans/model/manifest.py
@@ -25,7 +25,8 @@ def gather_files(folder):
     symlinks = {}
     keep_python = get_env("CONAN_KEEP_PYTHON_FILES", False)
     for root, dirs, files in walk(folder):
-        dirs[:] = [d for d in dirs if d != "__pycache__"]  # Avoid recursing pycache
+        if not keep_python:
+            dirs[:] = [d for d in dirs if d != "__pycache__"]  # Avoid recursing pycache
         for d in dirs:
             abs_path = os.path.join(root, d)
             if os.path.islink(abs_path):
diff --git a/conans/model/requires.py b/conans/model/requires.py
index 7f577236f..e36cfabe9 100644
--- a/conans/model/requires.py
+++ b/conans/model/requires.py
@@ -180,7 +180,7 @@ class Requirements(OrderedDict):
             new_reqs[name] = req
         return new_reqs
 
-    def __call__(self, reference, private=False, override=False):
+    def __call__(self, reference, private=False, override=False, **kwargs):
         self.add(reference, private, override)
 
     def __repr__(self):


Test patch:
diff --git a/conans/test/integration/conan_v2/test_requires_traits.py b/conans/test/integration/conan_v2/test_requires_traits.py
new file mode 100644
index 000000000..17414a3ea
--- /dev/null
+++ b/conans/test/integration/conan_v2/test_requires_traits.py
@@ -0,0 +1,24 @@
+import textwrap
+
+from conans.test.assets.genconanfile import GenConanfile
+from conans.test.utils.tools import TestClient
+
+
+def test_requires_traits():
+    """ traits can be passed to self.requires(), but:
+    - They have absolutely no effect
+    - They are not checked to be correct or match expected 2.0 traits
+    """
+    c = TestClient()
+    conanfile = textwrap.dedent("""
+        from conan import ConanFile
+        class Pkg(ConanFile):
+            def requirements(self):
+                self.requires("dep/1.0", transitive_headers=True)
+        """)
+    c.save({"dep/conanfile.py": GenConanfile("dep", "1.0"),
+            "consumer/conanfile.py": conanfile})
+    c.run("create dep")
+    # This should not crash
+    c.run("install consumer")
+    assert "dep/1.0" in c.out
diff --git a/conans/test/integration/conanfile/required_conan_version_test.py b/conans/test/integration/conanfile/required_conan_version_test.py
index 7f0c11f25..f0261c11b 100644
--- a/conans/test/integration/conanfile/required_conan_version_test.py
+++ b/conans/test/integration/conanfile/required_conan_version_test.py
@@ -35,3 +35,45 @@ class RequiredConanVersionTest(unittest.TestCase):
         client.run("install pkg/1.0@", assert_error=True)
         self.assertIn("Current Conan version (%s) does not satisfy the defined one (>=100.0)"
                       % __version__, client.out)
+
+    def test_required_conan_version_with_loading_issues(self):
+        # https://github.com/conan-io/conan/issues/11239
+        client = TestClient()
+        conanfile = textwrap.dedent("""
+                    from conan import missing_import
+
+                    required_conan_version = ">=100.0"
+
+                    class Lib(ConanFile):
+                        pass
+                    """)
+        client.save({"conanfile.py": conanfile})
+        client.run("export . pkg/1.0@", assert_error=True)
+        self.assertIn("Current Conan version (%s) does not satisfy the defined one (>=100.0)"
+                      % __version__, client.out)
+
+        # Assigning required_conan_version without spaces
+        conanfile = textwrap.dedent("""
+                            from conan import missing_import
+
+                            required_conan_version=">=100.0"
+
+                            class Lib(ConanFile):
+                                pass
+                            """)
+        client.save({"conanfile.py": conanfile})
+        client.run("export . pkg/1.0@", assert_error=True)
+        self.assertIn("Current Conan version (%s) does not satisfy the defined one (>=100.0)"
+                      % __version__, client.out)
+
+        # If the range is correct, everything works, of course
+        conanfile = textwrap.dedent("""
+                            from conan import ConanFile
+
+                            required_conan_version = ">1.0.0"
+
+                            class Lib(ConanFile):
+                                pass
+                            """)
+        client.save({"conanfile.py": conanfile})
+        client.run("export . pkg/1.0@")
diff --git a/conans/test/unittests/model/manifest_test.py b/conans/test/unittests/model/manifest_test.py
index 0945a0650..4a88b65b1 100644
--- a/conans/test/unittests/model/manifest_test.py
+++ b/conans/test/unittests/model/manifest_test.py
@@ -1,6 +1,6 @@
 import os
 
-
+from conans.client.tools import environment_append
 from conans.model.manifest import FileTreeManifest
 from conans.test.utils.test_files import temp_folder
 from conans.util.files import load, md5, save
@@ -49,3 +49,17 @@ class TestManifest:
         save(os.path.join(tmp_dir, "conanmanifest.txt"), "1478122267\nsome: file.py: 123\n")
         read_manifest = FileTreeManifest.load(tmp_dir)
         assert read_manifest.file_sums["some: file.py"] == "123"
+
+
+def test_pycache_included():
+    tmp_dir = temp_folder()
+    files = {"__pycache__/damn.py": "binarythings",
+             "pythonfile.pyc": "binarythings3"}
+    for filename, content in files.items():
+        save(os.path.join(tmp_dir, filename), content)
+
+    with environment_append({"CONAN_KEEP_PYTHON_FILES": "1"}):
+        manifest = FileTreeManifest.create(tmp_dir)
+    manifest = repr(manifest)
+    assert "pythonfile.pyc" in manifest
+    assert "__pycache__/damn.py" in manifest