Repository: conan-io/conan

Issue:
Support multiple profiles on command-line for composition
Profiles support static composition today via `include(other_profile)` directive.  Where this mechanism breaks down is when users want to combine partial profiles with completely unrelated information, and to do that today you need an intermediate profile which `includes()` both of the things you really want. 

So for example, I can't have four special ffmpeg-related profiles defining `ffmpeg` options in the `ffmpeg` project directory, and compose them with profiles that define the common settings of my organization: 

I want to be able to do: 
`conan create . bincrafters/testing -pr msvc_15_x64_release_mt -pr ffmpeg_with_options0`

and then: 
`conan create bincrafters/testing -pr gcc_8_x86_debug -pr ffmpeg_with_options3`

To do this today, the only feasible approach is to use one profile or the other, and then pass the rest of the items on the CLI manually.  Alternatively, you would have to create a bunch of "intermediate" profiles for every possible combination, which is clearly nonsensical. 

Obviously, this feature would require conflict resolution, but something like the order they appear on the command-line should suffice.  I am sure conflict resolution was a factor with `include()`, so this is not a new problem. 


- [x] I've read the [CONTRIBUTING guide](https://raw.githubusercontent.com/conan-io/conan/develop/.github/CONTRIBUTING.md).
- [x] I've specified the Conan version, operating system version and any tool that can be relevant.
- [x] I've explained the steps to reproduce the error or the motivation/use case of the question/suggestion.




Discussion:
Yes, I think it could make sense. I've felt the pain of the composed profiles sometimes especially with build requires.

Resolving patch:
diff --git a/conans/client/command.py b/conans/client/command.py
--- a/conans/client/command.py
+++ b/conans/client/command.py
@@ -377,7 +377,7 @@ def install(self, *args):
                                            remote_name=args.remote,
                                            verify=args.verify, manifests=args.manifests,
                                            manifests_interactive=args.manifests_interactive,
-                                           build=args.build, profile_name=args.profile,
+                                           build=args.build, profile_names=args.profile,
                                            update=args.update, generators=args.generator,
                                            no_imports=args.no_imports,
                                            install_folder=args.install_folder)
@@ -388,7 +388,7 @@ def install(self, *args):
                                                      remote_name=args.remote,
                                                      verify=args.verify, manifests=args.manifests,
                                                      manifests_interactive=args.manifests_interactive,
-                                                     build=args.build, profile_name=args.profile,
+                                                     build=args.build, profile_names=args.profile,
                                                      update=args.update,
                                                      generators=args.generator,
                                                      install_folder=args.install_folder)
@@ -504,7 +504,7 @@ def info(self, *args):
                                                settings=args.settings,
                                                options=args.options,
                                                env=args.env,
-                                               profile_name=args.profile,
+                                               profile_names=args.profile,
                                                remote_name=args.remote,
                                                build_order=args.build_order,
                                                check_updates=args.update,
@@ -522,7 +522,7 @@ def info(self, *args):
                                                        settings=args.settings,
                                                        options=args.options,
                                                        env=args.env,
-                                                       profile_name=args.profile,
+                                                       profile_names=args.profile,
                                                        remote_name=args.remote,
                                                        check_updates=args.update,
                                                        install_folder=args.install_folder)
@@ -535,7 +535,7 @@ def info(self, *args):
                                     settings=args.settings,
                                     options=args.options,
                                     env=args.env,
-                                    profile_name=args.profile,
+                                    profile_names=args.profile,
                                     update=args.update,
                                     install_folder=args.install_folder,
                                     build=args.dry_build)
@@ -743,7 +743,7 @@ def export_pkg(self, *args):
                             "will raise an error.")
         parser.add_argument("-o", "--options", nargs=1, action=Extender,
                             help='Define options values, e.g., -o pkg:with_qt=true')
-        parser.add_argument("-pr", "--profile", action=OnceArgument,
+        parser.add_argument("-pr", "--profile", action=Extender,
                             help='Profile for this package')
         parser.add_argument("-pf", "--package-folder", action=OnceArgument,
                             help="folder containing a locally created package. If a value is given,"
@@ -770,7 +770,7 @@ def export_pkg(self, *args):
                                           build_folder=args.build_folder,
                                           package_folder=args.package_folder,
                                           install_folder=args.install_folder,
-                                          profile_name=args.profile,
+                                          profile_names=args.profile,
                                           env=args.env,
                                           settings=args.settings,
                                           options=args.options,
@@ -1537,7 +1537,7 @@ def _add_common_install_arguments(parser, build_help):
                              '-e CXX=/usr/bin/clang++')
     parser.add_argument("-o", "--options", nargs=1, action=Extender,
                         help='Define options values, e.g., -o Pkg:with_qt=true')
-    parser.add_argument("-pr", "--profile", default=None, action=OnceArgument,
+    parser.add_argument("-pr", "--profile", default=None, action=Extender,
                         help='Apply the specified profile to the install command')
     parser.add_argument("-r", "--remote", action=OnceArgument,
                         help='Look in the specified remote server')
diff --git a/conans/client/conan_api.py b/conans/client/conan_api.py
--- a/conans/client/conan_api.py
+++ b/conans/client/conan_api.py
@@ -299,7 +299,7 @@ def inspect(self, path, attributes, remote_name=None):
         return result
 
     @api_method
-    def test(self, path, reference, profile_name=None, settings=None, options=None, env=None,
+    def test(self, path, reference, profile_names=None, settings=None, options=None, env=None,
              remote_name=None, update=False, build_modes=None, cwd=None, test_build_folder=None):
 
         settings = settings or []
@@ -308,7 +308,7 @@ def test(self, path, reference, profile_name=None, settings=None, options=None,
 
         conanfile_path = _get_conanfile_path(path, cwd, py=True)
         cwd = cwd or get_cwd()
-        graph_info = get_graph_info(profile_name, settings, options, env, cwd, None,
+        graph_info = get_graph_info(profile_names, settings, options, env, cwd, None,
                                     self._cache, self._user_io.out)
         ref = ConanFileReference.loads(reference)
         recorder = ActionRecorder()
@@ -320,7 +320,7 @@ def test(self, path, reference, profile_name=None, settings=None, options=None,
 
     @api_method
     def create(self, conanfile_path, name=None, version=None, user=None, channel=None,
-               profile_name=None, settings=None,
+               profile_names=None, settings=None,
                options=None, env=None, test_folder=None, not_export=False,
                build_modes=None,
                keep_source=False, keep_build=False, verify=None,
@@ -359,7 +359,7 @@ def create(self, conanfile_path, name=None, version=None, user=None, channel=Non
 
             manifests = _parse_manifests_arguments(verify, manifests, manifests_interactive, cwd)
             manifest_folder, manifest_interactive, manifest_verify = manifests
-            graph_info = get_graph_info(profile_name, settings, options, env, cwd, None,
+            graph_info = get_graph_info(profile_names, settings, options, env, cwd, None,
                                         self._cache, self._user_io.out)
 
             manager = self._init_manager(recorder)
@@ -378,7 +378,7 @@ def create(self, conanfile_path, name=None, version=None, user=None, channel=Non
 
     @api_method
     def export_pkg(self, conanfile_path, name, channel, source_folder=None, build_folder=None,
-                   package_folder=None, install_folder=None, profile_name=None, settings=None,
+                   package_folder=None, install_folder=None, profile_names=None, settings=None,
                    options=None, env=None, force=False, user=None, version=None, cwd=None):
 
         settings = settings or []
@@ -407,7 +407,7 @@ def export_pkg(self, conanfile_path, name, channel, source_folder=None, build_fo
                                            default=os.path.dirname(conanfile_path))
 
             # Checks that no both settings and info files are specified
-            graph_info = get_graph_info(profile_name, settings, options, env, cwd, install_folder,
+            graph_info = get_graph_info(profile_names, settings, options, env, cwd, install_folder,
                                         self._cache, self._user_io.out)
 
             conanfile = self._loader.load_export(conanfile_path, name, version, user, channel)
@@ -446,7 +446,7 @@ def download(self, reference, remote_name=None, package=None, recipe=False):
     @api_method
     def install_reference(self, reference, settings=None, options=None, env=None,
                           remote_name=None, verify=None, manifests=None,
-                          manifests_interactive=None, build=None, profile_name=None,
+                          manifests_interactive=None, build=None, profile_names=None,
                           update=False, generators=None, install_folder=None, cwd=None):
 
         try:
@@ -457,7 +457,7 @@ def install_reference(self, reference, settings=None, options=None, env=None,
             manifests = _parse_manifests_arguments(verify, manifests, manifests_interactive, cwd)
             manifest_folder, manifest_interactive, manifest_verify = manifests
 
-            graph_info = get_graph_info(profile_name, settings, options, env, cwd, None,
+            graph_info = get_graph_info(profile_names, settings, options, env, cwd, None,
                                         self._cache, self._user_io.out)
 
             if not generators:  # We don't want the default txt
@@ -480,7 +480,7 @@ def install_reference(self, reference, settings=None, options=None, env=None,
     @api_method
     def install(self, path="", settings=None, options=None, env=None,
                 remote_name=None, verify=None, manifests=None,
-                manifests_interactive=None, build=None, profile_name=None,
+                manifests_interactive=None, build=None, profile_names=None,
                 update=False, generators=None, no_imports=False, install_folder=None, cwd=None):
 
         try:
@@ -489,7 +489,7 @@ def install(self, path="", settings=None, options=None, env=None,
             manifests = _parse_manifests_arguments(verify, manifests, manifests_interactive, cwd)
             manifest_folder, manifest_interactive, manifest_verify = manifests
 
-            graph_info = get_graph_info(profile_name, settings, options, env, cwd, None,
+            graph_info = get_graph_info(profile_names, settings, options, env, cwd, None,
                                         self._cache, self._user_io.out)
 
             wspath = _make_abs_path(path, cwd)
@@ -552,7 +552,7 @@ def config_install(self, path_or_url, verify_ssl, config_type=None, args=None):
         return configuration_install(path_or_url, self._cache, self._user_io.out, verify_ssl,
                                      requester=self._requester, config_type=config_type, args=args)
 
-    def _info_args(self, reference_or_path, install_folder, profile_name, settings, options, env):
+    def _info_args(self, reference_or_path, install_folder, profile_names, settings, options, env):
         cwd = get_cwd()
         try:
             ref = ConanFileReference.loads(reference_or_path)
@@ -567,16 +567,16 @@ def _info_args(self, reference_or_path, install_folder, profile_name, settings,
                 if os.path.exists(os.path.join(cwd, GRAPH_INFO_FILE)):
                     install_folder = cwd
 
-        graph_info = get_graph_info(profile_name, settings, options, env, cwd, install_folder,
+        graph_info = get_graph_info(profile_names, settings, options, env, cwd, install_folder,
                                     self._cache, self._user_io.out)
 
         return ref, graph_info
 
     @api_method
     def info_build_order(self, reference, settings=None, options=None, env=None,
-                         profile_name=None, remote_name=None, build_order=None, check_updates=None,
+                         profile_names=None, remote_name=None, build_order=None, check_updates=None,
                          install_folder=None):
-        reference, graph_info = self._info_args(reference, install_folder, profile_name,
+        reference, graph_info = self._info_args(reference, install_folder, profile_names,
                                                 settings, options, env)
         recorder = ActionRecorder()
         deps_graph, _ = self._graph_manager.load_graph(reference, None, graph_info, ["missing"],
@@ -586,9 +586,9 @@ def info_build_order(self, reference, settings=None, options=None, env=None,
 
     @api_method
     def info_nodes_to_build(self, reference, build_modes, settings=None, options=None, env=None,
-                            profile_name=None, remote_name=None, check_updates=None,
+                            profile_names=None, remote_name=None, check_updates=None,
                             install_folder=None):
-        reference, graph_info = self._info_args(reference, install_folder, profile_name,
+        reference, graph_info = self._info_args(reference, install_folder, profile_names,
                                                 settings, options, env)
         recorder = ActionRecorder()
         deps_graph, conanfile = self._graph_manager.load_graph(reference, None, graph_info,
@@ -600,8 +600,8 @@ def info_nodes_to_build(self, reference, build_modes, settings=None, options=Non
 
     @api_method
     def info(self, reference, remote_name=None, settings=None, options=None, env=None,
-             profile_name=None, update=False, install_folder=None, build=None):
-        reference, graph_info = self._info_args(reference, install_folder, profile_name,
+             profile_names=None, update=False, install_folder=None, build=None):
+        reference, graph_info = self._info_args(reference, install_folder, profile_names,
                                                 settings, options, env)
         recorder = ActionRecorder()
         deps_graph, conanfile = self._graph_manager.load_graph(reference, None, graph_info, build,
@@ -953,7 +953,7 @@ def unlink(self, reference):
 Conan = ConanAPIV1
 
 
-def get_graph_info(profile_name, settings, options, env, cwd, install_folder, cache, output):
+def get_graph_info(profile_names, settings, options, env, cwd, install_folder, cache, output):
     try:
         graph_info = GraphInfo.load(install_folder)
         graph_info.profile.process_settings(cache, preprocess=False)
@@ -963,7 +963,7 @@ def get_graph_info(profile_name, settings, options, env, cwd, install_folder, ca
                                  % install_folder)
         graph_info = None
 
-    if profile_name or settings or options or profile_name or env or not graph_info:
+    if profile_names or settings or options or profile_names or env or not graph_info:
         if graph_info:
             # FIXME: Convert to Exception in Conan 2.0
             output.warn("Settings, options, env or profile specified. "
@@ -971,7 +971,7 @@ def get_graph_info(profile_name, settings, options, env, cwd, install_folder, ca
                         "Don't pass settings, options or profile arguments if you want to reuse "
                         "the installed graph-info file."
                         % install_folder)
-        profile = profile_from_args(profile_name, settings, options, env, cwd, cache)
+        profile = profile_from_args(profile_names, settings, options, env, cwd, cache)
         profile.process_settings(cache)
         graph_info = GraphInfo(profile=profile)
         # Preprocess settings and convert to real settings
diff --git a/conans/client/profile_loader.py b/conans/client/profile_loader.py
--- a/conans/client/profile_loader.py
+++ b/conans/client/profile_loader.py
@@ -213,16 +213,20 @@ def get_package_name_value(item):
     base_profile.env_values = current_env_values
 
 
-def profile_from_args(profile, settings, options, env, cwd, cache):
+def profile_from_args(profiles, settings, options, env, cwd, cache):
     """ Return a Profile object, as the result of merging a potentially existing Profile
     file and the args command-line arguments
     """
     default_profile = cache.default_profile  # Ensures a default profile creating
 
-    if profile is None:
+    if profiles is None:
         result = default_profile
     else:
-        result, _ = read_profile(profile, cwd, cache.profiles_path)
+        result = Profile()
+        for p in profiles:
+            tmp, _ = read_profile(p, cwd, cache.profiles_path)
+            result.update(tmp)
+
     args_profile = _profile_parse_args(settings, options, env)
 
     if result:


Test patch:
diff --git a/conans/test/functional/configuration/profile_test.py b/conans/test/functional/configuration/profile_test.py
--- a/conans/test/functional/configuration/profile_test.py
+++ b/conans/test/functional/configuration/profile_test.py
@@ -1,6 +1,7 @@
 import os
 import unittest
 from collections import OrderedDict
+from textwrap import dedent
 
 from parameterized import parameterized
 
@@ -391,3 +392,138 @@ def config(self):
         self.client.run("info Hello/0.1@lasote/stable --profile scopes_env")
         self.assertIn('''Requires:
         WinRequire/0.1@lasote/stable''', self.client.user_io.out)
+
+
+class ProfileAggregationTest(unittest.TestCase):
+
+    profile1 = dedent("""
+    [settings]
+    os=Windows
+    arch=x86_64
+
+    [env]
+    ENV1=foo
+    ENV2=bar
+
+    """)
+
+    profile2 = dedent("""
+    [settings]
+    arch=x86
+    build_type=Debug
+    compiler=Visual Studio
+    compiler.version=15
+
+    [env]
+    ENV1=foo2
+    ENV3=bar2
+    """)
+
+    conanfile = dedent("""
+    from conans.model.conan_file import ConanFile
+    import os
+
+    class DefaultNameConan(ConanFile):
+        settings = "os", "compiler", "arch", "build_type"
+
+        def build(self):
+            self.output.warn("ENV1:%s" % os.getenv("ENV1"))
+            self.output.warn("ENV2:%s" % os.getenv("ENV2"))
+            self.output.warn("ENV3:%s" % os.getenv("ENV3"))
+    """)
+
+    consumer = dedent("""
+    from conans.model.conan_file import ConanFile
+    import os
+
+    class DefaultNameConan(ConanFile):
+        settings = "os", "compiler", "arch", "build_type"
+        requires = "lib/1.0@user/channel"
+    """)
+
+    def setUp(self):
+        self.client = TestClient()
+        self.client.save({CONANFILE: self.conanfile,
+                          "profile1": self.profile1, "profile2": self.profile2})
+
+    def test_create(self):
+
+        # The latest declared profile has priority
+        self.client.run("create . lib/1.0@user/channel --profile profile1 -p profile2")
+        self.assertIn(dedent("""
+        [env]
+        ENV1=foo2
+        ENV2=bar
+        ENV3=bar2
+        """), self.client.out)
+        self.client.run("search lib/1.0@user/channel")
+        self.assertIn("arch: x86", self.client.out)
+
+    def test_info(self):
+
+        # The latest declared profile has priority
+        self.client.run("create . lib/1.0@user/channel --profile profile1 -p profile2")
+
+        self.client.save({CONANFILE: self.consumer})
+        self.client.run("info . --profile profile1 --profile profile2")
+        self.assertIn("b786e9ece960c3a76378ca4d5b0d0e922f4cedc1", self.client.out)
+
+        # Build order
+        self.client.run("info . --profile profile1 --profile profile2 "
+                        "--build-order lib/1.0@user/channel")
+        self.assertIn("[lib/1.0@user/channel]", self.client.out)
+
+    def test_install(self):
+        self.client.run("export . lib/1.0@user/channel")
+        # Install ref
+        self.client.run("install lib/1.0@user/channel -p profile1 -p profile2 --build missing")
+        self.assertIn(dedent("""
+               [env]
+               ENV1=foo2
+               ENV2=bar
+               ENV3=bar2
+               """), self.client.out)
+        self.client.run("search lib/1.0@user/channel")
+        self.assertIn("arch: x86", self.client.out)
+
+        # Install project
+        self.client.save({CONANFILE: self.consumer})
+        self.client.run("install . -p profile1 -p profile2 --build")
+        self.assertIn("arch=x86", self.client.out)
+        self.assertIn(dedent("""
+                       [env]
+                       ENV1=foo2
+                       ENV2=bar
+                       ENV3=bar2
+                       """), self.client.out)
+
+    def test_export_pkg(self):
+        self.client.run("export-pkg . lib/1.0@user/channel -pr profile1 -pr profile2")
+        # ID for the expected settings applied: x86, Visual Studio 15,...
+        self.assertIn("b786e9ece960c3a76378ca4d5b0d0e922f4cedc1", self.client.out)
+
+    def profile_crazy_inheritance_test(self):
+        profile1 = dedent("""
+            [settings]
+            os=Windows
+            arch=x86_64
+            compiler=Visual Studio
+            compiler.version=15
+            """)
+
+        profile2 = dedent("""
+            include(profile1)
+            [settings]
+            os=Linux
+            """)
+
+        self.client.save({"profile1": profile1, "profile2": profile2})
+        self.client.run("create . lib/1.0@user/channel --profile profile2 -p profile1")
+        self.assertIn(dedent("""
+                             Configuration:
+                             [settings]
+                             arch=x86_64
+                             compiler=Visual Studio
+                             compiler.runtime=MD
+                             compiler.version=15
+                             os=Windows"""), self.client.out)