Skip to content

Commit 261ade4

Browse files
committed
download files with multiple sequential requests
1 parent 6ad6445 commit 261ade4

File tree

1 file changed

+54
-26
lines changed

1 file changed

+54
-26
lines changed

mergin/client.py

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@
2626
import pytz
2727
import dateutil.parser
2828

29-
from .utils import save_to_file, generate_checksum, move_file
30-
from .multipart import MultipartReader, parse_boundary
29+
from .utils import generate_checksum, move_file
3130

3231

3332
class InvalidProject(Exception):
@@ -175,11 +174,14 @@ def _do_request(self, request):
175174
raise ClientError(info.get("detail"))
176175
raise ClientError(e.read().decode("utf-8"))
177176

178-
def get(self, path, data=None):
177+
def get(self, path, data=None, headers={}):
179178
url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
180179
if data:
181180
url += "?" + urllib.parse.urlencode(data)
182-
request = urllib.request.Request(url)
181+
if headers:
182+
request = urllib.request.Request(url, headers=headers)
183+
else:
184+
request = urllib.request.Request(url)
183185
return self._do_request(request)
184186

185187
def post(self, path, data, headers={}):
@@ -341,7 +343,7 @@ def project_versions(self, project_path):
341343

342344
def download_project(self, project_path, directory):
343345
"""
344-
Download last version of project into given directory.
346+
Download latest version of project into given directory.
345347
346348
:param project_path: Project's full name (<namespace>/<name>)
347349
:type project_path: String
@@ -353,16 +355,10 @@ def download_project(self, project_path, directory):
353355
raise Exception("Project directory already exists")
354356
os.makedirs(directory)
355357

356-
# TODO: this shouldn't be two independent operations, or we should use version tag in download requests.
357-
# But current server API doesn't allow something better.
358358
project_info = self.project_info(project_path)
359-
resp = self.get("/v1/project/download/{}".format(project_path))
360-
reader = MultipartReader(resp, parse_boundary(resp.headers["Content-Type"]))
361-
part = reader.next_part()
362-
while part:
363-
dest = os.path.join(directory, part.filename)
364-
save_to_file(part, dest)
365-
part = reader.next_part()
359+
360+
for file in project_info['files']:
361+
self._download_file(project_path, project_info['version'], file, directory)
366362

367363
data = {
368364
"name": project_path,
@@ -453,20 +449,11 @@ def backup_if_conflict(path, checksum):
453449

454450
fetch_files = pull_changes["added"] + pull_changes["updated"]
455451
if fetch_files:
456-
resp = self.post(
457-
"/v1/project/fetch/{}".format(project_path),
458-
fetch_files,
459-
{"Content-Type": "application/json"}
460-
)
461-
reader = MultipartReader(resp, parse_boundary(resp.headers["Content-Type"]))
462-
part = reader.next_part()
463452
temp_dir = os.path.join(directory, '.mergin', 'fetch_{}-{}'.format(local_info["version"], server_info["version"]))
464-
while part:
465-
dest = os.path.join(temp_dir, part.filename)
466-
save_to_file(part, dest)
467-
part = reader.next_part()
468-
453+
if not os.path.exists(temp_dir):
454+
os.makedirs(temp_dir)
469455
for file in fetch_files:
456+
self._download_file(project_path, server_info['version'], file, temp_dir)
470457
src = os.path.join(temp_dir, file["path"])
471458
dest = local_path(file["path"])
472459
backup_if_conflict(file["path"], file["checksum"])
@@ -484,3 +471,44 @@ def backup_if_conflict(path, checksum):
484471
local_info["files"] = server_info["files"]
485472
local_info["version"] = server_info["version"]
486473
save_project_file(directory, local_info)
474+
475+
def _download_file(self, project_path, project_version, file, directory):
476+
"""
477+
Helper to download single project file from server in chunks.
478+
479+
:param project_path: Project's full name (<namespace>/<name>)
480+
:type project_path: String
481+
:param project_version: Version of the project (v<n>)
482+
:type project_version: String
483+
:param file: File metadata item from Project['files']
484+
:type file: dict
485+
:param directory: Project's directory
486+
:type directory: String
487+
"""
488+
chunk_size = 2 * 1024 * 1024
489+
query_params = {
490+
"file": file['path'],
491+
"version": project_version
492+
}
493+
length = 0
494+
count = 0
495+
while length < file['size']:
496+
range_header = {"Range": "bytes={}-{}".format(length, length + chunk_size)}
497+
resp = self.get("/v1/project/raw/{}".format(project_path), data=query_params, headers=range_header)
498+
# TODO some kind of recovery? do_request already raises exception
499+
if resp.status in [200, 206]:
500+
file_dir = os.path.dirname(os.path.normpath(file['path']))
501+
if file_dir:
502+
if not os.path.exists(os.path.join(directory, file_dir)):
503+
os.makedirs(os.path.join(directory, file_dir))
504+
with open(os.path.join(directory, file['path'] + ".{}".format(count)), 'wb') as output:
505+
output.write(resp.read())
506+
length += (chunk_size + 1)
507+
count += 1
508+
509+
# merge chunks together (maybe do checksum check? (might be costly))
510+
with open(os.path.join(directory, file['path']), 'wb') as final:
511+
for i in range(count):
512+
with open(os.path.join(directory, file['path'] + ".{}".format(i)), 'rb') as chunk:
513+
shutil.copyfileobj(chunk, final)
514+
os.remove(os.path.join(directory, file['path'] + ".{}".format(i)))

0 commit comments

Comments
 (0)