[AIRFLOW-5368] Display DAG from the CLI
diff --git a/airflow/bin/cli.py b/airflow/bin/cli.py
index f2134c262ddde..24645a75d378d 100644
--- a/airflow/bin/cli.py
+++ b/airflow/bin/cli.py
@@ -17,7 +17,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
+import errno
 import importlib
 import logging
 
@@ -34,6 +34,7 @@
 import argparse
 from argparse import RawTextHelpFormatter
 
+from airflow.utils.dot_renderer import render_dag
 from airflow.utils.timezone import parse as parsedate
 import json
 from tabulate import tabulate
@@ -441,6 +442,33 @@ def set_is_paused(is_paused, args):
     print("Dag: {}, paused: {}".format(args.dag_id, str(is_paused)))
 
 
+def show_dag(args):
+    dag = get_dag(args)
+    dot = render_dag(dag)
+    if args.save:
+        filename, _, fileformat = args.save.rpartition('.')
+        dot.render(filename=filename, format=fileformat, cleanup=True)
+        print("File {} saved".format(args.save))
+    elif args.imgcat:
+        data = dot.pipe(format='png')
+        try:
+            proc = subprocess.Popen("imgcat", stdout=subprocess.PIPE, stdin=subprocess.PIPE)
+        except OSError as e:
+            if e.errno == errno.ENOENT:
+                raise AirflowException(
+                    "Failed to execute. Make sure the imgcat executables are on your systems \'PATH\'"
+                )
+            else:
+                raise
+        out, err = proc.communicate(data)
+        if out:
+            print(out.decode('utf-8'))
+        if err:
+            print(err.decode('utf-8'))
+    else:
+        print(dot.source)
+
+
 def _run(args, dag, ti):
     if args.local:
         run_job = jobs.LocalTaskJob(
@@ -1789,6 +1817,26 @@ class CLIFactory:
         'dag_regex': Arg(
             ("-dx", "--dag_regex"),
             "Search dag_id as regex instead of exact string", "store_true"),
+        # show_dag
+        'save': Arg(
+            ("-s", "--save"),
+            "Saves the result to the indicated file.\n"
+            "\n"
+            "The file format is determined by the file extension. For more information about supported "
+            "format, see: https://www.graphviz.org/doc/info/output.html\n"
+            "\n"
+            "If you want to create a PNG file then you should execute the following command:\n"
+            "airflow dags show <DAG_ID> --save output.png\n"
+            "\n"
+            "If you want to create a DOT file then you should execute the following command:\n"
+            "airflow dags show <DAG_ID> --save output.dot\n"
+        ),
+        'imgcat': Arg(
+            ("--imgcat", ),
+            "Displays graph using the imgcat tool. \n"
+            "\n"
+            "For more information, see: https://www.iterm2.com/documentation-images.html",
+            action='store_true'),
         # trigger_dag
         'run_id': Arg(("-r", "--run_id"), "Helps to identify this run"),
         'conf': Arg(
@@ -2182,6 +2230,12 @@ class CLIFactory:
                     'help': "Delete all DB records related to the specified DAG",
                     'args': ('dag_id', 'yes'),
                 },
+                {
+                    'func': show_dag,
+                    'name': 'show',
+                    'help': "Displays DAG's tasks with their dependencies",
+                    'args': ('dag_id', 'subdir', 'save', 'imgcat',),
+                },
                 {
                     'func': backfill,
                     'name': 'backfill',
diff --git a/airflow/utils/dot_renderer.py b/airflow/utils/dot_renderer.py
new file mode 100644
index 0000000000000..2ba386b1c830a
--- /dev/null
+++ b/airflow/utils/dot_renderer.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Renderer DAG (tasks and dependencies) to the graphviz object.
+"""
+
+import graphviz
+
+from airflow import DAG
+
+
+def _refine_color(color: str):
+    """
+    Converts color in #RGB (12 bits) format to #RRGGBB (32 bits), if it possible.
+    Otherwise, it returns the original value. Graphviz does not support colors in #RGB format.
+
+    :param color: Text representation of color
+    :return: Refined representation of color
+    """
+    if len(color) == 4 and color[0] == "#":
+        color_r = color[1]
+        color_g = color[2]
+        color_b = color[3]
+        return "#" + color_r + color_r + color_g + color_g + color_b + color_b
+    return color
+
+
+def render_dag(dag: DAG) -> graphviz.Digraph:
+    """
+    Renders the DAG object to the DOT object.
+
+    :param dag: DAG that will be rendered.
+    :return: Graphviz object
+    :rtype: graphviz.Digraph
+    """
+    dot = graphviz.Digraph(dag.dag_id, graph_attr={"rankdir": "LR", "labelloc": "t", "label": dag.dag_id})
+    for task in dag.tasks:
+        dot.node(
+            task.task_id,
+            _attributes={
+                "shape": "rectangle",
+                "style": "filled,rounded",
+                "color": _refine_color(task.ui_fgcolor),
+                "fillcolor": _refine_color(task.ui_color),
+            },
+        )
+        for downstream_task_id in task.downstream_task_ids:
+            dot.edge(task.task_id, downstream_task_id)
+    return dot
diff --git a/setup.py b/setup.py
index 7d870e6a4e143..a4d19ea2c4b1d 100644
--- a/setup.py
+++ b/setup.py
@@ -340,6 +340,7 @@ def do_setup():
             'flask-swagger==0.2.13',
             'flask-wtf>=0.14.2, <0.15',
             'funcsigs==1.0.0',
+            'graphviz>=0.12',
             'gunicorn>=19.5.0, <20.0',
             'iso8601>=0.1.12',
             'json-merge-patch==0.2',
diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py
index 9640d654d2e1a..903ea53f60d2f 100644
--- a/tests/cli/test_cli.py
+++ b/tests/cli/test_cli.py
@@ -16,6 +16,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+import contextlib
 import io
 import sys
 import unittest
@@ -341,6 +342,45 @@ def test_backfill(self, mock_run):
         )
         mock_run.reset_mock()
 
+    def test_show_dag_print(self):
+        temp_stdout = io.StringIO()
+        with contextlib.redirect_stdout(temp_stdout):
+            cli.show_dag(self.parser.parse_args([
+                'dags', 'show', 'example_bash_operator']))
+        out = temp_stdout.getvalue()
+        self.assertIn("label=example_bash_operator", out)
+        self.assertIn("graph [label=example_bash_operator labelloc=t rankdir=LR]", out)
+        self.assertIn("runme_2 -> run_after_loop", out)
+
+    @mock.patch("airflow.bin.cli.render_dag")
+    def test_show_dag_dave(self, mock_render_dag):
+        temp_stdout = io.StringIO()
+        with contextlib.redirect_stdout(temp_stdout):
+            cli.show_dag(self.parser.parse_args([
+                'dags', 'show', 'example_bash_operator', '--save', 'awesome.png']
+            ))
+        out = temp_stdout.getvalue()
+        mock_render_dag.return_value.render.assert_called_once_with(
+            cleanup=True, filename='awesome', format='png'
+        )
+        self.assertIn("File awesome.png saved", out)
+
+    @mock.patch("airflow.bin.cli.subprocess.Popen")
+    @mock.patch("airflow.bin.cli.render_dag")
+    def test_show_dag_imgcat(self, mock_render_dag, mock_popen):
+        mock_render_dag.return_value.pipe.return_value = b"DOT_DATA"
+        mock_popen.return_value.communicate.return_value = (b"OUT", b"ERR")
+        temp_stdout = io.StringIO()
+        with contextlib.redirect_stdout(temp_stdout):
+            cli.show_dag(self.parser.parse_args([
+                'dags', 'show', 'example_bash_operator', '--imgcat']
+            ))
+        out = temp_stdout.getvalue()
+        mock_render_dag.return_value.pipe.assert_called_once_with(format='png')
+        mock_popen.return_value.communicate.assert_called_once_with(b'DOT_DATA')
+        self.assertIn("OUT", out)
+        self.assertIn("ERR", out)
+
     @mock.patch("airflow.bin.cli.DAG.run")
     def test_cli_backfill_depends_on_past(self, mock_run):
         """
diff --git a/tests/utils/test_dot_renderer.py b/tests/utils/test_dot_renderer.py
new file mode 100644
index 0000000000000..87c930087de1e
--- /dev/null
+++ b/tests/utils/test_dot_renderer.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+import datetime
+import unittest
+
+import mock
+
+from airflow import DAG
+from airflow.operators.bash_operator import BashOperator
+from airflow.operators.python_operator import PythonOperator
+from airflow.utils import dot_renderer
+
+
+START_DATE = datetime.datetime.now()
+
+
+class TestDotRenderer(unittest.TestCase):
+    def test_should_render_dag(self):
+
+        dag = DAG(dag_id="DAG_ID")
+        task_1 = BashOperator(dag=dag, start_date=START_DATE, task_id="first", bash_command="echo 1")
+        task_2 = BashOperator(dag=dag, start_date=START_DATE, task_id="second", bash_command="echo 1")
+        task_3 = PythonOperator(
+            dag=dag, start_date=START_DATE, task_id="third", python_callable=mock.MagicMock()
+        )
+        task_1 >> task_2
+        task_1 >> task_3
+
+        dot = dot_renderer.render_dag(dag)
+        source = dot.source
+        # Should render DAG title
+        self.assertIn("label=DAG_ID", source)
+        self.assertIn("first", source)
+        self.assertIn("second", source)
+        self.assertIn("third", source)
+        self.assertIn("first -> second", source)
+        self.assertIn("first -> third", source)
+        self.assertIn('fillcolor="#f0ede4"', source)
+        self.assertIn('fillcolor="#f0ede4"', source)

I always feel frustrated when I can't see what the DAG I created looks like. I can only preview how it looks in the webserver. However, the webserver requires a separate configuration for proper works and slowly refreshes the views. Adding the ability to display DAG from the CLI level will improve my workflow and allow me to prepare sample DAGs for system tests faster.

<img width="1190" alt="Screenshot 2019-09-02 at 00 31 45" src="https://user-images.githubusercontent.com/12058428/64083103-e0befc00-cd19-11e9-9a42-8df2ab89e0fe.png">

It can also be useful to other people if they want to generate documentation for DAGs. They can easily generate graphics that can be attached to the documentation.

Make sure you have checked _all_ steps below.

### Jira

- [ ] My PR addresses the following [Airflow Jira](https://issues.apache.org/jira/browse/AIRFLOW/) issues and references them in the PR title. For example, "\[AIRFLOW-XXX\] My Airflow PR"
  - https://issues.apache.org/jira/browse/AIRFLOW-5368
  - In case you are fixing a typo in the documentation you can prepend your commit with \[AIRFLOW-XXX\], code changes always need a Jira issue.
  - In case you are proposing a fundamental code change, you need to create an Airflow Improvement Proposal ([AIP](https://cwiki.apache.org/confluence/display/AIRFLOW/Airflow+Improvements+Proposals)).
  - In case you are adding a dependency, check if the license complies with the [ASF 3rd Party License Policy](https://www.apache.org/legal/resolved.html#category-x).

### Description

- [ ] Here are some details about my PR, including screenshots of any UI changes:

### Tests

- [ ] My PR adds the following unit tests __OR__ does not need testing for this extremely good reason:

### Commits

- [ ] My commits all reference Jira issues in their subject lines, and I have squashed multiple commits if they address the same issue. In addition, my commits follow the guidelines from "[How to write a good git commit message](http://chris.beams.io/posts/git-commit/)":
  1. Subject is separated from body by a blank line
  1. Subject is limited to 50 characters (not including Jira issue reference)
  1. Subject does not end with a period
  1. Subject uses the imperative mood ("add", not "adding")
  1. Body wraps at 72 characters
  1. Body explains "what" and "why", not "how"

### Documentation

- [ ] In case of new functionality, my PR adds documentation that describes how to use it.
  - All the public functions and the classes in the PR contain docstrings that explain what it does
  - If you implement backwards incompatible changes, please leave a note in the [Updating.md](https://github.com/apache/airflow/blob/master/UPDATING.md) so we can assign it to a appropriate release

### Code Quality

- [ ] Passes `flake8`
