66import shutil
77import urllib.parse
88import urllib.request
9- from datetime import datetime
9+ import uuid
10+ import math
11+ import hashlib
12+ from datetime import datetime, timezone
1013
1114this_dir = os.path.dirname(os.path.realpath(__file__))
1215CHUNK_SIZE = 10 * 1024 * 1024
1316
1417try:
15- from requests_toolbelt import MultipartEncoder
16- import pytz
1718 import dateutil.parser
19+ from dateutil.tz import tzlocal
1820except ImportError:
1921 # this is to import all dependencies shipped with package (e.g. to use in qgis-plugin)
2022 deps_dir = os.path.join(this_dir, 'deps')
2325 for f in os.listdir(os.path.join(deps_dir)):
2426 sys.path.append(os.path.join(deps_dir, f))
2527
26- from requests_toolbelt import MultipartEncoder
27- import pytz
2828 import dateutil.parser
29+ from dateutil.tz import tzlocal
2930
30- from .utils import generate_checksum, move_file, save_to_file
31+ from .utils import save_to_file, generate_checksum, move_file, DateTimeEncoder
3132
3233
3334class InvalidProject(Exception):
@@ -54,11 +55,14 @@ def list_project_directory(directory):
5455 dirs[:] = [d for d in dirs if d not in excluded_dirs]
5556 for file in files:
5657 abs_path = os.path.abspath(os.path.join(root, file))
57- proj_path = abs_path[len(prefix) + 1:]
58+ rel_path = os.path.relpath(abs_path, start=prefix)
59+ # we need posix path
60+ proj_path = '/'.join(rel_path.split(os.path.sep))
5861 proj_files.append({
5962 "path": proj_path,
6063 "checksum": generate_checksum(abs_path),
61- "size": os.path.getsize(abs_path)
64+ "size": os.path.getsize(abs_path),
65+ "mtime": datetime.fromtimestamp(os.path.getmtime(abs_path), tzlocal())
6266 })
6367 return proj_files
6468
@@ -158,7 +162,7 @@ def __init__(self, url, auth_token=None, login=None, password=None):
158162
159163 def _do_request(self, request):
160164 if self._auth_session:
161- delta = self._auth_session["expire"] - datetime.now(pytz .utc)
165+ delta = self._auth_session["expire"] - datetime.now(timezone .utc)
162166 if delta.total_seconds() < 1:
163167 self._auth_session = None
164168 # Refresh auth token when login credentials are available
@@ -179,17 +183,14 @@ def get(self, path, data=None, headers={}):
179183 url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
180184 if data:
181185 url += "?" + urllib.parse.urlencode(data)
182- if headers:
183- request = urllib.request.Request(url, headers=headers)
184- else:
185- request = urllib.request.Request(url)
186+ request = urllib.request.Request(url, headers=headers)
186187 return self._do_request(request)
187188
188- def post(self, path, data, headers={}):
189+ def post(self, path, data=None , headers={}):
189190 url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
190191 if headers.get("Content-Type", None) == "application/json":
191- data = json.dumps(data).encode("utf-8")
192- request = urllib.request.Request(url, data, headers)
192+ data = json.dumps(data, cls=DateTimeEncoder ).encode("utf-8")
193+ request = urllib.request.Request(url, data, headers, method="POST" )
193194 return self._do_request(request)
194195
195196 def server_version(self):
@@ -247,7 +248,7 @@ def login(self, login, password):
247248 data = json.load(resp)
248249 session = data["session"]
249250 self._auth_session = {
250- "token" : session["token"],
251+ "token": "Bearer %s" % session["token"],
251252 "expire": dateutil.parser.parse(session["expire"])
252253 }
253254 self._user_info = {
@@ -281,11 +282,12 @@ def create_project(self, project_name, directory, is_public=False):
281282 self.post("/v1/project/%s" % namespace, params, {"Content-Type": "application/json"})
282283 data = {
283284 "name": "%s/%s" % (namespace, project_name),
284- "version": "",
285- "files": None
285+ "version": "v0 ",
286+ "files": []
286287 }
287288 save_project_file(directory, data)
288- self.push_project(directory)
289+ if len(os.listdir(directory)) > 1:
290+ self.push_project(directory)
289291
290292 def projects_list(self, tags=None, user=None, flag=None, q=None):
291293 """
@@ -379,37 +381,49 @@ def push_project(self, directory):
379381 local_info = inspect_project(directory)
380382 project_path = local_info["name"]
381383 server_info = self.project_info(project_path)
382- if local_info.get("version", "") != server_info.get("version", ""):
383- raise Exception("Update your local repository")
384+ server_version = server_info["version"] if server_info["version"] else "v0"
385+ if local_info.get("version", "v0") != server_version:
386+ raise ClientError("Update your local repository")
384387
385388 files = list_project_directory(directory)
386389 changes = project_changes(server_info["files"], files)
387- count = sum(len(items) for items in changes.values())
388- if count:
389- # Custom MultipartEncoder doesn't compute Content-Length,
390- # which is currently required by gunicorn.
391- # def fields():
392- # yield Field("changes", json.dumps(changes).encode("utf-8"))
393- # for file in (changes["added"] + changes["updated"]):
394- # path = file["path"]
395- # with open(os.path.join(directory, path), "rb") as f:
396- # yield Field(path, f, filename=path, content_type="application/octet-stream")
397- # encoder = MultipartEncoder(fields())
398-
399- fields = {"changes": json.dumps(changes).encode("utf-8")}
400- for file in (changes["added"] + changes["updated"]):
401- path = file["path"]
402- fields[path] = (path, open(os.path.join(directory, path), 'rb'), "application/octet-stream")
403- encoder = MultipartEncoder(fields=fields)
404- headers = {
405- "Content-Type": encoder.content_type,
406- "Content-Length": encoder.len
407- }
408- resp = self.post("/v1/project/data_sync/{}".format(project_path), encoder, headers=headers)
409- new_project_info = json.load(resp)
410- local_info["files"] = new_project_info["files"]
411- local_info["version"] = new_project_info["version"]
412- save_project_file(directory, local_info)
390+ upload_files = changes["added"] + changes["updated"]
391+
392+ for f in upload_files:
393+ f["chunks"] = [str(uuid.uuid4()) for i in range(math.ceil(f["size"] / CHUNK_SIZE))]
394+
395+ data = {
396+ "version": local_info.get("version"),
397+ "changes": changes
398+ }
399+ resp = self.post("/v1/project/push/%s" % project_path, data, {"Content-Type": "application/json"})
400+ info = json.load(resp)
401+
402+ # upload files' chunks and close transaction
403+ if upload_files:
404+ headers = {"Content-Type": "application/octet-stream"}
405+ for f in upload_files:
406+ with open(os.path.join(directory, f["path"]), 'rb') as file:
407+ for chunk in f["chunks"]:
408+ data = file.read(CHUNK_SIZE)
409+ checksum = hashlib.sha1()
410+ checksum.update(data)
411+ size = len(data)
412+ resp = self.post("/v1/project/push/chunk/%s/%s" % (info["transaction"], chunk), data, headers)
413+ data = json.load(resp)
414+ if not (data['size'] == size and data['checksum'] == checksum.hexdigest()):
415+ self.post("/v1/project/push/cancel/%s" % info["transaction"])
416+ raise ClientError("Mismatch between uploaded file and local one")
417+ try:
418+ resp = self.post("/v1/project/push/finish/%s" % info["transaction"])
419+ info = json.load(resp)
420+ except ClientError:
421+ self.post("/v1/project/push/cancel/%s" % info["transaction"])
422+ raise
423+
424+ local_info["files"] = info["files"]
425+ local_info["version"] = info["version"]
426+ save_project_file(directory, local_info)
413427
414428 def pull_project(self, directory):
415429 """
@@ -447,6 +461,7 @@ def backup_if_conflict(path, checksum):
447461 while os.path.exists(backup_path):
448462 backup_path = local_path("{}_conflict_copy{}".format(path, index))
449463 index += 1
464+ # it is unnecessary to copy conflicted file, it would be better to simply rename it
450465 shutil.copy(local_path(path), backup_path)
451466
452467 fetch_files = pull_changes["added"] + pull_changes["updated"]
@@ -507,3 +522,16 @@ def _download_file(self, project_path, project_version, file, directory):
507522 with open(os.path.join(directory, file['path'] + ".{}".format(i)), 'rb') as chunk:
508523 shutil.copyfileobj(chunk, final)
509524 os.remove(os.path.join(directory, file['path'] + ".{}".format(i)))
525+
526+ def delete_project(self, project_path):
527+ """
528+ Delete project repository on server.
529+
530+ :param project_path: Project's full name (<namespace>/<name>)
531+ :type project_path: String
532+
533+ """
534+ path = "/v1/project/%s" % project_path
535+ url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
536+ request = urllib.request.Request(url, method="DELETE")
537+ self._do_request(request)
0 commit comments