diff --git a/ddsc/core/download.py b/ddsc/core/download.py
index dab4306..ca96cc6 100644
--- a/ddsc/core/download.py
+++ b/ddsc/core/download.py
@@ -4,7 +4,7 @@ import traceback
 import math
 import requests
 from ddsc.core.localstore import HashUtil
-from ddsc.core.util import ProgressPrinter
+from ddsc.core.util import ProgressPrinter, plural_fmt
 from ddsc.core.parallel import TaskExecutor, TaskRunner
 from ddsc.core.ddsapi import DataServiceAuth, DataServiceApi
 from ddsc.core.remotestore import RemoteStore, ProjectFile, RemoteFileUrl
@@ -53,20 +53,37 @@ class ProjectDownload(object):
 
         self.try_create_dir(self.dest_directory)
         num_files_to_download = len(files_to_download)
-        print("Downloading {} files.".format(num_files_to_download))
+        print("Downloading {}.".format(plural_fmt('file', num_files_to_download)))
         self.download_files(files_to_download)
-        print("Verifying contents of {} downloaded files using file hashes.".format(num_files_to_download))
+        print("Verifying contents of {} using file hashes.".format(plural_fmt('downloaded file', num_files_to_download)))
         self.check_downloaded_files(files_to_download)
 
     def get_files_to_download(self):
         files_to_download = []
-        for project_file in self.remote_store.get_project_files(self.project):
-            if self.include_project_file(project_file):
+        local_verified_files = 0
+        included_project_files = self.get_included_project_files()
+        print("Checking {}.".format(plural_fmt('file', len(included_project_files))))
+        for project_file in included_project_files:
+            if self.file_exists_with_same_hash(project_file):
+                local_verified_files += 1
+            else:
                 local_path = project_file.get_local_path(self.dest_directory)
                 file_to_download = FileToDownload(project_file.json_data, local_path)
                 files_to_download.append(file_to_download)
+        if local_verified_files:
+            if local_verified_files == 1:
+                print("Verified 1 local file is already up to date.")
+            else:
+                print("Verified {} local files are already up to date.".format(local_verified_files))
         return files_to_download
 
+    def get_included_project_files(self):
+        included_project_files = []
+        for project_file in self.remote_store.get_project_files(self.project):
+            if self.path_filter.include_path(project_file.path):
+                included_project_files.append(project_file)
+        return included_project_files
+
     def include_project_file(self, project_file):
         if not self.path_filter.include_path(project_file.path):
             return False  # the file has been filtered out

improve communication when downloading a project
A user re-ran a failed `ddsclient download` command and was concerned about verifying the file hashes for the files downloaded in the first run.  When ddsclient downloads a project the first thing it does is create file hashes for any local files and compares them to the remote file hashes.
If the file hashes differ ddsclient will re-download those files. If the file hashes match the files will be excluded from the download process (since they are already correct). So the file hashes from the first run had already been verified, but not effectively communicated to the user.

Improve the messaging for downloading similar to what was done for uploading in PR #271
diff --git a/ddsc/core/tests/test_download.py b/ddsc/core/tests/test_download.py
index e43fff5..bb08107 100644
--- a/ddsc/core/tests/test_download.py
+++ b/ddsc/core/tests/test_download.py
@@ -144,8 +144,8 @@ class TestProjectDownload(TestCase):
         self.assertTrue(mock_file_downloader.return_value.run.called)
 
         mock_print.assert_has_calls([
-            call('Downloading 1 files.'),
-            call('Verifying contents of 1 downloaded files using file hashes.'),
+            call('Downloading 1 file.'),
+            call('Verifying contents of 1 downloaded file using file hashes.'),
             call('/tmp/dest/data1.txt 111 md5 FAILED'),
         ])
 
@@ -162,11 +162,15 @@ class TestProjectDownload(TestCase):
         self.assertEqual(project_download.check_warnings().strip(), 'WARNING: Path(s) not found: tmp/data.txt.')
 
     @patch('ddsc.core.download.FileToDownload')
-    def test_get_files_to_download(self, mock_file_to_download):
+    @patch('ddsc.core.download.os')
+    @patch('ddsc.core.download.print')
+    def test_get_files_to_download(self, mock_print, mock_os, mock_file_to_download):
         project_download = ProjectDownload(self.mock_remote_store, self.mock_project, '/tmp/dest',
                                            self.mock_path_filter)
         project_download.include_project_file = Mock()
         project_download.include_project_file.return_value = True
+        project_download.file_exists_with_same_hash = Mock()
+        project_download.file_exists_with_same_hash.return_value = False
         files = project_download.get_files_to_download()
         self.assertEqual(2, len(files))
         self.assertEqual(files[0], mock_file_to_download.return_value)
@@ -175,6 +179,48 @@ class TestProjectDownload(TestCase):
             call(self.mock_file1.json_data, self.mock_file1.get_local_path.return_value),
             call(self.mock_file2.json_data, self.mock_file2.get_local_path.return_value),
         ])
+        mock_print.assert_has_calls([
+            call("Checking 2 files.")
+        ])
+
+    @patch('ddsc.core.download.FileToDownload')
+    @patch('ddsc.core.download.os')
+    @patch('ddsc.core.download.print')
+    def test_get_files_to_download_one_verified_local(self, mock_print, mock_os, mock_file_to_download):
+        project_download = ProjectDownload(self.mock_remote_store, self.mock_project, '/tmp/dest',
+                                           self.mock_path_filter)
+        project_download.include_project_file = Mock()
+        project_download.include_project_file.return_value = True
+        project_download.file_exists_with_same_hash = Mock()
+        project_download.file_exists_with_same_hash.side_effect = [False, True]
+        files = project_download.get_files_to_download()
+        self.assertEqual(1, len(files))
+        self.assertEqual(files[0], mock_file_to_download.return_value)
+        mock_file_to_download.assert_has_calls([
+            call(self.mock_file1.json_data, self.mock_file1.get_local_path.return_value),
+        ])
+        mock_print.assert_has_calls([
+            call("Checking 2 files."),
+            call("Verified 1 local file is already up to date.")
+        ])
+
+    @patch('ddsc.core.download.FileToDownload')
+    @patch('ddsc.core.download.os')
+    @patch('ddsc.core.download.print')
+    def test_get_files_to_download_two_verified_local(self, mock_print, mock_os, mock_file_to_download):
+        project_download = ProjectDownload(self.mock_remote_store, self.mock_project, '/tmp/dest',
+                                           self.mock_path_filter)
+        project_download.include_project_file = Mock()
+        project_download.include_project_file.return_value = True
+        project_download.file_exists_with_same_hash = Mock()
+        project_download.file_exists_with_same_hash.return_value = True
+        files = project_download.get_files_to_download()
+        self.assertEqual(0, len(files))
+        mock_file_to_download.assert_not_called()
+        mock_print.assert_has_calls([
+            call("Checking 2 files."),
+            call("Verified 2 local files are already up to date.")
+        ])
 
     @patch('ddsc.core.download.os')
     @patch('ddsc.core.download.HashUtil')

pip install -e .
parse_log_pytest
requirements.txt
nose mock flake8 pytest
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
certifi==2025.1.31
charset-normalizer==3.4.1
-e git+https://github.com/Duke-GCB/DukeDSClient.git@535994f973ae20d05ae4be968b2faa1192bb8a4d#egg=DukeDSClient
exceptiongroup==1.2.2
flake8==7.2.0
future==0.16.0
idna==3.10
iniconfig==2.1.0
mccabe==0.7.0
mock==5.2.0
nose==1.3.7
packaging==24.2
pluggy==1.5.0
pycodestyle==2.13.0
pyflakes==3.3.2
pytest==8.3.5
pytz==2025.2
PyYAML==5.1
requests==2.32.3
six==1.10.0
tenacity==5.0.4
tomli==2.2.1
urllib3==2.3.0

name: DukeDSClient
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - certifi==2025.1.31
      - charset-normalizer==3.4.1
      - exceptiongroup==1.2.2
      - flake8==7.2.0
      - future==0.16.0
      - idna==3.10
      - iniconfig==2.1.0
      - mccabe==0.7.0
      - mock==5.2.0
      - nose==1.3.7
      - packaging==24.2
      - pluggy==1.5.0
      - pycodestyle==2.13.0
      - pyflakes==3.3.2
      - pytest==8.3.5
      - pytz==2025.2
      - pyyaml==5.1
      - requests==2.32.3
      - six==1.10.0
      - tenacity==5.0.4
      - tomli==2.2.1
      - urllib3==2.3.0
prefix: /opt/conda/envs/DukeDSClient

ddsc/core/tests/test_download.py::TestProjectDownload::test_get_files_to_download ddsc/core/tests/test_download.py::TestProjectDownload::test_get_files_to_download_one_verified_local ddsc/core/tests/test_download.py::TestProjectDownload::test_get_files_to_download_two_verified_local ddsc/core/tests/test_download.py::TestProjectDownload::test_run_mismatched_hash
ddsc/core/tests/test_download.py::TestProjectDownload::test_check_downloaded_files_value_mismatched_hash ddsc/core/tests/test_download.py::TestProjectDownload::test_check_downloaded_files_when_conflicted_hashes ddsc/core/tests/test_download.py::TestProjectDownload::test_check_downloaded_files_when_matching_hash ddsc/core/tests/test_download.py::TestProjectDownload::test_check_downloaded_files_when_no_supported_hashes ddsc/core/tests/test_download.py::TestProjectDownload::test_check_downloaded_files_when_two_files_with_conflicted_hashes ddsc/core/tests/test_download.py::TestProjectDownload::test_check_warnings ddsc/core/tests/test_download.py::TestProjectDownload::test_include_project_file ddsc/core/tests/test_download.py::TestProjectDownload::test_run ddsc/core/tests/test_download.py::TestProjectDownload::test_run_no_files ddsc/core/tests/test_download.py::TestProjectDownload::test_run_preprocessor ddsc/core/tests/test_download.py::TestDownloadSettings::test_get_data_service_auth_data ddsc/core/tests/test_download.py::TestDownloadContext::test_create_data_service ddsc/core/tests/test_download.py::TestDownloadContext::test_create_remote_store ddsc/core/tests/test_download.py::TestDownloadContext::test_send_error_message ddsc/core/tests/test_download.py::TestDownloadContext::test_send_message ddsc/core/tests/test_download.py::TestDownloadContext::test_send_processed_message ddsc/core/tests/test_download.py::TestFileToDownload::test_constructor ddsc/core/tests/test_download.py::TestFileDownloader::test_determine_bytes_per_chunk ddsc/core/tests/test_download.py::TestFileDownloader::test_download_files ddsc/core/tests/test_download.py::TestFileDownloader::test_group_files_by_size ddsc/core/tests/test_download.py::TestFileDownloader::test_make_big_empty_files ddsc/core/tests/test_download.py::TestFileDownloader::test_make_local_directories ddsc/core/tests/test_download.py::TestFileDownloader::test_make_ranges ddsc/core/tests/test_download.py::TestFileDownloader::test_run ddsc/core/tests/test_download.py::TestDownloadFilePartCommand::test_create_context ddsc/core/tests/test_download.py::TestDownloadFilePartCommand::test_on_message_error ddsc/core/tests/test_download.py::TestDownloadFilePartCommand::test_on_message_processed ddsc/core/tests/test_download.py::TestDownloadFilePartRun::test_download_file_part_run ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_download_chunk_inconsistent_s3 ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_download_chunk_inconsistent_swift ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_download_chunk_works ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_get_range_headers ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_get_url_and_headers_for_range ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_get_url_and_headers_for_range_with_no_slashes ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_on_bytes_read ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_on_bytes_read_too_much ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_retry_download_loop ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_retry_download_loop_handles_no_file_url ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_retry_download_loop_raises_when_out_of_retries ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_retry_download_loop_retries_then_works ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_retry_download_loop_too_few_actual_bytes_read ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_retry_download_loop_too_few_actual_bytes_read_then_works ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_run_handles_exception ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_verify_download_complete_ok ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_verify_download_complete_partial ddsc/core/tests/test_download.py::TestRetryChunkDownloader::test_verify_download_complete_too_large ddsc/core/tests/test_download.py::TestFileHash::test_get_supported_file_hashes ddsc/core/tests/test_download.py::TestFileHash::test_is_valid__hash_matches ddsc/core/tests/test_download.py::TestFileHash::test_is_valid__hash_mismatch ddsc/core/tests/test_download.py::TestFileHash::test_is_valid__no_supported_hash_found ddsc/core/tests/test_download.py::TestFileHash::test_separate_valid_and_failed_hashes ddsc/core/tests/test_download.py::TestFileHashStatus::test_determine_for_hashes__both_valid_and_failed_hashes ddsc/core/tests/test_download.py::TestFileHashStatus::test_determine_for_hashes__no_hashes_found ddsc/core/tests/test_download.py::TestFileHashStatus::test_determine_for_hashes__only_failed_hashes ddsc/core/tests/test_download.py::TestFileHashStatus::test_determine_for_hashes__only_valid_hashes ddsc/core/tests/test_download.py::TestFileHashStatus::test_get_status_line ddsc/core/tests/test_download.py::TestFileHashStatus::test_has_a_valid_hash ddsc/core/tests/test_download.py::TestFileHashStatus::test_raise_for_status
ddsc/core/download.py
ddsc/core/download.py