Repository: conan-io/conan

Issue:
Improve conan_build_info command
We want to leverage from the lock files feature and also from https://github.com/conan-io/conan/issues/5734 to generate a better build-info for Artifactory builds.
The proposed commands are:

- To incorporate to the `artifacts.properties` the build name and number:

`conan_build_info start <build_name> <build_number>`

- To remove from the `artifacts.properties` the build name and number:

`conan_build_info stop`

- To generate a build info json from a lockfile (and the local cache with #5734):

`conan_build_info create “bili.json” --lockfile “conan.lock”`

- To update a build info json with another one (typically different slave in CI):

`conan_build_info update “bili2.json” “bili1.json”`

- To publish the build info to Artifactory. The authentication process should be similar to the possibilities provided by the artifactory rest api:

`conan_build_info publish “bili2.json” --url http://artifactory_url --user --password --apikey`

**NOTE**: it is important to check if we can introduce the subcommands with a fallback to the old command to not break the previous usage.

**NOTE2**: Test is the dependencies of a module need or not to push the build number and id as properties to be later promoted.


Discussion:

Resolving patch:
diff --git a/conans/build_info/build_info.py b/conans/build_info/build_info.py
new file mode 100644
--- /dev/null
+++ b/conans/build_info/build_info.py
@@ -0,0 +1,324 @@
+import datetime
+import json
+import os
+from collections import defaultdict, namedtuple
+from six.moves.urllib.parse import urlparse, urljoin
+
+import requests
+
+from conans.client.cache.cache import ClientCache
+from conans.client.rest import response_to_str
+from conans.errors import AuthenticationException, RequestErrorException, ConanException
+from conans.model.ref import ConanFileReference
+from conans.paths import get_conan_user_home
+from conans.util.files import save
+
+
+class Artifact(namedtuple('Artifact', ["sha1", "md5", "name", "id"])):
+    def __hash__(self):
+        return hash(self.sha1)
+
+
+def _parse_options(contents):
+    for line in contents.splitlines():
+        key, value = line.split("=")
+        yield "options.{}".format(key), value
+
+
+def _parse_profile(contents):
+    import configparser
+
+    config = configparser.ConfigParser()
+    config.read_string(contents)
+
+    for section, values in config._sections.items():
+        for key, value in values.items():
+            yield "{}.{}".format(section, key), value
+
+
+class BuildInfoCreator(object):
+    def __init__(self, output, build_info_file, lockfile, multi_module=True, skip_env=True,
+                 user=None, password=None, apikey=None):
+        self._build_info_file = build_info_file
+        self._lockfile = lockfile
+        self._multi_module = multi_module
+        self._skip_env = skip_env
+        self._user = user
+        self._password = password
+        self._apikey = apikey
+        self._conan_cache = ClientCache(os.path.join(get_conan_user_home(), ".conan"), output)
+
+    def parse_pref(self, pref):
+        ref = ConanFileReference.loads(pref, validate=False)
+        rrev = ref.revision.split("#")[0].split(":")[0]
+        pid = ref.revision.split("#")[0].split(":")[1]
+        prev = ref.revision.split("#")[1]
+        return {
+            "name": ref.name,
+            "version": ref.version,
+            "user": ref.user,
+            "channel": ref.channel,
+            "rrev": rrev,
+            "pid": pid,
+            "prev": prev
+        }
+
+    def _get_reference(self, pref):
+        r = self.parse_pref(pref)
+        if r.get("user") and r.get("channel"):
+            return "{name}/{version}@{user}/{channel}".format(**r)
+        else:
+            return "{name}/{version}".format(**r)
+
+    def _get_package_reference(self, pref):
+        r = self.parse_pref(pref)
+        return "{reference}:{pid}".format(reference=self._get_reference(pref), **r)
+
+    def _get_metadata_artifacts(self, metadata, request_path, use_id=False, name_format="{}",
+                                package_id=None):
+        ret = {}
+        need_sources = False
+        if package_id:
+            data = metadata.packages[package_id].checksums
+        else:
+            data = metadata.recipe.checksums
+            need_sources = not ("conan_sources.tgz" in data)
+
+        for name, value in data.items():
+            name_or_id = name_format.format(name)
+            ret[value["sha1"]] = {"md5": value["md5"],
+                                  "name": name_or_id if not use_id else None,
+                                  "id": name_or_id if use_id else None}
+        if need_sources:
+            remote_name = metadata.recipe.remote
+            remotes = self._conan_cache.registry.load_remotes()
+            remote_url = remotes[remote_name].url
+            parsed_uri = urlparse(remote_url)
+            base_url = "{uri.scheme}://{uri.netloc}/artifactory/api/storage/conan/".format(
+                uri=parsed_uri)
+            request_url = urljoin(base_url, "{}/conan_sources.tgz".format(request_path))
+            if self._user and self._password:
+                response = requests.get(request_url, auth=(self._user, self._password))
+            elif self._apikey:
+                response = requests.get(request_url, headers={"X-JFrog-Art-Api": self._apikey})
+            else:
+                response = requests.get(request_url)
+
+            if response.status_code == 200:
+                data = response.json()
+                ret[data["checksums"]["sha1"]] = {"md5": data["checksums"],
+                                                  "name": "conan_sources.tgz",
+                                                  "id": None}
+            elif response.status_code == 401:
+                raise AuthenticationException(response_to_str(response))
+            else:
+                raise RequestErrorException(response_to_str(response))
+
+        return set([Artifact(k, **v) for k, v in ret.items()])
+
+    def _get_recipe_artifacts(self, pref, add_prefix, use_id):
+        r = self.parse_pref(pref)
+        if r.get("user") and r.get("channel"):
+            ref = "{name}/{version}@{user}/{channel}#{rrev}".format(**r)
+        else:
+            ref = "{name}/{version}#{rrev}".format(**r)
+        reference = ConanFileReference.loads(ref)
+        package_layout = self._conan_cache.package_layout(reference)
+        metadata = package_layout.load_metadata()
+        name_format = "{} :: {{}}".format(self._get_reference(pref)) if add_prefix else "{}"
+        if r.get("user") and r.get("channel"):
+            url = "{user}/{name}/{version}/{channel}/{rrev}/export".format(**r)
+        else:
+            url = "_/{name}/{version}/_/{rrev}/export".format(**r)
+
+        return self._get_metadata_artifacts(metadata, url, name_format=name_format, use_id=use_id)
+
+    def _get_package_artifacts(self, pref, add_prefix, use_id):
+        r = self.parse_pref(pref)
+        if r.get("user") and r.get("channel"):
+            ref = "{name}/{version}@{user}/{channel}#{rrev}".format(**r)
+        else:
+            ref = "{name}/{version}#{rrev}".format(**r)
+        reference = ConanFileReference.loads(ref)
+        package_layout = self._conan_cache.package_layout(reference)
+        metadata = package_layout.load_metadata()
+        name_format = "{} :: {{}}".format(self._get_package_reference(pref)) if add_prefix else "{}"
+        if r.get("user") and r.get("channel"):
+            url = "{user}/{name}/{version}/{channel}/{rrev}/package/{pid}/{prev}".format(**r)
+        else:
+            url = "_/{name}/{version}/_/{rrev}/package/{pid}/{prev}".format(**r)
+        arts = self._get_metadata_artifacts(metadata, url, name_format=name_format, use_id=use_id,
+                                            package_id=r["pid"])
+        return arts
+
+    def process_lockfile(self):
+        modules = defaultdict(lambda: {"id": None, "artifacts": set(), "dependencies": set()})
+
+        def _gather_deps(node_uid, contents, func):
+            node_content = contents["graph_lock"]["nodes"].get(node_uid)
+            artifacts = func(node_content["pref"], add_prefix=True, use_id=True)
+            for _, id_node in node_content.get("requires", {}).items():
+                artifacts.update(_gather_deps(id_node, contents, func))
+            return artifacts
+
+        with open(self._lockfile) as json_data:
+            data = json.load(json_data)
+
+        # Gather modules, their artifacts and recursively all required artifacts
+        for _, node in data["graph_lock"]["nodes"].items():
+            pref = node["pref"]
+            if node.get("modified"):  # Work only on generated nodes
+                # Create module for the recipe reference
+                recipe_key = self._get_reference(pref)
+                modules[recipe_key]["id"] = recipe_key
+                modules[recipe_key]["artifacts"].update(
+                    self._get_recipe_artifacts(pref, add_prefix=not self._multi_module,
+                                               use_id=False))
+                # TODO: what about `python_requires`?
+                # TODO: can we associate any properties to the recipe? Profile/options may be different per lockfile
+
+                # Create module for the package_id
+                package_key = self._get_package_reference(pref) if self._multi_module else recipe_key
+                modules[package_key]["id"] = package_key
+                modules[package_key]["artifacts"].update(
+                    self._get_package_artifacts(pref, add_prefix=not self._multi_module,
+                                                use_id=False))
+
+                # Recurse requires
+                if node.get("requires"):
+                    for _, node_id in node["requires"].items():
+                        modules[recipe_key]["dependencies"].update(
+                            _gather_deps(node_id, data, self._get_recipe_artifacts))
+                        modules[package_key]["dependencies"].update(
+                            _gather_deps(node_id, data, self._get_package_artifacts))
+
+                # TODO: Is the recipe a 'dependency' of the package
+
+        return modules
+
+    def create(self):
+        properties = self._conan_cache.read_put_headers()
+        modules = self.process_lockfile()
+        # Add extra information
+        ret = {"version": "1.0.1",
+               "name": properties["artifact_property_build.name"],
+               "number": properties["artifact_property_build.number"],
+               "type": "GENERIC",
+               "started": datetime.datetime.utcnow().isoformat().split(".")[0] + ".000Z",
+               "buildAgent": {"name": "Conan Client", "version": "1.X"},
+               "modules": list(modules.values())}
+
+        if not self._skip_env:
+            excluded = ["secret", "key", "password"]
+            environment = {"buildInfo.env.{}".format(k): v for k, v in os.environ.items() if
+                           k not in excluded}
+            ret["properties"] = environment
+
+        def dump_custom_types(obj):
+            if isinstance(obj, set):
+                artifacts = [{k: v for k, v in o._asdict().items() if v is not None} for o in obj]
+                return sorted(artifacts, key=lambda u: u.get("name") or u.get("id"))
+            raise TypeError
+
+        with open(self._build_info_file, "w") as f:
+            f.write(json.dumps(ret, indent=4, default=dump_custom_types))
+
+
+def create_build_info(output, build_info_file, lockfile, multi_module, skip_env, user, password,
+                      apikey):
+    bi = BuildInfoCreator(output, build_info_file, lockfile, multi_module, skip_env, user, password,
+                          apikey)
+    bi.create()
+
+
+def start_build_info(output, build_name, build_number):
+    paths = ClientCache(os.path.join(get_conan_user_home(), ".conan"), output)
+    content = "artifact_property_build.name={}\n" \
+              "artifact_property_build.number={}\n".format(build_name, build_number)
+    artifact_properties_file = paths.put_headers_path
+    try:
+        save(artifact_properties_file, content)
+    except Exception:
+        raise ConanException("Can't write properties file in %s" % artifact_properties_file)
+
+
+def stop_build_info(output):
+    paths = ClientCache(os.path.join(get_conan_user_home(), ".conan"), output)
+    artifact_properties_file = paths.put_headers_path
+    try:
+        save(artifact_properties_file, "")
+    except Exception:
+        raise ConanException("Can't write properties file in %s" % artifact_properties_file)
+
+
+def publish_build_info(build_info_file, url, user, password, apikey):
+    with open(build_info_file) as json_data:
+        parsed_uri = urlparse(url)
+        request_url = "{uri.scheme}://{uri.netloc}/artifactory/api/build".format(uri=parsed_uri)
+        if user and password:
+            response = requests.put(request_url, headers={"Content-Type": "application/json"},
+                                    data=json_data, auth=(user, password))
+        elif apikey:
+            response = requests.put(request_url, headers={"Content-Type": "application/json",
+                                                          "X-JFrog-Art-Api": apikey},
+                                    data=json_data)
+        else:
+            response = requests.put(request_url)
+
+        if response.status_code == 401:
+            raise AuthenticationException(response_to_str(response))
+        elif response.status_code != 204:
+            raise RequestErrorException(response_to_str(response))
+
+
+def find_module(build_info, module_id):
+    for it in build_info["modules"]:
+        if it["id"] == module_id:
+            return it
+    new_module = {"id": module_id, "artifacts": [], "dependencies": []}
+    build_info["modules"].append(new_module)
+    return new_module
+
+
+def merge_artifacts(lhs, rhs, key, cmp_key):
+    ret = {it[cmp_key]: it for it in lhs[key]}
+    for art in rhs[key]:
+        art_cmp_key = art[cmp_key]
+        if art_cmp_key in ret:
+            assert art[cmp_key] == ret[art_cmp_key][cmp_key], \
+                "({}) {} != {} for sha1={}".format(cmp_key, art[cmp_key], ret[art_cmp_key][cmp_key],
+                                                   art_cmp_key)
+        else:
+            ret[art_cmp_key] = art
+
+    return [value for _, value in ret.items()]
+
+
+def merge_buildinfo(lhs, rhs):
+    if not lhs or not rhs:
+        return lhs or rhs
+
+    # Check they are compatible
+    assert lhs["version"] == rhs["version"]
+    assert lhs["name"] == rhs["name"]
+    assert lhs["number"] == rhs["number"]
+
+    for rhs_module in rhs["modules"]:
+        lhs_module = find_module(lhs, rhs_module["id"])
+        lhs_module["artifacts"] = merge_artifacts(lhs_module, rhs_module, key="artifacts",
+                                                  cmp_key="name")
+        lhs_module["dependencies"] = merge_artifacts(lhs_module, rhs_module, key="dependencies",
+                                                     cmp_key="id")
+    return lhs
+
+
+def update_build_info(buildinfo, output_file):
+    build_info = {}
+    for it in buildinfo:
+        with open(it) as json_data:
+            data = json.load(json_data)
+
+        build_info = merge_buildinfo(build_info, data)
+
+    with open(output_file, "w") as f:
+        f.write(json.dumps(build_info, indent=4))
diff --git a/conans/build_info/command.py b/conans/build_info/command.py
--- a/conans/build_info/command.py
+++ b/conans/build_info/command.py
@@ -1,13 +1,26 @@
 import argparse
 import json
 import os
+import sys
 
 from conans.build_info.conan_build_info import get_build_info
+from conans.build_info.build_info import start_build_info, stop_build_info, create_build_info, \
+    update_build_info, publish_build_info
+from conans.errors import ConanException
 from conans.util.files import save
+from conans.client.output import ConanOutput
 
 
 def run():
+    if len(sys.argv) > 2 and sys.argv[1] == "--v2":
+        sys.argv.pop(1)
+        runv2()
+    else:
+        runv1()
 
+
+def runv1():
+    output = ConanOutput(sys.stdout, sys.stderr, True)
     parser = argparse.ArgumentParser(description='Extracts build-info from a specified '
                                                  'conan trace log and return a valid JSON')
     parser.add_argument('trace_path', help='Path to the conan trace log file e.g.: '
@@ -16,25 +29,107 @@ def run():
                         help='Optional file to output the JSON contents, if not specified the JSON'
                              ' will be printed to stdout')
 
-    args = parser.parse_args()
+    try:
+        args = parser.parse_args()
 
-    if not os.path.exists(args.trace_path):
-        print("Error, conan trace log not found! '%s'" % args.trace_path)
-        exit(1)
-    if args.output and not os.path.exists(os.path.dirname(args.output)):
-        print("Error, output file directory not found! '%s'" % args.trace_path)
-        exit(1)
+        if not os.path.exists(args.trace_path):
+            output.error("Conan trace log not found! '%s'" % args.trace_path)
+            exit(1)
+        if args.output and not os.path.exists(os.path.dirname(args.output)):
+            output.error("Output file directory not found! '%s'" % args.trace_path)
+            exit(1)
 
-    try:
         info = get_build_info(args.trace_path)
         the_json = json.dumps(info.serialize())
         if args.output:
             save(args.output, the_json)
         else:
-            print(the_json)
+            output.write(the_json)
     except Exception as exc:
-        print(exc)
+        output.error(exc)
         exit(1)
+    except SystemExit:
+        output.writeln("")
+        output.warn("Use 'conan_build_info --v2' to see the usage of the new recommended way to "
+                    "generate build info using lockfiles")
+
+
+def runv2():
+    output = ConanOutput(sys.stdout, sys.stderr, True)
+    parser = argparse.ArgumentParser(
+        description="Generates build info build info from lockfiles information",
+        prog="conan_build_info")
+    subparsers = parser.add_subparsers(dest="subcommand", help="sub-command help")
+
+    parser_start = subparsers.add_parser("start",
+                                         help="Command to incorporate to the "
+                                              "artifacts.properties the build name and number")
+    parser_start.add_argument("build_name", type=str, help="build name to assign")
+    parser_start.add_argument("build_number", type=int, help="build number to assign")
+
+    parser_stop = subparsers.add_parser("stop",
+                                        help="Command to remove from the artifacts.properties "
+                                             "the build name and number")
+
+    parser_create = subparsers.add_parser("create",
+                                          help="Command to generate a build info json from a "
+                                               "lockfile")
+    parser_create.add_argument("build_info_file", type=str,
+                               help="build info json for output")
+    parser_create.add_argument("--lockfile", type=str, required=True, help="input lockfile")
+    parser_create.add_argument("--multi-module", nargs="?", default=True,
+                               help="if enabled, the module_id will be identified by the "
+                                    "recipe reference plus the package ID")
+    parser_create.add_argument("--skip-env", nargs="?", default=True,
+                               help="capture or not the environment")
+    parser_create.add_argument("--user", type=str, nargs="?", default=None, help="user")
+    parser_create.add_argument("--password", type=str, nargs="?", default=None, help="password")
+    parser_create.add_argument("--apikey", type=str, nargs="?", default=None, help="apikey")
+
+    parser_update = subparsers.add_parser("update",
+                                          help="Command to update a build info json with another one")
+    parser_update.add_argument("buildinfo", nargs="+", help="buildinfo files to merge")
+    parser_update.add_argument("--output-file", default="buildinfo.json",
+                               help="path to generated build info file")
+
+    parser_publish = subparsers.add_parser("publish",
+                                           help="Command to publish the build info to Artifactory")
+    parser_publish.add_argument("buildinfo", type=str,
+                                help="build info to upload")
+    parser_publish.add_argument("--url", type=str, required=True, help="url")
+    parser_publish.add_argument("--user", type=str, nargs="?", default=None, help="user")
+    parser_publish.add_argument("--password", type=str, nargs="?", default=None, help="password")
+    parser_publish.add_argument("--apikey", type=str, nargs="?", default=None, help="apikey")
+
+    def check_credential_arguments():
+        if args.user and args.apikey:
+            parser.error("Please select one authentificacion method --user USER "
+                         "--password PASSWORD or --apikey APIKEY")
+        if args.user and not args.password:
+            parser.error(
+                "Please specify a password for user '{}' with --password PASSWORD".format(
+                    args.user))
+
+    try:
+        args = parser.parse_args()
+        if args.subcommand == "start":
+            start_build_info(output, args.build_name, args.build_number)
+        if args.subcommand == "stop":
+            stop_build_info(output)
+        if args.subcommand == "create":
+            check_credential_arguments()
+            create_build_info(output, args.build_info_file, args.lockfile, args.multi_module,
+                              args.skip_env, args.user, args.password, args.apikey)
+        if args.subcommand == "update":
+            update_build_info(args.buildinfo, args.output_file)
+        if args.subcommand == "publish":
+            check_credential_arguments()
+            publish_build_info(args.buildinfo, args.url, args.user, args.password,
+                               args.apikey)
+    except ConanException as exc:
+        output.error(exc)
+    except Exception as exc:
+        output.error(exc)
 
 
 if __name__ == "__main__":


Test patch:
diff --git a/conans/test/integration/test_build_info_creation.py b/conans/test/integration/test_build_info_creation.py
new file mode 100644
--- /dev/null
+++ b/conans/test/integration/test_build_info_creation.py
@@ -0,0 +1,173 @@
+import json
+import os
+import shutil
+import sys
+import textwrap
+import unittest
+
+from mock import patch, Mock
+
+from conans.client.cache.cache import ClientCache
+from conans.model.graph_lock import LOCKFILE
+from conans.build_info.command import run
+from conans.test.utils.test_files import temp_folder
+from conans.test.utils.tools import TestClient, TestBufferConanOutput, TestServer
+
+
+class MyBuildInfoCreation(unittest.TestCase):
+    @patch("conans.build_info.build_info.ClientCache")
+    def test_build_info_start(self, mock_cache):
+        conan_user_home = temp_folder(True)
+        mock_cache.return_value = ClientCache(os.path.join(conan_user_home, ".conan"),
+                                              TestBufferConanOutput())
+        sys.argv = ["conan_build_info", "--v2", "start", "MyBuildName", "42"]
+        run()
+        with open(mock_cache.return_value.put_headers_path) as f:
+            content = f.read()
+            self.assertIn("MyBuildName", content)
+            self.assertIn("42", content)
+
+    @patch("conans.build_info.build_info.ClientCache")
+    def test_build_info_stop(self, mock_cache):
+        conan_user_home = temp_folder(True)
+        mock_cache.return_value = ClientCache(os.path.join(conan_user_home, ".conan"),
+                                              TestBufferConanOutput())
+        sys.argv = ["conan_build_info", "--v2", "stop"]
+        run()
+        with open(mock_cache.return_value.put_headers_path) as f:
+            content = f.read()
+            self.assertEqual("", content)
+
+    def mock_response(url, data=None, **kwargs):
+        mock_resp = Mock()
+        mock_resp.status_code = 204
+        if "api/build" in url:
+            mock_resp.status_code = 204
+        if kwargs.get("auth", None) and (
+                kwargs["auth"][0] != "user" or kwargs["auth"][1] != "password"):
+            mock_resp.status_code = 401
+        elif kwargs["headers"].get("X-JFrog-Art-Api", None) and kwargs["headers"][
+            "X-JFrog-Art-Api"] != "apikey":
+            mock_resp.status_code = 401
+        buildinfo = json.load(data)
+        if not buildinfo["name"] == "MyBuildInfo" or not buildinfo["number"] == "42":
+            mock_resp.status_code = 400
+        mock_resp.content = None
+        return mock_resp
+
+    def _test_buildinfo(self, client, user_channel):
+        conanfile = textwrap.dedent("""
+            from conans import ConanFile, load
+            import os
+            class Pkg(ConanFile):
+                {requires}
+                exports_sources = "myfile.txt"
+                keep_imports = True
+                def imports(self):
+                    self.copy("myfile.txt", folder=True)
+                def package(self):
+                    self.copy("*myfile.txt")
+                """)
+        client.save({"PkgA/conanfile.py": conanfile.format(requires=""),
+                     "PkgA/myfile.txt": "HelloA"})
+        client.run("create PkgA PkgA/0.1@{}".format(user_channel))
+
+        client.save({"PkgB/conanfile.py": conanfile.format(
+            requires='requires = "PkgA/0.1@{}"'.format(user_channel)),
+            "PkgB/myfile.txt": "HelloB"})
+        client.run("create PkgB PkgB/0.1@{}".format(user_channel))
+
+        client.save({"PkgC/conanfile.py": conanfile.format(
+            requires='requires = "PkgA/0.1@{}"'.format(user_channel)),
+            "PkgC/myfile.txt": "HelloC"})
+        client.run("create PkgC PkgC/0.1@{}".format(user_channel))
+
+        client.save({"PkgD/conanfile.py": conanfile.format(
+            requires='requires = "PkgC/0.1@{0}", "PkgB/0.1@{0}"'.format(user_channel)),
+            "PkgD/myfile.txt": "HelloD"})
+
+        client.run("create PkgD PkgD/0.1@{}".format(user_channel))
+        client.run("graph lock PkgD/0.1@{}".format(user_channel))
+
+        client.run("create PkgA PkgA/0.2@{} --lockfile".format(user_channel))
+
+        shutil.copy(os.path.join(client.current_folder, "conan.lock"),
+                    os.path.join(client.current_folder, "temp.lock"))
+
+        client.run("create PkgB PkgB/0.1@{} --lockfile --build missing".format(user_channel))
+        client.run("upload * --all --confirm -r default")
+
+        sys.argv = ["conan_build_info", "--v2", "start", "MyBuildName", "42"]
+        run()
+        sys.argv = ["conan_build_info", "--v2", "create",
+                    os.path.join(client.current_folder, "buildinfo1.json"), "--lockfile",
+                    os.path.join(client.current_folder, LOCKFILE)]
+        run()
+
+        shutil.copy(os.path.join(client.current_folder, "temp.lock"),
+                    os.path.join(client.current_folder, "conan.lock"))
+
+        client.run("create PkgC PkgC/0.1@{} --lockfile --build missing".format(user_channel))
+        client.run("upload * --all --confirm -r default")
+
+        sys.argv = ["conan_build_info", "--v2", "create",
+                    os.path.join(client.current_folder, "buildinfo2.json"), "--lockfile",
+                    os.path.join(client.current_folder, LOCKFILE)]
+        run()
+
+        user_channel = "@" + user_channel if len(user_channel) > 2 else user_channel
+        with open(os.path.join(client.current_folder, "buildinfo1.json")) as f:
+            buildinfo = json.load(f)
+            self.assertEqual(buildinfo["name"], "MyBuildName")
+            self.assertEqual(buildinfo["number"], "42")
+            ids_list = [item["id"] for item in buildinfo["modules"]]
+            self.assertTrue("PkgB/0.1{}".format(user_channel) in ids_list)
+            self.assertTrue("PkgB/0.1{}:09f152eb7b3e0a6e15a2a3f464245864ae8f8644".format(
+                user_channel) in ids_list)
+
+        sys.argv = ["conan_build_info", "--v2", "update",
+                    os.path.join(client.current_folder, "buildinfo1.json"),
+                    os.path.join(client.current_folder, "buildinfo2.json"),
+                    "--output-file", os.path.join(client.current_folder, "mergedbuildinfo.json")]
+        run()
+
+        with open(os.path.join(client.current_folder, "mergedbuildinfo.json")) as f:
+            buildinfo = json.load(f)
+            self.assertEqual(buildinfo["name"], "MyBuildName")
+            self.assertEqual(buildinfo["number"], "42")
+            ids_list = [item["id"] for item in buildinfo["modules"]]
+            self.assertTrue("PkgC/0.1{}".format(user_channel) in ids_list)
+            self.assertTrue("PkgB/0.1{}".format(user_channel) in ids_list)
+            self.assertTrue("PkgC/0.1{}:09f152eb7b3e0a6e15a2a3f464245864ae8f8644".format(
+                user_channel) in ids_list)
+            self.assertTrue("PkgB/0.1{}:09f152eb7b3e0a6e15a2a3f464245864ae8f8644".format(
+                user_channel) in ids_list)
+
+        sys.argv = ["conan_build_info", "--v2", "publish",
+                    os.path.join(client.current_folder, "mergedbuildinfo.json"), "--url",
+                    "http://fakeurl:8081/artifactory", "--user", "user", "--password", "password"]
+        run()
+        sys.argv = ["conan_build_info", "--v2", "publish",
+                    os.path.join(client.current_folder, "mergedbuildinfo.json"), "--url",
+                    "http://fakeurl:8081/artifactory", "--apikey", "apikey"]
+        run()
+
+        sys.argv = ["conan_build_info", "--v2", "stop"]
+        run()
+
+    @patch("conans.build_info.build_info.get_conan_user_home")
+    @patch("conans.build_info.build_info.ClientCache")
+    @patch("conans.build_info.build_info.requests.put", new=mock_response)
+    def test_build_info_create_update_publish(self, mock_cache, user_home_mock):
+        base_folder = temp_folder(True)
+        cache_folder = os.path.join(base_folder, ".conan")
+        servers = {"default": TestServer([("*/*@*/*", "*")], [("*/*@*/*", "*")],
+                                         users={"lasote": "mypass"})}
+        client = TestClient(servers=servers, users={"default": [("lasote", "mypass")]},
+                            cache_folder=cache_folder)
+
+        mock_cache.return_value = client.cache
+        user_home_mock.return_value = base_folder
+        user_channels = ["", "user/channel"]
+        for user_channel in user_channels:
+            self._test_buildinfo(client, user_channel)
diff --git a/conans/test/unittests/client/cmd/conan_build_info_test.py b/conans/test/unittests/client/cmd/conan_build_info_test.py
new file mode 100644
--- /dev/null
+++ b/conans/test/unittests/client/cmd/conan_build_info_test.py
@@ -0,0 +1,499 @@
+import json
+import os
+import textwrap
+import unittest
+
+from conans.build_info.build_info import update_build_info
+from conans.test.utils.test_files import temp_folder
+from conans.tools import save
+
+
+class BuildInfoTest(unittest.TestCase):
+    buildinfo1 = textwrap.dedent("""
+    {
+        "version": "1.0.1",
+        "name": "MyBuildName",
+        "number": "42",
+        "type": "GENERIC",
+        "started": "2019-10-29T10:41:25.000Z",
+        "buildAgent": {
+            "name": "Conan Client",
+            "version": "1.X"
+        },
+        "modules": [
+            {
+                "id": "PkgB/0.1@user/channel",
+                "artifacts": [
+                    {
+                        "sha1": "aba8527a2c4fc142cf5262298824d3680ecb057f",
+                        "md5": "aad124317706ef90df47686329be8e2b",
+                        "name": "conan_sources.tgz"
+                    },
+                    {
+                        "sha1": "a058b1a9366a361d71ea5d67997009f7200de6e1",
+                        "md5": "a73be4ec0c7301d2ea2dacc873df5483",
+                        "name": "conanfile.py"
+                    },
+                    {
+                        "sha1": "6cbda4a7286d9bb37eb3a6c97b150ed4939f4095",
+                        "md5": "67abd07526f4abdf24f88f443c907f78",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": [
+                    {
+                        "sha1": "def7797033b5b46ca063aaaf21dc7a9c1b93a35a",
+                        "md5": "89b684b95f6f5c7a8e2fda664be22c5a",
+                        "id": "PkgA/0.2@user/channel :: conan_sources.tgz"
+                    },
+                    {
+                        "sha1": "7bd4da1c70ca29637b159a0131a8b886cfaeeb27",
+                        "md5": "00dbccdd251aa5652df8886cf153d2d6",
+                        "id": "PkgA/0.2@user/channel :: conanfile.py"
+                    },
+                    {
+                        "sha1": "7ca1713befc9eabcfaaa244133e3e173307705f4",
+                        "md5": "d723dfc35eb5a121e401818fdb43b210",
+                        "id": "PkgA/0.2@user/channel :: conanmanifest.txt"
+                    }
+                ]
+            },
+            {
+                "id": "PkgB/0.1@user/channel:09f152eb7b3e0a6e15a2a3f464245864ae8f8644",
+                "artifacts": [
+                    {
+                        "sha1": "45f961804e3bcc5267a2f6d130b4dcc16e2379ee",
+                        "md5": "d4f703971717722bd84c24eccf50b9fd",
+                        "name": "conan_package.tgz"
+                    },
+                    {
+                        "sha1": "9525339890e3b484d5e7d8f351957b6c2a28147f",
+                        "md5": "fd6b4a992aa1254fa5a404888ed8c7ce",
+                        "name": "conaninfo.txt"
+                    },
+                    {
+                        "sha1": "f8f2795005c8fbfbcddae7224888d66a8f47f533",
+                        "md5": "c18ffa171f4df3ab4af24dc443320a5a",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": [
+                    {
+                        "sha1": "a96d326d2449a103a4f9e6d81018ffd411b3f4a1",
+                        "md5": "43c402f3ad0cc9dfa89c5be37bf9b7e5",
+                        "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 :: conan_package.tgz"
+                    },
+                    {
+                        "sha1": "2f452380f6ec5db0baab369d0bc4286793710ca3",
+                        "md5": "95adc888e92d1a888454fae2093c0862",
+                        "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 :: conaninfo.txt"
+                    },
+                    {
+                        "sha1": "fe0b6d9343648ae2b60d5c6c4ce765291cc278fb",
+                        "md5": "2702b1656a7318c01112b90cca875867",
+                        "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 :: conanmanifest.txt"
+                    }
+                ]
+            },
+            {
+                "id": "PkgA/0.2@user/channel",
+                "artifacts": [
+                    {
+                        "sha1": "def7797033b5b46ca063aaaf21dc7a9c1b93a35a",
+                        "md5": "89b684b95f6f5c7a8e2fda664be22c5a",
+                        "name": "conan_sources.tgz"
+                    },
+                    {
+                        "sha1": "7bd4da1c70ca29637b159a0131a8b886cfaeeb27",
+                        "md5": "00dbccdd251aa5652df8886cf153d2d6",
+                        "name": "conanfile.py"
+                    },
+                    {
+                        "sha1": "7ca1713befc9eabcfaaa244133e3e173307705f4",
+                        "md5": "d723dfc35eb5a121e401818fdb43b210",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": []
+            },
+            {
+                "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9",
+                "artifacts": [
+                    {
+                        "sha1": "a96d326d2449a103a4f9e6d81018ffd411b3f4a1",
+                        "md5": "43c402f3ad0cc9dfa89c5be37bf9b7e5",
+                        "name": "conan_package.tgz"
+                    },
+                    {
+                        "sha1": "2f452380f6ec5db0baab369d0bc4286793710ca3",
+                        "md5": "95adc888e92d1a888454fae2093c0862",
+                        "name": "conaninfo.txt"
+                    },
+                    {
+                        "sha1": "fe0b6d9343648ae2b60d5c6c4ce765291cc278fb",
+                        "md5": "2702b1656a7318c01112b90cca875867",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": []
+            }
+        ]
+    }""")
+
+    buildinfo2 = textwrap.dedent("""
+    {
+        "version": "1.0.1",
+        "name": "MyBuildName",
+        "number": "42",
+        "type": "GENERIC",
+        "started": "2019-10-29T10:41:25.000Z",
+        "buildAgent": {
+            "name": "Conan Client",
+            "version": "1.X"
+        },
+        "modules": [
+            {
+                "id": "PkgC/0.1@user/channel",
+                "artifacts": [
+                    {
+                        "sha1": "410b7df1fd1483a5a7b4c47e67822fc1e3dd533b",
+                        "md5": "461fbd5d7e66ce86b8e56fb2524970dc",
+                        "name": "conan_sources.tgz"
+                    },
+                    {
+                        "sha1": "a058b1a9366a361d71ea5d67997009f7200de6e1",
+                        "md5": "a73be4ec0c7301d2ea2dacc873df5483",
+                        "name": "conanfile.py"
+                    },
+                    {
+                        "sha1": "594ff68dadb4cbeb36ff724286032698098b41e7",
+                        "md5": "19052f117ce1513c3a815f41fd704c24",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": [
+                    {
+                        "sha1": "def7797033b5b46ca063aaaf21dc7a9c1b93a35a",
+                        "md5": "89b684b95f6f5c7a8e2fda664be22c5a",
+                        "id": "PkgA/0.2@user/channel :: conan_sources.tgz"
+                    },
+                    {
+                        "sha1": "7bd4da1c70ca29637b159a0131a8b886cfaeeb27",
+                        "md5": "00dbccdd251aa5652df8886cf153d2d6",
+                        "id": "PkgA/0.2@user/channel :: conanfile.py"
+                    },
+                    {
+                        "sha1": "7ca1713befc9eabcfaaa244133e3e173307705f4",
+                        "md5": "d723dfc35eb5a121e401818fdb43b210",
+                        "id": "PkgA/0.2@user/channel :: conanmanifest.txt"
+                    }
+                ]
+            },
+            {
+                "id": "PkgC/0.1@user/channel:09f152eb7b3e0a6e15a2a3f464245864ae8f8644",
+                "artifacts": [
+                    {
+                        "sha1": "0b6a6755369820f66d6a858d3b44775fb1b38f54",
+                        "md5": "c1f3a9ff4ee80ab5e5492c1c381dff56",
+                        "name": "conan_package.tgz"
+                    },
+                    {
+                        "sha1": "6bce988c0cfc1d17588c0fddac573066afd8d26d",
+                        "md5": "bde279efd0a24162425017d937fe8484",
+                        "name": "conaninfo.txt"
+                    },
+                    {
+                        "sha1": "6a85f6893f316433cd935abad31c4daf80d09884",
+                        "md5": "5996a968f13f4e4722d24d9d98ed0923",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": [
+                    {
+                        "sha1": "a96d326d2449a103a4f9e6d81018ffd411b3f4a1",
+                        "md5": "43c402f3ad0cc9dfa89c5be37bf9b7e5",
+                        "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 :: conan_package.tgz"
+                    },
+                    {
+                        "sha1": "2f452380f6ec5db0baab369d0bc4286793710ca3",
+                        "md5": "95adc888e92d1a888454fae2093c0862",
+                        "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 :: conaninfo.txt"
+                    },
+                    {
+                        "sha1": "fe0b6d9343648ae2b60d5c6c4ce765291cc278fb",
+                        "md5": "2702b1656a7318c01112b90cca875867",
+                        "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 :: conanmanifest.txt"
+                    }
+                ]
+            },
+            {
+                "id": "PkgA/0.2@user/channel",
+                "artifacts": [
+                    {
+                        "sha1": "def7797033b5b46ca063aaaf21dc7a9c1b93a35a",
+                        "md5": "89b684b95f6f5c7a8e2fda664be22c5a",
+                        "name": "conan_sources.tgz"
+                    },
+                    {
+                        "sha1": "7bd4da1c70ca29637b159a0131a8b886cfaeeb27",
+                        "md5": "00dbccdd251aa5652df8886cf153d2d6",
+                        "name": "conanfile.py"
+                    },
+                    {
+                        "sha1": "7ca1713befc9eabcfaaa244133e3e173307705f4",
+                        "md5": "d723dfc35eb5a121e401818fdb43b210",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": []
+            },
+            {
+                "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9",
+                "artifacts": [
+                    {
+                        "sha1": "a96d326d2449a103a4f9e6d81018ffd411b3f4a1",
+                        "md5": "43c402f3ad0cc9dfa89c5be37bf9b7e5",
+                        "name": "conan_package.tgz"
+                    },
+                    {
+                        "sha1": "2f452380f6ec5db0baab369d0bc4286793710ca3",
+                        "md5": "95adc888e92d1a888454fae2093c0862",
+                        "name": "conaninfo.txt"
+                    },
+                    {
+                        "sha1": "fe0b6d9343648ae2b60d5c6c4ce765291cc278fb",
+                        "md5": "2702b1656a7318c01112b90cca875867",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": []
+            }
+        ]
+    }""")
+
+    result = textwrap.dedent("""
+    {
+        "version": "1.0.1",
+        "name": "MyBuildName",
+        "number": "42",
+        "type": "GENERIC",
+        "started": "2019-10-29T10:41:25.000Z",
+        "buildAgent": {
+            "name": "Conan Client",
+            "version": "1.X"
+        },
+        "modules": [
+            {
+                "id": "PkgB/0.1@user/channel",
+                "artifacts": [
+                    {
+                        "sha1": "aba8527a2c4fc142cf5262298824d3680ecb057f",
+                        "md5": "aad124317706ef90df47686329be8e2b",
+                        "name": "conan_sources.tgz"
+                    },
+                    {
+                        "sha1": "a058b1a9366a361d71ea5d67997009f7200de6e1",
+                        "md5": "a73be4ec0c7301d2ea2dacc873df5483",
+                        "name": "conanfile.py"
+                    },
+                    {
+                        "sha1": "6cbda4a7286d9bb37eb3a6c97b150ed4939f4095",
+                        "md5": "67abd07526f4abdf24f88f443c907f78",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": [
+                    {
+                        "sha1": "def7797033b5b46ca063aaaf21dc7a9c1b93a35a",
+                        "md5": "89b684b95f6f5c7a8e2fda664be22c5a",
+                        "id": "PkgA/0.2@user/channel :: conan_sources.tgz"
+                    },
+                    {
+                        "sha1": "7bd4da1c70ca29637b159a0131a8b886cfaeeb27",
+                        "md5": "00dbccdd251aa5652df8886cf153d2d6",
+                        "id": "PkgA/0.2@user/channel :: conanfile.py"
+                    },
+                    {
+                        "sha1": "7ca1713befc9eabcfaaa244133e3e173307705f4",
+                        "md5": "d723dfc35eb5a121e401818fdb43b210",
+                        "id": "PkgA/0.2@user/channel :: conanmanifest.txt"
+                    }
+                ]
+            },
+            {
+                "id": "PkgB/0.1@user/channel:09f152eb7b3e0a6e15a2a3f464245864ae8f8644",
+                "artifacts": [
+                    {
+                        "sha1": "45f961804e3bcc5267a2f6d130b4dcc16e2379ee",
+                        "md5": "d4f703971717722bd84c24eccf50b9fd",
+                        "name": "conan_package.tgz"
+                    },
+                    {
+                        "sha1": "9525339890e3b484d5e7d8f351957b6c2a28147f",
+                        "md5": "fd6b4a992aa1254fa5a404888ed8c7ce",
+                        "name": "conaninfo.txt"
+                    },
+                    {
+                        "sha1": "f8f2795005c8fbfbcddae7224888d66a8f47f533",
+                        "md5": "c18ffa171f4df3ab4af24dc443320a5a",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": [
+                    {
+                        "sha1": "a96d326d2449a103a4f9e6d81018ffd411b3f4a1",
+                        "md5": "43c402f3ad0cc9dfa89c5be37bf9b7e5",
+                        "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 :: conan_package.tgz"
+                    },
+                    {
+                        "sha1": "2f452380f6ec5db0baab369d0bc4286793710ca3",
+                        "md5": "95adc888e92d1a888454fae2093c0862",
+                        "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 :: conaninfo.txt"
+                    },
+                    {
+                        "sha1": "fe0b6d9343648ae2b60d5c6c4ce765291cc278fb",
+                        "md5": "2702b1656a7318c01112b90cca875867",
+                        "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 :: conanmanifest.txt"
+                    }
+                ]
+            },
+            {
+                "id": "PkgA/0.2@user/channel",
+                "artifacts": [
+                    {
+                        "sha1": "def7797033b5b46ca063aaaf21dc7a9c1b93a35a",
+                        "md5": "89b684b95f6f5c7a8e2fda664be22c5a",
+                        "name": "conan_sources.tgz"
+                    },
+                    {
+                        "sha1": "7bd4da1c70ca29637b159a0131a8b886cfaeeb27",
+                        "md5": "00dbccdd251aa5652df8886cf153d2d6",
+                        "name": "conanfile.py"
+                    },
+                    {
+                        "sha1": "7ca1713befc9eabcfaaa244133e3e173307705f4",
+                        "md5": "d723dfc35eb5a121e401818fdb43b210",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": []
+            },
+            {
+                "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9",
+                "artifacts": [
+                    {
+                        "sha1": "a96d326d2449a103a4f9e6d81018ffd411b3f4a1",
+                        "md5": "43c402f3ad0cc9dfa89c5be37bf9b7e5",
+                        "name": "conan_package.tgz"
+                    },
+                    {
+                        "sha1": "2f452380f6ec5db0baab369d0bc4286793710ca3",
+                        "md5": "95adc888e92d1a888454fae2093c0862",
+                        "name": "conaninfo.txt"
+                    },
+                    {
+                        "sha1": "fe0b6d9343648ae2b60d5c6c4ce765291cc278fb",
+                        "md5": "2702b1656a7318c01112b90cca875867",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": []
+            },
+            {
+                "id": "PkgC/0.1@user/channel",
+                "artifacts": [
+                    {
+                        "sha1": "410b7df1fd1483a5a7b4c47e67822fc1e3dd533b",
+                        "md5": "461fbd5d7e66ce86b8e56fb2524970dc",
+                        "name": "conan_sources.tgz"
+                    },
+                    {
+                        "sha1": "a058b1a9366a361d71ea5d67997009f7200de6e1",
+                        "md5": "a73be4ec0c7301d2ea2dacc873df5483",
+                        "name": "conanfile.py"
+                    },
+                    {
+                        "sha1": "594ff68dadb4cbeb36ff724286032698098b41e7",
+                        "md5": "19052f117ce1513c3a815f41fd704c24",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": [
+                    {
+                        "sha1": "def7797033b5b46ca063aaaf21dc7a9c1b93a35a",
+                        "md5": "89b684b95f6f5c7a8e2fda664be22c5a",
+                        "id": "PkgA/0.2@user/channel :: conan_sources.tgz"
+                    },
+                    {
+                        "sha1": "7bd4da1c70ca29637b159a0131a8b886cfaeeb27",
+                        "md5": "00dbccdd251aa5652df8886cf153d2d6",
+                        "id": "PkgA/0.2@user/channel :: conanfile.py"
+                    },
+                    {
+                        "sha1": "7ca1713befc9eabcfaaa244133e3e173307705f4",
+                        "md5": "d723dfc35eb5a121e401818fdb43b210",
+                        "id": "PkgA/0.2@user/channel :: conanmanifest.txt"
+                    }
+                ]
+            },
+            {
+                "id": "PkgC/0.1@user/channel:09f152eb7b3e0a6e15a2a3f464245864ae8f8644",
+                "artifacts": [
+                    {
+                        "sha1": "0b6a6755369820f66d6a858d3b44775fb1b38f54",
+                        "md5": "c1f3a9ff4ee80ab5e5492c1c381dff56",
+                        "name": "conan_package.tgz"
+                    },
+                    {
+                        "sha1": "6bce988c0cfc1d17588c0fddac573066afd8d26d",
+                        "md5": "bde279efd0a24162425017d937fe8484",
+                        "name": "conaninfo.txt"
+                    },
+                    {
+                        "sha1": "6a85f6893f316433cd935abad31c4daf80d09884",
+                        "md5": "5996a968f13f4e4722d24d9d98ed0923",
+                        "name": "conanmanifest.txt"
+                    }
+                ],
+                "dependencies": [
+                    {
+                        "sha1": "a96d326d2449a103a4f9e6d81018ffd411b3f4a1",
+                        "md5": "43c402f3ad0cc9dfa89c5be37bf9b7e5",
+                        "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 :: conan_package.tgz"
+                    },
+                    {
+                        "sha1": "2f452380f6ec5db0baab369d0bc4286793710ca3",
+                        "md5": "95adc888e92d1a888454fae2093c0862",
+                        "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 :: conaninfo.txt"
+                    },
+                    {
+                        "sha1": "fe0b6d9343648ae2b60d5c6c4ce765291cc278fb",
+                        "md5": "2702b1656a7318c01112b90cca875867",
+                        "id": "PkgA/0.2@user/channel:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9 :: conanmanifest.txt"
+                    }
+                ]
+            }
+        ]
+    }""")
+
+    def update_build_info_test(self):
+        tmp_dir = temp_folder()
+        file1 = os.path.join(tmp_dir, "buildinfo1.json")
+        file2 = os.path.join(tmp_dir, "buildinfo2.json")
+        outfile = os.path.join(tmp_dir, "mergedbuildinfo.json")
+        save(file1, self.buildinfo1)
+        save(file2, self.buildinfo2)
+        update_build_info([file1, file2], outfile)
+        with open(outfile, "r") as json_data:
+            mergedinfo = json.load(json_data)
+            res_json = json.loads(self.result)
+
+        self.assertEqual(mergedinfo["version"], res_json["version"])
+        self.assertEqual(mergedinfo["name"], res_json["name"])
+        self.assertEqual(mergedinfo["number"], res_json["number"])
+        self.assertEqual(mergedinfo["type"], res_json["type"])
+        self.assertEqual(mergedinfo["started"], res_json["started"])
+        self.assertDictEqual(mergedinfo["buildAgent"], res_json["buildAgent"])
+        for index in range(2):
+            self.assertEqual(mergedinfo["modules"][index]["id"],
+                             res_json["modules"][index]["id"])