Repository: Duke-GCB/DukeDSClient

Issue:
Improve upload performance for existing projects
Currently when uploading files to an existing project ddsclient runs a serial process that compares local file hashes against remote files hashes. It performs this comparison to skip files that have already been successfully uploaded. This process can take quite some time. In some cases hashing local files for these comparisons takes longer than uploading all the files.



Discussion:

Patch:
diff --git a/ddsc/core/d4s2.py b/ddsc/core/d4s2.py
index d913995..edab984 100644
--- a/ddsc/core/d4s2.py
+++ b/ddsc/core/d4s2.py
@@ -372,10 +372,12 @@ class D4S2Project(object):
         :param temp_directory: str path to directory who's files we will upload
         """
         self.print_func("Uploading to '{}'.".format(project_name))
-        items_to_send = [os.path.join(temp_directory, item) for item in os.listdir(os.path.abspath(temp_directory))]
+        paths_to_send = [os.path.join(temp_directory, item) for item in os.listdir(os.path.abspath(temp_directory))]
         project_name_or_id = ProjectNameOrId.create_from_name(project_name)
-        project_upload = ProjectUpload(self.config, project_name_or_id, items_to_send,
-                                       file_upload_post_processor=UploadedFileRelations(activity))
+
+        project_upload = ProjectUpload.create_for_paths(self.config, self.remote_store, project_name_or_id,
+                                                        paths_to_send,
+                                                        file_upload_post_processor=UploadedFileRelations(activity))
         project_upload.run()
 
     def _is_current_user(self, some_user):
diff --git a/ddsc/core/fileuploader.py b/ddsc/core/fileuploader.py
index ead4041..d8769b4 100644
--- a/ddsc/core/fileuploader.py
+++ b/ddsc/core/fileuploader.py
@@ -28,12 +28,13 @@ class FileUploader(object):
     3) Sends the complete message to finalize the 'upload'
     4) Sends create_file message to remote store with the 'upload' id
     """
-    def __init__(self, config, data_service, local_file, watcher, file_upload_post_processor=None):
+    def __init__(self, config, data_service, local_file, hash_data, watcher, file_upload_post_processor=None):
         """
         Setup for sending to remote store.
         :param config: ddsc.config.Config user configuration settings from YAML file/environment
         :param data_service: DataServiceApi data service we are sending the content to.
         :param local_file: LocalFile file we are sending to remote store
+        :param hash_data: HashData hash calculated for the local file
         :param watcher: ProgressPrinter we notify of our progress
         :param file_upload_post_processor: object: has run(data_service, file_response) method to run after download
         """
@@ -42,6 +43,7 @@ class FileUploader(object):
         self.upload_operations = FileUploadOperations(self.data_service, watcher)
         self.file_upload_post_processor = file_upload_post_processor
         self.local_file = local_file
+        self.hash_data = hash_data
         self.upload_id = None
         self.watcher = watcher
 
@@ -54,12 +56,11 @@ class FileUploader(object):
         :return: str uuid of the newly uploaded file
         """
         path_data = self.local_file.get_path_data()
-        hash_data = path_data.get_hash()
-        self.upload_id = self.upload_operations.create_upload(project_id, path_data, hash_data,
+        self.upload_id = self.upload_operations.create_upload(project_id, path_data, self.hash_data,
                                                               storage_provider_id=self.config.storage_provider_id)
         ParallelChunkProcessor(self).run()
         parent_data = ParentData(parent_kind, parent_id)
-        remote_file_data = self.upload_operations.finish_upload(self.upload_id, hash_data, parent_data,
+        remote_file_data = self.upload_operations.finish_upload(self.upload_id, self.hash_data, parent_data,
                                                                 self.local_file.remote_id)
         if self.file_upload_post_processor:
             self.file_upload_post_processor.run(self.data_service, remote_file_data)
diff --git a/ddsc/core/localstore.py b/ddsc/core/localstore.py
index e4d6187..d8d38ec 100644
--- a/ddsc/core/localstore.py
+++ b/ddsc/core/localstore.py
@@ -3,7 +3,7 @@ import math
 import mimetypes
 import os
 from ddsc.core.ignorefile import FileFilter, IgnoreFilePatterns
-from ddsc.core.util import KindType
+from ddsc.core.util import KindType, ProjectWalker, plural_fmt, join_with_commas_and_and
 
 
 class LocalProject(object):
@@ -57,11 +57,120 @@ class LocalProject(object):
         self.remote_id = remote_id
         self.sent_to_remote = True
 
+    def count_local_items(self):
+        return LocalItemsCounter(self)
+
+    def count_items_to_send(self, upload_bytes_per_chunk):
+        return ItemsToSendCounter(self, upload_bytes_per_chunk)
+
     def __str__(self):
         child_str = ', '.join([str(child) for child in self.children])
         return 'project: [{}]'.format(child_str)
 
 
+class LocalItemsCounter(object):
+    def __init__(self, local_project):
+        self.folders = 0
+        self.files = 0
+        for child in local_project.children:
+            self._count_recur(child)
+
+    def _count_recur(self, item):
+        if KindType.is_file(item):
+            self.files += 1
+        else:
+            self.folders += 1
+            for child in item.children:
+                self._count_recur(child)
+
+    def to_str(self, prefix):
+        parts = []
+        if self.files:
+            parts.append(plural_fmt('file', self.files))
+        if self.folders:
+            parts.append(plural_fmt('folder', self.folders))
+        combined_parts = join_with_commas_and_and(parts)
+        return "{} {}.".format(prefix, combined_parts)
+
+
+class ItemsToSendCounter(object):
+    """
+    Visitor that counts items that need to be sent in LocalContent.
+    """
+    def __init__(self, local_project, bytes_per_chunk):
+        self.projects = 0
+        self.folders = 0
+        self.existing_folders = 0
+        self.files = 0
+        self.existing_files = 0
+        self.chunks = 0
+        self.bytes_per_chunk = bytes_per_chunk
+        self._walk_project(local_project)
+
+    def _walk_project(self, project):
+        """
+        Increment counters for each project, folder, and files calling visit methods below.
+        :param project: LocalProject project we will count items of.
+        """
+        # This method will call visit_project, visit_folder, and visit_file below as it walks the project tree.
+        ProjectWalker.walk_project(project, self)
+
+    def visit_project(self, item):
+        """
+        Increments counter if the project is not already remote.
+        :param item: LocalProject
+        """
+        if not item.remote_id:
+            self.projects += 1
+
+    def visit_folder(self, item, parent):
+        """
+        Increments counter if item is not already remote
+        :param item: LocalFolder
+        :param parent: LocalFolder/LocalProject
+        """
+        if not item.remote_id:
+            self.folders += 1
+        else:
+            self.existing_folders += 1
+
+    def visit_file(self, item, parent):
+        """
+        Increments counter if item needs to be sent.
+        :param item: LocalFile
+        :param parent: LocalFolder/LocalProject
+        """
+        self.files += 1
+        self.chunks += item.count_chunks(self.bytes_per_chunk)
+        if item.remote_id:
+            self.existing_files += 1
+
+    def total_items(self):
+        """
+        Total number of files/folders/chunks that need to be sent.
+        :return: int number of items to be sent.
+        """
+        return self.projects + self.folders + self.chunks
+
+    def to_str(self, prefix, local_items_count):
+        parts = []
+        new_files = local_items_count.files - self.existing_files
+        existing_files = self.existing_files
+        new_folders = local_items_count.folders - self.existing_folders
+        existing_folders = self.existing_folders
+
+        if new_files:
+            parts.append(plural_fmt('new file', new_files))
+        if existing_files:
+            parts.append(plural_fmt('existing file', existing_files))
+        if new_folders:
+            parts.append(plural_fmt('new folder', new_folders))
+        if existing_folders:
+            parts.append(plural_fmt('existing folder', existing_folders))
+        combined_parts = join_with_commas_and_and(parts)
+        return "{} {}.".format(prefix, combined_parts)
+
+
 def _name_to_child_map(children):
     """
     Create a map of name to child based on a list.
@@ -201,8 +310,9 @@ class LocalFile(object):
         self.name = self.path_data.name()
         self.size = self.path_data.size()
         self.mimetype = self.path_data.mime_type()
-        self.need_to_send = True
         self.remote_id = ''
+        self.remote_file_hash_alg = None
+        self.remote_file_hash = None
         self.is_file = True
         self.kind = KindType.file_str
         self.sent_to_remote = False
@@ -222,21 +332,30 @@ class LocalFile(object):
 
     def update_remote_ids(self, remote_file):
         """
-        Based on a remote file try to assign a remote_id and compare hash info.
+        Based on a remote file try to assign a remote_id
         :param remote_file: RemoteFile remote data pull remote_id from
         """
         self.remote_id = remote_file.id
-        hash_data = self.path_data.get_hash()
-        if hash_data.matches(remote_file.hash_alg, remote_file.file_hash):
-            self.need_to_send = False
+        self.remote_file_hash_alg = remote_file.hash_alg
+        self.remote_file_hash = remote_file.file_hash
 
-    def set_remote_id_after_send(self, remote_id):
+    def calculate_local_hash(self):
+        return self.path_data.get_hash()
+
+    def hash_matches_remote(self, hash_data):
+        if self.remote_file_hash and self.remote_file_hash_alg:
+            return hash_data.matches(self.remote_file_hash_alg, self.remote_file_hash)
+        return False
+
+    def set_remote_values_after_send(self, remote_id, remote_hash_alg, remote_file_hash):
         """
         Set remote_id to specific value after this file has been sent to remote store.
         :param remote_id: str uuid of the file in the remote store
         """
         self.sent_to_remote = True
         self.remote_id = remote_id
+        self.remote_file_hash_alg = remote_hash_alg
+        self.remote_file_hash = remote_file_hash
 
     def count_chunks(self, bytes_per_chunk):
         """
diff --git a/ddsc/core/projectuploader.py b/ddsc/core/projectuploader.py
index 8873288..6a5d42d 100644
--- a/ddsc/core/projectuploader.py
+++ b/ddsc/core/projectuploader.py
@@ -1,6 +1,6 @@
 from ddsc.core.util import ProjectWalker, KindType
 from ddsc.core.ddsapi import DataServiceAuth, DataServiceApi
-from ddsc.core.fileuploader import FileUploader, FileUploadOperations, ParentData
+from ddsc.core.fileuploader import FileUploader, FileUploadOperations, ParentData, ParallelChunkProcessor
 from ddsc.core.parallel import TaskExecutor, TaskRunner
 
 
@@ -103,20 +103,34 @@ class ProjectUploader(object):
         self.runner = TaskRunner(TaskExecutor(settings.config.upload_workers))
         self.settings = settings
         self.small_item_task_builder = SmallItemUploadTaskBuilder(self.settings, self.runner)
-        self.small_items = []
-        self.large_items = []
+        self.small_files = []
+        self.large_files = []
 
     def run(self, local_project):
         """
         Upload a project by uploading project, folders, and small files then uploading the large files.
         :param local_project: LocalProject: project to upload
         """
-        # Walk project adding small items to runner saving large items to large_items
+        # Walks project adding project/folder to small_item_task_builder and adding files to small_files/large_files
         ProjectWalker.walk_project(local_project, self)
+
+        self.sort_files_list(self.small_files)
+        self.add_small_files_to_task_builder()
         # Run small items in parallel
         self.runner.run()
+
         # Run parts of each large item in parallel
-        self.upload_large_items()
+        self.sort_files_list(self.large_files)
+        self.upload_large_files()
+
+    @staticmethod
+    def sort_files_list(files_list):
+        """
+        Sort files that are new first so they will will be processed before files that already exist in DukeDS.
+        This is to allow us to immediately begin making progress in retrying an upload.
+        :param files_list: [(LocalFile, LocalFolder|LocalProject)]: list of files to upload
+        """
+        files_list.sort(key=lambda tuple: tuple[0].remote_id)
 
     # Methods called by ProjectWalker.walk_project
     def visit_project(self, item):
@@ -137,32 +151,50 @@ class ProjectUploader(object):
         else file is small add it to the small task list.
         """
         if self.is_large_file(item):
-            self.large_items.append((item, parent))
+            self.large_files.append((item, parent))
         else:
-            self.small_item_task_builder.visit_file(item, parent)
+            self.small_files.append((item, parent))
 
     def is_large_file(self, item):
         return item.size > self.settings.config.upload_bytes_per_chunk
 
-    def upload_large_items(self):
+    def add_small_files_to_task_builder(self):
+        for local_file, parent in self.small_files:
+            self.small_item_task_builder.visit_file(local_file, parent)
+
+    def upload_large_files(self):
         """
         Upload files that were too large.
         """
-        for local_file, parent in self.large_items:
-            if local_file.need_to_send:
-                self.process_large_file(local_file, parent)
+        for local_file, parent in self.large_files:
+            self.settings.watcher.transferring_item(local_file, increment_amt=0, override_msg_verb='checking')
+            hash_data = local_file.calculate_local_hash()
+            if local_file.hash_matches_remote(hash_data):
+                self.file_already_uploaded(local_file)
+            else:
+                self.settings.watcher.transferring_item(local_file, increment_amt=0)
+                self.process_large_file(local_file, parent, hash_data)
 
-    def process_large_file(self, local_file, parent):
+    def process_large_file(self, local_file, parent, hash_data):
         """
         Upload a single file using multiple processes to upload multiple chunks at the same time.
         Updates local_file with it's remote_id when done.
         :param local_file: LocalFile: file we are uploading
         :param parent: LocalFolder/LocalProject: parent of the file
         """
-        file_content_sender = FileUploader(self.settings.config, self.settings.data_service, local_file,
+        file_content_sender = FileUploader(self.settings.config, self.settings.data_service, local_file, hash_data,
                                            self.settings.watcher, self.settings.file_upload_post_processor)
         remote_id = file_content_sender.upload(self.settings.project_id, parent.kind, parent.remote_id)
-        local_file.set_remote_id_after_send(remote_id)
+        local_file.set_remote_values_after_send(remote_id, hash_data.alg, hash_data.value)
+
+    def file_already_uploaded(self, local_file):
+        """
+        Updates progress bar for a file that was already uploaded
+        :param local_file: LocalFile
+        """
+        num_chunks = ParallelChunkProcessor.determine_num_chunks(self.settings.config.upload_bytes_per_chunk,
+                                                                 local_file.size)
+        self.settings.watcher.increment_progress(num_chunks)
 
 
 class SmallItemUploadTaskBuilder(object):
@@ -206,14 +238,18 @@ class SmallItemUploadTaskBuilder(object):
         If file is small add create small file command otherwise raise error.
         Large files shouldn't be passed to SmallItemUploadTaskBuilder.
         """
-        if item.need_to_send:
-            if item.size > self.settings.config.upload_bytes_per_chunk:
-                msg = "Programmer Error: Trying to upload large file as small item size:{} name:{}"
-                raise ValueError(msg.format(item.size, item.name))
-            else:
-                command = CreateSmallFileCommand(self.settings, item, parent,
-                                                 self.settings.file_upload_post_processor)
-                self.task_runner_add(parent, item, command)
+        if item.size > self.settings.config.upload_bytes_per_chunk:
+            msg = "Programmer Error: Trying to upload large file as small item size:{} name:{}"
+            raise ValueError(msg.format(item.size, item.name))
+        else:
+            # Create a command to hash the file
+            hash_command = HashFileCommand(self.settings, item)
+            parent_task_id = self.item_to_id.get(parent)
+            hash_task_id = self.task_runner.add(parent_task_id, hash_command)
+            # Create a command to upload the file that waits for the results from the HashFileCommand
+            send_command = CreateSmallFileCommand(self.settings, item, parent,
+                                                  self.settings.file_upload_post_processor)
+            self.task_runner.add(hash_task_id, send_command)
 
     def task_runner_add(self, parent, item, command):
         """
@@ -330,6 +366,38 @@ def upload_folder_run(upload_context):
     return result.json()['id']
 
 
+class HashFileCommand(object):
+    """
+    Hashes file and returns result
+    """
+    def __init__(self, settings, local_file):
+        self.settings = settings
+        self.local_file = local_file
+        self.func = hash_file
+
+    def before_run(self, parent_task_result):
+        # Update progress bar that we are checking this file
+        self.settings.watcher.transferring_item(self.local_file, increment_amt=0, override_msg_verb='checking')
+
+    def create_context(self, message_queue, task_id):
+        params = self.local_file.get_path_data()
+        return UploadContext(self.settings, params, message_queue, task_id)
+
+    def after_run(self, result):
+        pass
+
+
+def hash_file(upload_context):
+    """
+    Function run by HashFileCommand to calculate a file hash.
+    :param upload_context: PathData: contains path to a local file to hash
+    :return HashData: result of hash (alg + value)
+    """
+    path_data = upload_context.params
+    hash_data = path_data.get_hash()
+    return hash_data
+
+
 class CreateSmallFileCommand(object):
     """
     Creates a small file in the data service.
@@ -353,9 +421,10 @@ class CreateSmallFileCommand(object):
         self.parent = parent
         self.func = create_small_file
         self.file_upload_post_processor = file_upload_post_processor
+        self.hash_data = None
 
     def before_run(self, parent_task_result):
-        pass
+        self.hash_data = parent_task_result
 
     def create_context(self, message_queue, task_id):
         """
@@ -365,19 +434,28 @@ class CreateSmallFileCommand(object):
        """
         parent_data = ParentData(self.parent.kind, self.parent.remote_id)
         path_data = self.local_file.get_path_data()
-        params = parent_data, path_data, self.local_file.remote_id
+        params = parent_data, path_data, self.hash_data, self.local_file.remote_id, \
+            self.local_file.remote_file_hash_alg, self.local_file.remote_file_hash
+
         return UploadContext(self.settings, params, message_queue, task_id)
 
     def after_run(self, remote_file_data):
         """
-        Save uuid of file to our LocalFile
+        Save uuid and hash values of file to our LocalFile if it was updated. If remote_file_data is None that means
+        the file was already up to date.
         :param remote_file_data: dict: DukeDS file data
         """
-        if self.file_upload_post_processor:
-            self.file_upload_post_processor.run(self.settings.data_service, remote_file_data)
-        remote_file_id = remote_file_data['id']
-        self.settings.watcher.transferring_item(self.local_file)
-        self.local_file.set_remote_id_after_send(remote_file_id)
+        if remote_file_data:
+            if self.file_upload_post_processor:
+                self.file_upload_post_processor.run(self.settings.data_service, remote_file_data)
+            remote_file_id = remote_file_data['id']
+            remote_hash_dict = remote_file_data['current_version']['upload']['hashes'][0]
+            self.local_file.set_remote_values_after_send(remote_file_id,
+                                                         remote_hash_dict['algorithm'],
+                                                         remote_hash_dict['value'])
+            self.settings.watcher.transferring_item(self.local_file)
+        else:
+            self.settings.watcher.increment_progress()
 
     def on_message(self, started_waiting):
         """
@@ -398,12 +476,13 @@ def create_small_file(upload_context):
     :param upload_context: UploadContext: contains data service setup and file details.
     :return dict: DukeDS file data
     """
-    data_service = upload_context.make_data_service()
-    parent_data, path_data, remote_file_id = upload_context.params
+    parent_data, path_data, hash_data, remote_file_id, remote_file_hash_alg, remote_file_hash = upload_context.params
+    if hash_data.matches(remote_file_hash_alg, remote_file_hash):
+        return None
 
+    data_service = upload_context.make_data_service()
     # The small file will fit into one chunk so read into memory and hash it.
     chunk = path_data.read_whole_file()
-    hash_data = path_data.get_hash()
 
     # Talk to data service uploading chunk and creating the file.
     upload_operations = FileUploadOperations(data_service, upload_context)
@@ -418,13 +497,14 @@ class ProjectUploadDryRun(object):
     Recursively visits children of the project passed to run.
     Builds a list of the names of folders/files that need to be uploaded.
     """
-    def __init__(self):
+    def __init__(self, local_project):
         self.upload_items = []
+        self._run(local_project)
 
     def add_upload_item(self, name):
         self.upload_items.append(name)
 
-    def run(self, local_project):
+    def _run(self, local_project):
         """
         Appends file/folder paths to upload_items based on the contents of this project that need to be uploaded.
         :param local_project: LocalProject: project we will build the list for
@@ -437,7 +517,8 @@ class ProjectUploadDryRun(object):
         :param item: object: project, folder or file we will add to upload_items if necessary.
         """
         if item.kind == KindType.file_str:
-            if item.need_to_send:
+            hash_data = item.calculate_local_hash()
+            if not item.hash_matches_remote(hash_data):
                 self.add_upload_item(item.path)
         else:
             if item.kind == KindType.project_str:
@@ -447,3 +528,18 @@ class ProjectUploadDryRun(object):
                     self.add_upload_item(item.path)
             for child in item.children:
                 self._visit_recur(child)
+
+    def get_report(self):
+        """
+        Returns text displaying the items that need to be uploaded or a message saying there are no files/folders
+        to upload.
+        :return: str: report text
+        """
+        if not self.upload_items:
+            return "\n\nNo changes found. Nothing needs to be uploaded.\n\n"
+        else:
+            result = "\n\nFiles/Folders that need to be uploaded:\n"
+            for item in self.upload_items:
+                result += "{}\n".format(item)
+            result += "\n"
+            return result
diff --git a/ddsc/core/upload.py b/ddsc/core/upload.py
index 76824d1..524d61e 100644
--- a/ddsc/core/upload.py
+++ b/ddsc/core/upload.py
@@ -1,91 +1,52 @@
 import datetime
-from ddsc.core.localstore import LocalProject
 from ddsc.core.remotestore import RemoteStore
 from ddsc.core.util import ProgressPrinter, ProjectWalker, plural_fmt
-from ddsc.core.projectuploader import UploadSettings, ProjectUploader, ProjectUploadDryRun
+from ddsc.core.projectuploader import UploadSettings, ProjectUploader
+from ddsc.core.localstore import LocalProject
 
 
 class ProjectUpload(object):
     """
     Allows uploading a local project to a remote duke-data-service.
     """
-    def __init__(self, config, project_name_or_id, folders, follow_symlinks=False, file_upload_post_processor=None):
+    def __init__(self, config, project_name_or_id, local_project, items_to_send_count, file_upload_post_processor=None):
         """
         Setup for uploading folders dictionary of paths to project_name using config.
         :param config: Config configuration for performing the upload(url, keys, etc)
         :param project_name_or_id: ProjectNameOrId: name or id of the project we will upload files to
-        :param folders: [str] list of paths of files/folders to upload to the project
-        :param follow_symlinks: bool if true we will traverse symbolic linked directories
+        :param local_project: LocalProject: contains files and folders to upload
+        :param items_to_send_count: ItemsToSendCounter
         :param file_upload_post_processor: object: has run(data_service, file_response) method to run after uploading
         """
         self.config = config
         self.remote_store = RemoteStore(config)
         self.project_name_or_id = project_name_or_id
-        self.remote_project = self.remote_store.fetch_remote_project(project_name_or_id)
-        self.local_project = ProjectUpload._load_local_project(folders, follow_symlinks, config.file_exclude_regex)
-        self.local_project.update_remote_ids(self.remote_project)
-        self.different_items = self._count_differences()
+        self.local_project = local_project
+        self.items_to_send_count = items_to_send_count
         self.file_upload_post_processor = file_upload_post_processor
 
     @staticmethod
-    def _load_local_project(folders, follow_symlinks, file_exclude_regex):
-        local_project = LocalProject(followsymlinks=follow_symlinks, file_exclude_regex=file_exclude_regex)
-        local_project.add_paths(folders)
-        return local_project
-
-    def _count_differences(self):
-        """
-        Count how many things we will be sending.
-        :param local_project: LocalProject project we will send data from
-        :return: LocalOnlyCounter contains counts for various items
-        """
-        different_items = LocalOnlyCounter(self.config.upload_bytes_per_chunk)
-        different_items.walk_project(self.local_project)
-        return different_items
-
-    def needs_to_upload(self):
-        """
-        Is there anything in the local project different from the remote project.
-        :return: bool is there any point in calling upload()
-        """
-        return self.different_items.total_items() != 0
+    def create_for_paths(config, remote_store, project_name_or_id, paths, follow_symlinks=False,
+                         file_upload_post_processor=None):
+        local_project = LocalProject(followsymlinks=follow_symlinks, file_exclude_regex=config.file_exclude_regex)
+        local_project.add_paths(paths)
+        remote_project = remote_store.fetch_remote_project(project_name_or_id)
+        local_project.update_remote_ids(remote_project)
+        items_to_send_count = local_project.count_items_to_send(config.upload_bytes_per_chunk)
+        return ProjectUpload(config, project_name_or_id, local_project, items_to_send_count,
+                             file_upload_post_processor=file_upload_post_processor)
 
     def run(self):
         """
         Upload different items within local_project to remote store showing a progress bar.
         """
-        progress_printer = ProgressPrinter(self.different_items.total_items(), msg_verb='sending')
+        progress_printer = ProgressPrinter(self.items_to_send_count.total_items(), msg_verb='sending')
         upload_settings = UploadSettings(self.config, self.remote_store.data_service, progress_printer,
                                          self.project_name_or_id, self.file_upload_post_processor)
         project_uploader = ProjectUploader(upload_settings)
         project_uploader.run(self.local_project)
         progress_printer.finished()
 
-    def dry_run_report(self):
-        """
-        Returns text displaying the items that need to be uploaded or a message saying there are no files/folders
-        to upload.
-        :return: str: report text
-        """
-        project_uploader = ProjectUploadDryRun()
-        project_uploader.run(self.local_project)
-        items = project_uploader.upload_items
-        if not items:
-            return "\n\nNo changes found. Nothing needs to be uploaded.\n\n"
-        else:
-            result = "\n\nFiles/Folders that need to be uploaded:\n"
-            for item in items:
-                result += "{}\n".format(item)
-            result += "\n"
-            return result
-
-    def get_differences_summary(self):
-        """
-        Print a summary of what is to be done.
-        :param different_items: LocalOnlyCounter item that contains the summary
-        """
-        return 'Uploading {}.'.format(self.different_items.result_str())
-
     def get_upload_report(self):
         """
         Generate and print a report onto stdout.
@@ -95,7 +56,7 @@ class ProjectUpload(object):
                                                          include_children=False)
         report = UploadReport(project.name)
         report.walk_project(self.local_project)
-        return report.get_content()
+        return report
 
     def get_url_msg(self):
         """
@@ -107,84 +68,26 @@ class ProjectUpload(object):
         return url
 
 
-class LocalOnlyCounter(object):
-    """
-    Visitor that counts items that need to be sent in LocalContent.
-    """
-    def __init__(self, bytes_per_chunk):
-        self.projects = 0
-        self.folders = 0
-        self.files = 0
-        self.chunks = 0
-        self.bytes_per_chunk = bytes_per_chunk
-
-    def walk_project(self, project):
-        """
-        Increment counters for each project, folder, and files calling visit methods below.
-        :param project: LocalProject project we will count items of.
-        """
-        # This method will call visit_project, visit_folder, and visit_file below as it walks the project tree.
-        ProjectWalker.walk_project(project, self)
-
-    def visit_project(self, item):
-        """
-        Increments counter if the project is not already remote.
-        :param item: LocalProject
-        """
-        if not item.remote_id:
-            self.projects += 1
-
-    def visit_folder(self, item, parent):
-        """
-        Increments counter if item is not already remote
-        :param item: LocalFolder
-        :param parent: LocalFolder/LocalProject
-        """
-        if not item.remote_id:
-            self.folders += 1
-
-    def visit_file(self, item, parent):
-        """
-        Increments counter if item needs to be sent.
-        :param item: LocalFile
-        :param parent: LocalFolder/LocalProject
-        """
-        if item.need_to_send:
-            self.files += 1
-            self.chunks += item.count_chunks(self.bytes_per_chunk)
-
-    def total_items(self):
-        """
-        Total number of files/folders/chunks that need to be sent.
-        :return: int number of items to be sent.
-        """
-        return self.projects + self.folders + self.chunks
-
-    def result_str(self):
-        """
-        Return a string representing the totals contained herein.
-        :return: str counts/types string
-        """
-        return '{}, {}, {}'.format(plural_fmt('project', self.projects),
-                                   plural_fmt('folder', self.folders),
-                                   plural_fmt('file', self.files))
-
-
 class UploadReport(object):
     """
     Creates a text report of items that were sent to the remote store.
     """
     def __init__(self, project_name):
         """
-        Create report witht the specified project name since the local store doesn't contain that info.
+        Create report with the specified project name since the local store doesn't contain that info.
         :param project_name: str project name for the report
         """
-        self.report_items = []
+        self.report_items = [ReportItem('SENT FILENAME', 'ID', 'SIZE', 'HASH')]
         self.project_name = project_name
-        self._add_report_item('SENT FILENAME', 'ID', 'SIZE', 'HASH')
+        self.sent_data = False
+        self.sent_files = 0
+        self.up_to_date_files = 0
+        self.sent_folders = 0
+        self.up_to_date_folders = 0
 
-    def _add_report_item(self, name, remote_id, size='', file_hash=''):
+    def _add_sent_item(self, name, remote_id, size='', file_hash=''):
         self.report_items.append(ReportItem(name, remote_id, size, file_hash))
+        self.sent_data = True
 
     def walk_project(self, project):
         """
@@ -200,7 +103,7 @@ class UploadReport(object):
         :param item: LocalContent project level item
         """
         if item.sent_to_remote:
-            self._add_report_item('Project', item.remote_id)
+            self._add_sent_item('Project', item.remote_id)
 
     def visit_folder(self, item, parent):
         """
@@ -209,7 +112,10 @@ class UploadReport(object):
         :param parent: LocalFolder/LocalContent not used here
         """
         if item.sent_to_remote:
-            self._add_report_item(item.path, item.remote_id)
+            self._add_sent_item(item.path, item.remote_id)
+            self.sent_folders += 1
+        else:
+            self.up_to_date_folders += 1
 
     def visit_file(self, item, parent):
         """
@@ -218,7 +124,10 @@ class UploadReport(object):
         :param parent: LocalFolder/LocalContent not used here
         """
         if item.sent_to_remote:
-            self._add_report_item(item.path, item.remote_id, item.size, item.get_hash_value())
+            self._add_sent_item(item.path, item.remote_id, item.size, item.remote_file_hash)
+            self.sent_files += 1
+        else:
+            self.up_to_date_files += 1
 
     def _report_header(self):
         return u"Upload Report for Project: '{}' {}\n".format(self.project_name, datetime.datetime.utcnow())
@@ -238,6 +147,38 @@ class UploadReport(object):
         lines.extend(self._report_body())
         return '\n'.join(lines)
 
+    def summary(self):
+        parts = []
+        uploaded_str = self.uploaded_items_str()
+        if uploaded_str:
+            parts.append(uploaded_str)
+        up_to_date_str = self.up_to_date_items_str()
+        if up_to_date_str:
+            parts.append(up_to_date_str)
+        return ' '.join(parts)
+
+    def uploaded_items_str(self):
+        parts = []
+        if self.sent_files:
+            parts.append(plural_fmt('file', self.sent_files))
+        if self.sent_folders:
+            parts.append(plural_fmt('folder', self.sent_folders))
+        if parts:
+            return "Uploaded {}.".format(" and ".join(parts))
+        else:
+            return ""
+
+    def up_to_date_items_str(self):
+        parts = []
+        if self.up_to_date_files:
+            parts.append(plural_fmt('file', self.up_to_date_files))
+        if self.up_to_date_folders:
+            parts.append(plural_fmt('folder', self.up_to_date_folders))
+        if parts:
+            return "{} are already up to date.".format(" and ".join(parts))
+        else:
+            return ""
+
 
 class ReportItem(object):
     """
@@ -266,4 +207,4 @@ class ReportItem(object):
         name_str = self.name.ljust(max_name)
         remote_id_str = self.remote_id.ljust(max_remote_id)
         size_str = self.size.ljust(max_size)
-        return u'{}    {}    {}    {}'.format(name_str, remote_id_str, size_str, self.file_hash)
+        return u'{}    {}    {}    {}'.format(name_str, remote_id_str, size_str, self.file_hash).rstrip()
diff --git a/ddsc/core/util.py b/ddsc/core/util.py
index 816614c..7dcfcff 100644
--- a/ddsc/core/util.py
+++ b/ddsc/core/util.py
@@ -44,7 +44,10 @@ class KindType(object):
 
 
 class NoOpProgressPrinter(object):
-    def transferring_item(self, item, increment_amt=1):
+    def transferring_item(self, item, increment_amt=1, override_msg_verb=None):
+        pass
+
+    def increment_progress(self, increment_amt=1):
         pass
 
     def finished(self):
@@ -68,21 +71,28 @@ class ProgressPrinter(object):
         self.msg_verb = msg_verb
         self.progress_bar = ProgressBar()
 
-    def transferring_item(self, item, increment_amt=1):
+    def transferring_item(self, item, increment_amt=1, override_msg_verb=None):
         """
         Update progress that item is about to be transferred.
         :param item: LocalFile, LocalFolder, or LocalContent(project) that is about to be sent.
         :param increment_amt: int amount to increase our count(how much progress have we made)
+        :param override_msg_verb: str: overrides msg_verb specified in constructor
         """
-        self.cnt += increment_amt
+        self.increment_progress(increment_amt)
         percent_done = int(float(self.cnt) / float(self.total) * 100.0)
         if KindType.is_project(item):
             details = 'project'
         else:
             details = os.path.basename(item.path)
-        self.progress_bar.update(percent_done, '{} {}'.format(self.msg_verb, details))
+        msg_verb = self.msg_verb
+        if override_msg_verb:
+            msg_verb = override_msg_verb
+        self.progress_bar.update(percent_done, '{} {}'.format(msg_verb, details))
         self.progress_bar.show()
 
+    def increment_progress(self, amt=1):
+        self.cnt += amt
+
     def finished(self):
         """
         Must be called to print final progress label.
@@ -428,3 +438,15 @@ def plural_fmt(name, cnt):
         return '{} {}'.format(cnt, name)
     else:
         return '{} {}s'.format(cnt, name)
+
+
+def join_with_commas_and_and(items):
+    if not items:
+        return ""
+    head_items = items[:-1]
+    last_item = items[-1]
+    head_items_str = ', '.join(head_items)
+    if head_items_str:
+        return ' and '.join([head_items_str, last_item])
+    else:
+        return last_item
diff --git a/ddsc/ddsclient.py b/ddsc/ddsclient.py
index 49a3776..1010b0a 100644
--- a/ddsc/ddsclient.py
+++ b/ddsc/ddsclient.py
@@ -6,7 +6,9 @@ import datetime
 import time
 from ddsc.core.d4s2 import D4S2Project, D4S2Error
 from ddsc.core.remotestore import RemoteStore, RemoteAuthRole, ProjectNameOrId
+from ddsc.core.localstore import LocalProject
 from ddsc.core.upload import ProjectUpload
+from ddsc.core.projectuploader import ProjectUploadDryRun
 from ddsc.cmdparser import CommandParser, format_destination_path, replace_invalid_path_chars
 from ddsc.core.download import ProjectDownload
 from ddsc.core.util import ProjectDetailsList, verify_terminal_encoding
@@ -165,15 +167,34 @@ class UploadCommand(BaseCommand):
         follow_symlinks = args.follow_symlinks  # should we follow symlinks when traversing folders
         dry_run = args.dry_run                  # do not upload anything, instead print out what you would upload
 
-        project_upload = ProjectUpload(self.config, project_name_or_id, folders, follow_symlinks=follow_symlinks)
+        # Find files and folders to upload
+        local_project = LocalProject(followsymlinks=follow_symlinks, file_exclude_regex=self.config.file_exclude_regex)
+        local_project.add_paths(folders)
+        local_items_count = local_project.count_local_items()
+        print(local_items_count.to_str(prefix="Checking"))
+
+        # Fetch remote project (if there is one) and update local_project with details from remote project
+        remote_project = self.remote_store.fetch_remote_project(project_name_or_id)
+        local_project.update_remote_ids(remote_project)
+        items_to_send_count = local_project.count_items_to_send(self.config.upload_bytes_per_chunk)
+        print(items_to_send_count.to_str(local_items_count=local_items_count, prefix="Synchronizing"))
+
         if dry_run:
-            print(project_upload.dry_run_report())
+            # Check hashes to see what needs to be uploaded
+            dry_run = ProjectUploadDryRun(local_project)
+            print(dry_run.get_report())
         else:
-            print(project_upload.get_differences_summary())
-            if project_upload.needs_to_upload():
-                project_upload.run()
+            # Upload files and folders
+            project_upload = ProjectUpload(self.config, project_name_or_id, local_project, items_to_send_count)
+            project_upload.run()
+
+            # Show user results of upload
+            upload_report = project_upload.get_upload_report()
+            print(upload_report.summary())
+            print()
+            if upload_report.sent_data:
                 print('\n')
-                print(project_upload.get_upload_report())
+                print(upload_report.get_content())
                 print('\n')
             print(project_upload.get_url_msg())
 


Test patch:
diff --git a/ddsc/core/tests/test_d4s2.py b/ddsc/core/tests/test_d4s2.py
index b4ab33c..89e39cf 100644
--- a/ddsc/core/tests/test_d4s2.py
+++ b/ddsc/core/tests/test_d4s2.py
@@ -131,8 +131,8 @@ class TestD4S2Project(TestCase):
         download_pre_processor = kwargs['file_download_pre_processor']
         self.assertEqual(DownloadedFileRelations, download_pre_processor.__class__)
 
-        mock_project_upload.assert_called()
-        args, kwargs = mock_project_upload.call_args
+        mock_project_upload.create_for_paths.assert_called()
+        args, kwargs = mock_project_upload.create_for_paths.call_args
         file_upload_post_processor = kwargs['file_upload_post_processor']
         self.assertEqual(UploadedFileRelations, file_upload_post_processor.__class__)
 
diff --git a/ddsc/core/tests/test_fileuploader.py b/ddsc/core/tests/test_fileuploader.py
index f75da01..17a6c3c 100644
--- a/ddsc/core/tests/test_fileuploader.py
+++ b/ddsc/core/tests/test_fileuploader.py
@@ -1,9 +1,9 @@
 from unittest import TestCase
 from ddsc.core.fileuploader import ParallelChunkProcessor, upload_async, FileUploadOperations, \
-    RetrySettings, ForbiddenSendExternalException, ChunkSender
+    RetrySettings, ForbiddenSendExternalException, ChunkSender, FileUploader
 from ddsc.core.ddsapi import DSResourceNotConsistentError, DataServiceError
 import requests
-from mock import MagicMock, Mock, patch, call
+from mock import MagicMock, Mock, patch, call, ANY
 
 
 class FakeConfig(object):
@@ -351,3 +351,20 @@ class TestChunkSender(TestCase):
             chunk_sender._send_chunk(chunk='abc', chunk_num=1)
 
         self.assertEqual(str(raised_exception.exception), 'Forbidden')
+
+
+class TestFileUploader(TestCase):
+    @patch('ddsc.core.fileuploader.FileUploadOperations')
+    @patch('ddsc.core.fileuploader.ParallelChunkProcessor')
+    def test_upload(self, mock_parallel_chunkprocessor, mock_file_upload_operations):
+        config, data_service, local_file, hash_data, watcher = Mock(), Mock(), Mock(), Mock(), Mock()
+        uploader = FileUploader(config, data_service, local_file, hash_data, watcher)
+        uploader.upload(project_id='mouse', parent_kind='dds-project', parent_id='abc123')
+
+        mock_create_upload = mock_file_upload_operations.return_value.create_upload
+        mock_create_upload.assert_called_with('mouse', local_file.get_path_data.return_value, hash_data,
+                                              storage_provider_id=config.storage_provider_id)
+        mock_parallel_chunkprocessor.assert_called_with(uploader)
+        mock_parallel_chunkprocessor.return_value.run.assert_called_with()
+        mock_finish_upload = mock_file_upload_operations.return_value.finish_upload
+        mock_finish_upload.assert_called_with(mock_create_upload.return_value, hash_data, ANY, local_file.remote_id)
diff --git a/ddsc/core/tests/test_localstore.py b/ddsc/core/tests/test_localstore.py
index 707bbdd..ba0172f 100644
--- a/ddsc/core/tests/test_localstore.py
+++ b/ddsc/core/tests/test_localstore.py
@@ -1,8 +1,8 @@
 import shutil
 import tarfile
 from unittest import TestCase
-from ddsc.core.localstore import LocalFile, LocalFolder, LocalProject, KindType
-from mock import patch
+from ddsc.core.localstore import LocalFile, LocalFolder, LocalProject, KindType, LocalItemsCounter, ItemsToSendCounter
+from mock import patch, Mock
 
 
 INCLUDE_ALL = ''
@@ -202,3 +202,53 @@ class TestLocalFile(TestCase):
         for file_size, bytes_per_chunk, expected in values:
             f.size = file_size
             self.assertEqual(expected, f.count_chunks(bytes_per_chunk))
+
+    @patch('ddsc.core.localstore.os')
+    @patch('ddsc.core.localstore.PathData')
+    def test_set_remote_values_after_send(self, mock_path_data, mock_os):
+        f = LocalFile('fakefile.txt')
+        self.assertEqual(f.remote_id, '')
+        self.assertEqual(f.remote_file_hash_alg, None)
+        self.assertEqual(f.remote_file_hash, None)
+        self.assertEqual(f.sent_to_remote, False)
+        f.set_remote_values_after_send(
+            remote_id='abc123',
+            remote_hash_alg='md5',
+            remote_file_hash='defjkl'
+        )
+        self.assertEqual(f.remote_id, 'abc123')
+        self.assertEqual(f.remote_file_hash_alg, 'md5')
+        self.assertEqual(f.remote_file_hash, 'defjkl')
+        self.assertEqual(f.sent_to_remote, True)
+
+
+class TestLocalItemsCounter(TestCase):
+    def test_to_str(self):
+        local_project = Mock()
+        local_project.children = [
+            Mock(kind='dds-file'),
+            Mock(kind='dds-folder', children=[
+                Mock(kind='dds-file'),
+            ]),
+        ]
+        counter = LocalItemsCounter(local_project)
+        self.assertEqual(counter.to_str(prefix="Checking"), 'Checking 2 files and 1 folder.')
+
+
+class TestItemsToSendCounter(TestCase):
+    def test_to_str(self):
+        local_project = Mock(kind='dds-project')
+        mock_file1 = Mock(kind='dds-file')
+        mock_file1.count_chunks.return_value = 10
+        mock_file2 = Mock(kind='dds-file')
+        mock_file2.count_chunks.return_value = 10
+        local_project.children = [
+            mock_file1,
+            Mock(kind='dds-folder', children=[
+                mock_file2,
+            ]),
+        ]
+        counter = ItemsToSendCounter(local_project, bytes_per_chunk=100)
+        counter_str = counter.to_str(prefix="Synchronizing", local_items_count=Mock(files=3, folders=3))
+        self.assertEqual(counter_str,
+                         'Synchronizing 1 new file, 2 existing files, 2 new folders and 1 existing folder.')
diff --git a/ddsc/core/tests/test_projectuploader.py b/ddsc/core/tests/test_projectuploader.py
index a543847..19cc76a 100644
--- a/ddsc/core/tests/test_projectuploader.py
+++ b/ddsc/core/tests/test_projectuploader.py
@@ -2,10 +2,10 @@ from unittest import TestCase
 import pickle
 import multiprocessing
 from ddsc.core.projectuploader import UploadSettings, UploadContext, ProjectUploadDryRun, CreateProjectCommand, \
-    upload_project_run, create_small_file
+    upload_project_run, create_small_file, ProjectUploader, HashFileCommand, CreateSmallFileCommand
 from ddsc.core.util import KindType
 from ddsc.core.remotestore import ProjectNameOrId
-from mock import MagicMock, Mock, patch
+from mock import MagicMock, Mock, patch, call, ANY
 
 
 class FakeDataServiceApi(object):
@@ -49,30 +49,42 @@ class TestProjectUploadDryRun(TestCase):
     def test_single_empty_non_existant_directory(self):
         local_file = MagicMock(kind=KindType.folder_str, children=[], path='joe', remote_id=None)
         local_project = MagicMock(kind=KindType.project_str, children=[local_file])
-        upload_dry_run = ProjectUploadDryRun()
-        upload_dry_run.run(local_project)
+        upload_dry_run = ProjectUploadDryRun(local_project)
         self.assertEqual(['joe'], upload_dry_run.upload_items)
 
     def test_single_empty_existing_directory(self):
         local_file = MagicMock(kind=KindType.folder_str, children=[], path='joe', remote_id='abc')
         local_project = MagicMock(kind=KindType.project_str, children=[local_file])
-        upload_dry_run = ProjectUploadDryRun()
-        upload_dry_run.run(local_project)
+        upload_dry_run = ProjectUploadDryRun(local_project)
         self.assertEqual([], upload_dry_run.upload_items)
 
     def test_some_files(self):
-        local_file1 = MagicMock(kind=KindType.file_str, path='joe.txt', need_to_send=True)
-        local_file2 = MagicMock(kind=KindType.file_str, path='data.txt', need_to_send=False)
-        local_file3 = MagicMock(kind=KindType.file_str, path='results.txt', need_to_send=True)
+        local_file1 = MagicMock(kind=KindType.file_str, path='joe.txt')
+        local_file1.hash_matches_remote.return_value = False
+        local_file2 = MagicMock(kind=KindType.file_str, path='data.txt')
+        local_file2.hash_matches_remote.return_value = True
+        local_file3 = MagicMock(kind=KindType.file_str, path='results.txt')
+        local_file3.hash_matches_remote.return_value = False
         local_project = MagicMock(kind=KindType.project_str, children=[local_file1, local_file2, local_file3])
-        upload_dry_run = ProjectUploadDryRun()
-        upload_dry_run.run(local_project)
+        upload_dry_run = ProjectUploadDryRun(local_project)
         self.assertEqual(['joe.txt', 'results.txt'], upload_dry_run.upload_items)
+        local_file1.hash_matches_remote.assert_called_with(
+            local_file1.calculate_local_hash.return_value
+        )
+        local_file2.hash_matches_remote.assert_called_with(
+            local_file2.calculate_local_hash.return_value
+        )
+        local_file3.hash_matches_remote.assert_called_with(
+            local_file3.calculate_local_hash.return_value
+        )
 
     def test_nested_directories(self):
-        local_file1 = MagicMock(kind=KindType.file_str, path='/data/2017/08/flyresults/joe.txt', need_to_send=True)
-        local_file2 = MagicMock(kind=KindType.file_str, path='/data/2017/08/flyresults/data.txt', need_to_send=False)
-        local_file3 = MagicMock(kind=KindType.file_str, path='/data/2017/08/flyresults/results.txt', need_to_send=True)
+        local_file1 = MagicMock(kind=KindType.file_str, path='/data/2017/08/flyresults/joe.txt')
+        local_file1.hash_matches_remote.return_value = False
+        local_file2 = MagicMock(kind=KindType.file_str, path='/data/2017/08/flyresults/data.txt')
+        local_file2.hash_matches_remote.return_value = True
+        local_file3 = MagicMock(kind=KindType.file_str, path='/data/2017/08/flyresults/results.txt')
+        local_file3.hash_matches_remote.return_value = False
         grandchild_folder = MagicMock(kind=KindType.folder_str,
                                       path="/data/2017/08/flyresults",
                                       children=[local_file1, local_file2, local_file3],
@@ -86,8 +98,7 @@ class TestProjectUploadDryRun(TestCase):
                                   children=[child_folder],
                                   remote_id=None)
         local_project = MagicMock(kind=KindType.project_str, children=[parent_folder])
-        upload_dry_run = ProjectUploadDryRun()
-        upload_dry_run.run(local_project)
+        upload_dry_run = ProjectUploadDryRun(local_project)
         expected_results = [
             '/data/2017',
             '/data/2017/08',
@@ -98,9 +109,12 @@ class TestProjectUploadDryRun(TestCase):
         self.assertEqual(expected_results, upload_dry_run.upload_items)
 
     def test_nested_directories_skip_parents(self):
-        local_file1 = MagicMock(kind=KindType.file_str, path='/data/2017/08/flyresults/joe.txt', need_to_send=True)
-        local_file2 = MagicMock(kind=KindType.file_str, path='/data/2017/08/flyresults/data.txt', need_to_send=False)
-        local_file3 = MagicMock(kind=KindType.file_str, path='/data/2017/08/flyresults/results.txt', need_to_send=True)
+        local_file1 = MagicMock(kind=KindType.file_str, path='/data/2017/08/flyresults/joe.txt')
+        local_file1.hash_matches_remote.return_value = False
+        local_file2 = MagicMock(kind=KindType.file_str, path='/data/2017/08/flyresults/data.txt')
+        local_file2.hash_matches_remote.return_value = True
+        local_file3 = MagicMock(kind=KindType.file_str, path='/data/2017/08/flyresults/results.txt')
+        local_file3.hash_matches_remote.return_value = False
         grandchild_folder = MagicMock(kind=KindType.folder_str,
                                       path="/data/2017/08/flyresults",
                                       children=[local_file1, local_file2, local_file3],
@@ -114,8 +128,7 @@ class TestProjectUploadDryRun(TestCase):
                                   children=[child_folder],
                                   remote_id='123')
         local_project = MagicMock(kind=KindType.project_str, children=[parent_folder])
-        upload_dry_run = ProjectUploadDryRun()
-        upload_dry_run.run(local_project)
+        upload_dry_run = ProjectUploadDryRun(local_project)
         expected_results = [
             '/data/2017/08/flyresults',
             '/data/2017/08/flyresults/joe.txt',
@@ -123,6 +136,18 @@ class TestProjectUploadDryRun(TestCase):
         ]
         self.assertEqual(expected_results, upload_dry_run.upload_items)
 
+    def test_get_report_no_changes(self):
+        local_project = MagicMock(kind=KindType.project_str, children=[])
+        upload_dry_run = ProjectUploadDryRun(local_project)
+        upload_dry_run.upload_items = []
+        self.assertEqual(upload_dry_run.get_report().strip(), 'No changes found. Nothing needs to be uploaded.')
+
+    def test_get_report_changes_exist(self):
+        local_project = MagicMock(kind=KindType.project_str, children=[])
+        upload_dry_run = ProjectUploadDryRun(local_project)
+        upload_dry_run.upload_items = ['somefile']
+        self.assertEqual(upload_dry_run.get_report().strip(), 'Files/Folders that need to be uploaded:\nsomefile')
+
 
 class TestCreateProjectCommand(TestCase):
     def test_constructor_fails_for_id(self):
@@ -150,13 +175,206 @@ class TestCreateSmallFile(TestCase):
     @patch('ddsc.core.projectuploader.FileUploadOperations', autospec=True)
     def test_create_small_file_passes_zero_index(self, mock_file_operations):
         mock_path_data = Mock()
+        mock_hash_data = Mock()
+        mock_hash_data.matches.return_value = False
         mock_path_data.read_whole_file.return_value = 'data'
         mock_file_operations.return_value.create_upload_and_chunk_url.return_value = (
             'someId', {'host': 'somehost', 'url': 'someurl'}
         )
 
-        upload_context = Mock(params=(Mock(), mock_path_data, Mock()))
+        upload_context = Mock(params=(Mock(), mock_path_data, mock_hash_data, Mock(), 'md5', 'abc'))
         resp = create_small_file(upload_context)
 
         self.assertEqual(resp, mock_file_operations.return_value.finish_upload.return_value)
         mock_file_operations.return_value.create_file_chunk_url.assert_not_called()
+
+    @patch('ddsc.core.projectuploader.FileUploadOperations', autospec=True)
+    def test_create_small_file_hash_matches(self, mock_file_operations):
+        mock_path_data = Mock()
+        mock_hash_data = Mock()
+        mock_hash_data.matches.return_value = True
+        mock_path_data.read_whole_file.return_value = 'data'
+        mock_file_operations.return_value.create_upload_and_chunk_url.return_value = (
+            'someId', {'host': 'somehost', 'url': 'someurl'}
+        )
+
+        upload_context = Mock(params=(Mock(), mock_path_data, mock_hash_data, Mock(), 'md5', 'abc'))
+        resp = create_small_file(upload_context)
+
+        self.assertEqual(resp, None)
+        mock_file_operations.return_value.create_file_chunk_url.assert_not_called()
+
+
+class TestProjectUploader(TestCase):
+    @patch('ddsc.core.projectuploader.ProjectWalker')
+    @patch('ddsc.core.projectuploader.TaskRunner')
+    @patch('ddsc.core.projectuploader.TaskExecutor')
+    @patch('ddsc.core.projectuploader.SmallItemUploadTaskBuilder')
+    def test_run_sorts_new_files_first(self, mock_small_task_builder, mock_task_executor, mock_task_runner,
+                                       mock_project_walker):
+        settings = Mock()
+        settings.config.upload_bytes_per_chunk = 100
+        uploader = ProjectUploader(settings)
+        uploader.process_large_file = Mock()
+        small_file_existing = Mock(remote_id='abc123', size=1000)
+        small_file_new = Mock(remote_id='', size=2000)
+        uploader.small_files = [
+            (small_file_existing, None),
+            (small_file_new, None),
+        ]
+        large_file_existing = Mock(remote_id='def456', size=1000)
+        large_file_existing.hash_matches_remote.return_value = False
+        large_file_new = Mock(remote_id='', size=2000)
+        large_file_new.hash_matches_remote.return_value = False
+        uploader.large_files = [
+            (large_file_existing, None),
+            (large_file_new, None),
+        ]
+        local_project = Mock()
+
+        uploader.run(local_project)
+
+        # small files should be sorted with new first
+        uploader.small_item_task_builder.visit_file.assert_has_calls([
+            call(small_file_new, None),
+            call(small_file_existing, None),
+        ])
+        # large files should be sorted with new first
+        uploader.process_large_file.assert_has_calls([
+            call(large_file_new, None, ANY),
+            call(large_file_existing, None, ANY),
+        ])
+
+    @patch('ddsc.core.projectuploader.ProjectWalker')
+    @patch('ddsc.core.projectuploader.TaskRunner')
+    @patch('ddsc.core.projectuploader.TaskExecutor')
+    @patch('ddsc.core.projectuploader.SmallItemUploadTaskBuilder')
+    def test_run_with_large_files_hash_matching(self, mock_small_task_builder, mock_task_executor, mock_task_runner,
+                                                mock_project_walker):
+        settings = Mock()
+        settings.config.upload_bytes_per_chunk = 100
+        uploader = ProjectUploader(settings)
+        uploader.process_large_file = Mock()
+        large_file_existing = Mock(remote_id='def456', size=1000)
+        large_file_existing.hash_matches_remote.return_value = True
+        large_file_new = Mock(remote_id='', size=2000)
+        large_file_new.hash_matches_remote.return_value = True
+        uploader.large_files = [
+            (large_file_existing, None),
+            (large_file_new, None),
+        ]
+        local_project = Mock()
+        uploader.run(local_project)
+        uploader.process_large_file.assert_not_called()
+        settings.watcher.transferring_item.assert_has_calls([
+            call(large_file_new, increment_amt=0, override_msg_verb='checking'),  # show checking
+            call(large_file_existing, increment_amt=0, override_msg_verb='checking'),   # show checking
+        ])
+        # The progress bar should be incremented for both files that are already at the remote store
+        settings.watcher.increment_progress.assert_has_calls([
+            call(20), call(10)
+        ])
+
+    @patch('ddsc.core.projectuploader.TaskRunner')
+    @patch('ddsc.core.projectuploader.TaskExecutor')
+    @patch('ddsc.core.projectuploader.SmallItemUploadTaskBuilder')
+    @patch('ddsc.core.projectuploader.FileUploader')
+    def test_process_large_file_sets_file_hash_and_alg(self, mock_file_uploader, mock_small_task_builder,
+                                                       mock_task_executor, mock_task_runner):
+        uploader = ProjectUploader(Mock())
+        local_file = Mock()
+        parent = Mock()
+        hash_data = Mock(
+            alg='md5',
+            value='defg'
+        )
+        mock_file_uploader.return_value.upload.return_value = 'abc'
+        uploader.process_large_file(local_file, parent, hash_data)
+        local_file.set_remote_values_after_send.assert_called_with(
+            'abc', 'md5', 'defg'
+        )
+
+    @patch('ddsc.core.projectuploader.TaskRunner')
+    @patch('ddsc.core.projectuploader.TaskExecutor')
+    @patch('ddsc.core.projectuploader.SmallItemUploadTaskBuilder')
+    @patch('ddsc.core.projectuploader.FileUploader')
+    def test_upload_large_files__updates_watcher(self, mock_file_uploader, mock_small_task_builder,
+                                                 mock_task_executor, mock_task_runner):
+        local_file1 = Mock(size=1000)
+        local_file1.hash_matches_remote.return_value = False
+        local_file2 = Mock(size=2000)
+        local_file2.hash_matches_remote.return_value = True
+        settings = Mock()
+        settings.config.upload_bytes_per_chunk = 1000
+        uploader = ProjectUploader(settings)
+        uploader.large_files = [
+            (local_file1, Mock()),
+            (local_file2, Mock()),
+        ]
+
+        uploader.upload_large_files()
+
+        settings.watcher.transferring_item.assert_has_calls([
+            # Show checking for file1
+            call(local_file1, increment_amt=0, override_msg_verb='checking'),
+            # Reset verb to sending for file1 (additional calls are made from ParallelChunkProcessor)
+            call(local_file1, increment_amt=0),
+            # Show checking for file2
+            call(local_file2, increment_amt=0, override_msg_verb='checking'),
+        ])
+        # The progress bar should be incremented for local_file2 since local matches remote
+        settings.watcher.increment_progress.assert_has_calls([
+            call(2)
+        ])
+
+
+class TestHashFileCommand(TestCase):
+    def test_before_run_shows_checking_message(self):
+        cmd = HashFileCommand(settings=Mock(), local_file=Mock())
+        cmd.before_run(None)
+        cmd.settings.watcher.transferring_item.assert_called_with(cmd.local_file, increment_amt=0,
+                                                                  override_msg_verb='checking')
+
+    def test_function_hashes_file(self):
+        cmd = HashFileCommand(settings=Mock(), local_file=Mock())
+        context = cmd.create_context(Mock(), '123')
+        result = cmd.func(context)
+        mock_path_data = cmd.local_file.get_path_data.return_value
+        self.assertEqual(result, mock_path_data.get_hash.return_value)
+
+
+class TestCreateSmallFileCommand(TestCase):
+    def test_before_run_updates_watcher(self):
+        cmd = CreateSmallFileCommand(settings=Mock(), local_file=Mock(), parent=Mock(),
+                                     file_upload_post_processor=Mock())
+        cmd.before_run('hash_data')
+        self.assertEqual(cmd.hash_data, 'hash_data')
+
+    def test_after_run_when_file_is_already_good(self):
+        cmd = CreateSmallFileCommand(settings=Mock(), local_file=Mock(), parent=Mock(),
+                                     file_upload_post_processor=Mock())
+        cmd.after_run(None)
+        cmd.file_upload_post_processor.run.assert_not_called()
+        cmd.local_file.set_remote_id_after_send.assert_not_called()
+        cmd.settings.watcher.increment_progress()
+
+    def test_after_run_when_file_is_sent(self):
+        cmd = CreateSmallFileCommand(settings=Mock(), local_file=Mock(), parent=Mock(),
+                                     file_upload_post_processor=Mock())
+        remote_file_data = {
+            'id': 'abc123',
+            'current_version': {
+                'upload': {
+                    'hashes': [
+                        {
+                            "algorithm": "md5",
+                            "value": "abcdefg",
+                        }
+                    ]
+                }
+            }
+        }
+        cmd.after_run(remote_file_data)
+        cmd.file_upload_post_processor.run.assert_called_with(cmd.settings.data_service, remote_file_data)
+        cmd.local_file.set_remote_values_after_send.assert_called_with('abc123', 'md5', 'abcdefg')
+        cmd.settings.watcher.transferring_item.assert_called_with(cmd.local_file)
diff --git a/ddsc/core/tests/test_upload.py b/ddsc/core/tests/test_upload.py
index 3ede7ae..ffb199d 100644
--- a/ddsc/core/tests/test_upload.py
+++ b/ddsc/core/tests/test_upload.py
@@ -1,56 +1,63 @@
 from __future__ import absolute_import
 from unittest import TestCase
-from ddsc.core.upload import ProjectUpload, LocalOnlyCounter
-from ddsc.core.localstore import LocalFile
-from ddsc.core.remotestore import ProjectNameOrId
-from mock import MagicMock, patch, Mock
-
-
-class TestUploadCommand(TestCase):
-    @patch("ddsc.core.upload.RemoteStore")
-    @patch("ddsc.core.upload.ProjectUpload")
-    @patch("ddsc.core.upload.ProjectUploadDryRun")
-    def test_nothing_to_do(self, MockProjectUploadDryRun, MockProjectUpload, MockRemoteStore):
-        MockProjectUploadDryRun().upload_items = []
-        name_or_id = ProjectNameOrId.create_from_name("someProject")
-        project_upload = ProjectUpload(MagicMock(), name_or_id, ["data"])
-        dry_run_report = project_upload.dry_run_report()
-        self.assertIn("No changes found. Nothing needs to be uploaded.", dry_run_report)
-
-    @patch("ddsc.core.upload.RemoteStore")
-    @patch("ddsc.core.upload.ProjectUpload")
-    @patch("ddsc.core.upload.ProjectUploadDryRun")
-    def test_two_files_to_upload(self, MockProjectUploadDryRun, MockProjectUpload, MockRemoteStore):
-        MockProjectUploadDryRun().upload_items = ['data.txt', 'data2.txt']
-        name_or_id = ProjectNameOrId.create_from_name("someProject")
-        project_upload = ProjectUpload(MagicMock(), name_or_id, ["data"])
-        dry_run_report = project_upload.dry_run_report()
-        self.assertIn("Files/Folders that need to be uploaded:", dry_run_report)
-        self.assertIn("data.txt", dry_run_report)
-        self.assertIn("data2.txt", dry_run_report)
-
-
-class TestLocalOnlyCounter(TestCase):
-    @patch('ddsc.core.localstore.os')
-    @patch('ddsc.core.localstore.PathData')
-    def test_total_items(self, mock_path_data, mock_os):
-        counter = LocalOnlyCounter(bytes_per_chunk=100)
-        self.assertEqual(0, counter.total_items())
-        f = LocalFile('fakefile.txt')
-        f.size = 0
-        counter.visit_file(f, None)
-        self.assertEqual(1, counter.total_items())
-        f = LocalFile('fakefile2.txt')
-        f.size = 200
-        counter.visit_file(f, None)
-        self.assertEqual(3, counter.total_items())
+from ddsc.core.upload import ProjectUpload, UploadReport
+from mock import patch, Mock
 
 
 class TestProjectUpload(TestCase):
     @patch("ddsc.core.upload.RemoteStore")
     @patch("ddsc.core.upload.LocalProject")
     def test_get_url_msg(self, mock_local_project, mock_remote_store):
-        project_upload = ProjectUpload(config=Mock(), project_name_or_id=Mock(), folders=Mock(),
-                                       follow_symlinks=False, file_upload_post_processor=None)
+        project_upload = ProjectUpload(config=Mock(), project_name_or_id=Mock(), local_project=Mock(),
+                                       items_to_send_count=None, file_upload_post_processor=None)
         mock_remote_store.return_value.data_service.portal_url.return_value = 'https://127.0.0.1/#/project/123'
         self.assertEqual(project_upload.get_url_msg(), 'URL to view project: https://127.0.0.1/#/project/123')
+
+    @patch("ddsc.core.upload.RemoteStore")
+    @patch("ddsc.core.upload.LocalProject")
+    def test_create_for_paths(self, mock_local_project, mock_remote_store):
+        config, remote_store, project_name_or_id, file_upload_post_processor = Mock(), Mock(), Mock(), Mock()
+        paths = ['/tmp/data']
+        project_upload = ProjectUpload.create_for_paths(config, remote_store, project_name_or_id, paths,
+                                                        follow_symlinks=False,
+                                                        file_upload_post_processor=file_upload_post_processor)
+        self.assertEqual(project_upload.config, config)
+        self.assertEqual(project_upload.local_project, mock_local_project.return_value)
+        self.assertEqual(project_upload.items_to_send_count,
+                         mock_local_project.return_value.count_items_to_send.return_value)
+
+        remote_store.fetch_remote_project.assert_called_with(project_name_or_id)
+        project_upload.local_project.update_remote_ids.assert_called_with(
+            remote_store.fetch_remote_project.return_value
+        )
+        mock_local_project.return_value.add_paths.assert_called_with(['/tmp/data'])
+
+
+class TestUploadReport(TestCase):
+    def setUp(self):
+        self.upload_report = UploadReport('mouse')
+        self.upload_report.visit_project(Mock(remote_id='project1'))
+        self.upload_report.visit_folder(Mock(path='data', remote_id='folder1', sent_to_remote=True), None)
+        self.upload_report.visit_folder(Mock(path='code', remote_id='folder2', sent_to_remote=False), None)
+        self.upload_report.visit_folder(Mock(path='results', remote_id='folder3', sent_to_remote=False), None)
+        self.upload_report.visit_file(Mock(path='data/file1.txt', remote_id='file1', remote_file_hash='abc',
+                                           size=1201, sent_to_remote=True), None)
+        self.upload_report.visit_file(Mock(path='code/run.sh', remote_id='file2', sent_to_remote=False), None)
+        self.upload_report.visit_file(Mock(path='code/clean.sh', remote_id='file3', sent_to_remote=False), None)
+
+    def test_summary(self):
+        self.assertEqual(self.upload_report.summary(),
+                         'Uploaded 1 file and 1 folder. 2 files and 2 folders are already up to date.')
+
+    @patch('ddsc.core.upload.datetime')
+    def test_get_content(self, mock_datetime):
+        mock_datetime.datetime.utcnow.return_value = '2019-10-08 13:56'
+        expected_content = """
+Upload Report for Project: 'mouse' 2019-10-08 13:56
+
+SENT FILENAME     ID          SIZE    HASH
+Project           project1
+data              folder1
+data/file1.txt    file1       1201    abc
+        """.strip()
+        self.assertEqual(self.upload_report.get_content(), expected_content)
diff --git a/ddsc/core/tests/test_util.py b/ddsc/core/tests/test_util.py
index 3e23d4d..b6f7d5e 100644
--- a/ddsc/core/tests/test_util.py
+++ b/ddsc/core/tests/test_util.py
@@ -1,7 +1,7 @@
 from unittest import TestCase
 
 from ddsc.core.util import verify_terminal_encoding, ProgressBar, ProgressPrinter, KindType, RemotePath, humanize_bytes,\
-    plural_fmt
+    plural_fmt, join_with_commas_and_and
 from mock import patch, Mock
 
 
@@ -174,6 +174,28 @@ class TestProgressPrinter(TestCase):
         self.assertEqual(False, progress_printer.waiting)
         self.assertEqual(1, progress_printer.progress_bar.show_running.call_count)
 
+    @patch('ddsc.core.util.ProgressBar')
+    @patch('ddsc.core.util.os')
+    def test_transferring_item_override_msg_verb(self, mock_os, mock_progress_bar):
+        progress_bar = mock_progress_bar.return_value
+        mock_os.path.basename.return_value = 'somefile.txt'
+        progress_printer = ProgressPrinter(total=10, msg_verb='sending')
+        progress_printer.transferring_item(Mock(), increment_amt=0)
+        progress_bar.update.assert_called_with(0, "sending somefile.txt")
+        progress_bar.reset_mock()
+        progress_printer.transferring_item(Mock(), increment_amt=0, override_msg_verb='checking')
+        progress_bar.update.assert_called_with(0, "checking somefile.txt")
+
+    def test_increment_progress(self):
+        progress_printer = ProgressPrinter(total=10, msg_verb='sending')
+        self.assertEqual(progress_printer.cnt, 0)
+        progress_printer.increment_progress(5)
+        self.assertEqual(progress_printer.cnt, 5)
+        progress_printer.increment_progress(4)
+        self.assertEqual(progress_printer.cnt, 9)
+        progress_printer.increment_progress()
+        self.assertEqual(progress_printer.cnt, 10)
+
 
 class TestRemotePath(TestCase):
     def test_add_leading_slash(self):
@@ -212,3 +234,17 @@ class TestPluralFmt(TestCase):
         self.assertEqual(plural_fmt("folder", 1), "1 folder")
         self.assertEqual(plural_fmt("folder", 2), "2 folders")
         self.assertEqual(plural_fmt("folder", 3), "3 folders")
+
+
+class TestJoinWithCommasAndAnd(TestCase):
+    def test_join_with_commas_and_and(self):
+        items = []
+        self.assertEqual(join_with_commas_and_and(items), '')
+        items = ['abc']
+        self.assertEqual(join_with_commas_and_and(items), 'abc')
+        items = ['abc', 'def']
+        self.assertEqual(join_with_commas_and_and(items), 'abc and def')
+        items = ['abc', 'def', 'hij']
+        self.assertEqual(join_with_commas_and_and(items), 'abc, def and hij')
+        items = ['abc', 'def', 'hij', 'klm']
+        self.assertEqual(join_with_commas_and_and(items), 'abc, def, hij and klm')
diff --git a/ddsc/tests/test_ddsclient.py b/ddsc/tests/test_ddsclient.py
index 1150fe1..1a62148 100644
--- a/ddsc/tests/test_ddsclient.py
+++ b/ddsc/tests/test_ddsclient.py
@@ -2,7 +2,7 @@ from __future__ import absolute_import
 from unittest import TestCase
 from ddsc.ddsclient import BaseCommand, UploadCommand, ListCommand, DownloadCommand, ClientCommand, MoveCommand
 from ddsc.ddsclient import ShareCommand, DeliverCommand, InfoCommand, read_argument_file_contents
-from mock import patch, MagicMock, Mock, call
+from mock import patch, MagicMock, Mock, call, ANY
 
 
 class TestBaseCommand(TestCase):
@@ -62,8 +62,15 @@ class TestBaseCommand(TestCase):
 
 class TestUploadCommand(TestCase):
     @patch("ddsc.ddsclient.ProjectUpload")
-    def test_without_dry_run(self, mock_project_upload):
-        cmd = UploadCommand(MagicMock())
+    @patch("ddsc.ddsclient.ProjectNameOrId")
+    @patch("ddsc.ddsclient.LocalProject")
+    @patch("ddsc.ddsclient.ProjectUploadDryRun")
+    @patch('ddsc.ddsclient.RemoteStore')
+    @patch('ddsc.ddsclient.print')
+    def test_without_dry_run(self, mock_print, mock_remote_store, mock_project_upload_dry_run, mock_local_project,
+                             mock_project_name_or_id, mock_project_upload):
+        mock_config = MagicMock()
+        cmd = UploadCommand(mock_config)
         args = Mock()
         args.project_name = "test"
         args.project_id = None
@@ -71,14 +78,41 @@ class TestUploadCommand(TestCase):
         args.follow_symlinks = False
         args.dry_run = False
         cmd.run(args)
-        args, kwargs = mock_project_upload.call_args
-        self.assertEqual('test', args[1].get_name_or_raise())
-        self.assertFalse(mock_project_upload.return_value.dry_run_report.called)
-        self.assertTrue(mock_project_upload.return_value.get_upload_report.called)
+
+        mock_project_name_or_id.create_from_name.assert_called_with("test")
+
+        # create local project with folders
+        mock_local_project.return_value.add_paths.assert_called_with(["data", "scripts"])
+
+        # update local project with details about remote files/folders
+        remote_project = mock_remote_store.return_value.fetch_remote_project
+        mock_local_project.return_value.update_remote_ids(remote_project)
+
+        # uploads with local project
+        items_to_send = mock_local_project.return_value.count_items_to_send.return_value
+        mock_project_upload.assert_called_with(mock_config, ANY, mock_local_project.return_value, items_to_send)
+        mock_project_upload.return_value.run.assert_called_with()
+        mock_print.assert_has_calls([
+            call(mock_local_project.return_value.count_local_items.return_value.to_str.return_value),
+            call(mock_local_project.return_value.count_items_to_send.return_value.to_str.return_value),
+            call(mock_project_upload.return_value.get_upload_report.return_value.summary.return_value),
+            call(),
+            call('\n'),
+            call(mock_project_upload.return_value.get_upload_report.return_value.get_content.return_value),
+            call('\n'),
+            call(mock_project_upload.return_value.get_url_msg.return_value),
+        ])
 
     @patch("ddsc.ddsclient.ProjectUpload")
-    def test_without_dry_run_project_id(self, mock_project_upload):
-        cmd = UploadCommand(MagicMock())
+    @patch("ddsc.ddsclient.ProjectNameOrId")
+    @patch("ddsc.ddsclient.LocalProject")
+    @patch("ddsc.ddsclient.ProjectUploadDryRun")
+    @patch('ddsc.ddsclient.RemoteStore')
+    @patch('ddsc.ddsclient.print')
+    def test_without_dry_run_project_id(self, mock_print, mock_remote_store, mock_project_upload_dry_run,
+                                        mock_local_project, mock_project_name_or_id, mock_project_upload):
+        mock_config = MagicMock()
+        cmd = UploadCommand(mock_config)
         args = Mock()
         args.project_name = None
         args.project_id = '123'
@@ -86,14 +120,28 @@ class TestUploadCommand(TestCase):
         args.follow_symlinks = False
         args.dry_run = False
         cmd.run(args)
-        args, kwargs = mock_project_upload.call_args
-        self.assertEqual('123', args[1].get_id_or_raise())
-        self.assertFalse(mock_project_upload.return_value.dry_run_report.called)
-        self.assertTrue(mock_project_upload.return_value.get_upload_report.called)
 
-    @patch("ddsc.ddsclient.ProjectUpload")
-    def test_with_dry_run(self, FakeProjectUpload):
-        cmd = UploadCommand(MagicMock())
+        mock_project_name_or_id.create_from_project_id.assert_called_with("123")
+
+        # create local project with folders
+        mock_local_project.return_value.add_paths.assert_called_with(["data", "scripts"])
+
+        # update local project with details about remote files/folders
+        remote_project = mock_remote_store.return_value.fetch_remote_project
+        mock_local_project.return_value.update_remote_ids(remote_project)
+
+        # uploads with local project
+        items_to_send = mock_local_project.return_value.count_items_to_send.return_value
+        mock_project_upload.assert_called_with(mock_config, ANY, mock_local_project.return_value, items_to_send)
+        mock_project_upload.return_value.run.assert_called_with()
+
+    @patch("ddsc.ddsclient.LocalProject")
+    @patch("ddsc.ddsclient.ProjectUploadDryRun")
+    @patch('ddsc.ddsclient.RemoteStore')
+    @patch('ddsc.ddsclient.print')
+    def test_with_dry_run(self, mock_print, mock_remote_store, mock_project_upload_dry_run, mock_local_project):
+        mock_config = MagicMock()
+        cmd = UploadCommand(mock_config)
         args = Mock()
         args.project_name = "test"
         args.project_id = None
@@ -101,8 +149,10 @@ class TestUploadCommand(TestCase):
         args.follow_symlinks = False
         args.dry_run = True
         cmd.run(args)
-        self.assertTrue(FakeProjectUpload().dry_run_report.called)
-        self.assertFalse(FakeProjectUpload().get_upload_report.called)
+
+        mock_local_project.assert_called_with(followsymlinks=False, file_exclude_regex=mock_config.file_exclude_regex)
+        mock_project_upload_dry_run.assert_called_with(mock_local_project.return_value)
+        mock_print.assert_called_with(mock_project_upload_dry_run.return_value.get_report.return_value)
 
 
 class TestDownloadCommand(TestCase):