test_sync_files.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import io
  2. from unittest import mock
  3. import pytest
  4. from urllib3.response import HTTPResponse
  5. from urllib3.util.request import set_file_position
  6. from clean_python.api_client import ApiException
  7. from clean_python.api_client import download_file
  8. from clean_python.api_client import download_fileobj
  9. from clean_python.api_client import upload_file
  10. from clean_python.api_client import upload_fileobj
  11. from clean_python.api_client.files import _SeekableChunkIterator
  12. from clean_python.api_client.files import DEFAULT_UPLOAD_TIMEOUT
  13. MODULE = "clean_python.api_client.files"
  14. @pytest.fixture
  15. def pool():
  16. pool = mock.Mock()
  17. return pool
  18. @pytest.fixture
  19. def responses_single():
  20. return [
  21. HTTPResponse(
  22. body=b"X" * 42,
  23. headers={"Content-Range": "bytes 0-41/42"},
  24. status=206,
  25. )
  26. ]
  27. @pytest.fixture
  28. def responses_double():
  29. return [
  30. HTTPResponse(
  31. body=b"X" * 64,
  32. headers={"Content-Range": "bytes 0-63/65"},
  33. status=206,
  34. ),
  35. HTTPResponse(
  36. body=b"X",
  37. headers={"Content-Range": "bytes 64-64/65"},
  38. status=206,
  39. ),
  40. ]
  41. def test_download_fileobj(pool, responses_single):
  42. stream = io.BytesIO()
  43. pool.request.side_effect = responses_single
  44. download_fileobj("some-url", stream, chunk_size=64, pool=pool)
  45. pool.request.assert_called_with(
  46. "GET",
  47. "some-url",
  48. headers={"Range": "bytes=0-63"},
  49. timeout=5.0,
  50. )
  51. assert stream.tell() == 42
  52. def test_download_fileobj_two_chunks(pool, responses_double):
  53. stream = io.BytesIO()
  54. pool.request.side_effect = responses_double
  55. callback_func = mock.Mock()
  56. download_fileobj(
  57. "some-url", stream, chunk_size=64, pool=pool, callback_func=callback_func
  58. )
  59. (_, kwargs1), (_, kwargs2) = pool.request.call_args_list
  60. assert kwargs1["headers"] == {"Range": "bytes=0-63"}
  61. assert kwargs2["headers"] == {"Range": "bytes=64-127"}
  62. assert stream.tell() == 65
  63. # Check callback func
  64. (args1, _), (args2, _) = callback_func.call_args_list
  65. assert args1 == (63, 65)
  66. assert args2 == (65, 65)
  67. def test_download_fileobj_no_multipart(pool, responses_single):
  68. """The remote server does not support range requests"""
  69. responses_single[0].status = 200
  70. pool.request.side_effect = responses_single
  71. with pytest.raises(ApiException) as e:
  72. download_fileobj("some-url", None, chunk_size=64, pool=pool)
  73. assert e.value.status == 200
  74. assert str(e.value) == "200: The file server does not support multipart downloads."
  75. def test_download_fileobj_forbidden(pool, responses_single):
  76. """The remote server does not support range requests"""
  77. responses_single[0].status = 403
  78. pool.request.side_effect = responses_single
  79. with pytest.raises(ApiException) as e:
  80. download_fileobj("some-url", None, chunk_size=64, pool=pool)
  81. assert e.value.status == 403
  82. @mock.patch(MODULE + ".download_fileobj")
  83. def test_download_file(download_fileobj, tmp_path):
  84. download_file(
  85. "http://domain/a.b",
  86. tmp_path / "c.d",
  87. chunk_size=64,
  88. timeout=3.0,
  89. pool="foo",
  90. headers_factory="bar",
  91. )
  92. args, kwargs = download_fileobj.call_args
  93. assert args[0] == "http://domain/a.b"
  94. assert isinstance(args[1], io.IOBase)
  95. assert args[1].mode == "wb"
  96. assert args[1].name == str(tmp_path / "c.d")
  97. assert kwargs["chunk_size"] == 64
  98. assert kwargs["timeout"] == 3.0
  99. assert kwargs["pool"] == "foo"
  100. assert kwargs["headers_factory"] == "bar"
  101. @mock.patch(MODULE + ".download_fileobj")
  102. def test_download_file_directory(download_fileobj, tmp_path):
  103. # if a target directory is specified, a filename is created from the url
  104. download_file("http://domain/a.b", tmp_path, chunk_size=64, timeout=3.0, pool="foo")
  105. args, kwargs = download_fileobj.call_args
  106. assert args[1].name == str(tmp_path / "a.b")
  107. @pytest.fixture
  108. def upload_response():
  109. return HTTPResponse(status=200)
  110. @pytest.fixture
  111. def fileobj():
  112. stream = io.BytesIO()
  113. stream.write(b"X" * 39)
  114. stream.seek(0)
  115. return stream
  116. @pytest.mark.parametrize(
  117. "chunk_size,expected_body",
  118. [
  119. (64, [b"X" * 39]),
  120. (39, [b"X" * 39]),
  121. (38, [b"X" * 38, b"X"]),
  122. (16, [b"X" * 16, b"X" * 16, b"X" * 7]),
  123. ],
  124. )
  125. def test_upload_fileobj(pool, fileobj, upload_response, chunk_size, expected_body):
  126. pool.request.return_value = upload_response
  127. upload_fileobj("some-url", fileobj, chunk_size=chunk_size, pool=pool)
  128. args, kwargs = pool.request.call_args
  129. assert args == ("PUT", "some-url")
  130. assert list(kwargs["body"]) == expected_body
  131. assert kwargs["headers"] == {"Content-Length": "39"}
  132. assert kwargs["timeout"] == DEFAULT_UPLOAD_TIMEOUT
  133. def test_upload_fileobj_callback(pool, fileobj, upload_response):
  134. expected_body = [b"X" * 16, b"X" * 16, b"X" * 7]
  135. chunk_size = 16
  136. pool.request.return_value = upload_response
  137. callback_func = mock.Mock()
  138. upload_fileobj(
  139. "some-url",
  140. fileobj,
  141. chunk_size=chunk_size,
  142. pool=pool,
  143. callback_func=callback_func,
  144. )
  145. args, kwargs = pool.request.call_args
  146. assert args == ("PUT", "some-url")
  147. assert list(kwargs["body"]) == expected_body
  148. assert kwargs["headers"] == {"Content-Length": "39"}
  149. assert kwargs["timeout"] == DEFAULT_UPLOAD_TIMEOUT
  150. # Check callback_func
  151. (args1, _), (args2, _), (args3, _) = callback_func.call_args_list
  152. assert args1 == (16, 39)
  153. assert args2 == (32, 39)
  154. assert args3 == (39, 39)
  155. def test_upload_fileobj_with_md5(pool, fileobj, upload_response):
  156. pool.request.return_value = upload_response
  157. upload_fileobj("some-url", fileobj, pool=pool, md5=b"abcd")
  158. # base64.b64encode(b"abcd")).decode()
  159. expected_md5 = "YWJjZA=="
  160. args, kwargs = pool.request.call_args
  161. assert kwargs["headers"] == {"Content-Length": "39", "Content-MD5": expected_md5}
  162. def test_upload_fileobj_empty_file():
  163. with pytest.raises(IOError, match="The file object is empty."):
  164. upload_fileobj("some-url", io.BytesIO())
  165. def test_upload_fileobj_non_binary_file():
  166. with pytest.raises(IOError, match="The file object is not in binary*"):
  167. upload_fileobj("some-url", io.StringIO())
  168. def test_upload_fileobj_errors(pool, fileobj, upload_response):
  169. upload_response.status = 400
  170. pool.request.return_value = upload_response
  171. with pytest.raises(ApiException) as e:
  172. upload_fileobj("some-url", fileobj, pool=pool)
  173. assert e.value.status == 400
  174. @mock.patch(MODULE + ".upload_fileobj")
  175. def test_upload_file(upload_fileobj, tmp_path):
  176. path = tmp_path / "myfile"
  177. with path.open("wb") as f:
  178. f.write(b"X")
  179. upload_file(
  180. "http://domain/a.b",
  181. path,
  182. chunk_size=1234,
  183. timeout=3.0,
  184. pool="foo",
  185. md5=b"abcd",
  186. headers_factory="bar",
  187. )
  188. args, kwargs = upload_fileobj.call_args
  189. assert args[0] == "http://domain/a.b"
  190. assert isinstance(args[1], io.IOBase)
  191. assert args[1].mode == "rb"
  192. assert args[1].name == str(path)
  193. assert kwargs["timeout"] == 3.0
  194. assert kwargs["chunk_size"] == 1234
  195. assert kwargs["pool"] == "foo"
  196. assert kwargs["md5"] == b"abcd"
  197. assert kwargs["headers_factory"] == "bar"
  198. def test_seekable_chunk_iterator():
  199. data = b"XYZ"
  200. body = _SeekableChunkIterator(io.BytesIO(data), chunk_size=4)
  201. pos = set_file_position(body, pos=0)
  202. assert pos == 0
  203. assert list(body) == [data]
  204. assert list(body) == []
  205. set_file_position(body, pos)
  206. assert list(body) == [data]
  207. def test_download_fileobj_with_headers(pool, responses_single):
  208. pool.request.side_effect = responses_single
  209. download_fileobj(
  210. "some-url",
  211. io.BytesIO(),
  212. chunk_size=64,
  213. pool=pool,
  214. headers_factory=lambda: {"foo": "bar"},
  215. )
  216. pool.request.assert_called_with(
  217. "GET",
  218. "some-url",
  219. headers={"Range": "bytes=0-63", "foo": "bar"},
  220. timeout=5.0,
  221. )
  222. def test_upload_fileobj_with_headers(pool, fileobj, upload_response):
  223. pool.request.return_value = upload_response
  224. upload_fileobj(
  225. "some-url", fileobj, pool=pool, headers_factory=lambda: {"foo": "bar"}
  226. )
  227. _, kwargs = pool.request.call_args
  228. assert kwargs["headers"] == {"Content-Length": "39", "foo": "bar"}
  229. def test_upload_fileobj_201_response(pool, fileobj):
  230. pool.request.return_value = HTTPResponse(status=201)
  231. # no error is raised
  232. upload_fileobj(
  233. "some-url", fileobj, pool=pool, headers_factory=lambda: {"foo": "bar"}
  234. )
  235. pool.request.assert_called_once()