hdx-python-utilities 4.1.1__tar.gz → 4.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/PKG-INFO +1 -1
  2. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/documentation/index.md +11 -1
  3. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/pyproject.toml +1 -0
  4. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/_version.py +2 -2
  5. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/downloader.py +81 -17
  6. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/retriever.py +7 -1
  7. hdx_python_utilities-4.1.2/tests/fixtures/downloader/resume_test.dat +9 -0
  8. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_downloader.py +156 -0
  9. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_retriever.py +21 -0
  10. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/uv.lock +14 -0
  11. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/.github/workflows/publish-test.yaml +0 -0
  12. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/.github/workflows/publish.yaml +0 -0
  13. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/.github/workflows/run-python-tests.yaml +0 -0
  14. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/.gitignore +0 -0
  15. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/.pre-commit-config.yaml +0 -0
  16. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/CLAUDE.md +0 -0
  17. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/LICENSE +0 -0
  18. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/README.md +0 -0
  19. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/documentation/.readthedocs.yaml +0 -0
  20. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/documentation/mkdocs.yaml +0 -0
  21. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/__init__.py +0 -0
  22. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/base_downloader.py +0 -0
  23. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/compare.py +0 -0
  24. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/dateparse.py +0 -0
  25. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/dictandlist.py +0 -0
  26. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/easy_logging.py +0 -0
  27. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/email.py +0 -0
  28. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/encoding.py +0 -0
  29. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/error_handler.py +0 -0
  30. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/errors_onexit.py +0 -0
  31. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/file_hashing.py +0 -0
  32. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/frictionless_wrapper.py +0 -0
  33. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/html.py +0 -0
  34. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/loader.py +0 -0
  35. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/matching.py +0 -0
  36. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/path.py +0 -0
  37. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/saver.py +0 -0
  38. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/session.py +0 -0
  39. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/state.py +0 -0
  40. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/text.py +0 -0
  41. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/url.py +0 -0
  42. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/useragent.py +0 -0
  43. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/uuid.py +0 -0
  44. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/src/hdx/utilities/zip_crc.py +0 -0
  45. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/compare/test_csv_processing.csv +0 -0
  46. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/compare/test_csv_processing2.csv +0 -0
  47. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/empty.yaml +0 -0
  48. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/hdx_config.json +0 -0
  49. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/hdx_config.yaml +0 -0
  50. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/hdx_email_configuration.json +0 -0
  51. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/hdx_email_configuration.yaml +0 -0
  52. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/logging_config.json +0 -0
  53. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/logging_config.yaml +0 -0
  54. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/project_configuration.json +0 -0
  55. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/project_configuration.yaml +0 -0
  56. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/smtp_config.json +0 -0
  57. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/smtp_config.yaml +0 -0
  58. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/user_agent_config.yaml +0 -0
  59. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/user_agent_config2.yaml +0 -0
  60. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/user_agent_config3.yaml +0 -0
  61. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/config/user_agent_config_wrong.yaml +0 -0
  62. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/basicauth.txt +0 -0
  63. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/bearertoken.txt +0 -0
  64. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/extra_params.json +0 -0
  65. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/extra_params.yaml +0 -0
  66. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/extra_params_tree.yaml +0 -0
  67. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/test_csv_processing.csv +0 -0
  68. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/test_csv_processing_blanks.csv +0 -0
  69. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/test_data.csv +0 -0
  70. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/test_data.xlsx +0 -0
  71. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/test_data1.csv/empty.txt +0 -0
  72. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/test_data2.csv +0 -0
  73. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/test_json_processing.json +0 -0
  74. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/test_xls_processing.xls +0 -0
  75. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/downloader/test_xlsx_processing.xlsx +0 -0
  76. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/file_hashing/bad_header.zip +0 -0
  77. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/file_hashing/bad_index.xlsx +0 -0
  78. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/file_hashing/empty.zip +0 -0
  79. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/file_hashing/test.xlsx +0 -0
  80. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/file_hashing/test_shapefile.zip +0 -0
  81. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/file_hashing/valid_sig_invalid_body.zip +0 -0
  82. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/html/response.html +0 -0
  83. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/loader/empty.json +0 -0
  84. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/loader/empty.yaml +0 -0
  85. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/loader/empty_list.json +0 -0
  86. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/retriever/fallbacks/test.csv +0 -0
  87. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/retriever/fallbacks/test.json +0 -0
  88. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/retriever/fallbacks/test.txt +0 -0
  89. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/retriever/fallbacks/test.yaml +0 -0
  90. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/retriever/retriever-test.csv +0 -0
  91. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/retriever/test.csv +0 -0
  92. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/retriever/test.json +0 -0
  93. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/retriever/test.txt +0 -0
  94. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/retriever/test.yaml +0 -0
  95. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/out.csv +0 -0
  96. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/out.json +0 -0
  97. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/out2.csv +0 -0
  98. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/out2.json +0 -0
  99. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/out5.json +0 -0
  100. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/out6.json +0 -0
  101. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/out7.json +0 -0
  102. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/out8.csv +0 -0
  103. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/out8.json +0 -0
  104. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/pretty-false_sortkeys-false.json +0 -0
  105. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/pretty-false_sortkeys-false.yaml +0 -0
  106. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/pretty-false_sortkeys-true.json +0 -0
  107. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/pretty-false_sortkeys-true.yaml +0 -0
  108. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/pretty-true_sortkeys-false.json +0 -0
  109. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/pretty-true_sortkeys-false.yaml +0 -0
  110. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/pretty-true_sortkeys-true.json +0 -0
  111. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/saver/pretty-true_sortkeys-true.yaml +0 -0
  112. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/state/analysis_dates.txt +0 -0
  113. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/state/last_build_date.txt +0 -0
  114. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/test_data.csv +0 -0
  115. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/text/text1.txt +0 -0
  116. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/fixtures/text/text2.txt +0 -0
  117. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/conftest.py +0 -0
  118. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_compare.py +0 -0
  119. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_dateparse.py +0 -0
  120. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_dictandlist.py +0 -0
  121. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_easy_logging.py +0 -0
  122. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_email.py +0 -0
  123. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_encoding.py +0 -0
  124. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_error_handler.py +0 -0
  125. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_file_hashing.py +0 -0
  126. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_html.py +0 -0
  127. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_loader.py +0 -0
  128. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_matching.py +0 -0
  129. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_path.py +0 -0
  130. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_saver.py +0 -0
  131. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_state.py +0 -0
  132. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_text.py +0 -0
  133. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_url.py +0 -0
  134. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_useragent.py +0 -0
  135. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_uuid.py +0 -0
  136. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/test_zip_crc.py +0 -0
  137. {hdx_python_utilities-4.1.1 → hdx_python_utilities-4.1.2}/tests/hdx/utilities/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hdx-python-utilities
3
- Version: 4.1.1
3
+ Version: 4.1.2
4
4
  Summary: HDX Python Utilities for streaming tabular data, date and time handling and other helpful functions
5
5
  Project-URL: Homepage, https://github.com/OCHA-DAP/hdx-python-utilities
6
6
  Author-email: Michael Rans <rans@email.com>
@@ -205,7 +205,15 @@ Other useful functions:
205
205
  # if no folder supplied
206
206
  # path = Download.get_path_for_url(url, folder)
207
207
 
208
- For more detail and additional functions, check the API documentation.
208
+ ### Resumable downloads
209
+
210
+ Pass `resume=True` and a fixed `path` to continue an interrupted download using
211
+ HTTP Range requests. If the server returns 200 instead of 206, the file is
212
+ downloaded from scratch. Add `retries` to automatically retry mid-stream
213
+ failures; each retry continues from the current end of the partial file.
214
+ `retries` has no effect without `resume=True`.
215
+
216
+ path = downloader.download_file(url, path=mypath, resume=True, retries=3)
209
217
 
210
218
  ## Retrieving files
211
219
 
@@ -261,6 +269,8 @@ Examples:
261
269
  retriever = Retrieve(downloader, fallback_dir, saved_dir, temp_dir, save=False, use_saved=True)
262
270
  data = retriever.download_json(url, filename, logstr="test json", fallback=False, log_level=logging.DEBUG)
263
271
 
272
+ `Retrieve.download_file` also accepts `resume=True` and `retries=N` — see [Resumable downloads](#resumable-downloads).
273
+
264
274
  ## Date utilities
265
275
 
266
276
  There are utilities to parse dates. By default, *no timezone information will be parsed
@@ -70,6 +70,7 @@ dev = [
70
70
  "pytest",
71
71
  "pytest-cov",
72
72
  "pytest-loguru",
73
+ "pytest-mock",
73
74
  "pre-commit",
74
75
  "ruff==0.14.13",
75
76
  ]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '4.1.1'
22
- __version_tuple__ = version_tuple = (4, 1, 1)
21
+ __version__ = version = '4.1.2'
22
+ __version_tuple__ = version_tuple = (4, 1, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -250,13 +250,16 @@ class Download(BaseDownload):
250
250
  f"Download of {url} failed in retrieval of stream!" % url
251
251
  )
252
252
 
253
- def stream_path(self, path: Path | str, errormsg: str) -> Path:
253
+ def stream_path(
254
+ self, path: Path | str, errormsg: str, append: bool = False
255
+ ) -> Path:
254
256
  """Stream file from url and store in provided path. Must call setup
255
257
  method first.
256
258
 
257
259
  Args:
258
260
  path: Path for downloaded file
259
261
  errormsg: Error message to display if there is a problem
262
+ append: Whether to append to an existing file. Defaults to False.
260
263
 
261
264
  Returns:
262
265
  Path of downloaded file
@@ -264,7 +267,7 @@ class Download(BaseDownload):
264
267
  f = None
265
268
  try:
266
269
  path = Path(path)
267
- f = path.open("wb")
270
+ f = path.open("ab" if append else "wb")
268
271
  for chunk in self.response.iter_content(chunk_size=10240):
269
272
  if chunk: # filter out keep-alive new chunks
270
273
  f.write(chunk)
@@ -325,6 +328,8 @@ class Download(BaseDownload):
325
328
  path (str): Full path to use for downloaded file instead of folder and filename.
326
329
  overwrite (bool): Whether to overwrite existing file. Defaults to False.
327
330
  keep (bool): Whether to keep already downloaded file. Defaults to False.
331
+ resume (bool): Whether to resume a partial download using Range requests where the server supports it. Defaults to False.
332
+ retries (int): Number of times to retry a mid-stream failure. Only effective when resume=True so that each retry can continue from the partial file. Defaults to 0.
328
333
  post (bool): Whether to use POST instead of GET. Defaults to False.
329
334
  parameters (dict): Parameters to pass. Defaults to None.
330
335
  timeout (float): Timeout for connecting to URL. Defaults to None (no timeout).
@@ -340,25 +345,84 @@ class Download(BaseDownload):
340
345
  path = kwargs.get("path")
341
346
  overwrite = kwargs.get("overwrite", False)
342
347
  keep = kwargs.get("keep", False)
348
+ resume = kwargs.get("resume", False)
349
+ retries = kwargs.get("retries", 0)
343
350
  try:
344
- path = get_path_for_url(url, folder, filename, path, overwrite, keep)
351
+ # When resuming, skip uniqueness renaming so we get back the exact target path
352
+ path = get_path_for_url(
353
+ url, folder, filename, path, overwrite, keep or resume
354
+ )
345
355
  except ValueError as ex:
346
356
  raise DownloadError(ex) from ex
347
- if keep and exists(path):
357
+ if keep and not resume and exists(path):
348
358
  return path
349
- self.setup(
350
- url,
351
- stream=True,
352
- post=kwargs.get("post", False),
353
- parameters=kwargs.get("parameters"),
354
- timeout=kwargs.get("timeout"),
355
- headers=kwargs.get("headers"),
356
- encoding=kwargs.get("encoding"),
357
- json_string=kwargs.get("json_string", False),
358
- )
359
- return self.stream_path(
360
- path, f"Download of {url} failed in retrieval of stream!"
361
- )
359
+ errormsg = f"Download of {url} failed in retrieval of stream!"
360
+ setup_headers = dict(kwargs.get("headers") or {})
361
+ append = False
362
+ if resume and path.exists() and path.stat().st_size > 0:
363
+ setup_headers["Range"] = f"bytes={path.stat().st_size}-"
364
+ # Range bytes must come from the raw stream: partial gzip streams
365
+ # lack the header and cannot be decoded, so request uncompressed.
366
+ setup_headers.setdefault("Accept-Encoding", "identity")
367
+ try:
368
+ self.setup(
369
+ url,
370
+ stream=True,
371
+ post=kwargs.get("post", False),
372
+ parameters=kwargs.get("parameters"),
373
+ timeout=kwargs.get("timeout"),
374
+ headers=setup_headers,
375
+ encoding=kwargs.get("encoding"),
376
+ json_string=kwargs.get("json_string", False),
377
+ )
378
+ except DownloadError:
379
+ if self.response is not None and self.response.status_code == 416:
380
+ return path
381
+ raise
382
+ append = self.response.status_code == 206
383
+ else:
384
+ self.setup(
385
+ url,
386
+ stream=True,
387
+ post=kwargs.get("post", False),
388
+ parameters=kwargs.get("parameters"),
389
+ timeout=kwargs.get("timeout"),
390
+ headers=setup_headers or None,
391
+ encoding=kwargs.get("encoding"),
392
+ json_string=kwargs.get("json_string", False),
393
+ )
394
+ attempts_remaining = retries
395
+ while True:
396
+ try:
397
+ return self.stream_path(path, errormsg, append=append)
398
+ except DownloadError:
399
+ if not resume or attempts_remaining == 0:
400
+ raise
401
+ attempts_remaining -= 1
402
+ logger.warning(
403
+ "Download of %s interrupted, retrying (attempt %d of %d)...",
404
+ url,
405
+ retries - attempts_remaining,
406
+ retries,
407
+ )
408
+ setup_headers["Range"] = f"bytes={path.stat().st_size}-"
409
+ setup_headers.setdefault("Accept-Encoding", "identity")
410
+ try:
411
+ self.setup(
412
+ url,
413
+ stream=True,
414
+ post=kwargs.get("post", False),
415
+ parameters=kwargs.get("parameters"),
416
+ timeout=kwargs.get("timeout"),
417
+ headers=setup_headers,
418
+ encoding=kwargs.get("encoding"),
419
+ json_string=kwargs.get("json_string", False),
420
+ )
421
+ except DownloadError:
422
+ if self.response is not None and self.response.status_code == 416:
423
+ return path
424
+ raise
425
+ append = self.response.status_code == 206
362
426
 
363
427
  def download(self, url: Path | str, **kwargs: Any) -> requests.Response:
364
428
  """Download url.
@@ -184,6 +184,8 @@ class Retrieve(BaseDownload):
184
184
  filename: str | None = None,
185
185
  logstr: str | None = None,
186
186
  fallback: bool = False,
187
+ resume: bool = False,
188
+ retries: int = 0,
187
189
  log_level: int = None,
188
190
  **kwargs: Any,
189
191
  ) -> Path:
@@ -194,6 +196,8 @@ class Retrieve(BaseDownload):
194
196
  filename: Filename of saved file. Defaults to getting from url.
195
197
  logstr: Text to use in log string to describe download. Defaults to filename.
196
198
  fallback: Whether to use static fallback if download fails. Defaults to False.
199
+ resume: Whether to resume a partial download using Range requests where the server supports it. Defaults to False.
200
+ retries: Number of times to retry a mid-stream failure. Only effective when resume=True. Defaults to 0.
197
201
  log_level: Level at which to log messages. Overrides level from constructor.
198
202
  **kwargs: Parameters to pass to download_file call
199
203
 
@@ -219,7 +223,9 @@ class Retrieve(BaseDownload):
219
223
  log_level,
220
224
  f"Downloading {logstr} from {self.get_url_logstr(url)} into {output_path}",
221
225
  )
222
- return self.downloader.download_file(url, path=output_path, **kwargs)
226
+ return self.downloader.download_file(
227
+ url, path=output_path, resume=resume, retries=retries, **kwargs
228
+ )
223
229
  except DownloadError:
224
230
  if not fallback:
225
231
  raise
@@ -0,0 +1,9 @@
1
+ id,name,value
2
+ 1,alpha,10
3
+ 2,beta,20
4
+ 3,gamma,30
5
+ 4,delta,40
6
+ 5,epsilon,50
7
+ 6,zeta,60
8
+ 7,eta,70
9
+ 8,theta,80
@@ -1314,3 +1314,159 @@ class TestDownloader:
1314
1314
  downloader1 = Download.get_downloader()
1315
1315
  downloader2 = Download.get_downloader("test")
1316
1316
  assert_downloaders(downloader1, downloader2, downloaders)
1317
+
1318
+
1319
+ class TestResumableDownload:
1320
+ url = "https://example.com/testfile.bin"
1321
+
1322
+ def _make_response(self, mocker, status_code, content=b""):
1323
+ import requests
1324
+
1325
+ mock_resp = mocker.MagicMock()
1326
+ mock_resp.status_code = status_code
1327
+ if status_code >= 400:
1328
+ mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError(
1329
+ response=mock_resp
1330
+ )
1331
+ else:
1332
+ mock_resp.raise_for_status.return_value = None
1333
+ mock_resp.iter_content.return_value = iter([content] if content else [])
1334
+ return mock_resp
1335
+
1336
+ def test_resume_206_appends(self, tmp_path, mocker):
1337
+ partial = tmp_path / "file.bin"
1338
+ partial.write_bytes(b"hello ")
1339
+ mock_resp = self._make_response(mocker, 206, b"world")
1340
+ with Download(user_agent="test") as downloader:
1341
+ mocker.patch.object(downloader.session, "get", return_value=mock_resp)
1342
+ result = downloader.download_file(self.url, path=partial, resume=True)
1343
+ assert result == partial
1344
+ assert partial.read_bytes() == b"hello world"
1345
+ sent_headers = downloader.session.get.call_args.kwargs["headers"]
1346
+ assert sent_headers["Range"] == "bytes=6-"
1347
+
1348
+ def test_resume_200_overwrites(self, tmp_path, mocker):
1349
+ partial = tmp_path / "file.bin"
1350
+ partial.write_bytes(b"partial")
1351
+ mock_resp = self._make_response(mocker, 200, b"fullcontent")
1352
+ with Download(user_agent="test") as downloader:
1353
+ mocker.patch.object(downloader.session, "get", return_value=mock_resp)
1354
+ downloader.download_file(self.url, path=partial, resume=True)
1355
+ assert partial.read_bytes() == b"fullcontent"
1356
+
1357
+ def test_resume_416_returns_existing(self, tmp_path, mocker):
1358
+ partial = tmp_path / "file.bin"
1359
+ partial.write_bytes(b"complete")
1360
+ mock_resp = self._make_response(mocker, 416)
1361
+ with Download(user_agent="test") as downloader:
1362
+ mocker.patch.object(downloader.session, "get", return_value=mock_resp)
1363
+ result = downloader.download_file(self.url, path=partial, resume=True)
1364
+ assert result == partial
1365
+ assert partial.read_bytes() == b"complete"
1366
+
1367
+ def test_resume_no_partial_file_does_full_download(self, tmp_path, mocker):
1368
+ path = tmp_path / "newfile.bin"
1369
+ mock_resp = self._make_response(mocker, 200, b"fullcontent")
1370
+ with Download(user_agent="test") as downloader:
1371
+ mocker.patch.object(downloader.session, "get", return_value=mock_resp)
1372
+ downloader.download_file(self.url, path=path, resume=True)
1373
+ assert path.read_bytes() == b"fullcontent"
1374
+ sent_headers = downloader.session.get.call_args.kwargs.get("headers")
1375
+ assert sent_headers is None or "Range" not in sent_headers
1376
+
1377
+ def test_no_resume_overwrites_partial(self, tmp_path, mocker):
1378
+ partial = tmp_path / "file.bin"
1379
+ partial.write_bytes(b"staledata")
1380
+ mock_resp = self._make_response(mocker, 200, b"freshcontent")
1381
+ with Download(user_agent="test") as downloader:
1382
+ mocker.patch.object(downloader.session, "get", return_value=mock_resp)
1383
+ downloader.download_file(self.url, path=partial, overwrite=True)
1384
+ assert partial.read_bytes() == b"freshcontent"
1385
+ sent_headers = downloader.session.get.call_args.kwargs.get("headers")
1386
+ assert sent_headers is None or "Range" not in sent_headers
1387
+
1388
+ def test_resume_real_file(self, tmp_path, fixturesfolder):
1389
+ fixture = fixturesfolder / "downloader" / "resume_test.dat"
1390
+ expected = fixture.read_bytes()
1391
+ split_at = 40
1392
+ partial = tmp_path / "resume_test.dat"
1393
+ partial.write_bytes(expected[:split_at])
1394
+ url = "https://raw.githubusercontent.com/OCHA-DAP/hdx-python-utilities/main/tests/fixtures/downloader/resume_test.dat"
1395
+ with Download(user_agent="test") as downloader:
1396
+ result = downloader.download_file(url, path=partial, resume=True)
1397
+ assert result == partial
1398
+ assert partial.read_bytes() == expected
1399
+
1400
+ def test_retry_succeeds_on_second_attempt(self, tmp_path, mocker):
1401
+ partial = tmp_path / "file.bin"
1402
+ partial.write_bytes(b"hello ")
1403
+ first_resp = self._make_response(mocker, 206, b"wo")
1404
+ first_resp.iter_content.side_effect = Exception("connection reset")
1405
+ second_resp = self._make_response(mocker, 206, b"world")
1406
+ with Download(user_agent="test") as downloader:
1407
+ mocker.patch.object(
1408
+ downloader.session, "get", side_effect=[first_resp, second_resp]
1409
+ )
1410
+ result = downloader.download_file(
1411
+ self.url, path=partial, resume=True, retries=1
1412
+ )
1413
+ assert result == partial
1414
+ assert partial.read_bytes() == b"hello world"
1415
+ second_call_headers = downloader.session.get.call_args_list[1].kwargs["headers"]
1416
+ assert second_call_headers["Range"] == "bytes=6-"
1417
+
1418
+ def test_retry_exhausted_raises(self, tmp_path, mocker):
1419
+ partial = tmp_path / "file.bin"
1420
+ partial.write_bytes(b"hello ")
1421
+ failing_resp = self._make_response(mocker, 206, b"world")
1422
+ failing_resp.iter_content.side_effect = Exception("connection reset")
1423
+ with Download(user_agent="test") as downloader:
1424
+ mocker.patch.object(
1425
+ downloader.session,
1426
+ "get",
1427
+ return_value=failing_resp,
1428
+ )
1429
+ with pytest.raises(DownloadError):
1430
+ downloader.download_file(self.url, path=partial, resume=True, retries=2)
1431
+ assert downloader.session.get.call_count == 3
1432
+
1433
+ def test_retry_not_attempted_without_resume(self, tmp_path, mocker):
1434
+ path = tmp_path / "file.bin"
1435
+ failing_resp = self._make_response(mocker, 200, b"content")
1436
+ failing_resp.iter_content.side_effect = Exception("connection reset")
1437
+ with Download(user_agent="test") as downloader:
1438
+ mocker.patch.object(downloader.session, "get", return_value=failing_resp)
1439
+ with pytest.raises(DownloadError):
1440
+ downloader.download_file(self.url, path=path, retries=3)
1441
+ assert downloader.session.get.call_count == 1
1442
+
1443
+ def test_retry_updates_range_from_partial_bytes(self, tmp_path, mocker):
1444
+ partial = tmp_path / "file.bin"
1445
+ partial.write_bytes(b"hello ")
1446
+
1447
+ def make_partial_resp(content, fail_after):
1448
+ resp = self._make_response(mocker, 206, b"")
1449
+ chunks = [content[i : i + 1] for i in range(len(content))]
1450
+
1451
+ def iter_content_side_effect(chunk_size=1):
1452
+ for i, chunk in enumerate(chunks):
1453
+ if i == fail_after:
1454
+ raise Exception("connection reset")
1455
+ yield chunk
1456
+
1457
+ resp.iter_content.side_effect = iter_content_side_effect
1458
+ return resp
1459
+
1460
+ first_resp = make_partial_resp(b"wor", fail_after=2)
1461
+ second_resp = self._make_response(mocker, 206, b"rld")
1462
+ with Download(user_agent="test") as downloader:
1463
+ mocker.patch.object(
1464
+ downloader.session, "get", side_effect=[first_resp, second_resp]
1465
+ )
1466
+ result = downloader.download_file(
1467
+ self.url, path=partial, resume=True, retries=1
1468
+ )
1469
+ assert result == partial
1470
+ assert partial.read_bytes() == b"hello world"
1471
+ second_call_headers = downloader.session.get.call_args_list[1].kwargs["headers"]
1472
+ assert second_call_headers["Range"] == "bytes=8-"
@@ -462,6 +462,27 @@ class TestRetriever:
462
462
  # Uses saved file
463
463
  assert headers == ["header1", "header2", "header3", "header4"]
464
464
 
465
+ def test_resume_forwarded_to_downloader(self, dirs, fallback_dir, mocker):
466
+ saved_dir, temp_dir = dirs
467
+ downloader = mocker.MagicMock(spec=Download)
468
+ filename = "test.txt"
469
+ expected_path = temp_dir / filename
470
+ downloader.download_file.return_value = expected_path
471
+ with Retrieve(
472
+ downloader,
473
+ fallback_dir,
474
+ saved_dir,
475
+ temp_dir,
476
+ save=False,
477
+ use_saved=False,
478
+ ) as retriever:
479
+ url = "http://example.com/test.txt"
480
+ path = retriever.download_file(url, filename, resume=True)
481
+ assert path == expected_path
482
+ downloader.download_file.assert_called_once_with(
483
+ url, path=expected_path, resume=True, retries=0
484
+ )
485
+
465
486
  def test_generate_retrievers(self, downloaders, dirs, fallback_dir):
466
487
  saved_dir, temp_dir = dirs
467
488
  Retrieve.generate_retrievers(
@@ -565,6 +565,7 @@ dev = [
565
565
  { name = "pytest" },
566
566
  { name = "pytest-cov" },
567
567
  { name = "pytest-loguru" },
568
+ { name = "pytest-mock" },
568
569
  { name = "ruff" },
569
570
  ]
570
571
 
@@ -599,6 +600,7 @@ dev = [
599
600
  { name = "pytest" },
600
601
  { name = "pytest-cov" },
601
602
  { name = "pytest-loguru" },
603
+ { name = "pytest-mock" },
602
604
  { name = "ruff", specifier = "==0.14.13" },
603
605
  ]
604
606
 
@@ -1335,6 +1337,18 @@ wheels = [
1335
1337
  { url = "https://files.pythonhosted.org/packages/33/ef/b0c2e96e3508bca8d1874e39789d541cd7f4731b38bcf9c7098f0b882001/pytest_loguru-0.4.0-py3-none-any.whl", hash = "sha256:3cc7b9c6b22cb158209ccbabf0d678dacd3f3c7497d6f46f1c338c13bee1ac77", size = 3886, upload-time = "2024-03-20T00:52:12.72Z" },
1336
1338
  ]
1337
1339
 
1340
+ [[package]]
1341
+ name = "pytest-mock"
1342
+ version = "3.15.1"
1343
+ source = { registry = "https://pypi.org/simple" }
1344
+ dependencies = [
1345
+ { name = "pytest" },
1346
+ ]
1347
+ sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
1348
+ wheels = [
1349
+ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
1350
+ ]
1351
+
1338
1352
  [[package]]
1339
1353
  name = "python-dateutil"
1340
1354
  version = "2.9.0.post0"