Repository: conan-io/conan
Pull request #17616 (2025-01-23 10:08:12)

increase sqlite timeout from 10 to 20
Changelog: Fix: Increase sqlite timeout from 10 to 20 seconds for very heavily loaded CI servers
Docs: Omit

Close https://github.com/conan-io/conan/pull/17424




Patch:
diff --git a/conan/internal/cache/db/table.py b/conan/internal/cache/db/table.py
index 5ad675ad6..e4e150d26 100644
--- a/conan/internal/cache/db/table.py
+++ b/conan/internal/cache/db/table.py
@@ -23,8 +23,8 @@ class BaseDbTable:
 
     @contextmanager
     def db_connection(self):
-        assert self._lock.acquire(timeout=10), "Conan failed to acquire database lock"
-        connection = sqlite3.connect(self.filename, isolation_level=None, timeout=10)
+        assert self._lock.acquire(timeout=20), "Conan failed to acquire database lock"
+        connection = sqlite3.connect(self.filename, isolation_level=None, timeout=20)
         try:
             yield connection
         finally:
diff --git a/conan/internal/model/conan_file.py b/conan/internal/model/conan_file.py
index 3af81e440..58fb90754 100644
--- a/conan/internal/model/conan_file.py
+++ b/conan/internal/model/conan_file.py
@@ -189,6 +189,10 @@ class ConanFile:
     def context(self):
         return self._conan_node.context
 
+    @property
+    def subgraph(self):
+        return self._conan_node.subgraph()
+
     @property
     def dependencies(self):
         # Caching it, this object is requested many times
diff --git a/conan/tools/sbom/__init__.py b/conan/tools/sbom/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/conan/tools/sbom/cyclonedx.py b/conan/tools/sbom/cyclonedx.py
new file mode 100644
index 000000000..a875833df
--- /dev/null
+++ b/conan/tools/sbom/cyclonedx.py
@@ -0,0 +1,76 @@
+
+def cyclonedx_1_4(graph, **kwargs):
+    """
+    (Experimental) Generate cyclone 1.4 sbom with json format
+    """
+    import uuid
+    import time
+    from datetime import datetime, timezone
+
+    has_special_root_node = not (getattr(graph.root.ref, "name", False) and getattr(graph.root.ref, "version", False) and getattr(graph.root.ref, "revision", False))
+    special_id = str(uuid.uuid4())
+
+    components = [node for node in graph.nodes]
+    if has_special_root_node:
+        components = components[1:]
+
+    dependencies = []
+    if has_special_root_node:
+        deps = {"ref": special_id,
+                "dependsOn": [f"pkg:conan/{d.dst.name}@{d.dst.ref.version}?rref={d.dst.ref.revision}"
+                              for d in graph.root.dependencies]}
+        dependencies.append(deps)
+    for c in components:
+        deps = {"ref": f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}"}
+        depends_on = [f"pkg:conan/{d.dst.name}@{d.dst.ref.version}?rref={d.dst.ref.revision}" for d in c.dependencies]
+        if depends_on:
+            deps["dependsOn"] = depends_on
+        dependencies.append(deps)
+
+    def _calculate_licenses(component):
+        if isinstance(component.conanfile.license, str): # Just one license
+            return [{"license": {
+                        "id": component.conanfile.license
+                    }}]
+        return [{"license": {
+                    "id": l
+                }} for l in c.conanfile.license]
+
+    sbom_cyclonedx_1_4 = {
+        **({"components": [{
+            "author": "Conan",
+            "bom-ref": special_id if has_special_root_node else f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}",
+            "description": c.conanfile.description,
+            **({"externalReferences": [{
+                "type": "website",
+                "url": c.conanfile.homepage
+            }]} if c.conanfile.homepage else {}),
+            **({"licenses": _calculate_licenses(c)} if c.conanfile.license else {}),
+            "name": c.name,
+            "purl": f"pkg:conan/{c.name}@{c.ref.version}",
+            "type": "library",
+            "version": str(c.ref.version),
+        } for c in components]} if components else {}),
+        **({"dependencies": dependencies} if dependencies else {}),
+        "metadata": {
+            "component": {
+                "author": "Conan",
+                "bom-ref": special_id if has_special_root_node else f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}",
+                "name": graph.root.conanfile.display_name,
+                "type": "library"
+            },
+            "timestamp": f"{datetime.fromtimestamp(time.time(), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}",
+            "tools": [{
+                "externalReferences": [{
+                    "type": "website",
+                    "url": "https://github.com/conan-io/conan"
+                }],
+                "name": "Conan-io"
+            }],
+        },
+        "serialNumber": f"urn:uuid:{uuid.uuid4()}",
+        "bomFormat": "CycloneDX",
+        "specVersion": "1.4",
+        "version": 1,
+    }
+    return sbom_cyclonedx_1_4
diff --git a/conans/client/graph/graph.py b/conans/client/graph/graph.py
index 2b2ef7bec..b5037c271 100644
--- a/conans/client/graph/graph.py
+++ b/conans/client/graph/graph.py
@@ -70,6 +70,23 @@ class Node(object):
         self.replaced_requires = {}  # To track the replaced requires for self.dependencies[old-ref]
         self.skipped_build_requires = False
 
+    def subgraph(self):
+        nodes = [self]
+        opened = [self]
+        while opened:
+            new_opened = []
+            for o in opened:
+                for n in o.neighbors():
+                    if n not in nodes:
+                        nodes.append(n)
+                    if n not in opened:
+                        new_opened.append(n)
+            opened = new_opened
+
+        graph = DepsGraph()
+        graph.nodes = nodes
+        return graph
+
     def __lt__(self, other):
         """
         @type other: Node


Test patch:
diff --git a/test/functional/sbom/test_cyclonedx.py b/test/functional/sbom/test_cyclonedx.py
new file mode 100644
index 000000000..8aafd1945
--- /dev/null
+++ b/test/functional/sbom/test_cyclonedx.py
@@ -0,0 +1,138 @@
+import textwrap
+
+import pytest
+
+from conan.test.assets.genconanfile import GenConanfile
+from conan.test.utils.tools import TestClient
+from conans.util.files import save
+import os
+
+# Using the sbom tool with "conan create"
+sbom_hook_post_package = """
+import json
+import os
+from conan.errors import ConanException
+from conan.api.output import ConanOutput
+from conan.tools.sbom.cyclonedx import cyclonedx_1_4
+
+def post_package(conanfile):
+    sbom_cyclonedx_1_4 = cyclonedx_1_4(conanfile.subgraph)
+    metadata_folder = conanfile.package_metadata_folder
+    file_name = "sbom.cdx.json"
+    with open(os.path.join(metadata_folder, file_name), 'w') as f:
+        json.dump(sbom_cyclonedx_1_4, f, indent=4)
+    ConanOutput().success(f"CYCLONEDX CREATED - {conanfile.package_metadata_folder}")
+"""
+
+@pytest.fixture()
+def hook_setup_post_package():
+    tc = TestClient()
+    hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
+    save(hook_path, sbom_hook_post_package)
+    return tc
+
+@pytest.fixture()
+def hook_setup_post_package_tl(transitive_libraries):
+    tc = transitive_libraries
+    hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
+    save(hook_path, sbom_hook_post_package)
+    return tc
+
+
+
+def test_sbom_generation_create(hook_setup_post_package_tl):
+    tc = hook_setup_post_package_tl
+    tc.run("new cmake_lib -d name=bar -d version=1.0 -d requires=engine/1.0 -f")
+    # bar -> engine/1.0 -> matrix/1.0
+    tc.run("create . --build=missing")
+    bar_layout = tc.created_layout()
+    assert os.path.exists(os.path.join(bar_layout.metadata(), "sbom.cdx.json"))
+
+def test_sbom_generation_skipped_dependencies(hook_setup_post_package):
+    tc = hook_setup_post_package
+    tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"),
+             "app/conanfile.py": GenConanfile("app", "1.0")
+                                .with_package_type("application")
+                                .with_requires("dep/1.0"),
+             "conanfile.py": GenConanfile("foo", "1.0").with_tool_requires("app/1.0")})
+    tc.run("create dep")
+    tc.run("create app")
+    tc.run("create .")
+    create_layout = tc.created_layout()
+
+    cyclone_path = os.path.join(create_layout.metadata(), "sbom.cdx.json")
+    content = tc.load(cyclone_path)
+    # A skipped dependency also shows up in the sbom
+    assert "pkg:conan/dep@1.0?rref=6a99f55e933fb6feeb96df134c33af44" in content
+
+
+# Using the sbom tool with "conan install"
+sbom_hook_post_generate = """
+import json
+import os
+from conan.errors import ConanException
+from conan.api.output import ConanOutput
+from conan.tools.sbom.cyclonedx import cyclonedx_1_4
+
+def post_generate(conanfile):
+    sbom_cyclonedx_1_4 = cyclonedx_1_4(conanfile.subgraph)
+    generators_folder = conanfile.generators_folder
+    file_name = "sbom.cdx.json"
+    os.mkdir(os.path.join(generators_folder, "sbom"))
+    with open(os.path.join(generators_folder, "sbom", file_name), 'w') as f:
+        json.dump(sbom_cyclonedx_1_4, f, indent=4)
+    ConanOutput().success(f"CYCLONEDX CREATED - {conanfile.generators_folder}")
+"""
+
+@pytest.fixture()
+def hook_setup_post_generate():
+    tc = TestClient()
+    hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
+    save(hook_path, sbom_hook_post_generate)
+    return tc
+
+def test_sbom_generation_install_requires(hook_setup_post_generate):
+    tc = hook_setup_post_generate
+    tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"),
+             "conanfile.py": GenConanfile("foo", "1.0").with_requires("dep/1.0")})
+    tc.run("export dep")
+    tc.run("create . --build=missing")
+
+    #cli -> foo -> dep
+    tc.run("install --requires=foo/1.0")
+    assert os.path.exists(os.path.join(tc.current_folder, "sbom", "sbom.cdx.json"))
+
+def test_sbom_generation_install_path(hook_setup_post_generate):
+    tc = hook_setup_post_generate
+    tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"),
+             "conanfile.py": GenConanfile("foo", "1.0").with_requires("dep/1.0")})
+    tc.run("create dep")
+
+    #foo -> dep
+    tc.run("install .")
+    assert os.path.exists(os.path.join(tc.current_folder, "sbom", "sbom.cdx.json"))
+
+def test_sbom_generation_install_path_consumer(hook_setup_post_generate):
+    tc = hook_setup_post_generate
+    tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"),
+             "conanfile.py": GenConanfile().with_requires("dep/1.0")})
+    tc.run("create dep")
+
+    #conanfile.py -> dep
+    tc.run("install .")
+    assert os.path.exists(os.path.join(tc.current_folder, "sbom", "sbom.cdx.json"))
+
+def test_sbom_generation_install_path_txt(hook_setup_post_generate):
+    tc = hook_setup_post_generate
+    tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"),
+             "conanfile.txt": textwrap.dedent(
+                 """
+                 [requires]
+                 dep/1.0
+                 """
+             )})
+    tc.run("create dep")
+
+    #foo -> dep
+    tc.run("install .")
+    assert os.path.exists(os.path.join(tc.current_folder, "sbom", "sbom.cdx.json"))
diff --git a/test/integration/graph/test_subgraph_reports.py b/test/integration/graph/test_subgraph_reports.py
new file mode 100644
index 000000000..597fb6386
--- /dev/null
+++ b/test/integration/graph/test_subgraph_reports.py
@@ -0,0 +1,48 @@
+import json
+import os
+import textwrap
+
+from conan.test.assets.genconanfile import GenConanfile
+from conan.test.utils.tools import TestClient
+from conans.model.recipe_ref import RecipeReference
+from conans.util.files import load
+
+
+def _metadata(c, ref):
+    pref = c.get_latest_package_reference(RecipeReference.loads(ref))
+    return c.get_latest_pkg_layout(pref).metadata()
+from conan.internal.model.lockfile import Lockfile
+def test_subgraph_reports():
+    c = TestClient()
+    subgraph_hook = textwrap.dedent("""\
+        import os, json
+        from conan.tools.files import save
+        from conan.internal.model.lockfile import Lockfile
+        def post_package(conanfile):
+            subgraph = conanfile.subgraph
+            save(conanfile, os.path.join(conanfile.package_metadata_folder, f"conangraph.json"),
+                 json.dumps(subgraph.serialize(), indent=2))
+            save(conanfile, os.path.join(conanfile.package_metadata_folder, f"conan.lock"),
+                 Lockfile(subgraph).dumps())
+        """)
+
+    c.save_home({"extensions/hooks/subgraph_hook/hook_subgraph.py": subgraph_hook})
+    c.save({"dep/conanfile.py": GenConanfile("dep", "0.1"),
+            "pkg/conanfile.py": GenConanfile("pkg", "0.1").with_requirement("dep/0.1"),
+            "app/conanfile.py": GenConanfile("app", "0.1").with_requirement("pkg/0.1")})
+    c.run("export dep")
+    c.run("export pkg")
+    # app -> pkg -> dep
+    c.run("create app --build=missing --format=json")
+
+    app_graph = json.loads(load(os.path.join(_metadata(c, "app/0.1"), "conangraph.json")))
+    pkg_graph = json.loads(load(os.path.join(_metadata(c, "pkg/0.1"), "conangraph.json")))
+    dep_graph = json.loads(load(os.path.join(_metadata(c, "dep/0.1"), "conangraph.json")))
+
+    app_lock = json.loads(load(os.path.join(_metadata(c, "app/0.1"), "conan.lock")))
+    pkg_lock = json.loads(load(os.path.join(_metadata(c, "pkg/0.1"), "conan.lock")))
+    dep_lock = json.loads(load(os.path.join(_metadata(c, "dep/0.1"), "conan.lock")))
+
+    assert len(app_graph["nodes"]) == len(app_lock["requires"])
+    assert len(pkg_graph["nodes"]) == len(pkg_lock["requires"])
+    assert len(dep_graph["nodes"]) == len(dep_lock["requires"])