Skip to content

Commit 71c47fd

Browse files
committed
Merge branch 'master' into project-history
2 parents 34b2e8c + 8e05f38 commit 71c47fd

7 files changed

Lines changed: 155 additions & 53 deletions

File tree

.github/workflows/autotests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: Auto Tests
22
on: [push]
33
env:
4-
TEST_MERGIN_URL: https://test.dev.merginmaps.com/
4+
TEST_MERGIN_URL: https://app.dev.merginmaps.com/
55
TEST_API_USERNAME: test_plugin
66
TEST_API_PASSWORD: ${{ secrets.MERGINTEST_API_PASSWORD }}
77
TEST_API_USERNAME2: test_plugin2

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Commands:
7171
download Download last version of Mergin Maps project
7272
download-file Download project file at specified version.
7373
list-projects List projects on the server
74+
list-files List files in a project
7475
login Login to the service and see how to set the token...
7576
pull Fetch changes from Mergin Maps repository
7677
push Upload local changes into Mergin Maps repository

mergin/cli.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
pip puts these tools).
88
"""
99

10-
from datetime import datetime, timezone
10+
from datetime import datetime, timezone, date
1111
import click
1212
import json
1313
import os
@@ -143,7 +143,7 @@ def _print_unhandled_exception():
143143

144144

145145
@click.group(
146-
epilog=f"Copyright (C) 2019-2021 Lutra Consulting\n\n(mergin-py-client v{__version__} / pygeodiff v{GeoDiff().version()})"
146+
epilog=f"Copyright (C) 2019-{date.today().year} Lutra Consulting\n\n(mergin-py-client v{__version__} / pygeodiff v{GeoDiff().version()})"
147147
)
148148
@click.option(
149149
"--url",
@@ -481,9 +481,9 @@ def show_version(ctx, version):
481481
return
482482
directory = os.getcwd()
483483
mp = MerginProject(directory)
484-
project_path = mp.project_full_name()
484+
project_id = mp.project_id()
485485
# TODO: handle exception when version not found
486-
version_info_dict = mc.project_version_info(project_path, version)[0]
486+
version_info_dict = mc.project_version_info(project_id, version)
487487
click.secho("Project: " + version_info_dict["namespace"] + "/" + version_info_dict["project_name"])
488488
click.secho("Version: " + version_info_dict["name"] + " by " + version_info_dict["author"])
489489
click.secho("Time: " + version_info_dict["created"])
@@ -661,6 +661,27 @@ def reset(ctx):
661661
except Exception as e:
662662
_print_unhandled_exception()
663663

664+
@cli.command()
665+
@click.argument("project")
666+
@click.option("--json", is_flag=True, default=False, help="Output in JSON format")
667+
@click.pass_context
668+
def list_files(ctx, project, json):
669+
"""List files in a project."""
670+
671+
mc = ctx.obj["client"]
672+
if mc is None:
673+
return
674+
675+
project_info = mc.project_info(project)
676+
project_files = project_info["files"]
677+
678+
if json:
679+
print(project_files)
680+
else:
681+
click.echo("Fetched {} files .".format(len(project_files)))
682+
for file in project_files:
683+
click.echo(" {:40}\t{:6.1f} MB".format(file['path'], file["size"] / (1024 * 1024)))
684+
664685

665686
if __name__ == "__main__":
666687
cli()

mergin/client.py

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import typing
1818
import warnings
1919

20-
from .common import ClientError, LoginError, InvalidProject
20+
from .common import ClientError, LoginError, InvalidProject, ErrorCode
2121
from .merginproject import MerginProject
2222
from .client_pull import (
2323
download_file_finalize,
@@ -205,19 +205,20 @@ def _do_request(self, request):
205205
try:
206206
return self.opener.open(request)
207207
except urllib.error.HTTPError as e:
208-
if e.headers.get("Content-Type", "") == "application/problem+json":
209-
info = json.load(e)
210-
err_detail = info.get("detail")
211-
else:
212-
err_detail = e.read().decode("utf-8")
208+
server_response = json.load(e)
213209

214-
error_msg = (
215-
f"HTTP Error: {e.code} {e.reason}\n"
216-
f"URL: {request.get_full_url()}\n"
217-
f"Method: {request.get_method()}\n"
218-
f"Detail: {err_detail}"
210+
# We first to try to get the value from the response otherwise we set a default value
211+
err_detail = server_response.get("detail", e.read().decode("utf-8"))
212+
server_code = server_response.get("code", None)
213+
214+
raise ClientError(
215+
detail=err_detail,
216+
url=request.get_full_url(),
217+
server_code=server_code,
218+
server_response=server_response,
219+
http_error=e.code,
220+
http_method=request.get_method(),
219221
)
220-
raise ClientError(error_msg)
221222
except urllib.error.URLError as e:
222223
# e.g. when DNS resolution fails (no internet connection?)
223224
raise ClientError("Error requesting " + request.full_url + ": " + str(e))
@@ -447,9 +448,9 @@ def create_workspace(self, workspace_name):
447448

448449
try:
449450
self.post("/v1/workspace", params, {"Content-Type": "application/json"})
450-
except Exception as e:
451-
detail = f"Workspace name: {workspace_name}"
452-
raise ClientError(str(e), detail)
451+
except ClientError as e:
452+
e.extra = f"Workspace name: {workspace_name}"
453+
raise e
453454

454455
def create_project(self, project_name, is_public=False, namespace=None):
455456
"""
@@ -496,9 +497,9 @@ def create_project(self, project_name, is_public=False, namespace=None):
496497
namespace = self.username()
497498
try:
498499
self.post(f"/v1/project/{namespace}", params, {"Content-Type": "application/json"})
499-
except Exception as e:
500-
detail = f"Namespace: {namespace}, project name: {project_name}"
501-
raise ClientError(str(e), detail)
500+
except ClientError as e:
501+
e.extra = f"Namespace: {namespace}, project name: {project_name}"
502+
raise e
502503

503504
def create_project_and_push(self, project_name, directory, is_public=False, namespace=None):
504505
"""
@@ -833,9 +834,9 @@ def set_project_access(self, project_path, access):
833834
try:
834835
request = urllib.request.Request(url, data=json.dumps(params).encode(), headers=json_headers, method="PUT")
835836
self._do_request(request)
836-
except Exception as e:
837-
detail = f"Project path: {project_path}"
838-
raise ClientError(str(e), detail)
837+
except ClientError as e:
838+
e.extra = f"Project path: {project_path}"
839+
raise e
839840

840841
def add_user_permissions_to_project(self, project_path, usernames, permission_level):
841842
"""
@@ -1040,13 +1041,10 @@ def project_status(self, directory):
10401041

10411042
return pull_changes, push_changes, push_changes_summary
10421043

1043-
def project_version_info(self, project_path, version):
1044+
def project_version_info(self, project_id, version):
10441045
"""Returns JSON with detailed information about a single project version"""
1045-
params = {"version_id": version}
1046-
params = {"page": version, "per_page": 1, "descending": False}
1047-
resp = self.get(f"/v1/project/versions/paginated/{project_path}", params)
1048-
j = json.load(resp)
1049-
return j["versions"]
1046+
resp = self.get(f"/v1/project/version/{project_id}/{version}")
1047+
return json.load(resp)
10501048

10511049
def project_file_history_info(self, project_path, file_path):
10521050
"""Returns JSON with full history of a single file within a project"""

mergin/common.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import os
2-
2+
from enum import Enum
33

44
CHUNK_SIZE = 100 * 1024 * 1024
55

@@ -10,8 +10,35 @@
1010
this_dir = os.path.dirname(os.path.realpath(__file__))
1111

1212

13+
# Error code from the public API, add to the end of enum as we handle more eror
14+
class ErrorCode(Enum):
15+
ProjectsLimitHit = "ProjectsLimitHit"
16+
StorageLimitHit = "StorageLimitHit"
17+
18+
1319
class ClientError(Exception):
14-
pass
20+
def __init__(self, detail, url=None, server_code=None, server_response=None, http_error=None, http_method=None):
21+
self.detail = detail
22+
self.url = url
23+
self.http_error = http_error
24+
self.http_method = http_method
25+
26+
self.server_code = server_code
27+
self.server_response = server_response
28+
29+
self.extra = None
30+
31+
def __str__(self):
32+
string_res = f"Detail: {self.detail}\n"
33+
if self.http_error:
34+
string_res += f"HTTP Error: {self.http_error}\n"
35+
if self.url:
36+
string_res += f"URL: {self.url}\n"
37+
if self.http_method:
38+
string_res += f"Method: {self.http_method}\n"
39+
if self.extra:
40+
string_res += f"{self.extra}\n"
41+
return string_res
1542

1643

1744
class LoginError(Exception):

mergin/test/test_client.py

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
decode_token_data,
2020
TokenError,
2121
ServerType,
22+
ErrorCode,
2223
)
2324
from ..client_push import push_project_async, push_project_cancel
2425
from ..client_pull import (
@@ -224,10 +225,9 @@ def test_create_remote_project_from_local(mc):
224225
assert project_info["namespace"] == API_USER
225226
assert project_info["id"] == source_mp.project_id()
226227

227-
versions = mc.project_versions(project)
228-
assert len(versions) == 1
229-
assert versions[0]["name"] == "v1"
230-
assert any(f for f in versions[0]["changes"]["added"] if f["path"] == "test.qgs")
228+
version = mc.project_version_info(project_info.get("id"), "v1")
229+
assert version["name"] == "v1"
230+
assert any(f for f in version["changes"]["added"] if f["path"] == "test.qgs")
231231

232232
# check we can fully download remote project
233233
mc.download_project(project, download_dir)
@@ -305,13 +305,9 @@ def test_push_pull_changes(mc):
305305
assert generate_checksum(os.path.join(project_dir, f_updated)) == f_remote_checksum
306306
assert project_info["id"] == mp.project_id()
307307
assert len(project_info["files"]) == len(mp.inspect_files())
308-
project_versions = mc.project_versions(project)
309-
assert len(project_versions) == 2
310-
f_change = next(
311-
(f for f in project_versions[-1]["changes"]["updated"] if f["path"] == f_updated),
312-
None,
313-
)
314-
assert "origin_checksum" not in f_change # internal client info
308+
project_version = mc.project_version_info(project_info["id"], "v2")
309+
updated_file = [f for f in project_version["changes"]["updated"] if f["path"] == f_updated][0]
310+
assert "origin_checksum" not in updated_file # internal client info
315311

316312
# test parallel changes
317313
with open(os.path.join(project_dir_2, f_updated), "w") as f:
@@ -796,7 +792,7 @@ def test_available_storage_validation(mcStorage):
796792
assert got_right_err
797793

798794
# Expecting empty project
799-
project_info = get_project_info(mcStorage, API_USER, test_project)
795+
project_info = get_project_info(mcStorage, STORAGE_WORKSPACE, test_project)
800796
assert project_info["version"] == "v0"
801797
assert project_info["disk_usage"] == 0
802798

@@ -2018,7 +2014,7 @@ def test_report(mc):
20182014
]
20192015
)
20202016
assert headers in content
2021-
assert "base.gpkg,simple,test_plugin" in content
2017+
assert f"base.gpkg,simple,{API_USER}" in content
20222018
assert "v3,update,,,2" in content
20232019
# files not edited are not in reports
20242020
assert "inserted_1_A.gpkg" not in content
@@ -2163,7 +2159,7 @@ def test_version_info(mc):
21632159
project = API_USER + "/" + test_project
21642160
project_dir = os.path.join(TMP_DIR, test_project) # primary project dir
21652161
test_gpkg = "test.gpkg"
2166-
file_path = os.path.join(project_dir, "test.gpkg")
2162+
file_path = os.path.join(project_dir, test_gpkg)
21672163

21682164
cleanup(mc, project, [project_dir])
21692165

@@ -2176,8 +2172,8 @@ def test_version_info(mc):
21762172

21772173
shutil.copy(os.path.join(TEST_DATA_DIR, "inserted_1_A_mod.gpkg"), file_path)
21782174
mc.push_project(project_dir)
2179-
2180-
info = mc.project_version_info(project, 2)[0]
2175+
project_info = mc.project_info(project)
2176+
info = mc.project_version_info(project_info.get("id"), "v2")
21812177
assert info["namespace"] == API_USER
21822178
assert info["project_name"] == test_project
21832179
assert info["name"] == "v2"
@@ -2344,9 +2340,14 @@ def test_project_metadata(mc):
23442340

23452341
# copy metadata in old format
23462342
os.makedirs(os.path.join(project_dir, ".mergin"), exist_ok=True)
2347-
project_metadata = os.path.join(project_dir, ".mergin", "mergin.json")
23482343
metadata_file = os.path.join(project_dir, "old_metadata.json")
2349-
shutil.copyfile(metadata_file, project_metadata)
2344+
# rewrite metadata nemespace to prevent failing tests with other user than test_plugin
2345+
with open(metadata_file, "r") as f:
2346+
metadata = json.load(f)
2347+
metadata["name"] = f"{API_USER}/{test_project}"
2348+
project_metadata_file = os.path.join(project_dir, ".mergin", "mergin.json")
2349+
with open(project_metadata_file, "w") as f:
2350+
json.dump(metadata, f, indent=2)
23502351

23512352
# verify we have correct metadata
23522353
mp = MerginProject(project_dir)
@@ -2357,7 +2358,12 @@ def test_project_metadata(mc):
23572358

23582359
# copy metadata in new format
23592360
metadata_file = os.path.join(project_dir, "new_metadata.json")
2360-
shutil.copyfile(metadata_file, project_metadata)
2361+
# rewrite metadata nemespace to prevent failing tests with other user than test_plugin
2362+
with open(metadata_file, "r") as f:
2363+
metadata = json.load(f)
2364+
metadata["namespace"] = API_USER
2365+
with open(project_metadata_file, "w") as f:
2366+
json.dump(metadata, f, indent=2)
23612367

23622368
# verify we have correct metadata
23632369
mp = MerginProject(project_dir)
@@ -2629,3 +2635,33 @@ def test_editor_push(mc: MerginClient, mc2: MerginClient):
26292635
conflicted_file = project_file
26302636
# There is no conflicted qgs file
26312637
assert conflicted_file is None
2638+
2639+
2640+
def test_error_push_already_named_project(mc: MerginClient):
2641+
test_project = "test_push_already_existing"
2642+
project_dir = os.path.join(TMP_DIR, test_project)
2643+
2644+
with pytest.raises(ClientError) as e:
2645+
mc.create_project_and_push(test_project, project_dir)
2646+
assert e.value.detail == "Project with the same name already exists"
2647+
assert e.value.http_error == 409
2648+
assert e.value.http_method == "POST"
2649+
assert e.value.url == f"{mc.url}v1/project/{API_USER}"
2650+
2651+
2652+
def test_error_projects_limit_hit(mcStorage: MerginClient):
2653+
test_project = "test_another_project_above_projects_limit"
2654+
test_project_fullname = STORAGE_WORKSPACE + "/" + test_project
2655+
2656+
project_dir = os.path.join(TMP_DIR, test_project, API_USER)
2657+
2658+
with pytest.raises(ClientError) as e:
2659+
mcStorage.create_project_and_push(test_project_fullname, project_dir)
2660+
assert e.value.server_code == ErrorCode.ProjectsLimitHit.value
2661+
assert (
2662+
e.value.detail
2663+
== "Maximum number of projects is reached. Please upgrade your subscription to create new projects (ProjectsLimitHit)"
2664+
)
2665+
assert e.value.http_error == 422
2666+
assert e.value.http_method == "POST"
2667+
assert e.value.url == f"{mcStorage.url}v1/project/testpluginstorage"

mergin/utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,22 @@ def is_mergin_config(path: str) -> bool:
275275
"""Check if the given path is for file mergin-config.json"""
276276
filename = os.path.basename(path).lower()
277277
return filename == "mergin-config.json"
278+
279+
280+
def bytes_to_human_size(bytes: int):
281+
"""
282+
Convert bytes to human readable size
283+
example :
284+
bytes_to_human_size(5600000) -> "5.3 MB"
285+
"""
286+
precision = 1
287+
if bytes < 1e-5:
288+
return "0.0 MB"
289+
elif bytes < 1024.0 * 1024.0:
290+
return f"{round( bytes / 1024.0, precision )} KB"
291+
elif bytes < 1024.0 * 1024.0 * 1024.0:
292+
return f"{round( bytes / 1024.0 / 1024.0, precision)} MB"
293+
elif bytes < 1024.0 * 1024.0 * 1024.0 * 1024.0:
294+
return f"{round( bytes / 1024.0 / 1024.0 / 1024.0, precision )} GB"
295+
else:
296+
return f"{round( bytes / 1024.0 / 1024.0 / 1024.0 / 1024.0, precision )} TB"

0 commit comments

Comments
 (0)