Repository: box/box-python-sdk

Issue:
Add support for getting file shared link download URLs
The [get_shared_link()](https://github.com/box/box-python-sdk/blob/master/boxsdk/object/item.py#L162) method returns:

```python
return item.shared_link['url']
```

However sometimes there may be a use case for getting a **direct link** to a file, which should also be present in the response from the [Box API](https://box-content.readme.io/reference#create-a-shared-link-for-a-file):

```json
{
    "type": "file",
    "id": "10559150999",
   ...
    "shared_link": {
        "url": "https://foo.box.com/s/7mcmdlavtye5o5i0ue8xmtwh2sx5bv8p",
        "download_url":"https://foo.box.com/shared/static/7mcmdlavtye5o5i0ue8xmtwh2sx5bv8p.png",
    ...
    }
}
```

Currently the Box Python SDK has a common `get_shared_link` method on the `Item` class, and since `download_url` is not a valid attribute for a shared link on a folder this probably should be added *only* in the context of `File`.

I'm beginning to implement this on my fork with the intent to submit back upstream. What seems like the least terrible approach would be to clone the `get_shared_link` method from the `Item` class into the `File` class, rename it `get_download_link` (or `get_download_url`?), and change the return value, but this seems like a lot of code duplication. Is there a better way?

Any recommendations on approaches or proper patterns to stick to would be most appreciated!

**

[Description of one specific use case for this feature](https://community.box.com/t5/Admin-Forum/quot-We-re-sorry-but-we-can-t-preview-zip-files-quot-is/m-p/11336)

Discussion:

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 5c9f373..2194c18 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -6,6 +6,11 @@ Release History
 Upcoming
 ++++++++
 
+1.4.1 (2016-02-11)
+++++++++++++++++++
+
+- Files now support getting a direct download url.
+
 1.4.0 (2016-01-05)
 ++++++++++++++++++
 
diff --git a/boxsdk/object/file.py b/boxsdk/object/file.py
index 96f6079..2b28053 100644
--- a/boxsdk/object/file.py
+++ b/boxsdk/object/file.py
@@ -237,3 +237,54 @@ def metadata(self, scope='global', template='properties'):
             :class:`Metadata`
         """
         return Metadata(self._session, self, scope, template)
+
+    def get_shared_link_download_url(
+            self,
+            access=None,
+            etag=None,
+            unshared_at=None,
+            allow_preview=None,
+            password=None,
+    ):
+        """
+        Get a shared link download url for the file with the given access permissions.
+        This url is a direct download url for the file.
+
+        :param access:
+            Determines who can access the shared link. May be open, company, or collaborators. If no access is
+            specified, the default access will be used.
+        :type access:
+            `unicode` or None
+        :param etag:
+            If specified, instruct the Box API to create the link only if the current version's etag matches.
+        :type etag:
+            `unicode` or None
+        :param unshared_at:
+            The date on which this link should be disabled. May only be set if the current user is not a free user
+            and has permission to set expiration dates.
+        :type unshared_at:
+            :class:`datetime.date` or None
+        :param allow_preview:
+            Whether or not the item being shared can be previewed when accessed via the shared link.
+            If this parameter is None, the default setting will be used.
+        :type allow_preview:
+            `bool` or None
+        :param password:
+            The password required to view this link. If no password is specified then no password will be set.
+            Please notice that this is a premium feature, which might not be available to your app.
+        :type password:
+            `unicode` or None
+        :returns:
+            The URL of the shared link that allows direct download.
+        :rtype:
+            `unicode`
+        :raises: :class:`BoxAPIException` if the specified etag doesn't match the latest version of the item.
+        """
+        item = self.create_shared_link(
+            access=access,
+            etag=etag,
+            unshared_at=unshared_at,
+            allow_preview=allow_preview,
+            password=password,
+        )
+        return item.shared_link['download_url']
diff --git a/boxsdk/object/item.py b/boxsdk/object/item.py
index 3883546..8487761 100644
--- a/boxsdk/object/item.py
+++ b/boxsdk/object/item.py
@@ -159,8 +159,17 @@ def move(self, parent_folder):
         }
         return self.update_info(data)
 
-    def get_shared_link(self, access=None, etag=None, unshared_at=None, allow_download=None, allow_preview=None, password=None):
-        """Get a shared link for the item with the given access permissions.
+    def create_shared_link(
+            self,
+            access=None,
+            etag=None,
+            unshared_at=None,
+            allow_download=None,
+            allow_preview=None,
+            password=None,
+    ):
+        """
+        Create a shared link for the item with the given access permissions.
 
         :param access:
             Determines who can access the shared link. May be open, company, or collaborators. If no access is
@@ -191,10 +200,11 @@ def get_shared_link(self, access=None, etag=None, unshared_at=None, allow_downlo
             Please notice that this is a premium feature, which might not be available to your app.
         :type password:
             `unicode` or None
-        :returns:
-            The URL of the shared link.
+        :return:
+            The updated object with s shared link.
+            Returns a new object of the same type, without modifying the original object passed as self.
         :rtype:
-            `unicode`
+            :class:`Item`
         :raises: :class:`BoxAPIException` if the specified etag doesn't match the latest version of the item.
         """
         data = {
@@ -216,7 +226,64 @@ def get_shared_link(self, access=None, etag=None, unshared_at=None, allow_downlo
         if password is not None:
             data['shared_link']['password'] = password
 
-        item = self.update_info(data, etag=etag)
+        return self.update_info(data, etag=etag)
+
+    def get_shared_link(
+            self,
+            access=None,
+            etag=None,
+            unshared_at=None,
+            allow_download=None,
+            allow_preview=None,
+            password=None,
+    ):
+        """
+        Get a shared link for the item with the given access permissions.
+        This url leads to a Box.com shared link page, where the item can be previewed, downloaded, etc.
+
+        :param access:
+            Determines who can access the shared link. May be open, company, or collaborators. If no access is
+            specified, the default access will be used.
+        :type access:
+            `unicode` or None
+        :param etag:
+            If specified, instruct the Box API to create the link only if the current version's etag matches.
+        :type etag:
+            `unicode` or None
+        :param unshared_at:
+            The date on which this link should be disabled. May only be set if the current user is not a free user
+            and has permission to set expiration dates.
+        :type unshared_at:
+            :class:`datetime.date` or None
+        :param allow_download:
+            Whether or not the item being shared can be downloaded when accessed via the shared link.
+            If this parameter is None, the default setting will be used.
+        :type allow_download:
+            `bool` or None
+        :param allow_preview:
+            Whether or not the item being shared can be previewed when accessed via the shared link.
+            If this parameter is None, the default setting will be used.
+        :type allow_preview:
+            `bool` or None
+        :param password:
+            The password required to view this link. If no password is specified then no password will be set.
+            Please notice that this is a premium feature, which might not be available to your app.
+        :type password:
+            `unicode` or None
+        :returns:
+            The URL of the shared link.
+        :rtype:
+            `unicode`
+        :raises: :class:`BoxAPIException` if the specified etag doesn't match the latest version of the item.
+        """
+        item = self.create_shared_link(
+            access=access,
+            etag=etag,
+            unshared_at=unshared_at,
+            allow_download=allow_download,
+            allow_preview=allow_preview,
+            password=password,
+        )
         return item.shared_link['url']
 
     def remove_shared_link(self, etag=None):
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..c34b498
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,5 @@
+[bdist_wheel]
+# This flag says that the code is written to work on both Python 2 and Python
+# 3. If at all possible, it is good practice to do this. If you cannot, you
+# will need to generate wheels for each Python version that you support.
+universal=1
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 23b01bc..b3dc950 100644
--- a/setup.py
+++ b/setup.py
@@ -61,7 +61,7 @@ def main():
         install_requires.append('ordereddict>=1.1')
     setup(
         name='boxsdk',
-        version='1.4.0',
+        version='1.4.1',
         description='Official Box Python SDK',
         long_description=open(join(base_dir, 'README.rst')).read(),
         author='Box',


Test patch:
diff --git a/test/unit/object/conftest.py b/test/unit/object/conftest.py
index 0d625af..65e3863 100644
--- a/test/unit/object/conftest.py
+++ b/test/unit/object/conftest.py
@@ -1,6 +1,7 @@
 # coding: utf-8
 
 from __future__ import unicode_literals
+from datetime import date
 import os
 from mock import Mock
 import pytest
@@ -162,3 +163,28 @@ def file_size(request):
 def mock_group(mock_box_session, mock_group_id):
     group = Group(mock_box_session, mock_group_id)
     return group
+
+
+@pytest.fixture(params=(True, False, None))
+def shared_link_can_download(request):
+    return request.param
+
+
+@pytest.fixture(params=(True, False, None))
+def shared_link_can_preview(request):
+    return request.param
+
+
+@pytest.fixture(params=('open', None))
+def shared_link_access(request):
+    return request.param
+
+
+@pytest.fixture(params=('hunter2', None))
+def shared_link_password(request):
+    return request.param
+
+
+@pytest.fixture(params=(date(2015, 5, 5), None))
+def shared_link_unshared_at(request):
+    return request.param
diff --git a/test/unit/object/test_file.py b/test/unit/object/test_file.py
index ecbd4b2..44a8ec4 100644
--- a/test/unit/object/test_file.py
+++ b/test/unit/object/test_file.py
@@ -234,3 +234,43 @@ def test_preflight_check(
         expect_json_response=False,
         data=expected_data,
     )
+
+
+def test_get_shared_link_download_url(
+        test_file,
+        mock_box_session,
+        shared_link_access,
+        shared_link_unshared_at,
+        shared_link_password,
+        shared_link_can_preview,
+        test_url,
+        etag,
+        if_match_header,
+):
+    # pylint:disable=redefined-outer-name, protected-access
+    expected_url = test_file.get_url()
+    mock_box_session.put.return_value.json.return_value = {'shared_link': {'url': None, 'download_url': test_url}}
+    expected_data = {'shared_link': {}}
+    if shared_link_access is not None:
+        expected_data['shared_link']['access'] = shared_link_access
+    if shared_link_unshared_at is not None:
+        expected_data['shared_link']['unshared_at'] = shared_link_unshared_at.isoformat()
+    if shared_link_can_preview is not None:
+        expected_data['shared_link']['permissions'] = permissions = {}
+        permissions['can_preview'] = shared_link_can_preview
+    if shared_link_password is not None:
+        expected_data['shared_link']['password'] = shared_link_password
+    url = test_file.get_shared_link_download_url(
+        etag=etag,
+        access=shared_link_access,
+        unshared_at=shared_link_unshared_at,
+        password=shared_link_password,
+        allow_preview=shared_link_can_preview,
+    )
+    mock_box_session.put.assert_called_once_with(
+        expected_url,
+        data=json.dumps(expected_data),
+        headers=if_match_header,
+        params=None,
+    )
+    assert url == test_url
diff --git a/test/unit/object/test_item.py b/test/unit/object/test_item.py
index abc080f..6226cef 100644
--- a/test/unit/object/test_item.py
+++ b/test/unit/object/test_item.py
@@ -1,7 +1,6 @@
 # coding: utf-8
 
 from __future__ import unicode_literals
-from datetime import date
 import json
 import pytest
 
@@ -56,31 +55,6 @@ def test_move_item(test_item_and_response, mock_box_session, test_folder, mock_o
     assert isinstance(move_response, test_item.__class__)
 
 
-@pytest.fixture(params=(True, False, None))
-def shared_link_can_download(request):
-    return request.param
-
-
-@pytest.fixture(params=(True, False, None))
-def shared_link_can_preview(request):
-    return request.param
-
-
-@pytest.fixture(params=('open', None))
-def shared_link_access(request):
-    return request.param
-
-
-@pytest.fixture(params=('hunter2', None))
-def shared_link_password(request):
-    return request.param
-
-
-@pytest.fixture(params=(date(2015, 5, 5), None))
-def shared_link_unshared_at(request):
-    return request.param
-
-
 def test_get_shared_link(
         test_item_and_response,
         mock_box_session,