Repository: conan-io/conan
Pull request: Feature/build order profile args
Changelog: Feature: Add profile arguments information to ``conan graph build-order`` to improve UX and usage in CI systems.
Docs: https://github.com/conan-io/docs/pull/3884

Close https://github.com/conan-io/conan/issues/17089

Problem statement:
[question] Profile information in merged build-order files
### What is your question?

Hi!
In our workflow, we merge the two build-order JSON files for the Release and Debug profile and then go through the build-order hierarchy and build the packages by using the `build_args` flags from the according JSON entry for a `conan install` command.
Two questions on this procedure:

1) Is the `conan install`command the correct (the intended) way of processing a build-order file?
2) Is there a way to extract the profile information out of merged build-order files? I can only see, that the origin of a package is listed in the `filenames` entry, but from there I have to do some manual processing to retrieve the information, which profile to use for building that package.

### Have you read the CONTRIBUTING guide?

- [x] I've read the CONTRIBUTING guide

Hints from issue discussion:
Hi @NilsKlimanis 

Thanks for your question.

I am just trying to finalize the docs new CI tutorial covering these aspects, see ongoing work in https://github.com/conan-io/docs/pull/3799

It is true that it is not that straightforward to recover the profile information from the build-order files, it assumes that the build-order filename itself encodes or maps back to a given profile or configuration, because in practice multiple profiles can be passed.

I will be trying (no guarantees, I have no idea if possible), to add profile and input arguments information in the build order files for next release, please keep tuned
Btw, Conan 2.8 added a new ``info`` section in the build order, that contains the ``package_id`` relevant configuration. In theory it should be possible to create a profile from it and apply it, but I also think that probably keeping the original profile arguments is more intuitive and straightforward.
Hi @memsharded, 
thanks for the quick reply!
I've tried out Version 2.8 with the according `info` section, and it looks quite good!
However, I can see, that the `build_type` (e.g. `Debug`/`Release`) is not always included.
It seems though, that these are only cases, where the package doesn't need compiling...
Can you confirm this?
> However, I can see, that the build_type (e.g. Debug/Release) is not always included.
It seems though, that these are only cases, where the package doesn't need compiling...
Can you confirm this?

Yes, exactly, the information that is in ``info`` is the minimal information that is necessary to reproduce this specific ``package_id``. For example for a ``header-library`` that is independent of everything, the ``info`` should be empty.
It basically means that you can use any profile or settings, and it will still produce the same final package binary.

In any case, the release cadence is every month, it means that if things go well, there might be the profiles argument information in just a few weeks, so maybe it can wait it a bit.

Commit message: proposed profile args for build-order

Patch to review:
diff --git a/conan/cli/commands/graph.py b/conan/cli/commands/graph.py
index a929f39f1e9..430bad75ec9 100644
--- a/conan/cli/commands/graph.py
+++ b/conan/cli/commands/graph.py
@@ -14,7 +14,7 @@
 from conan.errors import ConanException
 from conan.internal.deploy import do_deploys
 from conans.client.graph.graph import BINARY_MISSING
-from conans.client.graph.install_graph import InstallGraph
+from conans.client.graph.install_graph import InstallGraph, ProfileArgs
 from conans.errors import NotFoundException
 from conans.model.recipe_ref import ref_matches, RecipeReference
 
@@ -116,7 +116,8 @@ def graph_build_order(conan_api, parser, subparser, *args):
     out = ConanOutput()
     out.title("Computing the build order")
 
-    install_graph = InstallGraph(deps_graph, order_by=args.order_by)
+    install_graph = InstallGraph(deps_graph, order_by=args.order_by,
+                                 profile_args=ProfileArgs.from_args(args))
     if args.reduce:
         if args.order_by is None:
             raise ConanException("--reduce needs --order-by argument defined")
diff --git a/conans/client/graph/install_graph.py b/conans/client/graph/install_graph.py
index 63221cc3238..ab99c92569b 100644
--- a/conans/client/graph/install_graph.py
+++ b/conans/client/graph/install_graph.py
@@ -331,17 +331,41 @@ def merge(self, other):
                 self.filenames.append(d)
 
 
+class ProfileArgs:
+    def __init__(self, args):
+        self._args = args
+
+    @staticmethod
+    def from_args(args):
+        pr_args = []
+        for context in "host", "build":
+            for f in "profile", "settings", "options", "conf":
+                s = "pr" if f == "profile" else f[0]
+                pr_args += [f'-{s}:{context[0]} "{v}"' for v in
+                            getattr(args, f"{f}_{context}") or []]
+        return ProfileArgs(" ".join(pr_args))
+
+    @staticmethod
+    def deserialize(data):
+        return ProfileArgs(data.get("args"))
+
+    def serialize(self):
+        return {"args": self._args}
+
+
 class InstallGraph:
     """ A graph containing the package references in order to be built/downloaded
     """
 
-    def __init__(self, deps_graph, order_by=None):
+    def __init__(self, deps_graph, order_by=None, profile_args=None):
         self._nodes = {}  # ref with rev: _InstallGraphNode
         order_by = order_by or "recipe"
         self._order = order_by
         self._node_cls = _InstallRecipeReference if order_by == "recipe" else _InstallConfiguration
         self._is_test_package = False
         self.reduced = False
+        self._profiles = {"self": profile_args} if profile_args is not None else {}
+        self._filename = None
         if deps_graph is not None:
             self._initialize_deps_graph(deps_graph)
             self._is_test_package = deps_graph.root.conanfile.tested_reference_str is not None
@@ -368,15 +392,24 @@ def merge(self, other):
                 self._nodes[ref] = install_node
             else:
                 existing.merge(install_node)
+        # Make sure that self is also updated
+        current = self._profiles.pop("self", None)
+        if current is not None:
+            self._profiles[self._filename] = current
+        new = other._profiles.get("self")
+        if new is not None:
+            self._profiles[other._filename] = new
 
     @staticmethod
     def deserialize(data, filename):
         legacy = isinstance(data, list)
-        order, data, reduced = ("recipe", data, False) if legacy else \
-            (data["order_by"], data["order"], data["reduced"])
+        order, data, reduced, profiles = ("recipe", data, False, {}) if legacy else \
+            (data["order_by"], data["order"], data["reduced"], data.get("profiles", {}))
         result = InstallGraph(None, order_by=order)
         result.reduced = reduced
         result.legacy = legacy
+        result._filename = filename
+        result._profiles = {k: ProfileArgs.deserialize(v) for k, v in profiles.items()}
         for level in data:
             for item in level:
                 elem = result._node_cls.deserialize(item, filename)
@@ -461,17 +494,19 @@ def install_build_order(self):
         install_order = self.install_order()
         result = {"order_by": self._order,
                   "reduced": self.reduced,
-                  "order": [[n.serialize() for n in level] for level in install_order]}
+                  "order": [[n.serialize() for n in level] for level in install_order],
+                  "profiles": {k: v.serialize() for k, v in self._profiles.items()}
+                  }
         return result
 
     def _get_missing_invalid_packages(self):
         missing, invalid = [], []
 
-        def analyze_package(package):
-            if package.binary == BINARY_MISSING:
-                missing.append(package)
-            elif package.binary == BINARY_INVALID:
-                invalid.append(package)
+        def analyze_package(pkg):
+            if pkg.binary == BINARY_MISSING:
+                missing.append(pkg)
+            elif pkg.binary == BINARY_INVALID:
+                invalid.append(pkg)
         for _, install_node in self._nodes.items():
             if self._order == "recipe":
                 for package in install_node.packages.values():
@@ -505,7 +540,8 @@ def get_errors(self):
             return "\n".join(errors)
         return None
 
-    def _raise_invalid(self, invalid):
+    @staticmethod
+    def _raise_invalid(invalid):
         msg = ["There are invalid packages:"]
         for package in invalid:
             node = package.nodes[0]
diff --git a/test/integration/command_v2/test_info_build_order.py b/test/integration/command_v2/test_info_build_order.py
index 21f6b86a4d9..16cd8f1b1db 100644
--- a/test/integration/command_v2/test_info_build_order.py
+++ b/test/integration/command_v2/test_info_build_order.py
@@ -765,3 +765,22 @@ def validate(self):
         tc.run("graph build-order-merge --file=order.json --file=order.json --format=json", assert_error=True)
         assert "dep/1.0:da39a3ee5e6b4b0d3255bfef95601890afd80709: Invalid configuration" in tc.out
         assert "IndexError: list index out of range" not in tc.out
+
+
+def test_multi_configuration_profile_args():
+    c = TestClient()
+    c.save({"pkg/conanfile.py": GenConanfile().with_settings("os"),
+            "consumer/conanfile.txt": "[requires]\npkg/0.1",
+            "mypr": ""})
+    c.run("export pkg --name=pkg --version=0.1")
+    args = "-pr=mypr -s:b os=Linux -o:h *:shared=True -c:h user.my:conf=1"
+    c.run(f"graph build-order consumer --format=json --build=missing -s os=Windows {args} "
+          "--order-by=recipe", redirect_stdout="bo_win.json")
+    c.run(f"graph build-order consumer --format=json --build=missing -s os=Linux {args} "
+          "--order-by=recipe", redirect_stdout="bo_nix.json")
+    c.run("graph build-order-merge --file=bo_win.json --file=bo_nix.json --format=json",
+          redirect_stdout="bo3.json")
+    bo_json = json.loads(c.load("bo3.json"))
+    win = '-pr:h "mypr" -s:h "os=Windows" -o:h "*:shared=True" -c:h "user.my:conf=1" -s:b "os=Linux"'
+    nix = '-pr:h "mypr" -s:h "os=Linux" -o:h "*:shared=True" -c:h "user.my:conf=1" -s:b "os=Linux"'
+    assert bo_json["profiles"] == {"bo_win": {"args": win}, "bo_nix": {"args": nix}}


Code review comments:
diff_hunk: @@ -331,17 +331,41 @@ def merge(self, other):
                 self.filenames.append(d)
 
 
+class ProfileArgs:
+    def __init__(self, args):
+        self._args = args
+
+    @staticmethod
+    def from_args(args):
+        pr_args = []
+        for context in "host", "build":
+            for f in "profile", "settings", "options", "conf":
+                s = "pr" if f == "profile" else f[0]
+                pr_args += [f'-{s}:{context[0]} "{v}"' for v in
path: conans/client/graph/install_graph.py
text: @author:
```suggestion
                pr_args += [f'-{s}:{context[0]}="{v}"' for v in
```