Repository: conan-io/conan
Pull request #16611 (2024-07-04 12:23:13)

Add html output for graph build-order and graph build-order-merge
Changelog: Feature: Add html output for graph build-order and graph build-order-merge
Docs: TODO

Closes: https://github.com/conan-io/conan/issues/16356

![image](https://github.com/conan-io/conan/assets/5045666/e2a3c6f6-6784-4426-bdd1-7568e9b90370)




czoido: > I think it is a pity that we couldnt do this with the other js library, and use the same node formatters. If possible I'd try to use tool-requires and test-requires with a different shape, but not a big issue. Also the alignment of the layers, but I guess that you have already tried and it is far from easy, so not an issue either.
> 
> Good to go!

Yes, it looks like _vis.js_ is not the right library for this kind of diagrams... :face_with_diagonal_mouth:
I have updated the output to show the requires from the build context a bit different.

Patch:
diff --git a/conan/cli/commands/graph.py b/conan/cli/commands/graph.py
index 85d7cb510..8f4124744 100644
--- a/conan/cli/commands/graph.py
+++ b/conan/cli/commands/graph.py
@@ -8,6 +8,7 @@ from conan.cli.args import common_graph_args, validate_common_graph_args
 from conan.cli.command import conan_command, conan_subcommand
 from conan.cli.commands.list import prepare_pkglist_compact, print_serial
 from conan.cli.formatters.graph import format_graph_html, format_graph_json, format_graph_dot
+from conan.cli.formatters.graph.build_order_html import format_build_order_html
 from conan.cli.formatters.graph.graph_info_text import format_graph_info
 from conan.cli.printers.graph import print_graph_packages, print_graph_basic
 from conan.errors import ConanException
@@ -62,7 +63,8 @@ def json_build_order(build_order):
     cli_out_write(json.dumps(build_order, indent=4))
 
 
-@conan_subcommand(formatters={"text": cli_build_order, "json": json_build_order})
+@conan_subcommand(formatters={"text": cli_build_order, "json": json_build_order,
+                              "html": format_build_order_html})
 def graph_build_order(conan_api, parser, subparser, *args):
     """
     Compute the build order of a dependency graph.
@@ -129,7 +131,8 @@ def graph_build_order(conan_api, parser, subparser, *args):
     return install_order_serialized
 
 
-@conan_subcommand(formatters={"text": cli_build_order, "json": json_build_order})
+@conan_subcommand(formatters={"text": cli_build_order, "json": json_build_order,
+                              "html": format_build_order_html})
 def graph_build_order_merge(conan_api, parser, subparser, *args):
     """
     Merge more than 1 build-order file.
diff --git a/conan/cli/formatters/graph/build_order_html.py b/conan/cli/formatters/graph/build_order_html.py
new file mode 100644
index 000000000..e785bb01b
--- /dev/null
+++ b/conan/cli/formatters/graph/build_order_html.py
@@ -0,0 +1,272 @@
+from jinja2 import select_autoescape, Template
+from conan.api.output import cli_out_write
+
+build_order_html = r"""
+<html lang="en">
+    <head>
+        <script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.30.0/cytoscape.min.js"
+        integrity="sha512-zHc90yHSbkgx0bvVpDK/nVgxANlE+yKN/jKy91tZ4P/vId8AL7HyjSpZqHmEujWDWNwxYXcfaLdYWjAULl35MQ=="
+        crossorigin="anonymous"
+        referrerpolicy="no-referrer"></script>
+    </head>
+
+    <body>
+        <style>
+            body {
+                font: 14px helvetica neue, helvetica, arial, sans-serif;
+                display: flex;
+                margin: 0;
+                padding: 0;
+                height: 100vh;
+            }
+
+            .sidebar {
+                background: #f9f9f9;
+                border-right: 1px solid #ccc;
+                padding: 20px;
+                box-sizing: border-box;
+                overflow-y: auto;
+                font-size: 14px;
+                width: 440px;
+            }
+
+            .content {
+                flex-grow: 1;
+                display: flex;
+                flex-direction: column;
+                overflow: hidden;
+            }
+
+            #cy {
+                flex-grow: 1;
+            }
+
+            #node-info {
+                margin-top: 20px;
+                background: #f9f9f9;
+                padding: 12px;
+                font-family: monospace;
+                white-space: pre-wrap;
+                font-size: 12px;
+                word-wrap: break-word;
+            }
+
+            .legend {
+                margin-top: 20px;
+                font-size: 14px;
+            }
+
+            .legend-item {
+                display: flex;
+                align-items: center;
+                margin-bottom: 5px;
+            }
+
+            .legend-color {
+                width: 20px;
+                height: 20px;
+                margin-right: 10px;
+            }
+        </style>
+
+        <div class="sidebar">
+            <div class="legend">
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #ffff37;"></div>
+                    <span>All packages need to be built</span>
+                </div>
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #ff9b28;"></div>
+                    <span>Some packages need to be built</span>
+                </div>
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #70c7e6;"></div>
+                    <span>Cache</span>
+                </div>
+                <div class="legend-item">
+                    <div class="legend-color" style="background-color: #79eb8a;"></div>
+                    <span>Download</span>
+                </div>
+                <div class="legend-item">
+                    <div class="legend-color" style="border: 1px solid black; width: 20px; height: 20px;"></div>
+                    <span>Requirements in the <i>host</i> context</span>
+                </div>
+                <div class="legend-item">
+                    <div class="legend-color" style="border: 1px solid black; border-radius: 50%; width: 20px; height: 20px;"></div>
+                    <span>Requirements in the <i>build</i> context</span>
+                </div>
+            </div>
+            <div id="node-info">
+                <p>Click on a node to see details.</p>
+            </div>
+        </div>
+        <div class="content">
+            <div id="cy"></div>
+        </div>
+
+        <script type="text/javascript">
+            document.addEventListener("DOMContentLoaded", function() {
+                var buildOrderData = {{ build_order | tojson }};
+
+                var elements = [];
+                var edges = [];
+
+                var yOffset = 50;
+                var posX = 0;
+                var posY = 0;
+                var columns = 4;
+
+                buildOrderData.forEach((step, stepIndex) => {
+                    var stepId = 'step' + stepIndex;
+                    elements.push({
+                        data: { id: stepId, label: 'Step ' + (stepIndex + 1) },
+                        position: { x: posX, y: posY }
+                    });
+
+                    step.forEach((lib, libIndex) => {
+                        var libId = stepId + '_lib' + libIndex;
+                        var libLabel = lib.ref.split('#')[0];
+
+                        // Determine the type of the library
+                        var libData = Array.isArray(lib.packages) ? lib.packages[0][0] : lib;
+                        var libType = libData.binary || libData.build || libData.cache;
+
+                        var isAllBuild = true;
+                        var isSomeBuild = false;
+                        var shape = 'rectangle';
+                        var borderColor = '#00695C';
+
+                        if (Array.isArray(lib.packages)) {
+                            lib.packages.forEach(pkgArray => {
+                                pkgArray.forEach(pkg => {
+                                    if (pkg.binary === "Build") {
+                                        isSomeBuild = true;
+                                    } else {
+                                        isAllBuild = false;
+                                    }
+                                    if (pkg.context === "build") {
+                                        shape = 'ellipse';
+                                        borderColor = '#0000FF';  // Different border color for build context
+                                    }
+                                });
+                            });
+                        }
+
+                        var nodeColor;
+                        if (isAllBuild) {
+                            nodeColor = "#ffff37"; // Light orange for all build
+                        } else if (isSomeBuild) {
+                            nodeColor = "#ff9b28"; // Yellow for some build
+                        } else if (libType === "Cache") {
+                            nodeColor = "#70c7e6"; // Light green
+                        } else if (libType === "Download") {
+                            nodeColor = "#79eb8a"; // Light blue
+                        } else {
+                            nodeColor = "#FFFFFF"; // Default color
+                        }
+
+                        if (libIndex % columns === 0) {
+                            posX = 0;
+                            posY += yOffset; // move to the next row
+                        }
+
+                        elements.push({
+                            data: { id: libId, parent: stepId, label: libLabel, info: lib, color: nodeColor, shape: shape, borderColor: borderColor },
+                            position: { x: posX + libLabel.length / 2.0 * 12, y: posY }
+                        });
+                        posX += libLabel.length * 12;
+                    });
+
+                    if (stepIndex > 0) {
+                        var prevStepId = 'step' + (stepIndex - 1);
+                        edges.push({ data: { id: prevStepId + '_to_' + stepId, source: prevStepId, target: stepId } });
+                    }
+
+                    posY += yOffset * 2;
+                    posX = 0;
+                });
+
+                var cy = cytoscape({
+                    container: document.getElementById('cy'),
+                    boxSelectionEnabled: false,
+                    style: [
+                        {
+                            selector: 'node[color][shape][borderColor]',
+                            style: {
+                                'shape': 'data(shape)',
+                                'content': 'data(label)',
+                                'text-valign': 'center',
+                                'text-halign': 'center',
+                                'background-color': 'data(color)',
+                                'border-color': 'data(borderColor)',
+                                'border-width': 1,
+                                'width': function(ele) { return ele.data('label').length * 10.5; },
+                                'padding': '5px',
+                                'font-family': 'monospace',
+                                'font-size': '16px'
+                            }
+                        },
+                        {
+                            selector: ':parent',
+                            style: {
+                                'text-valign': 'top',
+                                'text-halign': 'center',
+                                'shape': 'round-rectangle',
+                                'background-opacity': 0.1,
+                                'border-color': '#004D40',
+                                'border-width': 2,
+                                'padding': 10,
+                                'font-family': 'monospace',
+                                'font-size': '16px'
+                            }
+                        },
+                        {
+                            selector: 'edge',
+                            style: {
+                                'curve-style': 'bezier',
+                                'target-arrow-shape': 'triangle',
+                                'line-color': '#004D40',
+                                'target-arrow-color': '#004D40',
+                                'width': 2
+                            }
+                        }
+                    ],
+                    elements: {
+                        nodes: elements,
+                        edges: edges
+                    },
+                    layout: {
+                        name: 'preset',
+                        padding: 5,
+                        fit: true
+                    }
+                });
+
+                // Add click event listener to nodes
+                cy.on('tap', 'node', function(evt){
+                    var node = evt.target;
+                    var info = node.data('info');
+                    var infoHtml = '';
+                    for (var key in info) {
+                        if (info.hasOwnProperty(key)) {
+                            infoHtml += '<p><strong>' + key + ':</strong> ' + JSON.stringify(info[key], null, 2) + '</p>';
+                        }
+                    }
+                    document.getElementById('node-info').innerHTML = infoHtml;
+                });
+            });
+        </script>
+    </body>
+</html>
+"""
+
+def _render_build_order(build_order, template):
+    from conans import __version__ as client_version
+    context = {'build_order': build_order, 'version': client_version, }
+    return template.render(context)
+
+
+def format_build_order_html(build_order):
+    build_order = build_order["order"] if isinstance(build_order, dict) else build_order
+    template = Template(build_order_html, autoescape=select_autoescape(['html', 'xml']))
+    cli_out_write(_render_build_order(build_order, template))
diff --git a/conans/client/downloaders/download_cache.py b/conans/client/downloaders/download_cache.py
index 51c587b93..c32765621 100644
--- a/conans/client/downloaders/download_cache.py
+++ b/conans/client/downloaders/download_cache.py
@@ -6,7 +6,7 @@ from threading import Lock
 from conans.errors import ConanException
 from conans.util.dates import timestamp_now
 from conans.util.files import load, save, remove_if_dirty
-from conans.util.locks import SimpleLock
+from conans.util.locks import simple_lock
 from conans.util.sha import sha256 as compute_sha256
 
 
@@ -35,7 +35,7 @@ class DownloadCache:
     @contextmanager
     def lock(self, lock_id):
         lock = os.path.join(self._path, self._LOCKS, lock_id)
-        with SimpleLock(lock):
+        with simple_lock(lock):
             # Once the process has access, make sure multithread is locked too
             # as SimpleLock doesn't work multithread
             thread_lock = self._thread_locks.setdefault(lock, Lock())
diff --git a/conans/util/locks.py b/conans/util/locks.py
index 40a0d7016..04abec3f1 100644
--- a/conans/util/locks.py
+++ b/conans/util/locks.py
@@ -1,120 +1,24 @@
-import os
-import time
+from contextlib import contextmanager
 
 import fasteners
 
-from conans.util.files import load, save
 
-logger = None
+@contextmanager
+def simple_lock(lock_path):
+    lock = fasteners.InterProcessLock(lock_path)
+    with lock:
+        yield
 
-class NoLock(object):
 
-    def __enter__(self):
-        pass
+@contextmanager
+def read_lock(lock_path):
+    rw_lock = fasteners.InterProcessReaderWriterLock(lock_path)
+    with rw_lock.read_lock():
+        yield
 
-    def __exit__(self, exc_type, exc_val, exc_tb):  # @UnusedVariable
-        pass
 
-
-class SimpleLock(object):
-
-    def __init__(self, filename):
-        self._lock = fasteners.InterProcessLock(filename, logger=logger)
-
-    def __enter__(self):
-        self._lock.acquire()
-
-    def __exit__(self, exc_type, exc_val, exc_tb):  # @UnusedVariable
-        self._lock.release()
-
-
-READ_BUSY_DELAY = 0.5
-WRITE_BUSY_DELAY = 0.25
-
-
-class Lock(object):
-
-    @staticmethod
-    def clean(folder):
-        if os.path.exists(folder + ".count"):
-            os.remove(folder + ".count")
-        if os.path.exists(folder + ".count.lock"):
-            os.remove(folder + ".count.lock")
-
-    def __init__(self, folder, locked_item, output):
-        self._count_file = folder + ".count"
-        self._count_lock_file = folder + ".count.lock"
-        self._locked_item = locked_item
-        self._output = output
-        self._first_lock = True
-
-    @property
-    def files(self):
-        return self._count_file, self._count_lock_file
-
-    def _info_locked(self):
-        if self._first_lock:
-            self._first_lock = False
-            self._output.info("%s is locked by another concurrent conan process, wait..."
-                              % str(self._locked_item))
-            self._output.info("If not the case, quit, and do 'conan remove --locks'")
-
-    def _readers(self):
-        try:
-            return int(load(self._count_file))
-        except IOError:
-            return 0
-        except (UnicodeEncodeError, ValueError):
-            self._output.warning("%s does not contain a number!" % self._count_file)
-            return 0
-
-
-class ReadLock(Lock):
-
-    def __enter__(self):
-        while True:
-            with fasteners.InterProcessLock(self._count_lock_file, logger=logger):
-                readers = self._readers()
-                if readers >= 0:
-                    save(self._count_file, str(readers + 1))
-                    break
-            self._info_locked()
-            time.sleep(READ_BUSY_DELAY)
-
-    def __exit__(self, exc_type, exc_val, exc_tb):   # @UnusedVariable
-        with fasteners.InterProcessLock(self._count_lock_file, logger=logger):
-            readers = self._readers()
-            save(self._count_file, str(readers - 1))
-
-
-class WriteLock(Lock):
-
-    def __enter__(self):
-        while True:
-            with fasteners.InterProcessLock(self._count_lock_file, logger=logger):
-                readers = self._readers()
-                if readers == 0:
-                    save(self._count_file, "-1")
-                    break
-            self._info_locked()
-            time.sleep(WRITE_BUSY_DELAY)
-
-    def __exit__(self, exc_type, exc_val, exc_tb):  # @UnusedVariable
-        with fasteners.InterProcessLock(self._count_lock_file, logger=logger):
-            save(self._count_file, "0")
-
-        if exc_type is not None:
-            # If there was an exception while locking this, might be empty
-            # Try to clean up the trailing filelocks
-            try:
-                os.remove(self._count_file)
-                os.remove(self._count_lock_file)
-                path = os.path.dirname(self._count_file)
-                for _ in range(3):
-                    try:  # Take advantage that os.rmdir does not delete non-empty dirs
-                        os.rmdir(path)
-                    except Exception:
-                        break  # not empty
-                    path = os.path.dirname(path)
-            except Exception:
-                pass
+@contextmanager
+def write_lock(lock_path):
+    rw_lock = fasteners.InterProcessReaderWriterLock(lock_path)
+    with rw_lock.write_lock():
+        yield


Test patch:
diff --git a/.ci/jenkins/testsv2.jenkins b/.ci/jenkins/testsv2.jenkins
index 08ba87fff..f443a2a7e 100644
--- a/.ci/jenkins/testsv2.jenkins
+++ b/.ci/jenkins/testsv2.jenkins
@@ -21,9 +21,9 @@ List<Map> getConfigurations(String moduleName, String branchName, String jobName
         configs.add([node: "Linux", pyvers: ["PY312"]])
     }
     if (branchName =~ /(^PR-.*)/) {
-        configs.add([node: "Linux", pyvers: ["PY36"]])
-        configs.add([node: "Windows", pyvers: ["PY36"]])
-        configs.add([node: "M2Macos", pyvers: ["PY36"]])
+        configs.add([node: "Linux", pyvers: ["PY38"]])
+        configs.add([node: "Windows", pyvers: ["PY38"]])
+        configs.add([node: "M2Macos", pyvers: ["PY38"]])
     }
     else if (jobName == "ConanNightlyv2" || branchName =~ /^release.*/) {
         configs.add([node: "Linux", pyvers: ["PY36", "PY38"]])
diff --git a/test/integration/command_v2/test_info_build_order.py b/test/integration/command_v2/test_info_build_order.py
index cf95afb54..b735920e1 100644
--- a/test/integration/command_v2/test_info_build_order.py
+++ b/test/integration/command_v2/test_info_build_order.py
@@ -73,6 +73,14 @@ def test_info_build_order():
     assert bo_json["order_by"] == "recipe"
     assert bo_json["order"] == result
 
+    # test html format
+    c.run("graph build-order consumer --build=missing --format=html")
+    assert "<body>" in c.stdout
+    c.run("graph build-order consumer --order-by=recipe --format=html")
+    assert "<body>" in c.stdout
+    c.run("graph build-order consumer --order-by=configuration --format=html")
+    assert "<body>" in c.stdout
+
 
 def test_info_build_order_configuration():
     c = TestClient()
@@ -332,6 +340,11 @@ def test_info_build_order_merge_multi_product():
 
     assert bo_json == result
 
+    # test that html format for build-order-merge generates something
+    c.run("graph build-order-merge --file=bo1.json --file=bo2.json --format=html")
+    assert "<body>" in c.stdout
+
+
 
 def test_info_build_order_merge_multi_product_configurations():
     c = TestClient()
diff --git a/test/integration/settings/per_package_settings_test.py b/test/integration/settings/per_package_settings_test.py
index 4cf0d8687..482be6976 100644
--- a/test/integration/settings/per_package_settings_test.py
+++ b/test/integration/settings/per_package_settings_test.py
@@ -148,3 +148,35 @@ def test_per_package_settings_build():
     c.run("install --tool-requires=cmake/0.1 -s:b build_type=Release -s:b cmake*:build_type=Debug "
           "--build=missing")
     assert "cmake/0.1: Building myself with Debug!!" in c.out
+
+
+def test_package_settings_mixed_patterns():
+    # https://github.com/conan-io/conan/issues/16606
+    c = TestClient()
+    profile = textwrap.dedent("""
+        [settings]
+        arch=x86_64
+        *@test/*:build_type=Release
+
+        os=Linux
+        compiler=gcc
+        &:compiler.version=12
+        compiler.libcxx = libstdc++
+        """)
+    conanfile = textwrap.dedent("""
+        from conan import ConanFile
+        class Pkg(ConanFile):
+            name = "mypkg"
+            version = "0.1"
+            settings = "os", "arch", "compiler", "build_type"
+            def build(self):
+                self.output.info(f"BUILD_TYPE={self.settings.build_type}!")
+                self.output.info(f"COMPILER_VERSION={self.settings.compiler.version}!")
+                self.output.info(f"COMPILER_LIBCXX={self.settings.compiler.libcxx}!")
+            """)
+    c.save({"profile": profile,
+            "conanfile.py": conanfile})
+    c.run("create . -pr=profile --user=test --channel=test")
+    assert "mypkg/0.1@test/test: BUILD_TYPE=Release!" in c.out
+    assert "mypkg/0.1@test/test: COMPILER_VERSION=12!" in c.out
+    assert "mypkg/0.1@test/test: COMPILER_LIBCXX=libstdc++!" in c.out