Add read/write_json methods to file module.

Provides better handling of json files than the existing json.read
method.

R=adrexler@google.com

Bug: NONE
Change-Id: I581d9f3e9297a06e3e0b604cff70ed5c1c0a62b0
Reviewed-on: https://chromium-review.googlesource.com/c/infra/luci/recipes-py/+/1525082
Commit-Queue: Robbie Iannucci <iannucci@chromium.org>
Reviewed-by: Robbie Iannucci <iannucci@chromium.org>
diff --git a/README.recipes.md b/README.recipes.md
index 70ab5e8..d7cd8ea 100644
--- a/README.recipes.md
+++ b/README.recipes.md
@@ -71,6 +71,7 @@
   * [file:examples/error](#recipes-file_examples_error)
   * [file:examples/flatten_single_directories](#recipes-file_examples_flatten_single_directories)
   * [file:examples/glob](#recipes-file_examples_glob)
+  * [file:examples/handle_json_file](#recipes-file_examples_handle_json_file)
   * [file:examples/listdir](#recipes-file_examples_listdir)
   * [file:examples/raw_copy](#recipes-file_examples_raw_copy)
   * [file:examples/symlink](#recipes-file_examples_symlink)
@@ -911,9 +912,9 @@
 
 File manipulation (read/write/delete/glob) methods.
 
-#### **class [FileApi](/recipe_modules/file/api.py#80)([RecipeApi](/recipe_engine/recipe_api.py#1008)):**
+#### **class [FileApi](/recipe_modules/file/api.py#81)([RecipeApi](/recipe_engine/recipe_api.py#1008)):**
 
-&mdash; **def [copy](/recipe_modules/file/api.py#116)(self, name, source, dest):**
+&mdash; **def [copy](/recipe_modules/file/api.py#117)(self, name, source, dest):**
 
 Copies a file (including mode bits) from source to destination on the
 local filesystem.
@@ -929,7 +930,7 @@
 
 Raises file.Error
 
-&mdash; **def [copytree](/recipe_modules/file/api.py#136)(self, name, source, dest, symlinks=False):**
+&mdash; **def [copytree](/recipe_modules/file/api.py#137)(self, name, source, dest, symlinks=False):**
 
 Recursively copies a directory tree.
 
@@ -945,7 +946,7 @@
 
 Raises file.Error
 
-&mdash; **def [ensure\_directory](/recipe_modules/file/api.py#308)(self, name, dest, mode=511):**
+&mdash; **def [ensure\_directory](/recipe_modules/file/api.py#339)(self, name, dest, mode=511):**
 
 Ensures that `dest` exists and is a directory.
 
@@ -958,7 +959,7 @@
 
 Raises file.Error if the path exists but is not a directory.
 
-&mdash; **def [filesizes](/recipe_modules/file/api.py#325)(self, name, files, test_data=None):**
+&mdash; **def [filesizes](/recipe_modules/file/api.py#356)(self, name, files, test_data=None):**
 
 Returns list of filesizes for the given files.
 
@@ -968,7 +969,7 @@
 
 Returns list[int], size of each file in bytes.
 
-&mdash; **def [flatten\_single\_directories](/recipe_modules/file/api.py#441)(self, name, path):**
+&mdash; **def [flatten\_single\_directories](/recipe_modules/file/api.py#472)(self, name, path):**
 
 Flattens singular directories, starting at path.
 
@@ -997,7 +998,7 @@
 
 Raises file.Error
 
-&mdash; **def [glob\_paths](/recipe_modules/file/api.py#241)(self, name, source, pattern, test_data=()):**
+&mdash; **def [glob\_paths](/recipe_modules/file/api.py#272)(self, name, source, pattern, test_data=()):**
 
 Performs glob expansion on `pattern`.
 
@@ -1016,7 +1017,7 @@
 
 Raises file.Error.
 
-&mdash; **def [listdir](/recipe_modules/file/api.py#284)(self, name, source, test_data=()):**
+&mdash; **def [listdir](/recipe_modules/file/api.py#315)(self, name, source, test_data=()):**
 
 List all files inside a directory.
 
@@ -1031,7 +1032,7 @@
 
 Raises file.Error.
 
-&mdash; **def [move](/recipe_modules/file/api.py#157)(self, name, source, dest):**
+&mdash; **def [move](/recipe_modules/file/api.py#158)(self, name, source, dest):**
 
 Moves a file or directory.
 
@@ -1044,7 +1045,21 @@
 
 Raises file.Error
 
-&mdash; **def [read\_raw](/recipe_modules/file/api.py#175)(self, name, source, test_data=''):**
+&mdash; **def [read\_json](/recipe_modules/file/api.py#242)(self, name, source, test_data=''):**
+
+Reads a file as UTF-8 encoded json.
+
+Args:
+  * name (str) - The name of the step.
+  * source (Path) - The path of the file to read.
+  * test_data (object) - Some default json serializable data for this step
+    to return when running under simulation.
+
+Returns (object) - The content of the file.
+
+Raise file.Error
+
+&mdash; **def [read\_raw](/recipe_modules/file/api.py#176)(self, name, source, test_data=''):**
 
 Reads a file as raw data.
 
@@ -1058,7 +1073,7 @@
 
 Raises file.Error
 
-&mdash; **def [read\_text](/recipe_modules/file/api.py#208)(self, name, source, test_data=''):**
+&mdash; **def [read\_text](/recipe_modules/file/api.py#209)(self, name, source, test_data=''):**
 
 Reads a file as UTF-8 encoded text.
 
@@ -1072,7 +1087,7 @@
 
 Raises file.Error
 
-&mdash; **def [remove](/recipe_modules/file/api.py#269)(self, name, source):**
+&mdash; **def [remove](/recipe_modules/file/api.py#300)(self, name, source):**
 
 Remove a file.
 
@@ -1084,7 +1099,7 @@
 
 Raises file.Error.
 
-&mdash; **def [rmcontents](/recipe_modules/file/api.py#364)(self, name, source):**
+&mdash; **def [rmcontents](/recipe_modules/file/api.py#395)(self, name, source):**
 
 Similar to rmtree, but removes only contents not the directory.
 
@@ -1099,7 +1114,7 @@
 
 Raises file.Error.
 
-&mdash; **def [rmglob](/recipe_modules/file/api.py#382)(self, name, source, pattern):**
+&mdash; **def [rmglob](/recipe_modules/file/api.py#413)(self, name, source, pattern):**
 
 Removes all entries in `source` matching the glob `pattern`.
 
@@ -1112,7 +1127,7 @@
 
 Raises file.Error.
 
-&mdash; **def [rmtree](/recipe_modules/file/api.py#347)(self, name, source):**
+&mdash; **def [rmtree](/recipe_modules/file/api.py#378)(self, name, source):**
 
 Recursively removes a directory.
 
@@ -1126,7 +1141,7 @@
 
 Raises file.Error.
 
-&mdash; **def [symlink](/recipe_modules/file/api.py#403)(self, name, source, linkname):**
+&mdash; **def [symlink](/recipe_modules/file/api.py#434)(self, name, source, linkname):**
 
 Creates a symlink on the local filesystem.
 
@@ -1139,14 +1154,14 @@
 
 Raises file.Error
 
-&mdash; **def [symlink\_tree](/recipe_modules/file/api.py#420)(self, root):**
+&mdash; **def [symlink\_tree](/recipe_modules/file/api.py#451)(self, root):**
 
 Creates a SymlinkTree, given a root directory.
 
 Args:
   * root (Path): root of a tree of symlinks.
 
-&mdash; **def [truncate](/recipe_modules/file/api.py#428)(self, name, path, size_mb=100):**
+&mdash; **def [truncate](/recipe_modules/file/api.py#459)(self, name, path, size_mb=100):**
 
 Creates an empty file with path and size_mb on the local filesystem.
 
@@ -1157,7 +1172,18 @@
 
 Raises file.Error
 
-&mdash; **def [write\_raw](/recipe_modules/file/api.py#194)(self, name, dest, data):**
+&mdash; **def [write\_json](/recipe_modules/file/api.py#259)(self, name, dest, data):**
+
+Write the given json serializable `data` to `dest`.
+
+Args:
+  * name (str) - The name of the step.
+  * dest (Path) - The path of the file to write.
+  * data (object) - Json serializable data to write.
+
+Raises file.Error.
+
+&mdash; **def [write\_raw](/recipe_modules/file/api.py#195)(self, name, dest, data):**
 
 Write the given `data` to `dest`.
 
@@ -1168,7 +1194,7 @@
 
 Raises file.Error.
 
-&mdash; **def [write\_text](/recipe_modules/file/api.py#227)(self, name, dest, text_data):**
+&mdash; **def [write\_text](/recipe_modules/file/api.py#228)(self, name, dest, text_data):**
 
 Write the given UTF-8 encoded `text_data` to `dest`.
 
@@ -1315,9 +1341,11 @@
     to a step link named `name`. If this is 'on_failure', only create this
     log when the step has a non-SUCCESS status.
 
-&mdash; **def [read](/recipe_modules/json/api.py#123)(self, name, path, add_json_log=True, output_name=None, \*\*kwargs):**
+&mdash; **def [read](/recipe_modules/json/api.py#122)(self, name, path, add_json_log=True, output_name=None, \*\*kwargs):**
 
 Returns a step that reads a JSON file.
+
+This method is deprecated. Use file.read_json instead.
 ### *recipe_modules* / [led](/recipe_modules/led)
 
 [DEPS](/recipe_modules/led/__init__.py#5): [cipd](#recipe_modules-cipd), [json](#recipe_modules-json), [path](#recipe_modules-path), [service\_account](#recipe_modules-service_account), [step](#recipe_modules-step)
@@ -2459,6 +2487,11 @@
 [DEPS](/recipe_modules/file/examples/glob.py#5): [file](#recipe_modules-file), [json](#recipe_modules-json), [path](#recipe_modules-path)
 
 &mdash; **def [RunSteps](/recipe_modules/file/examples/glob.py#11)(api):**
+### *recipes* / [file:examples/handle\_json\_file](/recipe_modules/file/examples/handle_json_file.py)
+
+[DEPS](/recipe_modules/file/examples/handle_json_file.py#1): [file](#recipe_modules-file), [path](#recipe_modules-path)
+
+&mdash; **def [RunSteps](/recipe_modules/file/examples/handle_json_file.py#7)(api):**
 ### *recipes* / [file:examples/listdir](/recipe_modules/file/examples/listdir.py)
 
 [DEPS](/recipe_modules/file/examples/listdir.py#5): [file](#recipe_modules-file), [path](#recipe_modules-path)
diff --git a/recipe_modules/file/api.py b/recipe_modules/file/api.py
index 8af4990..11b5208 100644
--- a/recipe_modules/file/api.py
+++ b/recipe_modules/file/api.py
@@ -8,8 +8,9 @@
 from recipe_engine import recipe_api
 
 
-import os
 import fnmatch
+import json
+import os
 
 
 class SymlinkTree(object):
@@ -238,6 +239,36 @@
     self._run(name, ['copy', self.m.raw_io.input_text(text_data), dest])
     self.m.path.mock_add_paths(dest)
 
+  def read_json(self, name, source, test_data=''):
+    """Reads a file as UTF-8 encoded json.
+
+    Args:
+      * name (str) - The name of the step.
+      * source (Path) - The path of the file to read.
+      * test_data (object) - Some default json serializable data for this step
+        to return when running under simulation.
+
+    Returns (object) - The content of the file.
+
+    Raise file.Error
+    """
+    test_data_text = json.dumps(test_data)
+    text = self.read_text(name, source, test_data=test_data_text)
+    return json.loads(text)
+
+  def write_json(self, name, dest, data):
+    """Write the given json serializable `data` to `dest`.
+
+    Args:
+      * name (str) - The name of the step.
+      * dest (Path) - The path of the file to write.
+      * data (object) - Json serializable data to write.
+
+    Raises file.Error.
+    """
+    text_data = json.dumps(data)
+    self.write_text(name, dest, text_data)
+
   def glob_paths(self, name, source, pattern, test_data=()):
     """Performs glob expansion on `pattern`.
 
diff --git a/recipe_modules/file/examples/handle_json_file.expected/basic.json b/recipe_modules/file/examples/handle_json_file.expected/basic.json
new file mode 100644
index 0000000..7a8c400
--- /dev/null
+++ b/recipe_modules/file/examples/handle_json_file.expected/basic.json
@@ -0,0 +1,34 @@
+[
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "{\"is_json\": true}",
+      "[START_DIR]/some_file.json"
+    ],
+    "infra_step": true,
+    "name": "write_json"
+  },
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[START_DIR]/some_file.json",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "read_json"
+  },
+  {
+    "jsonResult": null,
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/file/examples/handle_json_file.expected/failure.json b/recipe_modules/file/examples/handle_json_file.expected/failure.json
new file mode 100644
index 0000000..73cdc56
--- /dev/null
+++ b/recipe_modules/file/examples/handle_json_file.expected/failure.json
@@ -0,0 +1,43 @@
+[
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "{\"is_json\": true}",
+      "[START_DIR]/some_file.json"
+    ],
+    "infra_step": true,
+    "name": "write_json"
+  },
+  {
+    "cmd": [
+      "python",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[START_DIR]/some_file.json",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "read_json",
+    "~followup_annotations": [
+      "@@@STEP_TEXT@file command encountered system error JSON READ FAILURE@@@",
+      "@@@STEP_FAILURE@@@"
+    ]
+  },
+  {
+    "failure": {
+      "failure": {
+        "step": ""
+      },
+      "humanReason": "Step('read_json') failed 'JSON READ FAILURE' with: file command encountered system error JSON READ FAILURE"
+    },
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/file/examples/handle_json_file.py b/recipe_modules/file/examples/handle_json_file.py
new file mode 100644
index 0000000..a1493ba
--- /dev/null
+++ b/recipe_modules/file/examples/handle_json_file.py
@@ -0,0 +1,27 @@
+# Copyright 2019 The LUCI Authors. All rights reserved.
+# Use of this source code is governed under the Apache License, Version 2.0
+# that can be found in the LICENSE file.
+
+DEPS = [
+  "file",
+  "path",
+]
+
+
+def RunSteps(api):
+  dest = api.path['start_dir'].join('some_file.json')
+  data = {'is_json': True}
+
+  api.file.write_json('write_json', dest, data)
+
+  read_data = api.file.read_json('read_json', dest, test_data=data)
+
+  assert read_data == data, (read_data, data)
+
+def GenTests(api):
+  yield api.test('basic')
+  yield (
+      api.test('failure')
+      + api.step_data('read_json',
+          api.file.read_json(errno_name='JSON READ FAILURE'))
+  )
\ No newline at end of file
diff --git a/recipe_modules/file/test_api.py b/recipe_modules/file/test_api.py
index 109581a..3dd68ab 100644
--- a/recipe_modules/file/test_api.py
+++ b/recipe_modules/file/test_api.py
@@ -2,6 +2,7 @@
 # Use of this source code is governed under the Apache License, Version 2.0
 # that can be found in the LICENSE file.
 
+import json
 import os
 
 from recipe_engine import recipe_test_api
@@ -75,6 +76,23 @@
     return (self.m.raw_io.output_text(text_content)
             + self.errno(errno_name))
 
+  def read_json(self, json_content='', errno_name=0):
+    """Provides test mock for the `read_json` method.
+
+    Args:
+      json_content (object) - The json serializable data for this read_json step
+        to return.
+      errno_name (str|None) - The error name for this step to return, if any.
+
+    Example:
+      yield (api.test('my_test')
+        + api.step_data('read step name',
+            api.file.read_json({'is_content': true}))
+      )
+    """
+    return (self.m.raw_io.output_text(json.dumps(json_content))
+            + self.errno(errno_name))
+
   def glob_paths(self, names=(), errno_name=0):
     """Provides test mock for the `glob_paths` method.
 
diff --git a/recipe_modules/json/api.py b/recipe_modules/json/api.py
index b92b4de..7439e7d 100644
--- a/recipe_modules/json/api.py
+++ b/recipe_modules/json/api.py
@@ -119,9 +119,11 @@
     """
     return JsonOutputPlaceholder(self, add_json_log, name=name, leak_to=leak_to)
 
-  # TODO(you): This method should be in the `file` recipe_module
   def read(self, name, path, add_json_log=True, output_name=None, **kwargs):
-    """Returns a step that reads a JSON file."""
+    """Returns a step that reads a JSON file.
+
+    This method is deprecated. Use file.read_json instead.
+    """
     return self.m.python.inline(
         name,
         """