Repository: conan-io/conan
Pull request #17116 (2024-10-04 16:35:12)

Empty version range results in empty condition set
Changelog: (Bugfix): Empty version range results in empty condition set
Docs: https://github.com/conan-io/docs/pull/XXXX

This PR fixes a bug that occurs when an empty version range is supplied.
Previously an empty version range would result in a `_ConditionSet`, with an empty list of `conditions`.
This can lead to issues, when comparing two version ranges created from an empty version string. Meaning `VersionRange('').intersection(VersionRange('')) == None`, which is wrong.
This PR fixes this behavior and makes sure this is also properly checked for in the tests.

- [x] I've read the [Contributing guide](https://github.com/conan-io/conan/blob/develop2/.github/CONTRIBUTING.md).
- [x] I've followed the PEP8 style guides for Python code.


CLAassistant: [![CLA assistant check](https://cla-assistant.io/pull/badge/not_signed)](https://cla-assistant.io/conan-io/conan?pullRequest=17116) <br/>Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our [Contributor License Agreement](https://cla-assistant.io/conan-io/conan?pullRequest=17116) before we can accept your contribution.<br/><sub>You have signed the CLA already but the status is still pending? Let us [recheck](https://cla-assistant.io/check/conan-io/conan?pullRequest=17116) it.</sub>

Patch:
diff --git a/conan/cli/cli.py b/conan/cli/cli.py
index dfacd78b2..68769f6e5 100644
--- a/conan/cli/cli.py
+++ b/conan/cli/cli.py
@@ -18,7 +18,6 @@ from conan.cli.exit_codes import SUCCESS, ERROR_MIGRATION, ERROR_GENERAL, USER_C
 from conan.internal.cache.home_paths import HomePaths
 from conans import __version__ as client_version
 from conan.errors import ConanException, ConanInvalidConfiguration, ConanMigrationError
-from conans.util.files import exception_message_safe
 
 _CONAN_INTERNAL_CUSTOM_COMMANDS_PATH = "_CONAN_INTERNAL_CUSTOM_COMMANDS_PATH"
 
@@ -231,8 +230,7 @@ class Cli:
 
         assert isinstance(exception, Exception)
         output.error(traceback.format_exc(), error_type="exception")
-        msg = exception_message_safe(exception)
-        output.error(msg, error_type="exception")
+        output.error(str(exception), error_type="exception")
         return ERROR_UNEXPECTED
 
 
diff --git a/conans/client/graph/graph_binaries.py b/conans/client/graph/graph_binaries.py
index e3c6483ae..3fae7fc3e 100644
--- a/conans/client/graph/graph_binaries.py
+++ b/conans/client/graph/graph_binaries.py
@@ -13,7 +13,7 @@ from conans.client.graph.graph import (BINARY_BUILD, BINARY_CACHE, BINARY_DOWNLO
                                        BINARY_INVALID, BINARY_EDITABLE_BUILD, RECIPE_PLATFORM,
                                        BINARY_PLATFORM)
 from conans.client.graph.proxy import should_update_reference
-from conans.errors import NoRemoteAvailable, NotFoundException, \
+from conans.errors import NotFoundException, \
     PackageNotFoundException, conanfile_exception_formatter, ConanConnectionError, ConanException
 from conans.model.info import RequirementInfo, RequirementsInfo
 from conans.model.package_ref import PkgReference
@@ -348,8 +348,6 @@ class GraphBinariesAnalyzer:
                 self._get_package_from_remotes(node, remotes, update)
             except NotFoundException:
                 output.warning("Can't update, no package in remote")
-            except NoRemoteAvailable:
-                output.warning("Can't update, there are no remotes configured or enabled")
             else:
                 cache_time = cache_latest_prev.timestamp
                 # TODO: cache 2.0 should we update the date if the prev is the same?
diff --git a/conans/client/loader.py b/conans/client/loader.py
index efaed1a64..c5760f68c 100644
--- a/conans/client/loader.py
+++ b/conans/client/loader.py
@@ -1,3 +1,4 @@
+import traceback
 from importlib import invalidate_caches, util as imp_util
 import inspect
 import os
@@ -373,7 +374,6 @@ def _load_python_file(conan_file_path):
     except ConanException:
         raise
     except Exception:
-        import traceback
         trace = traceback.format_exc().split('\n')
         raise ConanException("Unable to load conanfile in %s\n%s" % (conan_file_path,
                                                                      '\n'.join(trace[3:])))
diff --git a/conans/errors.py b/conans/errors.py
index a1609f386..294658603 100644
--- a/conans/errors.py
+++ b/conans/errors.py
@@ -6,8 +6,8 @@
     matching exception.
 
     see return_plugin.py
-
 """
+import traceback
 from contextlib import contextmanager
 
 
@@ -34,37 +34,22 @@ def conanfile_remove_attr(conanfile, names, method):
 
 
 @contextmanager
-def conanfile_exception_formatter(conanfile_name, func_name):
+def conanfile_exception_formatter(conanfile, funcname):
     """
     Decorator to throw an exception formatted with the line of the conanfile where the error ocurrs.
     """
-
-    def _raise_conanfile_exc(e):
-        from conan.api.output import LEVEL_DEBUG, ConanOutput
-        if ConanOutput.level_allowed(LEVEL_DEBUG):
-            import traceback
-            raise ConanException(traceback.format_exc())
-        m = scoped_traceback(f"{conanfile_name}: Error in {func_name}() method", e,
-                             scope="conanfile.py")
-        raise ConanException(m)
-
     try:
         yield
-    # TODO: Move ConanInvalidConfiguration from here?
     except ConanInvalidConfiguration as exc:
-        msg = "{}: Invalid configuration: {}".format(str(conanfile_name), exc)
+        # TODO: This is never called from `conanfile.validate()` but could be called from others
+        msg = "{}: Invalid configuration: {}".format(str(conanfile), exc)
         raise ConanInvalidConfiguration(msg)
-    except AttributeError as exc:
-        list_methods = [m for m in dir(list) if not m.startswith('__')]
-        if "NoneType" in str(exc) and func_name in ['layout', 'package_info'] and \
-            any(method in str(exc) for method in list_methods):
-            raise ConanException("{}: {}. No default values are set for components. You are probably "
-                                 "trying to manipulate a component attribute in the '{}' method "
-                                 "without defining it previously".format(str(conanfile_name), exc, func_name))
-        else:
-            _raise_conanfile_exc(exc)
     except Exception as exc:
-        _raise_conanfile_exc(exc)
+        m = scoped_traceback(f"{conanfile}: Error in {funcname}() method", exc, scope="conanfile.py")
+        from conan.api.output import LEVEL_DEBUG, ConanOutput
+        if ConanOutput.level_allowed(LEVEL_DEBUG):
+            m = traceback.format_exc() + "\n" + m
+        raise ConanException(m)
 
 
 def scoped_traceback(header_msg, exception, scope):
@@ -74,7 +59,6 @@ def scoped_traceback(header_msg, exception, scope):
     the traces.
     """
     import sys
-    import traceback
     content_lines = []
     try:
         scope_reached = False
@@ -105,26 +89,16 @@ def scoped_traceback(header_msg, exception, scope):
 
 
 class ConanException(Exception):
-    """
-         Generic conans exception
-    """
-    def __init__(self, *args, **kwargs):
-        self.info = None
-        self.remote = kwargs.pop("remote", None)
-        super(ConanException, self).__init__(*args, **kwargs)
-
-    def remote_message(self):
-        if self.remote:
-            return " [Remote: {}]".format(self.remote.name)
-        return ""
+    """ Generic conan exception """
+    def __init__(self, msg=None, remote=None):
+        self.remote = remote
+        super().__init__(msg)
 
     def __str__(self):
-        from conans.util.files import exception_message_safe
-        msg = super(ConanException, self).__str__()
+        msg = super().__str__()
         if self.remote:
-            return "{}.{}".format(exception_message_safe(msg), self.remote_message())
-
-        return exception_message_safe(msg)
+            return f"{msg}. [Remote: {self.remote.name}]"
+        return msg
 
 
 class ConanReferenceDoesNotExistInDB(ConanException):
@@ -137,12 +111,6 @@ class ConanReferenceAlreadyExistsInDB(ConanException):
     pass
 
 
-class NoRemoteAvailable(ConanException):
-    """ No default remote configured or the specified remote do not exists
-    """
-    pass
-
-
 class ConanConnectionError(ConanException):
     pass
 
@@ -191,37 +159,19 @@ class NotFoundException(ConanException):  # 404
     """
         404 error
     """
-
-    def __init__(self, *args, **kwargs):
-        self.remote = kwargs.pop("remote", None)
-        super(NotFoundException, self).__init__(*args, **kwargs)
+    pass
 
 
 class RecipeNotFoundException(NotFoundException):
 
-    def __init__(self, ref, remote=None):
-        from conans.model.recipe_ref import RecipeReference
-        assert isinstance(ref, RecipeReference), "RecipeNotFoundException requires a RecipeReference"
-        self.ref = ref
-        super(RecipeNotFoundException, self).__init__(remote=remote)
-
-    def __str__(self):
-        tmp = repr(self.ref)
-        return "Recipe not found: '{}'".format(tmp, self.remote_message())
+    def __init__(self, ref):
+        super().__init__(f"Recipe not found: '{ref.repr_notime()}'")
 
 
 class PackageNotFoundException(NotFoundException):
 
-    def __init__(self, pref, remote=None):
-        from conans.model.package_ref import PkgReference
-        assert isinstance(pref, PkgReference), "PackageNotFoundException requires a PkgReference"
-        self.pref = pref
-
-        super(PackageNotFoundException, self).__init__(remote=remote)
-
-    def __str__(self):
-        return "Binary package not found: '{}'{}".format(self.pref.repr_notime(),
-                                                         self.remote_message())
+    def __init__(self, pref):
+        super().__init__(f"Binary package not found: '{pref.repr_notime()}'")
 
 
 EXCEPTION_CODE_MAPPING = {InternalErrorException: 500,
diff --git a/conans/model/version_range.py b/conans/model/version_range.py
index 106682f25..838d8e5ae 100644
--- a/conans/model/version_range.py
+++ b/conans/model/version_range.py
@@ -66,6 +66,10 @@ class _ConditionSet:
 
     def __init__(self, expression, prerelease):
         expressions = expression.split()
+        if not expressions:
+            # Guarantee at least one expression
+            expressions = [""]
+
         self.prerelease = prerelease
         self.conditions = []
         for e in expressions:
diff --git a/conans/server/service/authorize.py b/conans/server/service/authorize.py
index 5c3204f01..7a40300b1 100644
--- a/conans/server/service/authorize.py
+++ b/conans/server/service/authorize.py
@@ -207,7 +207,7 @@ class BasicAuthorizer(Authorizer):
                     else:
                         raise ForbiddenException("Permission denied")
                 else:
-                    raise AuthenticationException()
+                    raise AuthenticationException("Authentication error")
 
         return False
 
diff --git a/conans/util/files.py b/conans/util/files.py
index 9a42d0bb9..a47d95301 100644
--- a/conans/util/files.py
+++ b/conans/util/files.py
@@ -301,13 +301,6 @@ def tar_extract(fileobj, destination_dir):
     the_tar.close()
 
 
-def exception_message_safe(exc):
-    try:
-        return str(exc)
-    except Exception:
-        return repr(exc)
-
-
 def merge_directories(src, dst):
     from conan.tools.files import copy
     copy(None, pattern="*", src=src, dst=dst)


Test patch:
diff --git a/test/unittests/model/version/test_version_range.py b/test/unittests/model/version/test_version_range.py
index 5b0101245..629363160 100644
--- a/test/unittests/model/version/test_version_range.py
+++ b/test/unittests/model/version/test_version_range.py
@@ -33,6 +33,7 @@ values = [
     # pre-releases
     ['',                             [[[">=", "0.0.0-"]]],    ["1.0"],                ["1.0-pre.1"]],
     ['*, include_prerelease=True',   [[[">=", "0.0.0-"]]],    ["1.0", "1.0-pre.1", "0.0.0", "0.0.0-pre.1"],   []],
+    [', include_prerelease=True',   [[[">=", "0.0.0-"]]],    ["1.0", "1.0-pre.1", "0.0.0", "0.0.0-pre.1"],   []],
     ['>1- <2.0',                     [[['>', '1-'], ['<', '2.0-']]],
                                      ["1.0", "1.1", "1.9"],   ["1-pre.1", "1.5.1-pre1", "2.1-pre1"]],
     ['>1- <2.0 || ^3.2 ',  [[['>', '1-'], ['<', '2.0-']], [['>=', '3.2-'], ['<', '4.0-']]],
@@ -53,14 +54,19 @@ values = [
     ['>=2.0-pre.0, include_prerelease', [[['>=', '2.0-pre.0']]], ["2.1", "2.0-pre.1"], ["1.5"]],
     # Build metadata
     ['>=1.0.0+2', [[['>=', '1.0.0+2']]], ["1.0.0+2", "1.0.0+3"], ["1.0.0+1"]],
-    ['>=2.2.0+build.en.305 <2.2.0+build.en.306', [], ["2.2.0+build.en.305", "2.2.0+build.en.305.1", "2.2.0+build.en.305.2"], ["2.2.0+build.en.306"]]
+    ['>=2.2.0+build.en.305 <2.2.0+build.en.306', [[['>=', '2.2.0+build.en.305'], ['<', '2.2.0+build.en.306']]],
+                                                 ["2.2.0+build.en.305", "2.2.0+build.en.305.1", "2.2.0+build.en.305.2"], ["2.2.0+build.en.306"]]
 ]
 
 
 @pytest.mark.parametrize("version_range, conditions, versions_in, versions_out", values)
 def test_range(version_range, conditions, versions_in, versions_out):
     r = VersionRange(version_range)
+    assert len(r.condition_sets) == len(conditions), \
+        f"Expected {r} to have {len(conditions)} condition sets, but got {len(r.condition_sets)}"
     for condition_set, expected_condition_set in zip(r.condition_sets, conditions):
+        assert len(condition_set.conditions) == len(expected_condition_set), \
+            f"Expected {r} to have {len(expected_condition_set)} conditions, but got {len(condition_set.conditions)}"
         for condition, expected_condition in zip(condition_set.conditions, expected_condition_set):
             assert condition.operator == expected_condition[0], f"Expected {r} condition operator to be {expected_condition[0]}, but got {condition.operator}"
             assert condition.version == expected_condition[1], f"Expected {r} condition version to be {expected_condition[1]}, but got {condition.version}"
@@ -81,6 +87,10 @@ def test_range(version_range, conditions, versions_in, versions_out):
     ['*, include_prerelease', False, ["1.5.1"], ["1.5.1-pre1", "2.1-pre1"]],
     ['*, include_prerelease', None, ["1.5.1", "1.5.1-pre1", "2.1-pre1"], []],
 
+    [', include_prerelease', True, ["1.5.1", "1.5.1-pre1", "2.1-pre1"], []],
+    [', include_prerelease', False, ["1.5.1"], ["1.5.1-pre1", "2.1-pre1"]],
+    [', include_prerelease', None, ["1.5.1", "1.5.1-pre1", "2.1-pre1"], []],
+
     ['>1 <2.0', True, ["1.5.1", "1.5.1-pre1"], ["2.1-pre1"]],
     ['>1 <2.0', False, ["1.5.1"], ["1.5.1-pre1", "2.1-pre1"]],
     ['>1 <2.0', None, ["1.5.1"], ["1.5.1-pre1", "2.1-pre1"]],
diff --git a/test/unittests/model/version/test_version_range_intersection.py b/test/unittests/model/version/test_version_range_intersection.py
index 0d32da715..1ad131974 100644
--- a/test/unittests/model/version/test_version_range_intersection.py
+++ b/test/unittests/model/version/test_version_range_intersection.py
@@ -3,18 +3,25 @@ import pytest
 from conans.model.version_range import VersionRange
 
 values = [
+    # empty / any ranges
+    ['', "", ">=0.0.0"],
+    ['*', "*", ">=0.0.0"],
     # single lower limits bounds
     ['>1.0', ">1.0", ">1.0"],
     ['>=1.0', ">1.0", ">1.0"],
     ['>1.0', ">1.1", ">1.1"],
     ['>1.0', ">=1.1", ">=1.1"],
     ['>=1.0', ">=1.1", ">=1.1"],
+    ['', ">1.0", ">1.0"],
+    ['>1.0', "", ">1.0"],
     # single upper limits bounds
     ['<2.0', "<2.0", "<2.0"],
     ['<=1.0', "<1.0", "<1.0"],
     ['<2.0', "<2.1", "<2.0"],
     ['<2.0', "<=1.1", "<=1.1"],
     ['<=1.0', "<=1.1", "<=1.0"],
+    ['', "<2.0", ">=0.0.0 <2.0"],
+    ['<2.0', "", ">=0.0.0 <2.0"],
     # One lower limit, one upper
     ['>=1.0', "<2.0", ">=1.0 <2.0"],
     ['>=1', '<=1', ">=1 <=1"],
@@ -83,6 +90,9 @@ def test_range_intersection_incompatible(range1, range2):
 
 
 prerelease_values = [
+    [", include_prerelease",
+     ">=1.0-pre.1 <1.0-pre.99, include_prerelease",
+     ">=1.0-pre.1 <1.0-pre.99, include_prerelease"],
     [">=1.0-pre.1 <1.0-pre.99, include_prerelease",
      ">=1.0-pre.1 <1.0-pre.99, include_prerelease",
      ">=1.0-pre.1 <1.0-pre.99, include_prerelease"],