Repository: devopshq/artifactory

Issue:
Support for Deploy Artifact by Checksum
What do you guys think about adding support for [deploying artifacts by checksum](https://www.jfrog.com/confluence/display/RTF/Artifactory+REST+API#ArtifactoryRESTAPI-DeployArtifactbyChecksum)? It uses the checksum to check if the artifact already exists on the server and copies it over to the new location without requiring the upload.

I think it'd be good to use for default behavior because the result is the same as a normal deploy, other than the fact that it doesn't require the client to upload.

Discussion:
zhan9san: please hold on 
It seems i misunderstand deploy_by_check_sum
zhan9san: > please hold on
> It seems i misunderstand deploy_by_check_sum

The old implementation requires uploading content, and that's not expected.

Please review the latest one.

Patch:
diff --git a/README.md b/README.md
index 7bda380..40fd87c 100644
--- a/README.md
+++ b/README.md
@@ -298,6 +298,37 @@ path.deploy_file(
 )
 ```
 
+[Deploy artifact by checksum](https://www.jfrog.com/confluence/display/RTF6X/Artifactory+REST+API#ArtifactoryRESTAPI-DeployArtifactbyChecksum): deploy an artifact to the specified destination by checking if the artifact
+content already exists in Artifactory. If Artifactory already contains a user
+readable artifact with the same checksum the artifact content is copied over to
+the new location without requiring content transfer.
+
+```python
+from artifactory import ArtifactoryPath
+
+path = ArtifactoryPath("http://my-artifactory/artifactory/my_repo/foo")
+sha1 = "1be5d2dbe52ddee96ef2d17d354e2be0a155a951"
+sha256 = "00bbf80ccca376893d60183e1a714e707fd929aea3e458f9ffda60f7ae75cc51"
+
+# If you don't know sha value, you can calculate it via
+# sha1 = artifactory.sha1sum("local_path_of_your_file")
+# or
+# sha256 = artifactory.sha256sum("local_path_of_your_file")
+
+# Each of the following 4 methods works fine if the artifact content already
+# exists in Artifactory.
+path.deploy_by_checksum(sha1=sha1)
+
+# deploy by sha1 via checksum parameter
+path.deploy_by_checksum(checksum=sha1)
+
+# deploy by sha256 via sha256 parameter
+path.deploy_by_checksum(sha256=sha256)
+
+# deploy by sha256 via checksum parameter
+path.deploy_by_checksum(checksum=sha256)
+```
+
 Deploy a debian package ```myapp-1.0.deb``` to an ```existent``` folder
 
 ```python
diff --git a/artifactory.py b/artifactory.py
index 95e037a..8d74112 100755
--- a/artifactory.py
+++ b/artifactory.py
@@ -1030,11 +1030,30 @@ class _ArtifactoryAccessor(pathlib._Accessor):
         parameters=None,
         explode_archive=None,
         explode_archive_atomic=None,
+        checksum=None,
+        by_checksum=False,
     ):
         """
         Uploads a given file-like object
         HTTP chunked encoding will be attempted
+
+        If by_checksum is True, fobj should be None
+
+        :param pathobj: ArtifactoryPath object
+        :param fobj: file object to be deployed
+        :param md5:
+        :param sha1:
+        :param sha256:
+        :param parameters: Artifact properties
+        :param explode_archive(bool): True: archive will be exploded upon deployment
+        :param explode_archive_atomic(bool): True: archive will be exploded in an atomic operation upon deployment
+        :param checksum: sha1Value or sha256Value
+        :param by_checksum(bool): if True, deploy artifact by checksum, default False
         """
+
+        if fobj and by_checksum:
+            raise RuntimeError("Either fobj or by_checksum, but not both")
+
         if isinstance(fobj, urllib3.response.HTTPResponse):
             fobj = HTTPResponseWrapper(fobj)
 
@@ -1055,6 +1074,10 @@ class _ArtifactoryAccessor(pathlib._Accessor):
             headers["X-Explode-Archive"] = "true"
         if explode_archive_atomic:
             headers["X-Explode-Archive-Atomic"] = "true"
+        if by_checksum:
+            headers["X-Checksum-Deploy"] = "true"
+            if checksum:
+                headers["X-Checksum"] = checksum
 
         text, code = self.rest_put_stream(
             url,
@@ -1764,6 +1787,32 @@ class ArtifactoryPath(pathlib.Path, PureArtifactoryPath):
                 explode_archive_atomic=explode_archive_atomic,
             )
 
+    def deploy_by_checksum(
+        self,
+        sha1=None,
+        sha256=None,
+        checksum=None,
+        parameters={},
+    ):
+        """
+        Deploy an artifact to the specified destination by checking if the
+        artifact content already exists in Artifactory.
+
+        :param pathobj: ArtifactoryPath object
+        :param sha1: sha1Value
+        :param sha256: sha256Value
+        :param checksum: sha1Value or sha256Value
+        """
+        return self._accessor.deploy(
+            self,
+            fobj=None,
+            sha1=sha1,
+            sha256=sha256,
+            checksum=checksum,
+            by_checksum=True,
+            parameters=parameters,
+        )
+
     def deploy_deb(
         self, file_name, distribution, component, architecture, parameters={}
     ):


Test patch:
diff --git a/tests/integration/test_integration_artifactory_path.py b/tests/integration/test_integration_artifactory_path.py
index 89ee9ae..5442e99 100644
--- a/tests/integration/test_integration_artifactory_path.py
+++ b/tests/integration/test_integration_artifactory_path.py
@@ -6,6 +6,8 @@ import tempfile
 import pytest
 
 import artifactory
+from artifactory import sha1sum
+from artifactory import sha256sum
 
 if sys.version_info[0] < 3:
     import StringIO as io
@@ -173,6 +175,87 @@ def test_deploy_file(path):
     p.unlink()
 
 
+@pytest.fixture()
+def deploy_file(path):
+    p = path("/integration-artifactory-path-repo/foo")
+    p1 = path("/integration-artifactory-path-repo/bar")
+    if p.exists():
+        p.unlink()
+    if p1.exists():
+        p1.unlink()
+
+    tf = tempfile.NamedTemporaryFile()
+    tf.write(b"Some test string")
+    tf.flush()
+
+    sha1 = sha1sum(tf.name)
+    sha256 = sha256sum(tf.name)
+
+    p.deploy_file(tf.name)
+    tf.close()
+    with p.open() as fd:
+        result = fd.read()
+    assert result == b"Some test string"
+
+    return (sha1, sha256)
+
+
+def test_deploy_file_by_checksum(path, deploy_file):
+    p = path("/integration-artifactory-path-repo/foo")
+    p1 = path("/integration-artifactory-path-repo/bar")
+
+    # The matrix is a full list of all possible combinations
+    # 1st row is for 1st parameter of deploy_by_checksum
+    # 2nd row is for 2nd parameter and 3rd is for 3rd parameter
+    # matrix = [
+    #     [sha1, sha1_non_existent, None],
+    #     [sha256, sha256_non_existent, None],
+    #     [sha1, sha1_non_existent, sha256, sha256_non_existent, None]
+    # ]
+
+    sha1_non_existent = "1111111111111111111111111111111111111111"
+    sha256_non_existent = (
+        "1111111111111111111111111111111111111111111111111111111111111111"
+    )
+    p1.deploy_by_checksum(sha1=deploy_file[0])
+    with p1.open() as fd:
+        result = fd.read()
+    p1.unlink()
+    assert result == b"Some test string"
+
+    with pytest.raises(RuntimeError) as excinfo:
+        p.deploy_by_checksum(sha1=sha1_non_existent)
+
+    p1.deploy_by_checksum(sha256=deploy_file[1])
+    with p1.open() as fd:
+        result = fd.read()
+    p1.unlink()
+    assert result == b"Some test string"
+
+    with pytest.raises(RuntimeError) as excinfo:
+        p.deploy_by_checksum(sha256=sha256_non_existent)
+
+    p1.deploy_by_checksum(checksum=deploy_file[0])
+    with p1.open() as fd:
+        result = fd.read()
+    p1.unlink()
+    assert result == b"Some test string"
+
+    with pytest.raises(RuntimeError) as excinfo:
+        p.deploy_by_checksum(checksum=sha1_non_existent)
+
+    p1.deploy_by_checksum(checksum=deploy_file[1])
+    with p1.open() as fd:
+        result = fd.read()
+    p1.unlink()
+    assert result == b"Some test string"
+
+    with pytest.raises(RuntimeError) as excinfo:
+        p.deploy_by_checksum(checksum=sha256_non_existent)
+
+    p.unlink()
+
+
 def test_open(path):
     p = path("/integration-artifactory-path-repo/foo")
 
diff --git a/tests/unit/test_artifactory_path.py b/tests/unit/test_artifactory_path.py
index 9b65c5a..48fbeed 100644
--- a/tests/unit/test_artifactory_path.py
+++ b/tests/unit/test_artifactory_path.py
@@ -1,4 +1,5 @@
 #!/usr/bin/env python
+import json
 import os
 import pathlib
 import tempfile
@@ -395,30 +396,43 @@ class PureArtifactoryPathTest(unittest.TestCase):
 
 class ClassSetup(unittest.TestCase):
     def setUp(self):
-        self.file_stat = {
+        self.artifact_url = "http://artifactory.local/artifactory/ext-release-local/org/company/tool/1.0/tool-1.0.tar.gz"
+        self.path = ArtifactoryPath(self.artifact_url)
+        self.sha1 = "fc6c9e8ba6eaca4fa97868ac900570282133c095"
+        self.sha256 = "fc6c9e8ba6eaca4fa97868ac900570282133c095fc6c9e8ba6eaca4fa97868ac900570282133c095"
+        # Response for deploying artifact by checksum
+        self.file_stat_without_modification_date = {
             "repo": "ext-release-local",
             "path": "/org/company/tool/1.0/tool-1.0.tar.gz",
             "created": "2014-02-24T21:20:59.999+04:00",
             "createdBy": "someuser",
-            "lastModified": "2014-02-24T21:20:36.000+04:00",
-            "modifiedBy": "anotheruser",
-            "lastUpdated": "2014-02-24T21:20:36.000+04:00",
             "downloadUri": "http://artifactory.local/artifactory/ext-release-local/org/company/tool/1.0/tool-1.0.tar.gz",
             "mimeType": "application/octet-stream",
             "size": "26776462",
             "checksums": {
-                "sha1": "fc6c9e8ba6eaca4fa97868ac900570282133c095",
-                "sha256": "fc6c9e8ba6eaca4fa97868ac900570282133c095fc6c9e8ba6eaca4fa97868ac900570282133c095",
+                "sha1": self.sha1,
+                "sha256": self.sha256,
                 "md5": "2af7d54a09e9c36d704cb3a2de28aff3",
             },
             "originalChecksums": {
-                "sha1": "fc6c9e8ba6eaca4fa97868ac900570282133c095",
-                "sha256": "fc6c9e8ba6eaca4fa97868ac900570282133c095fc6c9e8ba6eaca4fa97868ac900570282133c095",
+                "sha1": self.sha1,
+                "sha256": self.sha256,
                 "md5": "2af7d54a09e9c36d704cb3a2de28aff3",
             },
-            "uri": "http://artifactory.local/artifactory/api/storage/ext-release-local/org/company/tool/1.0/tool-1.0.tar.gz",
+            "uri": "http://artifactory.local/artifactory/ext-release-local/org/company/tool/1.0/tool-1.0.tar.gz",
         }
 
+        # Response for file info api
+        self.file_stat = self.file_stat_without_modification_date.copy()
+        self.file_stat.update(
+            {
+                "lastModified": "2014-02-24T21:20:36.000+04:00",
+                "modifiedBy": "anotheruser",
+                "lastUpdated": "2014-02-24T21:20:36.000+04:00",
+                "uri": "http://artifactory.local/artifactory/api/storage/ext-release-local/org/company/tool/1.0/tool-1.0.tar.gz",
+            }
+        )
+
         self.dir_stat = {
             "repo": "libs-release-local",
             "path": "/",
@@ -441,6 +455,10 @@ class ClassSetup(unittest.TestCase):
           "uri" : "http://artifactory.local/artifactory/api/storage/ext-release-local/org/company/tool/1.0/tool-1.0.tar.gz"
         }"""
 
+        self.deploy_by_checksum_error = {
+            "errors": [{"status": 400, "message": "Checksum values not provided"}]
+        }
+
 
 class ArtifactoryAccessorTest(ClassSetup):
     """Test the real artifactory integration"""
@@ -896,6 +914,116 @@ class ArtifactoryPathTest(ClassSetup):
         self.assertIn("X-Explode-Archive-Atomic", headers)
         self.assertEqual(headers["X-Explode-Archive-Atomic"], "true")
 
+    def test_deploy_by_checksum_sha1(self):
+        """
+        Test that file is deployed by sha1
+        :return:
+        """
+        with responses.RequestsMock() as rsps:
+            rsps.add(
+                responses.PUT,
+                self.artifact_url,
+                json=self.file_stat_without_modification_date,
+                status=200,
+            )
+            self.path.deploy_by_checksum(sha1=self.sha1)
+
+            self.assertEqual(len(rsps.calls), 1)
+            self.assertEqual(rsps.calls[0].request.url, self.artifact_url)
+            headers = rsps.calls[0].request.headers
+            self.assertEqual(headers["X-Checksum-Deploy"], "true")
+            self.assertEqual(headers["X-Checksum-Sha1"], self.sha1)
+            self.assertNotIn("X-Checksum-Sha256", headers)
+            self.assertNotIn("X-Checksum", headers)
+
+    def test_deploy_by_checksum_sha256(self):
+        """
+        Test that file is deployed by sha256
+        :return:
+        """
+        with responses.RequestsMock() as rsps:
+            rsps.add(
+                responses.PUT,
+                self.artifact_url,
+                json=self.file_stat_without_modification_date,
+                status=200,
+            )
+            self.path.deploy_by_checksum(sha256=self.sha256)
+
+            self.assertEqual(len(rsps.calls), 1)
+            self.assertEqual(rsps.calls[0].request.url, self.artifact_url)
+            headers = rsps.calls[0].request.headers
+            self.assertEqual(headers["X-Checksum-Deploy"], "true")
+            self.assertEqual(headers["X-Checksum-Sha256"], self.sha256)
+            self.assertNotIn("X-Checksum-Sha1", headers)
+            self.assertNotIn("X-Checksum", headers)
+
+    def test_deploy_by_checksum_sha1_or_sha256(self):
+        """
+        Test that file is deployed by sha1 or sha256
+        :return:
+        """
+        with responses.RequestsMock() as rsps:
+            rsps.add(
+                responses.PUT,
+                self.artifact_url,
+                json=self.file_stat_without_modification_date,
+                status=200,
+            )
+            self.path.deploy_by_checksum(checksum=self.sha1)
+
+            self.assertEqual(len(rsps.calls), 1)
+            self.assertEqual(rsps.calls[0].request.url, self.artifact_url)
+            headers = rsps.calls[0].request.headers
+            self.assertEqual(headers["X-Checksum-Deploy"], "true")
+            self.assertEqual(headers["X-Checksum"], self.sha1)
+            self.assertNotIn("X-Checksum-Sha1", headers)
+            self.assertNotIn("X-Checksum-Sha256", headers)
+
+        with responses.RequestsMock() as rsps:
+            rsps.add(
+                responses.PUT,
+                self.artifact_url,
+                json=self.file_stat_without_modification_date,
+                status=200,
+            )
+            self.path.deploy_by_checksum(checksum=self.sha256)
+
+            self.assertEqual(len(rsps.calls), 1)
+            self.assertEqual(rsps.calls[0].request.url, self.artifact_url)
+            headers = rsps.calls[0].request.headers
+            self.assertEqual(headers["X-Checksum-Deploy"], "true")
+            self.assertEqual(headers["X-Checksum"], self.sha256)
+            self.assertNotIn("X-Checksum-Sha1", headers)
+            self.assertNotIn("X-Checksum-Sha256", headers)
+
+    def test_deploy_by_checksum_error(self):
+        """
+        Test that file is deployed by checksum, which raises error
+        :return:
+        """
+        with responses.RequestsMock() as rsps:
+            rsps.add(
+                responses.PUT,
+                self.artifact_url,
+                json=self.deploy_by_checksum_error,
+                status=400,
+            )
+            with self.assertRaises(RuntimeError) as context:
+                self.path.deploy_by_checksum(sha1=f"{self.sha1}invalid")
+
+            self.assertEqual(
+                str(context.exception), json.dumps(self.deploy_by_checksum_error)
+            )
+
+            self.assertEqual(len(rsps.calls), 1)
+            self.assertEqual(rsps.calls[0].request.url, self.artifact_url)
+            headers = rsps.calls[0].request.headers
+            self.assertEqual(headers["X-Checksum-Deploy"], "true")
+            self.assertEqual(headers["X-Checksum-Sha1"], f"{self.sha1}invalid")
+            self.assertNotIn("X-Checksum-Sha256", headers)
+            self.assertNotIn("X-Checksum", headers)
+
     @responses.activate
     def test_deploy_deb(self):
         """