Repository: Duke-GCB/DukeDSClient

Issue:
Add create_folder developer API
Request from user to create a folder using the `DukeDS` developer API.
Example command to create a folder and subfolder for a project named 'mouse':
```
import DukeDS
DukeDS.create_folder('mouse', '/date/results')
```
Currently you can only create a folder indirectly by uploading a file.
User wanted to pre-create the folder to avoid issues related to duplicate folders when uploading files in parallel instances.

Discussion:

Patch:
diff --git a/DukeDS/__init__.py b/DukeDS/__init__.py
index c0fc0f2..7ab856f 100644
--- a/DukeDS/__init__.py
+++ b/DukeDS/__init__.py
@@ -2,6 +2,7 @@ from ddsc.sdk.dukeds import DukeDS
 list_projects = DukeDS.list_projects
 create_project = DukeDS.create_project
 delete_project = DukeDS.delete_project
+create_folder = DukeDS.create_folder
 list_files = DukeDS.list_files
 download_file = DukeDS.download_file
 upload_file = DukeDS.upload_file
@@ -11,6 +12,7 @@ can_deliver_to_user_with_email = DukeDS.can_deliver_to_user_with_email
 can_deliver_to_user_with_username = DukeDS.can_deliver_to_user_with_username
 
 __all__ = ['list_projects', 'create_project', 'delete_project',
+           'create_folder',
            'list_files', 'download_file', 'upload_file', 'delete_file',
            'move_file_or_folder',
            'can_deliver_to_user_with_email', 'can_deliver_to_user_with_username']
diff --git a/ddsc/sdk/client.py b/ddsc/sdk/client.py
index 3abe9df..bcbbaa2 100644
--- a/ddsc/sdk/client.py
+++ b/ddsc/sdk/client.py
@@ -333,8 +333,7 @@ class Project(BaseResponseItem):
         :param path: str: path within a project specifying a file or folder to download
         :return: File|Folder
         """
-        child_finder = ChildFinder(path, self)
-        return child_finder.get_child()
+        return ChildFinder.get_child_for_path(self, path)
 
     def try_get_item_for_path(self, path):
         """
@@ -342,12 +341,7 @@ class Project(BaseResponseItem):
         :param path: str: path within a project specifying a file or folder to download
         :return: File|Folder|Project|None
         """
-        try:
-            if path == REMOTE_PATH_SEP:
-                return self
-            return self.get_child_for_path(path)
-        except ItemNotFound:
-            return None
+        return ChildFinder.try_get_item_for_path(self, path)
 
     def create_folder(self, folder_name):
         """
@@ -471,6 +465,22 @@ class Folder(BaseResponseItem):
         """
         return self.dds_connection.create_folder(folder_name, KindType.folder_str, self.id)
 
+    def get_child_for_path(self, path):
+        """
+        Based on a remote path get a single remote child. When not found raises ItemNotFound.
+        :param path: str: path within a project specifying a file or folder to download
+        :return: File|Folder
+        """
+        return ChildFinder.get_child_for_path(self, path)
+
+    def try_get_item_for_path(self, path):
+        """
+        Based on a remote path get a single remote child. When not found returns None.
+        :param path: str: path within a project specifying a file or folder to download
+        :return: File|Folder|Project|None
+        """
+        return ChildFinder.try_get_item_for_path(self, path)
+
     def upload_file(self, local_path, remote_filename=None):
         """
         Upload a new file based on a file on the file system as a top level child of this folder.
@@ -626,13 +636,13 @@ class ChildFinder(object):
     """
     Recursively looks for a child based on a path
     """
-    def __init__(self, remote_path, node):
+    def __init__(self, node, remote_path):
         """
-        :param remote_path: path under a project in DDSConnection
         :param node: Project|Folder to find children under
+        :param remote_path: path under a project in DDSConnection
         """
-        self.remote_path = remote_path
         self.node = node
+        self.remote_path = remote_path
 
     def get_child(self):
         """
@@ -651,6 +661,30 @@ class ChildFinder(object):
                 return self._get_child_recurse(tail, child)
         raise ItemNotFound("No item at path {}".format(self.remote_path))
 
+    @staticmethod
+    def get_child_for_path(node, path):
+        """
+        Based on a remote path get a single remote child. When not found raises ItemNotFound.
+        :param path: str: path within a project specifying a file or folder to download
+        :return: File|Folder
+        """
+        child_finder = ChildFinder(node, path)
+        return child_finder.get_child()
+
+    @staticmethod
+    def try_get_item_for_path(node, path):
+        """
+        Based on a remote path get a single remote child. When not found returns None.
+        :param path: str: path within a project specifying a file or folder to download
+        :return: File|Folder|Project|None
+        """
+        try:
+            if path == REMOTE_PATH_SEP:
+                return node
+            return ChildFinder.get_child_for_path(node, path)
+        except ItemNotFound:
+            return None
+
 
 class PathToFiles(object):
     def __init__(self):
diff --git a/ddsc/sdk/dukeds.py b/ddsc/sdk/dukeds.py
index 3353cff..56796a0 100644
--- a/ddsc/sdk/dukeds.py
+++ b/ddsc/sdk/dukeds.py
@@ -1,6 +1,6 @@
 from __future__ import absolute_import
 import logging
-from ddsc.sdk.client import Client, FileUpload, PathToFiles, ItemNotFound, DuplicateNameError
+from ddsc.sdk.client import Client, FileUpload, PathToFiles, ItemNotFound, DuplicateNameError, RemotePath
 from ddsc.core.userutil import UserUtil
 
 
@@ -35,6 +35,15 @@ class DukeDS(object):
         """
         Session().delete_project(project_name)
 
+    @staticmethod
+    def create_folder(project_name, remote_path):
+        """
+        Create a folder and any necessary parent folders for the specified path.
+        :param project_name: str: name of the project to create a folder in
+        :param remote_path: str: remote path specifying which folder(s) to create
+        """
+        return Session().create_folder(project_name, remote_path)
+
     @staticmethod
     def list_files(project_name):
         """
@@ -151,6 +160,21 @@ class Session(object):
         project.delete()
         self.clear_project_cache()
 
+    def create_folder(self, project_name, remote_path):
+        """
+        Create a folder and any necessary parent folders for the specified path.
+        :param project_name: str: name of the project to create a folder in
+        :param remote_path: str: remote path specifying which folder(s) to create
+        """
+        project = self._get_project_for_name(project_name)
+        remote_path_parts = RemotePath.split(remote_path)
+        parent = project
+        for path_part in remote_path_parts:
+            parent_folder = parent.try_get_item_for_path(path_part)
+            if not parent_folder:
+                parent_folder = parent.create_folder(path_part)
+            parent = parent_folder
+
     def list_files(self, project_name):
         """
         Return a list of file paths that make up project_name


Test patch:
diff --git a/ddsc/sdk/tests/test_client.py b/ddsc/sdk/tests/test_client.py
index 1748b77..6d0f475 100644
--- a/ddsc/sdk/tests/test_client.py
+++ b/ddsc/sdk/tests/test_client.py
@@ -1,6 +1,6 @@
 from unittest import TestCase
 from ddsc.sdk.client import Client, DDSConnection, BaseResponseItem, Project, Folder, File, FileDownload, FileUpload, \
-    ChildFinder, PathToFiles, ItemNotFound, ProjectSummary
+    ChildFinder, PathToFiles, ItemNotFound, ProjectSummary, REMOTE_PATH_SEP
 from ddsc.core.util import KindType
 from mock import patch, Mock, call
 
@@ -472,40 +472,14 @@ class TestProject(TestCase):
     def test_get_child_for_path(self, mock_child_finder):
         mock_dds_connection = Mock()
         mock_child = Mock()
-        mock_child_finder.return_value.get_child.return_value = mock_child
+        mock_child_finder.get_child_for_path.return_value = mock_child
 
         project = Project(mock_dds_connection, self.project_dict)
         child = project.get_child_for_path('/data/file1.dat')
 
-        mock_child_finder.assert_called_with('/data/file1.dat', project)
+        mock_child_finder.get_child_for_path.assert_called_with(project, '/data/file1.dat')
         self.assertEqual(child, mock_child)
 
-    @patch('ddsc.sdk.client.ChildFinder')
-    def test_try_get_item_for_path__with_project(self, mock_child_finder):
-        mock_dds_connection = Mock()
-        project = Project(mock_dds_connection, self.project_dict)
-        project.get_child_for_path = Mock()
-        item = project.try_get_item_for_path('/')
-        self.assertEqual(item, project)
-        project.get_child_for_path.assert_not_called()
-
-    def test_try_get_item_for_path__with_child(self):
-        mock_dds_connection = Mock()
-        project = Project(mock_dds_connection, self.project_dict)
-        project.get_child_for_path = Mock()
-        item = project.try_get_item_for_path('/data/file1.dat')
-        self.assertEqual(item, project.get_child_for_path.return_value)
-        project.get_child_for_path.assert_called_with('/data/file1.dat')
-
-    def test_try_get_item_for_path__child_not_found(self):
-        mock_dds_connection = Mock()
-        project = Project(mock_dds_connection, self.project_dict)
-        project.get_child_for_path = Mock()
-        project.get_child_for_path.side_effect = ItemNotFound("Not Found")
-        item = project.try_get_item_for_path('/data/file1.dat')
-        self.assertEqual(item, None)
-        project.get_child_for_path.assert_called_with('/data/file1.dat')
-
     def test_create_folder(self):
         mock_dds_connection = Mock()
         mock_folder = Mock()
@@ -764,7 +738,7 @@ class TestChildFinder(TestCase):
             mock_folder,
             mock_file
         ]
-        child_finder = ChildFinder('data.txt', mock_project)
+        child_finder = ChildFinder(mock_project, 'data.txt')
         found_child = child_finder.get_child()
         self.assertEqual(found_child, mock_file)
 
@@ -780,7 +754,7 @@ class TestChildFinder(TestCase):
         mock_project.get_children.return_value = [
             mock_folder,
         ]
-        child_finder = ChildFinder('results/data.txt', mock_project)
+        child_finder = ChildFinder(mock_project, 'results/data.txt')
         found_child = child_finder.get_child()
         self.assertEqual(found_child, mock_file)
 
@@ -790,10 +764,28 @@ class TestChildFinder(TestCase):
         mock_project.get_children.return_value = [
             mock_folder,
         ]
-        child_finder = ChildFinder('data.txt', mock_project)
+        child_finder = ChildFinder(mock_project, 'data.txt')
         with self.assertRaises(ItemNotFound):
             child_finder.get_child()
 
+    def test_try_get_item_for_path_with_slash(self):
+        node = Mock()
+        result = ChildFinder.try_get_item_for_path(node, REMOTE_PATH_SEP)
+        self.assertEqual(result, node)
+
+    @patch('ddsc.sdk.client.ChildFinder.get_child_for_path')
+    def test_try_get_item_for_path_with_child_found(self, mock_get_child_for_path):
+        node = Mock()
+        result = ChildFinder.try_get_item_for_path(node, '/file.txt')
+        self.assertEqual(result, mock_get_child_for_path.return_value)
+
+    @patch('ddsc.sdk.client.ChildFinder.get_child_for_path')
+    def test_try_get_item_for_path_with_child_not_found(self, mock_get_child_for_path):
+        mock_get_child_for_path.side_effect = ItemNotFound()
+        node = Mock()
+        result = ChildFinder.try_get_item_for_path(node, '/file.txt')
+        self.assertEqual(result, None)
+
 
 class TestPathToFiles(TestCase):
     def test_path_creation(self):
diff --git a/ddsc/sdk/tests/test_dukeds.py b/ddsc/sdk/tests/test_dukeds.py
index 91fd6a0..97fa356 100644
--- a/ddsc/sdk/tests/test_dukeds.py
+++ b/ddsc/sdk/tests/test_dukeds.py
@@ -205,6 +205,19 @@ class TestDukeDS(TestCase):
         DukeDS.move_file_or_folder('myproject', '/data/file1.txt', '/data/file1_bak.txt')
         mock_project.move_file_or_folder.assert_called_with('/data/file1.txt', '/data/file1_bak.txt')
 
+    @patch('ddsc.sdk.dukeds.Client')
+    def test_create_folder(self, mock_client):
+        mock_project = Mock()
+        mock_project.try_get_item_for_path.return_value = None
+        mock_project.create_folder.return_value.try_get_item_for_path.return_value = None
+        mock_project.name = 'myproject'
+        mock_client.return_value.get_projects.return_value = [
+            mock_project
+        ]
+        DukeDS.create_folder('myproject', '/data/raw')
+        mock_project.create_folder.assert_called_with("data")
+        mock_project.create_folder.return_value.create_folder.assert_called_with("raw")
+
 
 class TestSession(TestCase):
     @patch('ddsc.sdk.dukeds.Client', autospec=True)