Repository: conan-io/conan
Pull request #17603 (2025-01-21 16:14:17)

serialize original required ref
Changelog: Feature: Serialize in ``--format=json`` graph output the original requirements version range, not only the resolved one.
Docs: Omit


Close https://github.com/conan-io/conan/issues/17592


Patch:
diff --git a/conan/api/conan_api.py b/conan/api/conan_api.py
index 091af879f..7b6fd14fe 100644
--- a/conan/api/conan_api.py
+++ b/conan/api/conan_api.py
@@ -6,7 +6,6 @@ from conan.api.subapi.command import CommandAPI
 from conan.api.subapi.local import LocalAPI
 from conan.api.subapi.lockfile import LockfileAPI
 from conan.api.subapi.workspace import WorkspaceAPI
-from conan import conan_version
 from conan.api.subapi.config import ConfigAPI
 from conan.api.subapi.download import DownloadAPI
 from conan.api.subapi.export import ExportAPI
@@ -19,9 +18,9 @@ from conan.api.subapi.remotes import RemotesAPI
 from conan.api.subapi.remove import RemoveAPI
 from conan.api.subapi.search import SearchAPI
 from conan.api.subapi.upload import UploadAPI
-from conans.client.migrations import ClientMigrator
 from conan.errors import ConanException
 from conan.internal.paths import get_conan_user_home
+from conans.client.migrations import ClientMigrator
 from conan.internal.model.version_range import validate_conan_version
 
 
@@ -36,12 +35,11 @@ class ConanAPI:
         self.workspace = WorkspaceAPI(self)
         self.cache_folder = self.workspace.home_folder() or cache_folder or get_conan_user_home()
         self.home_folder = self.cache_folder  # Lets call it home, deprecate "cache"
+        self.migrate()
 
-        # Migration system
-        migrator = ClientMigrator(self.cache_folder, conan_version)
-        migrator.migrate()
-
+        # This API is depended upon by the subsequent ones, it should be initialized first
         self.config = ConfigAPI(self)
+
         self.remotes = RemotesAPI(self)
         self.command = CommandAPI(self)
         # Search recipes by wildcard and packages filtering by configuration
@@ -60,6 +58,24 @@ class ConanAPI:
         self.lockfile = LockfileAPI(self)
         self.local = LocalAPI(self)
 
-        required_range_new = self.config.global_conf.get("core:required_conan_version")
-        if required_range_new:
-            validate_conan_version(required_range_new)
+        _check_conan_version(self)
+
+    def reinit(self):
+        self.config.reinit()
+        self.remotes.reinit()
+        self.local.reinit()
+
+        _check_conan_version(self)
+
+    def migrate(self):
+        # Migration system
+        # TODO: A prettier refactoring of migrators would be nice
+        from conan import conan_version
+        migrator = ClientMigrator(self.cache_folder, conan_version)
+        migrator.migrate()
+
+
+def _check_conan_version(conan_api):
+    required_range_new = conan_api.config.global_conf.get("core:required_conan_version")
+    if required_range_new:
+        validate_conan_version(required_range_new)
diff --git a/conan/api/subapi/config.py b/conan/api/subapi/config.py
index 6070802e3..0267766a1 100644
--- a/conan/api/subapi/config.py
+++ b/conan/api/subapi/config.py
@@ -16,11 +16,11 @@ from conans.client.graph.graph import CONTEXT_HOST, RECIPE_VIRTUAL, Node
 from conans.client.graph.graph_builder import DepsGraphBuilder
 from conans.client.graph.profile_node_definer import consumer_definer
 from conan.errors import ConanException
-from conan.internal.model.conf import ConfDefinition, BUILT_IN_CONFS
+from conan.internal.model.conf import ConfDefinition, BUILT_IN_CONFS, CORE_CONF_PATTERN
 from conan.internal.model.pkg_type import PackageType
 from conan.api.model import RecipeReference
 from conan.internal.model.settings import Settings
-from conans.util.files import load, save
+from conans.util.files import load, save, rmdir, remove
 
 
 class ConfigAPI:
@@ -28,6 +28,7 @@ class ConfigAPI:
     def __init__(self, conan_api):
         self.conan_api = conan_api
         self._new_config = None
+        self._cli_core_confs = None
 
     def home(self):
         return self.conan_api.cache_folder
@@ -40,6 +41,7 @@ class ConfigAPI:
         requester = self.conan_api.remotes.requester
         configuration_install(cache_folder, requester, path_or_url, verify_ssl, config_type=config_type, args=args,
                               source_folder=source_folder, target_folder=target_folder)
+        self.conan_api.reinit()
 
     def install_pkg(self, ref, lockfile=None, force=False, remotes=None, profile=None):
         ConanOutput().warning("The 'conan config install-pkg' is experimental",
@@ -100,6 +102,7 @@ class ConfigAPI:
         config_versions = {ref.split("/", 1)[0]: ref for ref in config_versions}
         config_versions[pkg.pref.ref.name] = pkg.pref.repr_notime()
         save(config_version_file, json.dumps({"config_version": list(config_versions.values())}))
+        self.conan_api.reinit()
         return pkg.pref
 
     def get(self, name, default=None, check_type=None):
@@ -114,11 +117,19 @@ class ConfigAPI:
         configuration defined with the new syntax as in profiles, this config will be composed
         to the profile ones and passed to the conanfiles.conf, which can be passed to collaborators
         """
+        # Lazy loading
         if self._new_config is None:
-            cache_folder = self.conan_api.cache_folder
-            self._new_config = self.load_config(cache_folder)
+            self._new_config = ConfDefinition()
+            self._populate_global_conf()
         return self._new_config
 
+    def _populate_global_conf(self):
+        cache_folder = self.conan_api.cache_folder
+        new_config = self.load_config(cache_folder)
+        self._new_config.update_conf_definition(new_config)
+        if self._cli_core_confs is not None:
+            self._new_config.update_conf_definition(self._cli_core_confs)
+
     @staticmethod
     def load_config(home_folder):
         # Do not document yet, keep it private
@@ -191,3 +202,35 @@ class ConfigAPI:
             appending_recursive_dict_update(settings, settings_user)
 
         return Settings(settings)
+
+    def clean(self):
+        contents = os.listdir(self.home())
+        packages_folder = self.global_conf.get("core.cache:storage_path") or os.path.join(self.home(), "p")
+        for content in contents:
+            content_path = os.path.join(self.home(), content)
+            if content_path == packages_folder or content == "version.txt":
+                continue
+            ConanOutput().debug(f"Removing {content_path}")
+            if os.path.isdir(content_path):
+                rmdir(content_path)
+            else:
+                remove(content_path)
+        self.conan_api.reinit()
+        # CHECK: This also generates a remotes.json that is not there after a conan profile show?
+        self.conan_api.migrate()
+
+    def set_core_confs(self, core_confs):
+        confs = ConfDefinition()
+        for c in core_confs:
+            if not CORE_CONF_PATTERN.match(c):
+                raise ConanException(f"Only core. values are allowed in --core-conf. Got {c}")
+        confs.loads("\n".join(core_confs))
+        confs.validate()
+        self._cli_core_confs = confs
+        # Last but not least, apply the new configuration
+        self.conan_api.reinit()
+
+    def reinit(self):
+        if self._new_config is not None:
+            self._new_config.clear()
+            self._populate_global_conf()
diff --git a/conan/api/subapi/local.py b/conan/api/subapi/local.py
index 9a7ae9d9f..71dc48677 100644
--- a/conan/api/subapi/local.py
+++ b/conan/api/subapi/local.py
@@ -118,3 +118,6 @@ class LocalAPI:
         conanfile = app.loader.load_named(conanfile_path, name=name, version=version, user=user,
                                           channel=channel, remotes=remotes, graph_lock=lockfile)
         return conanfile
+
+    def reinit(self):
+        self.editable_packages = EditablePackages(self._conan_api.home_folder)
diff --git a/conan/api/subapi/remotes.py b/conan/api/subapi/remotes.py
index 9e6ae90b1..ebc17c4d9 100644
--- a/conan/api/subapi/remotes.py
+++ b/conan/api/subapi/remotes.py
@@ -35,6 +35,9 @@ class RemotesAPI:
         # Wraps an http_requester to inject proxies, certs, etc
         self._requester = ConanRequester(self.conan_api.config.global_conf, self.conan_api.cache_folder)
 
+    def reinit(self):
+        self._requester = ConanRequester(self.conan_api.config.global_conf, self.conan_api.cache_folder)
+
     def list(self, pattern=None, only_enabled=True):
         """
         Obtain a list of ``Remote`` objects matching the pattern.
diff --git a/conan/cli/command.py b/conan/cli/command.py
index 5447203b3..4a7e80971 100644
--- a/conan/cli/command.py
+++ b/conan/cli/command.py
@@ -4,7 +4,6 @@ from contextlib import redirect_stdout
 
 from conan.api.output import ConanOutput
 from conan.errors import ConanException
-from conan.internal.model.conf import CORE_CONF_PATTERN
 
 
 class OnceArgument(argparse.Action):
@@ -126,14 +125,7 @@ class ConanArgumentParser(argparse.ArgumentParser):
             ConanOutput().error("The --lockfile-packages arg is private and shouldn't be used")
         global_conf = self._conan_api.config.global_conf
         if args.core_conf:
-            from conan.internal.model.conf import ConfDefinition
-            confs = ConfDefinition()
-            for c in args.core_conf:
-                if not CORE_CONF_PATTERN.match(c):
-                    raise ConanException(f"Only core. values are allowed in --core-conf. Got {c}")
-            confs.loads("\n".join(args.core_conf))
-            confs.validate()
-            global_conf.update_conf_definition(confs)
+            self._conan_api.config.set_core_confs(args.core_conf)
 
         # TODO: This might be even better moved to the ConanAPI so users without doing custom
         #  commands can benefit from it
diff --git a/conan/cli/commands/config.py b/conan/cli/commands/config.py
index 9aeaa2e5f..8454d1401 100644
--- a/conan/cli/commands/config.py
+++ b/conan/cli/commands/config.py
@@ -1,3 +1,4 @@
+from conan.api.input import UserInput
 from conan.api.model import Remote
 from conan.api.output import cli_out_write
 from conan.cli.command import conan_command, conan_subcommand, OnceArgument
@@ -132,3 +133,12 @@ def config_show(conan_api, parser, subparser, *args):
     args = parser.parse_args(*args)
 
     return conan_api.config.show(args.pattern)
+
+
+@conan_subcommand()
+def config_clean(conan_api, parser, subparser, *args):
+    """
+    Clean the configuration files in the Conan home folder. (Keeping installed packages)
+    """
+    parser.parse_args(*args)
+    conan_api.config.clean()
diff --git a/conan/internal/model/conf.py b/conan/internal/model/conf.py
index ed3cf8ed7..b5971a091 100644
--- a/conan/internal/model/conf.py
+++ b/conan/internal/model/conf.py
@@ -706,3 +706,6 @@ class ConfDefinition:
     def validate(self):
         for conf in self._pattern_confs.values():
             conf.validate()
+
+    def clear(self):
+        self._pattern_confs.clear()
diff --git a/conan/internal/model/requires.py b/conan/internal/model/requires.py
index 2c3d03451..f5e5aae6f 100644
--- a/conan/internal/model/requires.py
+++ b/conan/internal/model/requires.py
@@ -15,6 +15,7 @@ class Requirement:
         # * prevents the usage of more positional parameters, always ref + **kwargs
         # By default this is a generic library requirement
         self.ref = ref
+        self._required_ref = ref  # Store the original reference
         self._headers = headers  # This dependent node has headers that must be -I<headers-path>
         self._libs = libs
         self._build = build  # This dependent node is a build tool that runs at build time only
@@ -152,7 +153,8 @@ class Requirement:
         return "{}, Traits: {}".format(self.ref, traits)
 
     def serialize(self):
-        result = {"ref": str(self.ref)}
+        result = {"ref": str(self.ref),
+                  "require": str(self._required_ref)}
         serializable = ("run", "libs", "skip", "test", "force", "direct", "build",
                         "transitive_headers", "transitive_libs", "headers",
                         "package_id_mode", "visible")
diff --git a/conan/tools/files/files.py b/conan/tools/files/files.py
index 6cbb550cd..433c30e2d 100644
--- a/conan/tools/files/files.py
+++ b/conan/tools/files/files.py
@@ -224,7 +224,8 @@ def rename(conanfile, src, dst):
     :param dst: Path to be renamed to.
     """
 
-    # FIXME: This function has been copied from legacy. Needs to fix: which() call and wrap subprocess call.
+    # FIXME: This function has been copied from legacy. Needs to fix: which()
+    # call and wrap subprocess call.
     if os.path.exists(dst):
         raise ConanException("rename {} to {} failed, dst exists.".format(src, dst))
 
@@ -310,7 +311,7 @@ def unzip(conanfile, filename, destination=".", keep_permissions=False, pattern=
         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()]
+            names = [zi.filename.replace("\\", "/") for zi in zip_info]
             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")
@@ -363,8 +364,11 @@ def untargz(filename, destination=".", pattern=None, strip_root=False, extract_f
         else:
             members = tarredgzippedFile.getmembers()
 
+            if pattern:
+                members = list(filter(lambda m: fnmatch(m.name, pattern),
+                                      tarredgzippedFile.getmembers()))
             if strip_root:
-                names = [n.replace("\\", "/") for n in tarredgzippedFile.getnames()]
+                names = [member.name.replace("\\", "/") for member in members]
                 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")
@@ -381,9 +385,6 @@ def untargz(filename, destination=".", pattern=None, strip_root=False, extract_f
                         linkpath = member.linkpath.replace("\\", "/")
                         member.linkpath = linkpath.split("/", 1)[1]
                         member.linkname = member.linkpath
-            if pattern:
-                members = list(filter(lambda m: fnmatch(m.name, pattern),
-                                      tarredgzippedFile.getmembers()))
             tarredgzippedFile.extractall(destination, members=members)
 
 
diff --git a/conans/client/graph/graph_builder.py b/conans/client/graph/graph_builder.py
index 970d95f95..51ac37afa 100644
--- a/conans/client/graph/graph_builder.py
+++ b/conans/client/graph/graph_builder.py
@@ -318,6 +318,7 @@ class DepsGraphBuilder(object):
                 continue
             if pattern.channel != "*" and pattern.channel != require.ref.channel:
                 continue
+            require._required_ref = require.ref.copy()  # Save the original ref before replacing
             original_require = repr(require.ref)
             if alternative_ref.version != "*":
                 require.ref.version = alternative_ref.version
diff --git a/conans/client/migrations.py b/conans/client/migrations.py
index 82997ba4e..026ef8b81 100644
--- a/conans/client/migrations.py
+++ b/conans/client/migrations.py
@@ -3,7 +3,6 @@ import sqlite3
 import textwrap
 
 from conan.api.output import ConanOutput
-from conan.api.subapi.config import ConfigAPI
 from conan.internal.default_settings import migrate_settings_file
 from conans.migrations import Migrator
 from conans.util.dates import timestamp_now
@@ -76,6 +75,7 @@ def _migrate_default_compatibility(cache_folder):
 
 
 def _migrate_pkg_db_lru(cache_folder, old_version):
+    from conan.api.subapi.config import ConfigAPI
     config = ConfigAPI.load_config(cache_folder)
     storage = config.get("core.cache:storage_path") or os.path.join(cache_folder, "p")
     db_filename = os.path.join(storage, 'cache.sqlite3')


Test patch:
diff --git a/test/integration/command/config_test.py b/test/integration/command/config_test.py
index 17c552ec4..c84ea3e36 100644
--- a/test/integration/command/config_test.py
+++ b/test/integration/command/config_test.py
@@ -2,7 +2,10 @@ import json
 import os
 import textwrap
 
+import pytest
+
 from conan.api.conan_api import ConanAPI
+from conan.test.assets.genconanfile import GenConanfile
 from conan.internal.model.conf import BUILT_IN_CONFS
 from conan.test.utils.test_files import temp_folder
 from conan.test.utils.tools import TestClient
@@ -201,3 +204,67 @@ def test_config_show():
     tc.run("config show zlib/*:foo")
     assert "zlib/*:user.mycategory:foo" in tc.out
     assert "zlib/*:user.myothercategory:foo" in tc.out
+
+
+@pytest.mark.parametrize("storage_path", [None, "p", "../foo"])
+def test_config_clean(storage_path):
+    tc = TestClient(light=True)
+    absolut_storage_path = os.path.abspath(os.path.join(tc.current_folder, storage_path)) if storage_path else os.path.join(tc.cache_folder, "p")
+
+    storage = f"core.cache:storage_path={storage_path}" if storage_path else ""
+    tc.save_home({"global.conf": f"core.upload:retry=7\n{storage}",
+                  "extensions/compatibility/mycomp.py": "",
+                  "extensions/commands/cmd_foo.py": "",
+                  })
+
+    tc.run("profile detect --name=foo")
+    tc.run("remote add bar http://fakeurl")
+
+    tc.save({"conanfile.py": GenConanfile("pkg", "0.1")})
+    tc.run("create .")
+
+    assert os.path.exists(absolut_storage_path)
+
+    tc.run("config clean")
+    tc.run("profile list")
+    assert "foo" not in tc.out
+    tc.run("remote list")
+    assert "bar" not in tc.out
+    tc.run("config show core.upload:retry")
+    assert "7" not in tc.out
+    assert not os.path.exists(os.path.join(tc.cache_folder, "extensions"))
+    assert os.path.exists(absolut_storage_path)
+
+
+def test_config_reinit():
+    custom_global_conf = "core.upload:retry=7"
+    global_conf_folder = temp_folder()
+    with open(os.path.join(global_conf_folder, "global.conf"), "w") as f:
+        f.write(custom_global_conf)
+
+    cache_folder = temp_folder()
+    conan_api = ConanAPI(cache_folder=cache_folder)
+    # Ensure reinitialization does not invalidate references
+    config_api = conan_api.config
+    assert config_api.global_conf.get("core.upload:retry", check_type=int) != 7
+
+    conan_api.config.install(global_conf_folder, verify_ssl=False)
+    # Already has an effect, the config installation reinitializes the config
+    assert config_api.global_conf.get("core.upload:retry", check_type=int) == 7
+
+
+def test_config_reinit_core_conf():
+    tc = TestClient(light=True)
+    tc.save_home({"extensions/commands/cmd_foo.py": textwrap.dedent("""
+        import json
+        from conan.cli.command import conan_command
+        from conan.api.output import ConanOutput
+
+        @conan_command()
+        def foo(conan_api, parser, *args, **kwargs):
+            ''' Foo '''
+            parser.parse_args(*args)
+            ConanOutput().info(f"Retry: {conan_api.config.global_conf.get('core.upload:retry', check_type=int)}")
+    """)})
+    tc.run("foo -cc core.upload:retry=7")
+    assert "Retry: 7" in tc.out
diff --git a/test/integration/command/create_test.py b/test/integration/command/create_test.py
index e68f52946..620d11f51 100644
--- a/test/integration/command/create_test.py
+++ b/test/integration/command/create_test.py
@@ -488,11 +488,11 @@ def test_create_format_json():
         '1': {'ref': 'hello/0.1', 'run': False, 'libs': True, 'skip': False,
               'test': False, 'force': False, 'direct': True, 'build': False,
               'transitive_headers': None, 'transitive_libs': None, 'headers': True,
-              'package_id_mode': None, 'visible': True},
+              'package_id_mode': None, 'visible': True, 'require': 'hello/0.1'},
         '2': {'ref': 'pkg/0.2', 'run': False, 'libs': True, 'skip': False, 'test': False,
               'force': False, 'direct': False, 'build': False, 'transitive_headers': None,
               'transitive_libs': None, 'headers': True, 'package_id_mode': None,
-              'visible': True}
+              'visible': True, 'require': 'pkg/0.2'}
     }
     assert consumer_info["dependencies"] == consumer_deps
     # hello/0.1 pkg information
@@ -505,7 +505,7 @@ def test_create_format_json():
             "ref": "pkg/0.2", "run": False, "libs": True, "skip": False, "test": False,
             "force": False, "direct": True, "build": False, "transitive_headers": None,
             "transitive_libs": None, "headers": True, "package_id_mode": "semver_mode",
-            "visible": True
+            "visible": True, 'require': 'pkg/0.2'
         }
     }
     assert hello_pkg_info["dependencies"] == hello_pkg_info_deps
diff --git a/test/integration/command/info/info_test.py b/test/integration/command/info/info_test.py
index 8f254820a..59765ee41 100644
--- a/test/integration/command/info/info_test.py
+++ b/test/integration/command/info/info_test.py
@@ -176,6 +176,15 @@ class TestJsonOutput:
         graph = json.loads(client.stdout)
         assert graph["graph"]["nodes"]["0"]["settings"]["build_type"] == "Debug"
 
+    def test_json_info_requirements(self):
+        c = TestClient()
+        c.save({"pkg/conanfile.py": GenConanfile("pkg", "0.1"),
+                "app/conanfile.py": GenConanfile().with_require("pkg/[>=0.0 <1.0]")})
+        c.run("create pkg")
+        c.run("graph info app --format=json")
+        graph = json.loads(c.stdout)
+        assert graph["graph"]["nodes"]["0"]["dependencies"]["1"]["require"] == "pkg/[>=0.0 <1.0]"
+
 
 class TestAdvancedCliOutput:
     """ Testing more advanced fields output, like SCM or PYTHON-REQUIRES
diff --git a/test/integration/command_v2/test_inspect.py b/test/integration/command_v2/test_inspect.py
index cd7445800..e23ee392a 100644
--- a/test/integration/command_v2/test_inspect.py
+++ b/test/integration/command_v2/test_inspect.py
@@ -136,8 +136,8 @@ def test_requiremens_inspect():
             'options:',
             'options_definitions:',
             'package_type: None',
-            "requires: [{'ref': 'zlib/1.2.13', 'run': False, 'libs': True, 'skip': "
-            "False, 'test': False, 'force': False, 'direct': True, 'build': "
+            "requires: [{'ref': 'zlib/1.2.13', 'require': 'zlib/1.2.13', 'run': False, "
+            "'libs': True, 'skip': False, 'test': False, 'force': False, 'direct': True, 'build': "
             "False, 'transitive_headers': None, 'transitive_libs': None, 'headers': "
             "True, 'package_id_mode': None, 'visible': True}]",
             'revision_mode: hash',
diff --git a/test/integration/graph/test_replace_requires.py b/test/integration/graph/test_replace_requires.py
index 4d6373c82..c27d729c5 100644
--- a/test/integration/graph/test_replace_requires.py
+++ b/test/integration/graph/test_replace_requires.py
@@ -150,6 +150,8 @@ def test_replace_requires_json_format():
     assert "pkg/0.1: pkg/0.2" in c.out  # The replacement happens
     graph = json.loads(c.stdout)
     assert graph["graph"]["replaced_requires"] == {"pkg/0.1": "pkg/0.2"}
+    assert graph["graph"]["nodes"]["0"]["dependencies"]["1"]["ref"] == "pkg/0.2"
+    assert graph["graph"]["nodes"]["0"]["dependencies"]["1"]["require"] == "pkg/0.1"
 
 
 def test_replace_requires_test_requires():
diff --git a/test/integration/lockfile/test_graph_overrides.py b/test/integration/lockfile/test_graph_overrides.py
index 17698db54..2212e2989 100644
--- a/test/integration/lockfile/test_graph_overrides.py
+++ b/test/integration/lockfile/test_graph_overrides.py
@@ -35,8 +35,8 @@ def test_overrides_half_diamond(override, force):
     # apply the lockfile to pkgb, should it lock to pkga/0.2
     c.run("graph info pkgb --lockfile=pkgc/conan.lock --format=json")
     dependencies = json.loads(c.stdout)["graph"]["nodes"]["0"]["dependencies"]
-    assert "pkga/0.2" in str(dependencies)
-    assert "pkga/0.1" not in str(dependencies)
+    assert "pkga/0.2" == dependencies["1"]["ref"]
+    assert "pkga/0.1" == dependencies["1"]["require"]
 
 
 @pytest.mark.parametrize("override, force", [(True, False), (False, True)])
@@ -140,9 +140,8 @@ def test_overrides_diamond(override, force):
     c.run("graph info pkgb --lockfile=pkgd/conan.lock --format=json")
     json_graph = json.loads(c.stdout)
     deps = json_graph["graph"]["nodes"]["0"]["dependencies"]
-    assert "pkga/0.3" in str(deps)
-    assert "pkga/0.2" not in str(deps)
-    assert "pkga/0.1" not in str(deps)
+    assert "pkga/0.3" == deps["1"]["ref"]
+    assert "pkga/0.1" == deps["1"]["require"]
     # Redundant assert, but checking "overrides" summary
     overrides = json_graph['graph']["overrides"]
     assert len(overrides) == 1
diff --git a/test/unittests/tools/files/test_zipping.py b/test/unittests/tools/files/test_zipping.py
index 42e89bcee..0c41a494d 100644
--- a/test/unittests/tools/files/test_zipping.py
+++ b/test/unittests/tools/files/test_zipping.py
@@ -1,6 +1,6 @@
-import os
 import zipfile
-from os.path import basename
+import tarfile
+from os.path import join, exists
 
 import pytest
 
@@ -8,6 +8,7 @@ from conan.tools.files import unzip
 from conan.test.utils.mocks import ConanFileMock
 from conan.test.utils.test_files import temp_folder
 from conans.util.files import save
+from conan.errors import ConanException
 
 
 def test_impossible_to_import_untargz():
@@ -15,17 +16,136 @@ def test_impossible_to_import_untargz():
         from conan.tools.files import untargz
 
 
-def test_unzip():
+def create_example_zip(root_file=True, subfolder=False):
     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))
+    archive = join(tmp_dir, "zipfile.zip")
+    zf = zipfile.ZipFile(archive, mode="w")
+    if root_file:
+        foo_txt = join(tmp_dir, "foo.txt")
+        save(foo_txt, "foo-content")
+        zf.write(foo_txt, "foo.txt")
+    if subfolder:
+        src_bar_txt = join(tmp_dir, "src", "bar.txt")
+        save(src_bar_txt, "bar-content")
+        zf.write(src_bar_txt, join("src", "bar.txt"))
     zf.close()
+    return archive
+
+
+def test_unzip():
+    archive = create_example_zip(subfolder=True)
+    conanfile = ConanFileMock({})
+
+    # Unzip and check permissions are kept
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir)
+    assert exists(join(dest_dir, "foo.txt"))
+    assert exists(join(dest_dir, "src", "bar.txt"))
+
+
+def test_unzip_with_pattern():
+    archive = create_example_zip(subfolder=True)
+    conanfile = ConanFileMock({})
+
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir, pattern="foo.txt")
+    assert exists(join(dest_dir, "foo.txt"))
+    assert not exists(join(dest_dir, "src", "bar.txt"))
+
+
+def test_unzip_with_strip_root():
+    archive = create_example_zip(root_file=False, subfolder=True)
+    conanfile = ConanFileMock({})
+
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir, strip_root=True)
+    assert exists(join(dest_dir, "bar.txt"))
+
+
+def test_unzip_with_strip_root_fails():
+    archive = create_example_zip(root_file=True, subfolder=True)
+    conanfile = ConanFileMock({})
+
+    dest_dir = temp_folder()
+    with pytest.raises(ConanException) as error:
+        unzip(conanfile, archive, dest_dir, strip_root=True)
+    assert "The zip file contains more than 1 folder in the root" in str(error.value)
+
+
+def test_unzip_with_strip_root_and_pattern():
+    archive = create_example_zip(root_file=True, subfolder=True)
+    conanfile = ConanFileMock({})
+
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir, pattern="src/*", strip_root=True)
+    assert exists(join(dest_dir, "bar.txt"))
+    assert not exists(join(dest_dir, "foo.txt"))
+
+
+def create_example_tar(root_file=True, subfolder=False):
+    tmp_dir = temp_folder()
+    tar_path = join(tmp_dir, "file.tgz")
+    tar = tarfile.open(tar_path, "w:gz")
+    if root_file:
+        foo_txt = join(tmp_dir, "foo.txt")
+        save(foo_txt, "foo-content")
+        tar.add(foo_txt, "foo.txt")
+    if subfolder:
+        src_bar_txt = join(tmp_dir, "src", "bar.txt")
+        save(src_bar_txt, "bar-content")
+        tar.add(src_bar_txt, join("src", "bar.txt"))
+    tar.close()
+    return tar_path
+
+
+def test_untargz():
+    archive = create_example_tar(subfolder=True)
+    conanfile = ConanFileMock({})
+
+    # Unzip and check permissions are kept
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir)
+    assert exists(join(dest_dir, "foo.txt"))
+    assert exists(join(dest_dir, "src", "bar.txt"))
+
+
+def test_untargz_with_pattern():
+    archive = create_example_tar(subfolder=True)
+    conanfile = ConanFileMock({})
+
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir, pattern="foo.txt")
+    assert exists(join(dest_dir, "foo.txt"))
+    assert not exists(join(dest_dir, "src", "bar.txt"))
+
+
+def test_untargz_with_strip_root():
+    archive = create_example_tar(root_file=False, subfolder=True)
+    conanfile = ConanFileMock({})
+
+    # Unzip and check permissions are kept
+    dest_dir = temp_folder()
+    unzip(conanfile, archive, dest_dir, strip_root=True)
+    assert exists(join(dest_dir, "bar.txt"))
+
+
+def test_untargz_with_strip_root_fails():
+    archive = create_example_tar(root_file=True, subfolder=True)
+    conanfile = ConanFileMock({})
+
+    # Unzip and check permissions are kept
+    dest_dir = temp_folder()
+    with pytest.raises(ConanException) as error:
+        unzip(conanfile, archive, dest_dir, strip_root=True)
+    assert "The tgz file contains more than 1 folder in the root" in str(error.value)
+
 
+def test_untargz_with_strip_root_and_pattern():
+    archive = create_example_tar(root_file=True, subfolder=True)
     conanfile = ConanFileMock({})
 
     # 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"))
+    unzip(conanfile, archive, dest_dir, pattern="src/*", strip_root=True)
+    assert exists(join(dest_dir, "bar.txt"))
+    assert not exists(join(dest_dir, "foo.txt"))