Repository: PrefectHQ/prefect

Issue:
prefect describe CLI does not return JSON
## Description
`prefect describe` calls seem to give back no valid JSON but just a printed Python Dict. To make it more useful in downstream commands to e.g. parse the output via jq it would be great if this is JSON. Maybe that was even intended, not sure if it is a bug or enhancement.

One example:
```
prefect describe flows --name TestRecoGenerationFlow_dev  | jq
parse error: Invalid numeric literal at line 1, column 8
```

Output of this looks like the following:
```
{'name': 'TestRecoGenerationFlow_dev', 'version': 17, 'created': '2020-07-07T12:43:44.554817+00:00', 'description': None, ...
```

With the `None` values instead of `null` it is clear that this is no JSON


## Expected Behavior
Proper returning of JSON so that it can be used in downstream tools or pipes.




## Reproduction
```
prefect describe flows --name <anyname>  | jq
```


## Environment
```
{
  "config_overrides": {},
  "env_vars": [],
  "system_information": {
    "platform": "macOS-10.15.4-x86_64-i386-64bit",
    "prefect_version": "0.12.2",
    "python_version": "3.8.2"
  }
}
```


Discussion:
Hi @ludwigm, makes sense to me. Would you be interested in contributing the above fix?
@ludwigm The `describe` CLI commands have an `--output` option which you can provide json to in order to get json output.

```bash
prefect describe flows -n ETL --output json
{"name": "ETL", "version": 15, "created": "2020-05-20T18:31:12.084788+00:00", "description": null, "parameters": [], "archived": false, "storage": {"key": null, "type": "S3", "flows": {"ETL": "etl/2020-05-20t18-31-09-936334-00-00"}, "bucket": "my-prefect-flows", "secrets": [], "__version__": "0.11.2+43.gac8639392", "client_options": null}, "environment": {"type": "RemoteEnvironment", "labels": ["s3-flow-storage"], "executor": "prefect.engine.executors.LocalExecutor", "__version__": "0.11.2+43.gac8639392", "executor_kwargs": {}}}
```
Any reason not to make json output the default? I don't see why I'd ever want a printed dict version of the output, but could see uses for other common formats like json or yaml. 
Yeah @jcrist JSON should be made the default
Cool, I'll push up a quick fix for this.

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
@@ -6,10 +6,28 @@
 from prefect.utilities.graphql import EnumValue, with_args
 
 
+output_option = click.option(
+    "--output", "-o", type=click.Choice(["json", "yaml"]), hidden=True, default="json"
+)
+
+
+def display_output(obj, output="json"):
+    """Display `obj` in a selected format {'json', 'yaml'}"""
+    if output == "json":
+        click.echo(json.dumps(obj, sort_keys=True, indent=2))
+    elif output == "yaml":
+        import yaml
+
+        click.echo(yaml.safe_dump(obj))
+    else:
+        # click CLI validation prevents ever getting here
+        raise ValueError(f"Invalid output type `{output}`")
+
+
 @click.group(hidden=True)
 def describe():
     """
-    Describe commands that render JSON output of Prefect object metadata.
+    Output information about different Prefect objects.
 
     \b
     Usage:
@@ -56,7 +74,7 @@ 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)
-@click.option("--output", "-o", type=click.Choice(["json"]), hidden=True)
+@output_option
 def flows(name, version, project, output):
     """
     Describe a Prefect flow.
@@ -66,8 +84,8 @@ def flows(name, version, project, output):
         --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.
+        --output, -o    TEXT    Output format, one of {'json', 'yaml'}.
+                                Defaults to json.
     """
 
     where_clause = {"_and": {"name": {"_eq": name}, "version": {"_eq": version}}}
@@ -106,10 +124,7 @@ def flows(name, version, project, output):
 
     flow_data = result.data.flow
     if flow_data:
-        if output == "json":
-            click.echo(json.dumps(flow_data[0]))
-        else:
-            click.echo(flow_data[0])
+        display_output(flow_data[0].to_dict(), output=output)
     else:
         click.secho("{} not found".format(name), fg="red")
 
@@ -118,7 +133,7 @@ def flows(name, version, project, output):
 @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)
-@click.option("--output", "-o", type=click.Choice(["json"]), hidden=True)
+@output_option
 def tasks(name, version, project, output):
     """
     Describe tasks from a Prefect flow. This command is similar to `prefect describe flow`
@@ -129,8 +144,8 @@ def tasks(name, version, project, output):
         --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.
+        --output, -o    TEXT    Output format, one of {'json', 'yaml'}.
+                                Defaults to json.
     """
 
     where_clause = {"_and": {"name": {"_eq": name}, "version": {"_eq": version}}}
@@ -175,11 +190,7 @@ def tasks(name, version, project, output):
     task_data = flow_data[0].tasks
 
     if task_data:
-        for item in task_data:
-            if output == "json":
-                click.echo(json.dumps(item))
-            else:
-                click.echo(item)
+        display_output(task_data.to_list(), output=output)
     else:
         click.secho("No tasks found for flow {}".format(name), fg="red")
 
@@ -189,7 +200,7 @@ def tasks(name, version, project, output):
     "--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)
-@click.option("--output", "-o", type=click.Choice(["json"]), hidden=True)
+@output_option
 def flow_runs(name, flow_name, output):
     """
     Describe a Prefect flow run.
@@ -198,8 +209,8 @@ def flow_runs(name, flow_name, output):
     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.
+        --output, -o        TEXT    Output format, one of {'json', 'yaml'}.
+                                    Defaults to json.
     """
     query = {
         "query": {
@@ -234,9 +245,6 @@ def flow_runs(name, flow_name, output):
     flow_run_data = result.data.flow_run
 
     if flow_run_data:
-        if output == "json":
-            click.echo(json.dumps(flow_run_data[0]))
-        else:
-            click.echo(flow_run_data[0])
+        display_output(flow_run_data[0].to_dict(), output=output)
     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,6 +1,8 @@
 import json
+import yaml
 from unittest.mock import MagicMock
 
+import pytest
 from click.testing import CliRunner
 
 from prefect.cli.describe import describe
@@ -11,20 +13,14 @@ def test_describe_init():
     runner = CliRunner()
     result = runner.invoke(describe)
     assert result.exit_code == 0
-    assert (
-        "Describe commands that render JSON output of Prefect object metadata."
-        in result.output
-    )
+    assert "Output information about different Prefect objects." in result.output
 
 
 def test_describe_help():
     runner = CliRunner()
     result = runner.invoke(describe, ["--help"])
     assert result.exit_code == 0
-    assert (
-        "Describe commands that render JSON output of Prefect object metadata."
-        in result.output
-    )
+    assert "Output information about different Prefect objects." in result.output
 
 
 def test_describe_flows(monkeypatch):
@@ -128,7 +124,8 @@ def test_describe_flows_populated(monkeypatch):
         assert post.call_args[1]["json"]["query"].split() == query.split()
 
 
-def test_describe_flows_json_output(monkeypatch):
+@pytest.mark.parametrize("output", ["json", "yaml"])
+def test_describe_flows_output(monkeypatch, output):
     post = MagicMock(
         return_value=MagicMock(
             json=MagicMock(return_value=dict(data=dict(flow=[{"name": "flow"}])))
@@ -144,11 +141,15 @@ def test_describe_flows_json_output(monkeypatch):
         runner = CliRunner()
         result = runner.invoke(
             describe,
-            ["flows", "--name", "flow", "--project", "proj", "--output", "json"],
+            ["flows", "--name", "flow", "--project", "proj", "--output", output],
         )
         assert result.exit_code == 0
 
-        assert json.loads(result.output) == {"name": "flow"}
+        if output == "json":
+            res = json.loads(result.output)
+        elif output == "yaml":
+            res = yaml.safe_load(result.output)
+        assert res == {"name": "flow"}
 
 
 def test_describe_tasks(monkeypatch):
@@ -230,7 +231,8 @@ def test_describe_tasks_not_found(monkeypatch):
         assert "No tasks found for flow flow" in result.output
 
 
-def test_describe_tasks_json_output(monkeypatch):
+@pytest.mark.parametrize("output", ["json", "yaml"])
+def test_describe_tasks_output(monkeypatch, output):
     post = MagicMock(
         return_value=MagicMock(
             json=MagicMock(
@@ -248,11 +250,15 @@ def test_describe_tasks_json_output(monkeypatch):
         runner = CliRunner()
         result = runner.invoke(
             describe,
-            ["tasks", "--name", "flow", "--project", "proj", "--output", "json"],
+            ["tasks", "--name", "flow", "--project", "proj", "--output", output],
         )
         assert result.exit_code == 0
 
-        assert json.loads(result.output) == {"name": "task"}
+        if output == "json":
+            res = json.loads(result.output)
+        elif output == "yaml":
+            res = yaml.safe_load(result.output)
+        assert res == [{"name": "task"}]
 
 
 def test_describe_flow_runs(monkeypatch):
@@ -363,7 +369,8 @@ def test_describe_flow_runs_populated(monkeypatch):
         assert post.call_args[1]["json"]["query"].split() == query.split()
 
 
-def test_describe_flow_runs_json_output(monkeypatch):
+@pytest.mark.parametrize("output", ["json", "yaml"])
+def test_describe_flow_runs_output(monkeypatch, output):
     post = MagicMock(
         return_value=MagicMock(
             json=MagicMock(
@@ -380,8 +387,12 @@ def test_describe_flow_runs_json_output(monkeypatch):
     ):
         runner = CliRunner()
         result = runner.invoke(
-            describe, ["flow-runs", "--name", "flow-run", "--output", "json"]
+            describe, ["flow-runs", "--name", "flow-run", "--output", output]
         )
         assert result.exit_code == 0
 
-        assert json.loads(result.output) == {"name": "flow-run"}
+        if output == "json":
+            res = json.loads(result.output)
+        elif output == "yaml":
+            res = yaml.safe_load(result.output)
+        assert res == {"name": "flow-run"}