Skip to content

Commit c4b47fc

Browse files
authored
Merge pull request #8 from lutraconsulting/upload_revisited
Upload revisited
2 parents 4d00498 + 41a6bff commit c4b47fc

File tree

6 files changed

+133
-57
lines changed

6 files changed

+133
-57
lines changed

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44

55
- Added filters for listing projects (owner, shared, query)
66
- Changed Basic auth to Bearer token-based auth
7-
- Improved CLI: added login, credentials in environment variables
8-
- Fixed missing Content-Length header in upload request
7+
- Improved CLI: added login, credentials in env variables, delete project
8+
- Download/upload files with multiple sequential requests (chunked transfer)
99

1010
## 2019.3
1111

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
# Mergin python client
22

3-
Repo for mergin client and basic utils.
3+
Repo for [mergin](https://public.cloudmergin.com/) client and basic utils.
4+
5+
Python 3.0+ required.
46

57
For using mergin client with its dependencies locally run:
68

9+
pip install wheel
710
python3 setup.py sdist bdist_wheel
811
mkdir -p mergin/deps
9-
pip wheel -r mergin_client.egg-info/requires.txt -w mergin/deps
12+
pip wheel -r mergin_client.egg-info/requires.txt -w mergin/deps
13+
14+
To run cli you need to install click:
15+
16+
pip install click

cli.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,14 @@ def pretty_diff(diff):
5151
def _init_client():
5252
url = os.environ.get('MERGIN_URL')
5353
auth_token = os.environ.get('MERGIN_AUTH')
54-
return MerginClient(url, auth_token=auth_token)
54+
return MerginClient(url, auth_token='Bearer {}'.format(auth_token))
5555

5656

5757
@click.group()
5858
def cli():
5959
pass
6060

61+
6162
@cli.command()
6263
@click.argument('url')
6364
@click.option('--login', prompt=True)
@@ -67,7 +68,9 @@ def login(url, login, password):
6768
c = MerginClient(url)
6869
session = c.login(login, password)
6970
print('export MERGIN_URL="%s"' % url)
70-
print('export MERGIN_AUTH="Bearer %s"' % session['token'])
71+
print('export MERGIN_AUTH="%s"' % session['token'])
72+
print('export MERGIN_AUTH_HEADER="Authorization: %s"' % session['token'])
73+
7174

7275
@cli.command()
7376
@click.argument('directory', type=click.Path(exists=True))
@@ -85,6 +88,7 @@ def init(directory, public):
8588
except Exception as e:
8689
click.secho(str(e), fg='red')
8790

91+
8892
@cli.command()
8993
@click.argument('project')
9094
@click.argument('directory', type=click.Path(), required=False)
@@ -155,6 +159,7 @@ def status():
155159
click.secho("No local changes!", fg="magenta")
156160
# TODO: show conflicts
157161

162+
158163
@cli.command()
159164
def push():
160165
"""Upload local changes into Mergin repository"""
@@ -168,6 +173,7 @@ def push():
168173
except Exception as e:
169174
click.secho(str(e), fg='red')
170175

176+
171177
@cli.command()
172178
def pull():
173179
"""Fetch changes from Mergin repository"""
@@ -179,6 +185,7 @@ def pull():
179185
except InvalidProject:
180186
click.secho('Invalid project directory', fg='red')
181187

188+
182189
@cli.command()
183190
def version():
184191
"""Check and display server version"""
@@ -189,6 +196,7 @@ def version():
189196
if not ok:
190197
click.secho("Server doesn't meet the minimum required version: %s" % c.min_server_version, fg='yellow')
191198

199+
192200
@cli.command()
193201
@click.argument('directory', required=False)
194202
def modtime(directory):
@@ -209,5 +217,30 @@ def modtime(directory):
209217
click.echo()
210218

211219

220+
@cli.command()
221+
@click.argument('project', required=False)
222+
def remove(project):
223+
"""Remove project from server and locally (if exists)."""
224+
local_info = None
225+
if not project:
226+
from mergin.client import inspect_project
227+
try:
228+
local_info = inspect_project(os.path.join(os.getcwd()))
229+
project = local_info['name']
230+
except InvalidProject:
231+
click.secho('Invalid project directory', fg='red')
232+
return
233+
234+
c = _init_client()
235+
try:
236+
c.delete_project(project)
237+
if local_info:
238+
import shutil
239+
shutil.rmtree(os.path.join(os.getcwd()))
240+
click.echo('Done')
241+
except Exception as e:
242+
click.secho(str(e), fg='red')
243+
244+
212245
if __name__ == '__main__':
213246
cli()

mergin/client.py

Lines changed: 76 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@
66
import shutil
77
import urllib.parse
88
import urllib.request
9-
from datetime import datetime
9+
import uuid
10+
import math
11+
import hashlib
12+
from datetime import datetime, timezone
1013

1114
this_dir = os.path.dirname(os.path.realpath(__file__))
1215
CHUNK_SIZE = 10 * 1024 * 1024
1316

1417
try:
15-
from requests_toolbelt import MultipartEncoder
16-
import pytz
1718
import dateutil.parser
19+
from dateutil.tz import tzlocal
1820
except 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')
@@ -23,11 +25,10 @@
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

3334
class 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)

mergin/utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import os
22
import io
3+
import json
34
import hashlib
5+
from datetime import datetime
46

57

68
def generate_checksum(file, chunk_size=4096):
@@ -45,3 +47,11 @@ def move_file(src, dest):
4547
if not os.path.exists(dest_dir):
4648
os.makedirs(dest_dir)
4749
os.rename(src, dest)
50+
51+
52+
class DateTimeEncoder(json.JSONEncoder):
53+
def default(self, obj):
54+
if isinstance(obj, datetime):
55+
return obj.isoformat()
56+
57+
return super().default(obj)

setup.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@
1717

1818
platforms='any',
1919
install_requires=[
20-
'pytz',
21-
'python-dateutil',
22-
'requests_toolbelt'
20+
'python-dateutil'
2321
],
2422

2523
test_suite='nose.collector',

0 commit comments

Comments
 (0)