Repository: PrefectHQ/prefect

Issue:
describe CLI command outputs dictionary instead of json
## Current behavior
Currently commands like `prefect describe flow ...` outputs a string representation of a Python dictionary




## Proposed behavior
Output parsable JSON instead of a dictionary




## Example
Docs show output similar to:
```
$ prefect describe flows --name my-flow
{
    "name": "my-flow",
    "version": 1,
    "project": {
        "name": "Demo"
    },
    "created": "2019-07-25T14:23:21.704585+00:00",
    "description": null,
    "parameters": [],
    "archived": false,
    "storage": {
        "type": "Docker",
        "flows": {
            "my-flow": "/root/.prefect/my-flow.prefect"
        },
        "image_tag": "9b444406-355b-4d32-832e-77ddc21d2073",
        "image_name": "my-flow",
        "__version__": "0.6.0",
        "registry_url": "gcr.io/my-registry/",
        "prefect_version": "master"
    },
    "environment": {
        "type": "RemoteEnvironment",
        "executor": "prefect.engine.executors.SynchronousExecutor",
        "__version__": "0.6.1",
        "executor_kwargs": {}
    }
}

```

It should mimc that behavior 


Discussion:

Resolving patch:
diff --git a/src/prefect/cli/describe.py b/src/prefect/cli/describe.py
--- a/src/prefect/cli/describe.py
+++ b/src/prefect/cli/describe.py
@@ -1,3 +1,5 @@
+import json
+
 import click
 
 from prefect.client import Client
@@ -21,7 +23,7 @@ def describe():
 
     \b
     Examples:
-        $ prefect describe flows --name My-Flow --version 2
+        $ prefect describe flows --name My-Flow --version 2 -o json
         {
             "name": "My-Flow",
             "version": 2,
@@ -54,7 +56,8 @@ def describe():
 @click.option("--name", "-n", required=True, help="A flow name to query.", hidden=True)
 @click.option("--version", "-v", type=int, help="A flow version to query.", hidden=True)
 @click.option("--project", "-p", help="The name of a project to query.", hidden=True)
-def flows(name, version, project):
+@click.option("--output", "-o", type=click.Choice(["json"]), hidden=True)
+def flows(name, version, project, output):
     """
     Describe a Prefect flow.
 
@@ -63,6 +66,8 @@ def flows(name, version, project):
         --name, -n      TEXT    A flow name to query                [required]
         --version, -v   INTEGER A flow version to query
         --project, -p   TEXT    The name of a project to query
+        --output, -o    TEXT    Output style, currently supports `json`.
+                                Defaults to Python dictionary format.
     """
 
     where_clause = {"_and": {"name": {"_eq": name}, "version": {"_eq": version},}}
@@ -100,9 +105,11 @@ def flows(name, version, project):
     result = Client().graphql(query)
 
     flow_data = result.data.flow
-
     if flow_data:
-        click.echo(flow_data[0])
+        if output == "json":
+            click.echo(json.dumps(flow_data[0]))
+        else:
+            click.echo(flow_data[0])
     else:
         click.secho("{} not found".format(name), fg="red")
 
@@ -111,7 +118,8 @@ def flows(name, version, project):
 @click.option("--name", "-n", required=True, help="A flow name to query.", hidden=True)
 @click.option("--version", "-v", type=int, help="A flow version to query.", hidden=True)
 @click.option("--project", "-p", help="The name of a project to query.", hidden=True)
-def tasks(name, version, project):
+@click.option("--output", "-o", type=click.Choice(["json"]), hidden=True)
+def tasks(name, version, project, output):
     """
     Describe tasks from a Prefect flow. This command is similar to `prefect describe flow`
     but instead of flow metadata it outputs task metadata.
@@ -121,6 +129,8 @@ def tasks(name, version, project):
         --name, -n      TEXT    A flow name to query                [required]
         --version, -v   INTEGER A flow version to query
         --project, -p   TEXT    The name of a project to query
+        --output, -o    TEXT    Output style, currently supports `json`.
+                                Defaults to Python dictionary format.
     """
 
     where_clause = {"_and": {"name": {"_eq": name}, "version": {"_eq": version},}}
@@ -166,7 +176,10 @@ def tasks(name, version, project):
 
     if task_data:
         for item in task_data:
-            click.echo(item)
+            if output == "json":
+                click.echo(json.dumps(item))
+            else:
+                click.echo(item)
     else:
         click.secho("No tasks found for flow {}".format(name), fg="red")
 
@@ -176,7 +189,8 @@ def tasks(name, version, project):
     "--name", "-n", required=True, help="A flow run name to query", hidden=True
 )
 @click.option("--flow-name", "-fn", help="A flow name to query", hidden=True)
-def flow_runs(name, flow_name):
+@click.option("--output", "-o", type=click.Choice(["json"]), hidden=True)
+def flow_runs(name, flow_name, output):
     """
     Describe a Prefect flow run.
 
@@ -184,6 +198,8 @@ def flow_runs(name, flow_name):
     Options:
         --name, -n          TEXT    A flow run name to query            [required]
         --flow-name, -fn    TEXT    A flow name to query
+        --output, -o    TEXT    Output style, currently supports `json`.
+                                Defaults to Python dictionary format.
     """
     query = {
         "query": {
@@ -218,6 +234,9 @@ def flow_runs(name, flow_name):
     flow_run_data = result.data.flow_run
 
     if flow_run_data:
-        click.echo(flow_run_data[0])
+        if output == "json":
+            click.echo(json.dumps(flow_run_data[0]))
+        else:
+            click.echo(flow_run_data[0])
     else:
         click.secho("{} not found".format(name), fg="red")


Test patch:
diff --git a/tests/cli/test_describe.py b/tests/cli/test_describe.py
--- a/tests/cli/test_describe.py
+++ b/tests/cli/test_describe.py
@@ -1,12 +1,8 @@
-import sys
+import json
 from unittest.mock import MagicMock
 
-import click
-import pytest
-import requests
 from click.testing import CliRunner
 
-import prefect
 from prefect.cli.describe import describe
 from prefect.utilities.configuration import set_temporary_config
 
@@ -132,6 +128,29 @@ def test_describe_flows_populated(monkeypatch):
         assert post.call_args[1]["json"]["query"].split() == query.split()
 
 
+def test_describe_flows_json_output(monkeypatch):
+    post = MagicMock(
+        return_value=MagicMock(
+            json=MagicMock(return_value=dict(data=dict(flow=[{"name": "flow"}])))
+        )
+    )
+    session = MagicMock()
+    session.return_value.post = post
+    monkeypatch.setattr("requests.Session", session)
+
+    with set_temporary_config(
+        {"cloud.graphql": "http://my-cloud.foo", "cloud.auth_token": "secret_token"}
+    ):
+        runner = CliRunner()
+        result = runner.invoke(
+            describe,
+            ["flows", "--name", "flow", "--project", "proj", "--output", "json"],
+        )
+        assert result.exit_code == 0
+
+        assert json.loads(result.output) == {"name": "flow"}
+
+
 def test_describe_tasks(monkeypatch):
     post = MagicMock(
         return_value=MagicMock(
@@ -211,6 +230,31 @@ def test_describe_tasks_not_found(monkeypatch):
         assert "No tasks found for flow flow" in result.output
 
 
+def test_describe_tasks_json_output(monkeypatch):
+    post = MagicMock(
+        return_value=MagicMock(
+            json=MagicMock(
+                return_value=dict(data=dict(flow=[{"tasks": [{"name": "task"}]}]))
+            )
+        )
+    )
+    session = MagicMock()
+    session.return_value.post = post
+    monkeypatch.setattr("requests.Session", session)
+
+    with set_temporary_config(
+        {"cloud.graphql": "http://my-cloud.foo", "cloud.auth_token": "secret_token"}
+    ):
+        runner = CliRunner()
+        result = runner.invoke(
+            describe,
+            ["tasks", "--name", "flow", "--project", "proj", "--output", "json"],
+        )
+        assert result.exit_code == 0
+
+        assert json.loads(result.output) == {"name": "task"}
+
+
 def test_describe_flow_runs(monkeypatch):
     post = MagicMock(
         return_value=MagicMock(
@@ -317,3 +361,27 @@ def test_describe_flow_runs_populated(monkeypatch):
 
         assert post.called
         assert post.call_args[1]["json"]["query"].split() == query.split()
+
+
+def test_describe_flow_runs_json_output(monkeypatch):
+    post = MagicMock(
+        return_value=MagicMock(
+            json=MagicMock(
+                return_value=dict(data=dict(flow_run=[{"name": "flow-run"}]))
+            )
+        )
+    )
+    session = MagicMock()
+    session.return_value.post = post
+    monkeypatch.setattr("requests.Session", session)
+
+    with set_temporary_config(
+        {"cloud.graphql": "http://my-cloud.foo", "cloud.auth_token": "secret_token"}
+    ):
+        runner = CliRunner()
+        result = runner.invoke(
+            describe, ["flow-runs", "--name", "flow-run", "--output", "json"]
+        )
+        assert result.exit_code == 0
+
+        assert json.loads(result.output) == {"name": "flow-run"}