Repository: conan-io/conan
Pull request: [graph] Changed graph info json output
Changelog: Bugfix: Fixed and completed the `graph info xxxx --format json` output, to publicly document it.
Docs: https://github.com/conan-io/docs/pull/3236

Closes: https://github.com/conan-io/conan/issues/13911
Closes: https://github.com/conan-io/conan/issues/13862

For instance:

```
{
    "nodes": {
         "0": {
            "ref": "pkga/0.1",
            "id": 0,
            "recipe": "Consumer",
            "package_id": "041db7e16de982fbd01f61f3809fc1c206fa9931",
            "prev": null,
            "build_id": null,
            "binary": null,
            "invalid_build": false,
            "info_invalid": null,
            "name": "pkga",
            "user": null,
            "channel": null,
            "url": null,
            "license": null,
            "author": null,
            "description": null,
            "homepage": null,
            "build_policy": null,
            "upload_policy": null,
            "revision_mode": "hash",
            "provides": null,
            "deprecated": null,
            "win_bash": null,
            "win_bash_run": null,
            "default_options": null,
            "options_description": null,
            "version": "0.1",
            "topics": null,
            "package_type": "unknown",
            "settings": {},
            "options": {},
            "options_definitions": {},
            "generators": [],
            "system_requires": {},
            "recipe_folder": "/private/var/folders/s8/zgn806y90xq7bdh64hhfw9th0000gr/T/tmpze4f_1t9conans/path with spaces",
            "source_folder": null,
            "build_folder": null,
            "generators_folder": null,
            "package_folder": null,
            "cpp_info": {
                "root": {
                    "includedirs": [
                        "include"
                    ],
                    "srcdirs": null,
                    "libdirs": [
                        "lib"
                    ],
                    "resdirs": null,
                    "bindirs": [
                        "bin"
                    ],
                    "builddirs": null,
                    "frameworkdirs": null,
                    "system_libs": null,
                    "frameworks": null,
                    "libs": null,
                    "defines": null,
                    "cflags": null,
                    "cxxflags": null,
                    "sharedlinkflags": null,
                    "exelinkflags": null,
                    "objects": null,
                    "sysroot": null,
                    "requires": null,
                    "properties": null
                }
            },
            "label": "conanfile.py (pkga/0.1)",
            "dependencies": {
                "1": {
                    "ref": "pkgb/0.1@common/unstable",
                    "run": "False",
                    "libs": "True",
                    "skip": "False",
                    "test": "False",
                    "force": "False",
                    "direct": "True",
                    "build": "False",
                    "transitive_headers": "None",
                    "transitive_libs": "None",
                    "headers": "True",
                    "package_id_mode": "semver_mode",
                    "visible": "True"
                },
                "3": {
                    "ref": "ros_perception/1.1.4@3rdparty/unstable",
                    "run": "False",
                    "libs": "True",
                    "skip": "False",
                    "test": "False",
                    "force": "False",
                    "direct": "True",
                    "build": "False",
                    "transitive_headers": "None",
                    "transitive_libs": "None",
                    "headers": "True",
                    "package_id_mode": "semver_mode",
                    "visible": "True"
                },
                "2": {
                    "ref": "ros_core/pr-53@3rdparty/snapshot",
                    "run": "False",
                    "libs": "True",
                    "skip": "False",
                    "test": "False",
                    "force": "True",
                    "direct": "True",
                    "build": "False",
                    "transitive_headers": "None",
                    "transitive_libs": "None",
                    "headers": "True",
                    "package_id_mode": "semver_mode",
                    "visible": "True"
                }
            },
            "context": "host",
            "test": false
    },

    [....],

     "root": {
        "0": "pkga/0.1"
    },
    "overrides": {
        "ros_core/[~1.1]@3rdparty/unstable": [
            "ros_core/pr-53@3rdparty/snapshot"
        ],
        "ros_core/1.1.4@3rdparty/unstable": [
            "ros_core/pr-53@3rdparty/snapshot#4d670581ccb765839f2239cc8dff8fbd"
        ]
    },
    "resolved-ranges": {
        "pkgb/[~0]@common/unstable": "pkgb/0.1@common/unstable",
        "ros_perception/[~1.1]@3rdparty/unstable": "ros_perception/1.1.4@3rdparty/unstable"
    }
}
```


Problem statement:
[question] Conan2: Reading / dumping version ranges for dependencies ?
### What is your question?

In order to integrate a Coan2 into our existing CI infrastructure, we would like to print (or dump to file) not only the RESOLVED dependencies  (such as those found in self.dependencies) but also the requirement as set in the requirements() method, that could be a version range, such as:
`self.test_requires("gtest/[>1]")`

It seems this information is available in the dependency graph (and is printed by Conan2 under "Resolved version ranges") but the CLI command "`conan graph info`" does not display it.

Is it mandatory to use the graph API ? I'd rather not use it as it's marked as unstable for now.

I've searched through the existing issues and I haven't found what I'm looking for.

Thanks for any help.


### Have you read the CONTRIBUTING guide?

- [X] I've read the CONTRIBUTING guide

Discuss graph info json output
Things to do:

- Convert ``nodes`` list to dict {id: nodecontents}
- Add the metadata author, license, homepage, etc always, even if ``null``
- Convert the ``requires`` to a full serialization of ``transitive_deps`` in the form: ``{id: {ref: rref, build=True, libs=False, ...``
- Add summary of graph resolution, similar to command terminal stdout for ``resolved-ranges``, ``overrides``, etc.

Hints from issue discussion:
Hi @ATS-AValli 

Thanks for your question.

To see if I understood it correctly, you would like to have the same or equivalent of what is printed to screen in the json output of ``conan graph info .. --format=json``?

We plan to put some time this sprint to improve and stabilize that ``json`` output, so we could take this into consideration.
Hi!

> Thanks for your question.
> 
> To see if I understood it correctly, you would like to have the same or equivalent of what is printed to screen in the json output of `conan graph info .. --format=json`?

Not exactly, I've tried using "graph info" (with JSON format) and nowhere I could locate the requirement as it was specified in the requirements() method.

To be more clear, let's imagine we have the following method:

```
    def requirements(self):
        self.requires("mypackagef/[>0]", transitive_headers=True)
 [Imagine more self.requires() here]
```

What I would like to catch, other than the RESOLVED dependency (which could become "mypackage/1.1.1@user/channel"), is "mypackagef/[>0]".
Now, of course since it's specified in the code already I could copy and paste the same string in my desired output but I would like to read it from the code.

Or said in other words, I would like to have the same data that is being printed during "install" or "create" right below:

Resolved version ranges

I've been looking into the source of conan 2 and saw that this is the result of a call to graph.load_graph().

Thanks.


> Not exactly, I've tried using "graph info" (with JSON format) and nowhere I could locate the requirement as it was specified in the requirements() method.

Yes, my suggestion/question, was if you would be happy if we added some new information to the json that contained the same information that is printed on screen.

> Or said in other words, I would like to have the same data that is being printed during "install" or "create" right below:

Yes, this is what I meant, adding this to the json


> Yes, my suggestion/question, was if you would be happy if we added some new information to the json that contained the same information that is printed on screen.

I would be very happy! :-)

> Yes, this is what I meant, adding this to the json

Now, I get it. Yes, that's what we would need.

Thanks a lot for the prompt response; I hope this improvement can make it through the pipeline in appropriate time.

Commit message: Printing the same

Patch to review:
diff --git a/conan/cli/formatters/graph/graph_info_text.py b/conan/cli/formatters/graph/graph_info_text.py
index 6c5edc90227..55b6e258a82 100644
--- a/conan/cli/formatters/graph/graph_info_text.py
+++ b/conan/cli/formatters/graph/graph_info_text.py
@@ -6,15 +6,15 @@
 
 def filter_graph(graph, package_filter, field_filter=None):
     if package_filter is not None:
-        graph["nodes"] = [n for n in graph["nodes"]
-                          if any(fnmatch.fnmatch(n["ref"] or "", p) for p in package_filter)]
+        graph["nodes"] = {id_: n for id_, n in graph["nodes"].items()
+                          if any(fnmatch.fnmatch(n["ref"] or "", p) for p in package_filter)}
     if field_filter is not None:
         if "ref" not in field_filter:
             field_filter.append("ref")
-        result = []
-        for n in graph["nodes"]:
+        result = {}
+        for id_, n in graph["nodes"].items():
             new_node = OrderedDict((k, v) for k, v in n.items() if k in field_filter)
-            result.append(new_node)
+            result[id_] = new_node
         graph["nodes"] = result
     return graph
 
@@ -31,7 +31,7 @@ def format_graph_info(result):
     out.title("Basic graph information")
     serial = graph.serialize()
     serial = filter_graph(serial, package_filter, field_filter)
-    for n in serial["nodes"]:
+    for _, n in serial["nodes"].items():
         out.writeln(f"{n['ref']}:")  # FIXME: This can be empty for consumers and it is ugly ":"
         _serial_pretty_printer(n, indent="  ")
     if graph.error:
diff --git a/conans/client/graph/graph.py b/conans/client/graph/graph.py
index 101394301d3..f2367d2dba0 100644
--- a/conans/client/graph/graph.py
+++ b/conans/client/graph/graph.py
@@ -208,6 +208,15 @@ def __repr__(self):
         return repr(self.conanfile)
 
     def serialize(self):
+        def _topics():
+            ret = self.conanfile.topics
+            if ret and isinstance(ret, (list, tuple)):
+                return ret
+            elif ret:
+                return [ret]
+            else:
+                return None
+
         result = OrderedDict()
         result["ref"] = self.ref.repr_notime() if self.ref is not None else "conanfile"
         result["id"] = getattr(self, "id")  # Must be assigned by graph.serialize()
@@ -216,6 +225,11 @@ def serialize(self):
         result["prev"] = self.prev
         from conans.client.installer import build_id
         result["build_id"] = build_id(self.conanfile)
+        result['url'] = self.conanfile.url,
+        result['homepage'] = self.conanfile.homepage,
+        result['license'] = self.conanfile.license,
+        result['author'] = self.conanfile.author,
+        result['topics'] = _topics()
         result["binary"] = self.binary
         # TODO: This doesn't match the model, check it
         result["invalid_build"] = self.cant_build
@@ -224,7 +238,8 @@ def serialize(self):
         result.update(self.conanfile.serialize())
         result["context"] = self.context
         result["test"] = self.test
-        result["requires"] = {n.id: n.ref.repr_notime() for n in self.neighbors()}
+        result["transitive_deps"] = {d.node.id: d.require.serialize()
+                                     for d in self.transitive_deps.values() if d.node is not None}
         return result
 
     def overrides(self):
@@ -381,6 +396,8 @@ def serialize(self):
         for i, n in enumerate(self.nodes):
             n.id = i
         result = OrderedDict()
-        result["nodes"] = [n.serialize() for n in self.nodes]
+        result["nodes"] = {n.id: n.serialize() for n in self.nodes}
         result["root"] = {self.root.id: repr(self.root.ref)}  # TODO: ref of consumer/virtual
+        result["overrides"] = self.overrides().serialize()
+        result["resolved-ranges"] = {repr(r): s.repr_notime() for r, s in self.resolved_ranges.items()}
         return result
diff --git a/conans/test/integration/command/info/info_test.py b/conans/test/integration/command/info/info_test.py
index c99e733d57d..c33e404d0cc 100644
--- a/conans/test/integration/command/info/info_test.py
+++ b/conans/test/integration/command/info/info_test.py
@@ -70,7 +70,7 @@ class MyTest(ConanFile):
                             """)
         client.save({"conanfile.py": conanfile})
         client.run("graph info . --format=json")
-        recipe = json.loads(client.stdout)["nodes"][0]
+        recipe = json.loads(client.stdout)["nodes"]["0"]
         assert type(recipe["topics"]) == list
         assert recipe["topics"] == ["foo"]
         assert type(recipe["provides"]) == list
@@ -90,7 +90,7 @@ class MyTest(ConanFile):
                     """)
         client2.save({"conanfile.py": conanfile2})
         client2.run("graph info . --format=json")
-        recipe = json.loads(client2.stdout)["nodes"][0]
+        recipe = json.loads(client2.stdout)["nodes"]["0"]
         assert type(recipe["topics"]) == list
         assert recipe["topics"] == ["foo"]
         assert type(recipe["provides"]) == list
@@ -163,10 +163,10 @@ def test_json_package_filter(self):
         conanfile = GenConanfile("pkg", "0.1").with_setting("build_type")
         client.save({"conanfile.py": conanfile})
         client.run("graph info . --package-filter=nothing --format=json")
-        assert '"nodes": []' in client.out
+        assert '"nodes": {}' in client.out
         client.run("graph info . --package-filter=pkg* --format=json")
         graph = json.loads(client.stdout)
-        assert graph["nodes"][0]["ref"] == "pkg/0.1"
+        assert graph["nodes"]["0"]["ref"] == "pkg/0.1"
 
     def test_json_info_outputs(self):
         client = TestClient()
@@ -174,7 +174,7 @@ def test_json_info_outputs(self):
         client.save({"conanfile.py": conanfile})
         client.run("graph info . -s build_type=Debug --format=json")
         graph = json.loads(client.stdout)
-        assert graph["nodes"][0]["settings"]["build_type"] == "Debug"
+        assert graph["nodes"]["0"]["settings"]["build_type"] == "Debug"
 
 
 class TestAdvancedCliOutput:
@@ -199,7 +199,7 @@ class pkg(ConanFile):
 
         client.run("graph info . --format=json")
         info = json.loads(client.stdout)
-        assert info["nodes"][0]["python_requires"] == ['tool/0.1#4d670581ccb765839f2239cc8dff8fbd']
+        assert info["nodes"]["0"]["python_requires"] == ['tool/0.1#4d670581ccb765839f2239cc8dff8fbd']
 
     def test_build_id_info(self):
         client = TestClient()
diff --git a/conans/test/integration/graph/test_validate_build.py b/conans/test/integration/graph/test_validate_build.py
index 99d88d2d361..5e263e1d482 100644
--- a/conans/test/integration/graph/test_validate_build.py
+++ b/conans/test/integration/graph/test_validate_build.py
@@ -46,11 +46,11 @@ def package_id(self):
     # What happens with a conan info?
     t.run(f"graph info --requires=foo/1.0 {settings_gcc} --format=json", redirect_stdout="myjson")
     myjson = json.loads(t.load("myjson"))["nodes"]
-    assert myjson[1]["invalid_build"] == "This doesn't build in GCC"
+    assert myjson["1"]["invalid_build"] == "This doesn't build in GCC"
 
     t.run(f"graph info --requires=foo/1.0 {settings_clang} --format=json", redirect_stdout="myjson")
     myjson = json.loads(t.load("myjson"))["nodes"]
-    assert myjson[1]["invalid_build"] is False
+    assert myjson["1"]["invalid_build"] is False
 
 
 def test_with_options_validate_build_test():


Code review comments:
diff_hunk: @@ -224,7 +238,8 @@ def serialize(self):
         result.update(self.conanfile.serialize())
         result["context"] = self.context
         result["test"] = self.test
-        result["requires"] = {n.id: n.ref.repr_notime() for n in self.neighbors()}
+        result["transitive_deps"] = {d.node.id: d.require.serialize()
path: conans/client/graph/graph.py
text: @user1:
Maybe we want to call it ``requires``? ``transitive_requires``? ``dependencies``?

@author:
Yeah, I wanted to bring it here to discuss the best name. Perhaps, keeping `requires` is good enough? wdyt @user2 @user3 ?

@user4:
I'd say `dependencies`, for symmetry with `self.dependencies` in a ConanFile

@user1:
Yes, ``dependencies`` could be the best, it is the closest, it is how ``dependencies`` is computed:
```python
 def from_node(node):
        d = OrderedDict((require, ConanFileInterface(transitive.node.conanfile))
                        for require, transitive in node.transitive_deps.items())
        return ConanFileDependencies(d)
```
It is basically a thin adaptation over ``transitive_deps``, with an interface to protect internal data on top of it (we might want to make accessible some of this data over the ``ConanFileInterface`` if necessary and requested by users)