Repository: conan-io/conan

Issue:
Printable dependency graph
I have an idea for a feature add that I would be happy to implement if I knew that you would be open to incorporating it into the code base.

Often while working on a big project with a lot of dependencies, it's nice to be able to see at a glance what the dependency tree looks like. The current way of seeing it via `conan info` is nice but hard to view mentally because it's just flat text.

My idea would be to add something like `conan graph` which would generate a [dot](http://www.graphviz.org/content/dot-language) file that could be fed into a graph generator like [graphviz](http://www.graphviz.org/) or [mermaid](https://knsv.github.io/mermaid/).

Thank you.


Discussion:
Yes, I think it is a good idea, have been previously around, but never got priority enough to be implemented. If you are willing to do it, great, very welcome.

Some tips:
- The ``conan info`` command receives same parameters as ``install``. Depending on the inputs, the dependency graph could be different, also the binaries. Same should be for ``conan graph``
- Then, as much code will be duplicated, you might consider using a ``conan info --graph`` instead, or do refactor internals.
- Consider abstracting and having a TextPrinter and a GraphPrinter. Probably, html/js graph would be cool too, so that could be implemented in another Printer.

Just ask if you need any help with the implementation, testing, setup, or understanding something from the code. Thanks very much!! 

Resolving patch:
diff --git a/conans/client/command.py b/conans/client/command.py
--- a/conans/client/command.py
+++ b/conans/client/command.py
@@ -421,6 +421,8 @@ def info(self, *args):
         parser.add_argument("--build_order", "-bo",
                             help='given a modified reference, return an ordered list to build (CI)',
                             nargs=1, action=Extender)
+        parser.add_argument("--graph", "-g", nargs="?", const="graph.dot",
+                            help='Output project dependencies in DOT format for visual graphing')
         build_help = 'given a build policy (same install command "build" parameter), return an ordered list of  ' \
                      'packages that would be built from sources in install command (simulation)'
 
@@ -455,7 +457,8 @@ def info(self, *args):
                            build_mode=args.build,
                            scopes=scopes,
                            env_values=env_values,
-                           profile_name=args.profile)
+                           profile_name=args.profile,
+                           graph_filename=args.graph)
 
     def build(self, *args):
         """ Utility command to run your current project 'conanfile.py' build() method.
diff --git a/conans/client/grapher.py b/conans/client/grapher.py
new file mode 100644
--- /dev/null
+++ b/conans/client/grapher.py
@@ -0,0 +1,43 @@
+import os
+from conans.model.ref import ConanFileReference
+
+class ConanGrapher(object):
+    def __init__(self, project_reference, deps_graph):
+        self._deps_graph = deps_graph
+        self._project_reference = project_reference
+
+    def graph(self, output_file):
+        graph_lines = []
+
+        graph_lines.append('digraph {\n')
+
+        for node in self._deps_graph.nodes:
+            ref = node.conan_ref
+
+            if ref is None:
+                ref = self._project_reference
+
+            depends = self._deps_graph.neighbors(node)
+
+            if depends:
+                graph_lines.append('    "%s" -> {' % str(ref))
+
+                for i, dep in enumerate(depends):
+                    graph_lines.append('"%s"' % str(dep.conan_ref))
+
+                    if i == len(depends) - 1:
+                        graph_lines.append('}\n')
+                    else:
+                        graph_lines.append(' ')
+
+        graph_lines.append('}\n')
+
+        graph_lines_string = ''.join(graph_lines)
+
+        self.graph_file(graph_lines_string, output_file)
+
+    def graph_file(self, graph, output_filename):
+        output_file = os.path.join(os.getcwd(), output_filename)
+
+        with open(output_file, 'w') as f:
+            f.write(graph)
diff --git a/conans/client/manager.py b/conans/client/manager.py
--- a/conans/client/manager.py
+++ b/conans/client/manager.py
@@ -8,6 +8,7 @@
 from conans.client.detect import detected_os
 from conans.client.export import export_conanfile
 from conans.client.generators import write_generators
+from conans.client.grapher import ConanGrapher
 from conans.client.importer import run_imports, undo_imports
 from conans.client.installer import ConanInstaller
 from conans.client.loader import ConanFileLoader
@@ -197,7 +198,7 @@ def _get_graph(self, reference, current_path, remote, options, settings, filenam
         return (builder, deps_graph, project_reference, registry, conanfile,
                 remote_proxy, loader)
 
-    def info(self, reference, current_path, remote=None, options=None, settings=None,
+    def info(self, reference, current_path, graph_filename, remote=None, options=None, settings=None,
              info=None, filename=None, update=False, check_updates=False, scopes=None,
              build_order=None, build_mode=None, package_settings=None, env_values=None,
              profile_name=None):
@@ -248,9 +249,13 @@ def read_dates(deps_graph):
         else:
             graph_updates_info = {}
 
-        Printer(self._user_io.out).print_info(deps_graph, project_reference,
-                                              info, registry, graph_updates_info,
-                                              remote, read_dates(deps_graph))
+        if graph_filename:
+            grapher = ConanGrapher(project_reference, deps_graph)
+            grapher.graph(graph_filename)
+        else:
+            Printer(self._user_io.out).print_info(deps_graph, project_reference,
+                                                  info, registry, graph_updates_info,
+                                                  remote, read_dates(deps_graph))
 
     def read_profile(self, profile_name, cwd):
         if not profile_name:


Test patch:
diff --git a/conans/test/command/info_test.py b/conans/test/command/info_test.py
--- a/conans/test/command/info_test.py
+++ b/conans/test/command/info_test.py
@@ -1,7 +1,10 @@
 import unittest
+import os
+import re
 from conans.test.tools import TestClient
 from conans.test.utils.cpp_test_files import cpp_hello_conan_files
 from conans.paths import CONANFILE
+from conans.model.ref import ConanFileReference
 import textwrap
 
 
@@ -44,6 +47,77 @@ def _create(self, number, version, deps=None, deps_dev=None, export=True):
             self.client.run("export lasote/stable")
             self.assertNotIn("WARN: Conanfile doesn't have 'url'", self.client.user_io.out)
 
+    def graph_test(self):
+        self.client = TestClient()
+
+        test_deps = {
+            "Hello0": ["Hello1", "Hello2", "Hello3"],
+            "Hello1": ["Hello4"],
+            "Hello2": [],
+            "Hello3": ["Hello7"],
+            "Hello4": ["Hello5", "Hello6"],
+            "Hello5": [],
+            "Hello6": [],
+            "Hello7": ["Hello8"],
+            "Hello8": ["Hello9", "Hello10"],
+            "Hello9": [],
+            "Hello10": [],
+        }
+
+        def create_export(test_deps, name):
+            deps = test_deps[name]
+            for dep in deps:
+                create_export(test_deps, dep)
+
+            expanded_deps = ["%s/0.1@lasote/stable" % dep for dep in deps]
+            export = False if name == "Hello0" else True
+            self._create(name, "0.1", expanded_deps, export=export)
+
+        def check_conan_ref(ref):
+            self.assertEqual(ref.version, "0.1")
+            self.assertEqual(ref.user, "lasote")
+            self.assertEqual(ref.channel, "stable")
+
+        def check_digraph_line(line):
+            self.assertTrue(dot_regex.match(line))
+
+            # root node (current project) is special case
+            line = line.replace("@PROJECT", "@lasote/stable")
+
+            node_matches = node_regex.findall(line)
+
+            parent = ConanFileReference.loads(node_matches[0])
+            deps = [ConanFileReference.loads(ref) for ref in node_matches[1:]]
+
+            check_conan_ref(parent)
+            for dep in deps:
+                check_conan_ref(dep)
+                self.assertIn(dep.name, test_deps[parent.name])
+
+        def check_file(dot_file):
+            with open(dot_file) as dot_file_contents:
+                lines = dot_file_contents.readlines()
+                self.assertEqual(lines[0], "digraph {\n");
+                for line in lines[1:-1]:
+                    check_digraph_line(line)
+                self.assertEqual(lines[-1], "}\n");
+
+        create_export(test_deps, "Hello0")
+
+        node_regex = re.compile(r'"([^"]+)"');
+        dot_regex = re.compile(r'^\s+"[^"]+" -> {"[^"]+"( "[^"]+")*}$')
+
+        # default case - file will be named graph.dot
+        self.client.run("info --graph")
+        dot_file = os.path.join(self.client.current_folder, "graph.dot")
+        check_file(dot_file)
+
+        # arbitrary case - file will be named according to argument
+        arg_filename = "test.dot"
+        self.client.run("info --graph=%s" % arg_filename)
+        dot_file = os.path.join(self.client.current_folder, arg_filename)
+        check_file(dot_file)
+
     def only_names_test(self):
         self.client = TestClient()
         self._create("Hello0", "0.1")