Repository: conan-io/conan
Pull request #17517 (2024-12-20 22:31:36)

better traces and msgs
Changelog: Feature: Better logging, printing the username for repositories, successful auth event and trace-level messages including full URL requests.
Docs: Omit

Close https://github.com/conan-io/conan/issues/8853


Patch:
diff --git a/conan/api/model.py b/conan/api/model.py
index e0c6fe91d..4c6132bbd 100644
--- a/conan/api/model.py
+++ b/conan/api/model.py
@@ -25,6 +25,7 @@ class Remote:
         self.disabled = disabled
         self.allowed_packages = allowed_packages
         self.remote_type = remote_type
+        self.caching = {}
 
     def __eq__(self, other):
         if other is None:
@@ -45,6 +46,9 @@ class Remote:
     def __repr__(self):
         return str(self)
 
+    def invalidate_cache(self):
+        self.caching = {}
+
 
 class MultiPackagesList:
     def __init__(self):
diff --git a/conan/api/output.py b/conan/api/output.py
index cce692af6..4d110270c 100644
--- a/conan/api/output.py
+++ b/conan/api/output.py
@@ -212,12 +212,12 @@ class ConanOutput:
 
     def trace(self, msg):
         if self._conan_output_level <= LEVEL_TRACE:
-            self._write_message(msg, fg=Color.BRIGHT_WHITE)
+            self._write_message(msg, fg=Color.BLUE)
         return self
 
     def debug(self, msg):
         if self._conan_output_level <= LEVEL_DEBUG:
-            self._write_message(msg)
+            self._write_message(msg, fg=Color.MAGENTA)
         return self
 
     def verbose(self, msg, fg=None, bg=None):
diff --git a/conan/cli/commands/profile.py b/conan/cli/commands/profile.py
index c188d9c00..16457a164 100644
--- a/conan/cli/commands/profile.py
+++ b/conan/cli/commands/profile.py
@@ -9,35 +9,44 @@ from conan.errors import ConanException
 from conans.util.files import save
 
 
-def print_profiles(profiles):
-    host, build = profiles
-    cli_out_write("Host profile:")
-    cli_out_write(host.dumps())
-    cli_out_write("Build profile:")
-    cli_out_write(build.dumps())
+def _print_profiles(profiles):
+    if "host" in profiles:
+        ConanOutput().info("Host profile:")
+        cli_out_write(profiles["host"].dumps())
+    if "build" in profiles:
+        ConanOutput().info("Build profile:")
+        cli_out_write(profiles["build"].dumps())
 
 
 def profiles_list_cli_output(profiles):
-    cli_out_write("Profiles found in the cache:")
+    ConanOutput().info("Profiles found in the cache:")
     for p in profiles:
         cli_out_write(p)
 
 
-def json_profiles(profiles):
-    host, build = profiles
-    result = {"host": host.serialize(),
-              "build": build.serialize()}
+def _json_profiles(profiles):
+    result = {}
+    if "host" in profiles:
+        result["host"] = profiles["host"].serialize()
+    if "build" in profiles:
+        result["build"] = profiles["build"].serialize()
     cli_out_write(json.dumps(result))
 
 
-@conan_subcommand(formatters={"text": print_profiles, "json": json_profiles})
+@conan_subcommand(formatters={"text": _print_profiles, "json": _json_profiles})
 def profile_show(conan_api, parser, subparser, *args):
     """
     Show aggregated profiles from the passed arguments.
     """
     add_profiles_args(subparser)
+    subparser.add_argument("-cx", "--context", choices=["host", "build"])
     args = parser.parse_args(*args)
-    result = conan_api.profiles.get_profiles_from_args(args)
+    profiles = conan_api.profiles.get_profiles_from_args(args)
+    result = {}
+    if not args.context or args.context == "host":
+        result["host"] = profiles[0]
+    if not args.context or args.context == "build":
+        result["build"] = profiles[1]
     return result
 
 
diff --git a/conan/tools/env/environment.py b/conan/tools/env/environment.py
index fc4ef2f99..c6f9f6cd9 100644
--- a/conan/tools/env/environment.py
+++ b/conan/tools/env/environment.py
@@ -318,10 +318,9 @@ class Environment:
 
     def vars(self, conanfile, scope="build"):
         """
-        Return an EnvVars object from the current Environment object
         :param conanfile: Instance of a conanfile, usually ``self`` in a recipe
         :param scope: Determine the scope of the declared variables.
-        :return:
+        :return: An EnvVars object from the current Environment object
         """
         return EnvVars(conanfile, self._values, scope)
 
diff --git a/conan/tools/files/files.py b/conan/tools/files/files.py
index bbb96bf90..b3a05026b 100644
--- a/conan/tools/files/files.py
+++ b/conan/tools/files/files.py
@@ -451,6 +451,7 @@ def replace_in_file(conanfile, file_path, search, replace, strict=True, encoding
            string is not found, so nothing is actually replaced.
     :param encoding: (Optional, Defaulted to utf-8): Specifies the input and output files text
            encoding.
+    :return: ``True`` if the pattern was found, ``False`` otherwise if `strict` is ``False``.
     """
     output = conanfile.output
     content = load(conanfile, file_path, encoding=encoding)
@@ -463,6 +464,7 @@ def replace_in_file(conanfile, file_path, search, replace, strict=True, encoding
             return False
     content = content.replace(search, replace)
     save(conanfile, file_path, content, encoding=encoding)
+    return True
 
 
 def collect_libs(conanfile, folder=None):
diff --git a/conan/tools/gnu/gnutoolchain.py b/conan/tools/gnu/gnutoolchain.py
index 336d8e06a..6407f5c30 100644
--- a/conan/tools/gnu/gnutoolchain.py
+++ b/conan/tools/gnu/gnutoolchain.py
@@ -237,7 +237,7 @@ class GnuToolchain:
 
     @staticmethod
     def _dict_to_list(flags):
-        return [f"{k}={v}" if v else k for k, v in flags.items()]
+        return [f"{k}={v}" if v is not None else k for k, v in flags.items()]
 
     @property
     def cxxflags(self):
diff --git a/conans/client/remote_manager.py b/conans/client/remote_manager.py
index 5e6d21b75..1257b889c 100644
--- a/conans/client/remote_manager.py
+++ b/conans/client/remote_manager.py
@@ -187,7 +187,13 @@ class RemoteManager:
             raise
 
     def search_recipes(self, remote, pattern):
-        return self._call_remote(remote, "search", pattern)
+        cached_method = remote.caching.setdefault("search_recipes", {})
+        try:
+            return cached_method[pattern]
+        except KeyError:
+            result = self._call_remote(remote, "search", pattern)
+            cached_method[pattern] = result
+            return result
 
     def search_packages(self, remote, ref):
         packages = self._call_remote(remote, "search_packages", ref)
@@ -234,7 +240,14 @@ class RemoteManager:
                        if k in ("shared", "fPIC", "header_only")]
             if options:
                 headers['Conan-PkgID-Options'] = ';'.join(options)
-        return self._call_remote(remote, "get_latest_package_reference", pref, headers=headers)
+
+        cached_method = remote.caching.setdefault("get_latest_package_reference", {})
+        try:
+            return cached_method[pref]
+        except KeyError:
+            result = self._call_remote(remote, "get_latest_package_reference", pref, headers=headers)
+            cached_method[pref] = result
+            return result
 
     def get_recipe_revision_reference(self, ref, remote) -> bool:
         assert ref.revision is not None, "recipe_exists needs a revision"
diff --git a/conans/client/rest/auth_manager.py b/conans/client/rest/auth_manager.py
index 525de701a..e79fd4040 100644
--- a/conans/client/rest/auth_manager.py
+++ b/conans/client/rest/auth_manager.py
@@ -19,7 +19,7 @@ from conan.errors import ConanException
 LOGIN_RETRIES = 3
 
 
-class RemoteCreds:
+class _RemoteCreds:
     def __init__(self, localdb):
         self._localdb = localdb
 
@@ -28,11 +28,14 @@ class RemoteCreds:
         if creds is None:
             user, token, _ = self._localdb.get_login(remote.url)
             creds = user, token
+            usermsg = f"with user '{user}'" if user else "anonymously"
+            ConanOutput().info(f"Connecting to remote '{remote.name}' {usermsg}")
             setattr(remote, "_creds", creds)
         return creds
 
     def set(self, remote, user, token):
         setattr(remote, "_creds", (user, token))
+        ConanOutput().success(f"Authenticated in remote '{remote.name}' with user '{user}'")
         self._localdb.store(user, token, None, remote.url)
 
 
@@ -40,7 +43,7 @@ class ConanApiAuthManager:
 
     def __init__(self, requester, cache_folder, localdb, global_conf):
         self._requester = requester
-        self._creds = RemoteCreds(localdb)
+        self._creds = _RemoteCreds(localdb)
         self._global_conf = global_conf
         self._cache_folder = cache_folder
 
diff --git a/conans/client/rest/conan_requester.py b/conans/client/rest/conan_requester.py
index edb2ce989..b6700c9f5 100644
--- a/conans/client/rest/conan_requester.py
+++ b/conans/client/rest/conan_requester.py
@@ -9,6 +9,7 @@ import urllib3
 from jinja2 import Template
 from requests.adapters import HTTPAdapter
 
+from conan.api.output import ConanOutput
 from conan.internal.cache.home_paths import HomePaths
 
 from conan import __version__
@@ -198,6 +199,7 @@ class ConanRequester:
             for var_name in ("http_proxy", "https_proxy", "ftp_proxy", "all_proxy", "no_proxy"):
                 popped = True if os.environ.pop(var_name, None) else popped
                 popped = True if os.environ.pop(var_name.upper(), None) else popped
+        ConanOutput(scope="HttpRequest").trace(f"{method}: {url}")
         try:
             all_kwargs = self._add_kwargs(url, kwargs)
             tmp = getattr(self._http_requester, method)(url, **all_kwargs)
diff --git a/conans/requirements_server.txt b/conans/requirements_server.txt
index f7a3f7ab0..d4147ed38 100644
--- a/conans/requirements_server.txt
+++ b/conans/requirements_server.txt
@@ -1,4 +1,4 @@
 # Server
-bottle>=0.12.8, < 0.13
+bottle>=0.12.8, < 0.14
 pluginbase>=0.5
 PyJWT>=2.4.0, <3.0.0


Test patch:
diff --git a/test/functional/test_profile_detect_api.py b/test/functional/test_profile_detect_api.py
index 077092f3d..22ed57578 100644
--- a/test/functional/test_profile_detect_api.py
+++ b/test/functional/test_profile_detect_api.py
@@ -30,11 +30,10 @@ class TestProfileDetectAPI:
             """)
 
         client.save({"profile1": tpl1})
-        client.run("profile show -pr=profile1")
+        client.run("profile show -pr=profile1 --context=host")
         #FIXME: check update setting
         update = str(int(detect_api.detect_msvc_update("194")) % 10)
         expected = textwrap.dedent(f"""\
-            Host profile:
             [settings]
             compiler=msvc
             compiler.cppstd=14
@@ -63,13 +62,12 @@ class TestProfileDetectAPI:
             """)
 
         client.save({"profile1": tpl1})
-        client.run("profile show -pr=profile1")
+        client.run("profile show -pr=profile1 --context=host")
         libc_name, libc_version = detect_api.detect_libc()
         assert libc_name is not None
         assert libc_version is not None
         _, version, _ = detect_api.detect_gcc_compiler()
         expected = textwrap.dedent(f"""\
-            Host profile:
             [settings]
             compiler=gcc
             compiler.version={version}
@@ -93,3 +91,27 @@ class TestProfileDetectAPI:
         client.run("profile show -pr=profile1")
         sdk_version = detect_api.detect_sdk_version(sdk="macosx")
         assert f"os.sdk_version={sdk_version}" in client.out
+
+@pytest.mark.parametrize("context", [None, "host", "build"])
+@pytest.mark.parametrize("f", ["json", "text"])
+def test_profile_show_aggregate_usecase(context, f):
+    tc = TestClient(light=True)
+
+    context_arg = f"--context {context}" if context else ""
+    tc.run(f'profile show {context_arg} -s:h="os=Windows" -s:b="os=Linux" --format={f}')
+
+    if context == "host":
+        if f == "text":
+            assert "Host profile:" not in tc.stdout
+            assert "Host profile:" in tc.stderr
+        assert "Linux" not in tc.out
+    if context == "build":
+        if f == "text":
+            assert "Build profile:" not in tc.stdout
+            assert "Build profile:" in tc.stderr
+        assert "Windows" not in tc.out
+
+    if context in (None, "host"):
+        assert "Windows" in tc.out
+    if context in (None, "build"):
+        assert "Linux" in tc.out
diff --git a/test/integration/command_v2/custom_commands_test.py b/test/integration/command_v2/custom_commands_test.py
index c28b9d50e..cea9c844b 100644
--- a/test/integration/command_v2/custom_commands_test.py
+++ b/test/integration/command_v2/custom_commands_test.py
@@ -419,3 +419,53 @@ class TestCommandAPI:
         c.run("export .")
         c.run("mycommand myremote myurl")
         assert json.loads(c.stdout) == {"myremote": "myurl"}
+
+
+class TestCommandsRemoteCaching:
+    def test_remotes_cache(self):
+        complex_command = textwrap.dedent("""
+            import json
+            from conan.cli.command import conan_command
+            from conan.api.output import ConanOutput
+
+            @conan_command()
+            def mycache(conan_api, parser, *args, **kwargs):
+                \""" this is a command with subcommands \"""
+
+                remotes = conan_api.remotes.list()
+                host = conan_api.profiles.get_profile(["default"])
+                build = conan_api.profiles.get_profile(["default"])
+
+                deps_graph = conan_api.graph.load_graph_requires(["pkg/0.1"], None, host, build,
+                                                                 None, remotes, None, None)
+                conan_api.graph.analyze_binaries(deps_graph, None, remotes=remotes)
+
+                # SECOND RUN!!!
+                # This will break if the caching is not working
+                remotes[0].url = "broken"
+                deps_graph = conan_api.graph.load_graph_requires(["pkg/0.1"], None, host, build,
+                                                                 None, remotes, None, None)
+                conan_api.graph.analyze_binaries(deps_graph, None, remotes=remotes)
+
+                # Now invalidate the cache and see how it breaks
+                try:
+                    remotes[0].invalidate_cache()
+                    deps_graph = conan_api.graph.load_graph_requires(["pkg/0.1"], None, host, build,
+                                                                     None, remotes, None, None)
+                    conan_api.graph.analyze_binaries(deps_graph, None, remotes=remotes)
+                except Exception as e:
+                    ConanOutput().warning(f"Cache invalidated, as expected: {e}")
+            """)
+
+        c = TestClient(default_server_user=True)
+        c.save_home({"extensions/commands/cmd_mycache.py": complex_command})
+
+        c.save({"dep/conanfile.py": GenConanfile("dep", "0.1"),
+                "pkg/conanfile.py": GenConanfile("pkg", "0.1").with_require("dep/0.1")})
+        c.run("create dep")
+        c.run("create pkg")
+        c.run("upload * -r=default -c")
+        c.run("remove * -c")
+        c.run("mycache")
+        # Does not break unexpectedly, caching is working
+        assert "WARN: Cache invalidated, as expected: Invalid URL 'broken" in c.out
diff --git a/test/integration/command_v2/list_test.py b/test/integration/command_v2/list_test.py
index b8bcbed18..32fd98760 100644
--- a/test/integration/command_v2/list_test.py
+++ b/test/integration/command_v2/list_test.py
@@ -724,6 +724,8 @@ class TestListRemotes:
 
     def test_search_no_matching_recipes(self, client):
         expected_output = textwrap.dedent("""\
+        Connecting to remote 'default' with user 'admin'
+        Connecting to remote 'other' with user 'admin'
         Local Cache
           ERROR: Recipe 'whatever/0.1' not found
         default
diff --git a/test/integration/command_v2/search_test.py b/test/integration/command_v2/search_test.py
index 6d6ae26b3..7262f6d81 100644
--- a/test/integration/command_v2/search_test.py
+++ b/test/integration/command_v2/search_test.py
@@ -30,7 +30,9 @@ class TestSearch:
         assert "error: the following arguments are required: reference" in self.client.out
 
     def test_search_no_matching_recipes(self, remotes):
-        expected_output = ("remote1\n"
+        expected_output = ("Connecting to remote 'remote1' anonymously\n"
+                           "Connecting to remote 'remote2' anonymously\n"
+                           "remote1\n"
                            "  ERROR: Recipe 'whatever' not found\n"
                            "remote2\n"
                            "  ERROR: Recipe 'whatever' not found\n")
diff --git a/test/integration/configuration/test_profile_jinja.py b/test/integration/configuration/test_profile_jinja.py
index 5b4a67fa7..a985f992c 100644
--- a/test/integration/configuration/test_profile_jinja.py
+++ b/test/integration/configuration/test_profile_jinja.py
@@ -241,12 +241,11 @@ class TestProfileDetectAPI:
             """)
 
         client.save({"profile1": tpl1})
-        client.run("profile show -pr=profile1")
+        client.run("profile show -pr=profile1 --context=host")
         pr = client.get_default_host_profile()
         the_os = pr.settings['os']
         arch = pr.settings['arch']
         expected = textwrap.dedent(f"""\
-            Host profile:
             [settings]
             arch={arch}
             os={the_os}
diff --git a/test/unittests/tools/files/test_file_read_and_write.py b/test/unittests/tools/files/test_file_read_and_write.py
index 4a6d12ab1..1fee30fbe 100644
--- a/test/unittests/tools/files/test_file_read_and_write.py
+++ b/test/unittests/tools/files/test_file_read_and_write.py
@@ -3,6 +3,9 @@
 
 import os
 
+import pytest
+
+from conan.errors import ConanException
 from conan.tools.files import replace_in_file, save, load
 from conan.test.utils.mocks import ConanFileMock
 from conan.test.utils.test_files import temp_folder
@@ -36,18 +39,18 @@ def test_replace_in_file():
 
     # By default utf-8 is used
     save(conanfile, file_path, "你很重，伙計")
-    replace_in_file(conanfile, file_path, "重", "0")
+    assert 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")
+    assert replace_in_file(conanfile, file_path, "¼", "0", encoding="cp1252")
     contents = load(conanfile, file_path, encoding="cp1252")
     assert contents == "Ö0"
 
     save(conanfile, file_path, "Ö¼", encoding="ISO-8859-1")
-    replace_in_file(conanfile, file_path, "¼", "0", encoding="ISO-8859-1")
+    assert replace_in_file(conanfile, file_path, "¼", "0", encoding="ISO-8859-1")
     contents = load(conanfile, file_path, encoding="ISO-8859-1")
     assert contents == "Ö0"
 
@@ -57,3 +60,9 @@ def test_replace_in_file():
     replace_in_file(conanfile, file_path, "重", "0", encoding="utf-16")
     contents = load(conanfile, file_path, encoding="utf-16")
     assert contents == "你很0，伙計"
+
+    with pytest.raises(ConanException, match="didn't find pattern"):
+        replace_in_file(conanfile, file_path, "not existing", "0", encoding="utf-16")
+
+    assert not replace_in_file(conanfile, file_path, "not existing", "0",
+                               encoding="utf-16", strict=False)
diff --git a/test/unittests/tools/gnu/test_gnutoolchain.py b/test/unittests/tools/gnu/test_gnutoolchain.py
index cf053530e..c53f9d7bb 100644
--- a/test/unittests/tools/gnu/test_gnutoolchain.py
+++ b/test/unittests/tools/gnu/test_gnutoolchain.py
@@ -192,5 +192,9 @@ def test_update_or_prune_any_args(cross_building_conanfile):
     assert "'--force" not in new_autoreconf_args
     # Add new value to make_args
     at.make_args.update({"--new-complex-flag": "new-value"})
+    at.make_args.update({"--new-empty-flag": ""})
+    at.make_args.update({"--new-no-value-flag": None})
     new_make_args = cmd_args_to_string(GnuToolchain._dict_to_list(at.make_args))
     assert "--new-complex-flag=new-value" in new_make_args
+    assert "--new-empty-flag=" in new_make_args
+    assert "--new-no-value-flag" in new_make_args and "--new-no-value-flag=" not in new_make_args