Skip to content

Commit c0ac231

Browse files
committed
Merge remote-tracking branch 'origin/master' into push-v2-integration
2 parents 0f5e7f3 + 6c419f3 commit c0ac231

File tree

9 files changed

+221
-45
lines changed

9 files changed

+221
-45
lines changed

.github/workflows/autotests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ env:
1010
concurrency:
1111
group: ci-${{github.ref}}-autotests
1212
cancel-in-progress: true
13-
13+
1414
jobs:
1515
tests:
1616
runs-on: ubuntu-latest
@@ -19,7 +19,7 @@ jobs:
1919

2020
- uses: actions/setup-python@v2
2121
with:
22-
python-version: '3.8'
22+
python-version: "3.8"
2323

2424
- name: Install python package dependencies
2525
run: |

mergin/client.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -392,14 +392,18 @@ def server_type(self):
392392
if not self._server_type:
393393
try:
394394
config = self.server_config()
395-
if config["server_type"] == "ce":
395+
stype = config.get("server_type")
396+
if stype == "ce":
396397
self._server_type = ServerType.CE
397-
elif config["server_type"] == "ee":
398+
elif stype == "ee":
398399
self._server_type = ServerType.EE
399-
elif config["server_type"] == "saas":
400+
elif stype == "saas":
400401
self._server_type = ServerType.SAAS
401-
except (ClientError, KeyError):
402-
self._server_type = ServerType.OLD
402+
except ClientError as e:
403+
if getattr(e, "http_error", None) == 404:
404+
self._server_type = ServerType.OLD
405+
else:
406+
raise
403407

404408
return self._server_type
405409

mergin/client_pull.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from .common import CHUNK_SIZE, ClientError
2424
from .merginproject import MerginProject
25-
from .utils import save_to_file
25+
from .utils import cleanup_tmp_dir, save_to_file
2626

2727

2828
# status = download_project_async(...)
@@ -250,7 +250,7 @@ def download_project_finalize(job):
250250
# final update of project metadata
251251
job.mp.update_metadata(job.project_info)
252252

253-
job.tmp_dir.cleanup()
253+
cleanup_tmp_dir(job.mp, job.tmp_dir)
254254

255255

256256
def download_project_cancel(job):
@@ -263,6 +263,7 @@ def download_project_cancel(job):
263263
job.is_cancelled = True
264264
job.executor.shutdown(wait=True)
265265
job.mp.log.info("--- download cancelled")
266+
cleanup_tmp_dir(job.mp, job.tmp_dir)
266267

267268

268269
class UpdateTask:
@@ -550,6 +551,7 @@ def pull_project_cancel(job):
550551
job.is_cancelled = True
551552
job.executor.shutdown(wait=True)
552553
job.mp.log.info("--- pull cancelled")
554+
cleanup_tmp_dir(job.mp, job.tmp_dir) # delete our temporary dir and all its content
553555

554556

555557
class FileToMerge:
@@ -637,6 +639,7 @@ def pull_project_finalize(job: PullJob):
637639
except Exception as e:
638640
job.mp.log.error("Failed to apply pull changes: " + str(e))
639641
job.mp.log.info("--- pull aborted")
642+
cleanup_tmp_dir(job.mp, job.tmp_dir) # delete our temporary dir and all its content
640643
raise ClientError("Failed to apply pull changes: " + str(e))
641644

642645
job.mp.update_metadata(job.project_info)
@@ -646,7 +649,7 @@ def pull_project_finalize(job: PullJob):
646649
else:
647650
job.mp.log.info("--- pull finished -- at version " + job.mp.version())
648651

649-
job.tmp_dir.cleanup() # delete our temporary dir and all its content
652+
cleanup_tmp_dir(job.mp, job.tmp_dir) # delete our temporary dir and all its content
650653
return conflicts
651654

652655

mergin/client_push.py

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@
3232
MAX_UPLOAD_MEDIA_SIZE,
3333
ClientError,
3434
)
35-
from .merginproject import MerginProject
35+
from .merginproject import MerginProject, pygeodiff
3636
from .editor import filter_changes
37-
from .utils import get_data_checksum
37+
from .utils import get_data_checksum, cleanup_tmp_dir
38+
3839

3940
POST_JSON_HEADERS = {"Content-Type": "application/json"}
4041

@@ -381,6 +382,7 @@ def push_project_finalize(job: UploadJob):
381382
f"Transferred size ({job.transferred_size}) and expected total size ({job.total_size}) do not match!"
382383
)
383384
job.mp.log.error("--- push finish failed! " + error_msg)
385+
cleanup_tmp_dir(job.mp, job.tmp_dir) # delete our temporary dir and all its content
384386
raise ClientError("Upload error: " + error_msg)
385387

386388
if server_features.get("v2_push_enabled"):
@@ -410,12 +412,29 @@ def push_project_finalize(job: UploadJob):
410412
resp = job.mc.post("/v1/project/push/finish/%s" % job.transaction_id)
411413
job.server_resp = json.load(resp)
412414
except ClientError as err:
413-
# Log additional metadata on server error 502 or 504
414-
if hasattr(err, "http_error") and err.http_error in (502, 504):
415+
# Log additional metadata on server error 502 or 504 (extended logging only)
416+
http_code = getattr(err, "http_error", None)
417+
if http_code in (502, 504):
415418
job.mp.log.error(
416-
f"Push failed with HTTP error {err.http_error}. "
419+
f"Push failed with HTTP error {http_code}. "
417420
f"Upload details: {len(job.upload_queue_items)} file chunks, total size {job.total_size} bytes."
418421
)
422+
job.mp.log.error("Files:")
423+
for f in job.changes.get("added", []) + job.changes.get("updated", []):
424+
path = f.get("path", "<unknown>")
425+
size = f.get("size", "?")
426+
if "diff" in f:
427+
diff_info = f.get("diff", {})
428+
diff_size = diff_info.get("size", "?")
429+
# best-effort: number of geodiff changes (if available)
430+
changes_cnt = _geodiff_changes_count(job.mp, diff_info.get("path"))
431+
if changes_cnt is None:
432+
job.mp.log.error(f" - {path}, size={size}, diff_size={diff_size}, changes=n/a")
433+
else:
434+
job.mp.log.error(f" - {path}, size={size}, diff_size={diff_size}, changes={changes_cnt}")
435+
else:
436+
job.mp.log.error(f" - {path}, size={size}")
437+
419438
# server returns various error messages with filename or something generic
420439
# it would be better if it returned list of failed files (and reasons) whenever possible
421440
job.mp.log.error("--- push finish failed! " + str(err))
@@ -428,6 +447,7 @@ def push_project_finalize(job: UploadJob):
428447
job.mp.log.info("cancel response: " + resp_cancel.msg)
429448
except ClientError as err2:
430449
job.mp.log.info("cancel response: " + str(err2))
450+
cleanup_tmp_dir(job.mp, job.tmp_dir) # delete our temporary dir and all its content
431451
raise err
432452

433453
job.mp.update_metadata(job.server_resp)
@@ -436,16 +456,35 @@ def push_project_finalize(job: UploadJob):
436456
except Exception as e:
437457
job.mp.log.error("Failed to apply push changes: " + str(e))
438458
job.mp.log.info("--- push aborted")
459+
cleanup_tmp_dir(job.mp, job.tmp_dir) # delete our temporary dir and all its content
439460
raise ClientError("Failed to apply push changes: " + str(e))
440461

441-
job.tmp_dir.cleanup() # delete our temporary dir and all its content
462+
cleanup_tmp_dir(job.mp, job.tmp_dir) # delete our temporary dir and all its content
442463
job.mc.upload_chunks_cache.clear() # clear the upload chunks cache
443-
444464
remove_diff_files(job)
445465

446466
job.mp.log.info("--- push finished - new project version " + job.server_resp["version"])
447467

448468

469+
def _geodiff_changes_count(mp: MerginProject, diff_rel_path: str):
470+
"""
471+
Best-effort: return number of changes in the .gpkg diff (int) or None.
472+
Never raises – diagnostics/logging must not fail.
473+
"""
474+
475+
diff_abs = mp.fpath_meta(diff_rel_path)
476+
try:
477+
return pygeodiff.GeoDiff().changes_count(diff_abs)
478+
except (
479+
pygeodiff.GeoDiffLibError,
480+
pygeodiff.GeoDiffLibConflictError,
481+
pygeodiff.GeoDiffLibUnsupportedChangeError,
482+
pygeodiff.GeoDiffLibVersionError,
483+
FileNotFoundError,
484+
):
485+
return None
486+
487+
449488
def push_project_cancel(job: UploadJob):
450489
"""
451490
To be called (from main thread) to cancel a job that has uploads in progress.
@@ -466,7 +505,9 @@ def push_project_cancel(job: UploadJob):
466505
job.server_resp = resp_cancel.msg
467506
except ClientError as err:
468507
job.mp.log.error("--- push cancelling failed! " + str(err))
508+
cleanup_tmp_dir(job.mp, job.tmp_dir)
469509
raise err
510+
cleanup_tmp_dir(job.mp, job.tmp_dir) # delete our temporary dir and all its content
470511
job.mp.log.info("--- push cancel response: " + str(job.server_resp))
471512

472513

mergin/test/test_client.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import glob
1414
from unittest.mock import patch, Mock
1515

16+
from unittest.mock import patch, Mock
17+
1618
from .. import InvalidProject
1719
from ..client import (
1820
MerginClient,
@@ -2328,6 +2330,7 @@ def test_clean_diff_files(mc):
23282330
shutil.copy(mp.fpath("inserted_1_A.gpkg"), mp.fpath(f_updated))
23292331
mc.push_project(project_dir)
23302332

2333+
# Get the directory path
23312334
directory = os.path.split(mp.fpath_meta("inserted_1_A.gpkg"))[0]
23322335
diff_files = [f for f in os.listdir(directory) if "-diff-" in f]
23332336

@@ -3235,3 +3238,19 @@ def test_push_file_limits(mc):
32353238
with patch("mergin.local_changes.MAX_UPLOAD_MEDIA_SIZE", 1):
32363239
with pytest.raises(ClientError, match=f"Some files exceeded maximum upload size. Files: test.txt."):
32373240
mc.push_project(project_dir)
3241+
3242+
3243+
def test_server_type(mc):
3244+
"""Test mc.server_type() method"""
3245+
# success
3246+
assert mc.server_type() == ServerType.SAAS
3247+
mc._server_type = None
3248+
with patch("mergin.client.MerginClient.get") as mock_client_get:
3249+
# 404
3250+
mock_client_get.side_effect = ClientError(detail="Not found", http_error=404)
3251+
assert mc.server_type() == ServerType.OLD
3252+
mc._server_type = None
3253+
# 503
3254+
mock_client_get.side_effect = ClientError(detail="Service unavailable", http_error=503)
3255+
with pytest.raises(ClientError, match="Service unavailable"):
3256+
mc.server_type()

mergin/test/test_push_logging.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from types import SimpleNamespace
2+
from pathlib import Path
3+
import logging
4+
import tempfile
5+
from unittest.mock import MagicMock
6+
import pytest
7+
8+
from pygeodiff import GeoDiff
9+
from mergin.client_push import push_project_finalize, UploadJob
10+
from mergin.common import ClientError
11+
from mergin.merginproject import MerginProject
12+
from mergin.client import MerginClient
13+
14+
15+
@pytest.mark.parametrize("status_code", [502, 504])
16+
def test_push_finalize_logs_on_5xx_real_diff(caplog, status_code, tmp_path):
17+
# test data
18+
data_dir = Path(__file__).resolve().parent / "test_data"
19+
base = data_dir / "base.gpkg"
20+
modified = data_dir / "inserted_1_A.gpkg"
21+
assert base.exists() and modified.exists()
22+
23+
# real MerginProject in temp dir
24+
proj_dir = tmp_path / "proj"
25+
meta_dir = proj_dir / ".mergin"
26+
meta_dir.mkdir(parents=True)
27+
mp = MerginProject(str(proj_dir))
28+
29+
# route MP logs into pytest caplog
30+
logger = logging.getLogger("mergin_test")
31+
logger.setLevel(logging.DEBUG)
32+
logger.propagate = True
33+
caplog.set_level(logging.ERROR, logger="mergin_test")
34+
mp.log = logger
35+
36+
# generate a real diff into .mergin/
37+
diff_path = meta_dir / "base_to_inserted_1_A.diff"
38+
GeoDiff().create_changeset(str(base), str(modified), str(diff_path))
39+
diff_rel = diff_path.name
40+
diff_size = diff_path.stat().st_size
41+
file_size = modified.stat().st_size
42+
43+
# mock MerginClient: only patch post(); simulate 5xx on finish
44+
tx = "tx-1"
45+
46+
def mc_post(url, *args, **kwargs):
47+
if url.endswith(f"/v1/project/push/finish/{tx}"):
48+
err = ClientError("Gateway error")
49+
setattr(err, "http_error", status_code) # emulate HTTP code on the exception
50+
raise err
51+
if url.endswith(f"/v1/project/push/cancel/{tx}"):
52+
return SimpleNamespace(msg="cancelled")
53+
return SimpleNamespace(msg="ok")
54+
55+
mc = MagicMock(spec=MerginClient)
56+
mc.post.side_effect = mc_post
57+
58+
tmp_dir = tempfile.TemporaryDirectory(prefix="python-api-client-test-")
59+
60+
# build a real UploadJob that references the diff/file sizes
61+
job = UploadJob(
62+
project_path="u/p",
63+
changes={
64+
"added": [],
65+
"updated": [
66+
{
67+
"path": modified.name,
68+
"size": file_size,
69+
"diff": {"path": diff_rel, "size": diff_size},
70+
"chunks": [1],
71+
}
72+
],
73+
"removed": [],
74+
},
75+
transaction_id=tx,
76+
mp=mp,
77+
mc=mc,
78+
tmp_dir=tmp_dir,
79+
)
80+
81+
job.total_size = 1234
82+
job.transferred_size = 1234
83+
job.upload_queue_items = [1]
84+
job.executor = SimpleNamespace(shutdown=lambda wait=True: None)
85+
job.futures = [SimpleNamespace(done=lambda: True, exception=lambda: None, running=lambda: False)]
86+
job.server_resp = {"version": "n/a"}
87+
88+
with pytest.raises(ClientError):
89+
push_project_finalize(job)
90+
91+
text = caplog.text
92+
assert f"Push failed with HTTP error {status_code}" in text
93+
assert "Upload details:" in text
94+
assert "Files:" in text
95+
assert modified.name in text
96+
assert f"size={file_size}" in text
97+
assert f"diff_size={diff_size}" in text
98+
assert ("changes=" in text) or ("changes=n/a" in text)

mergin/utils.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import sqlite3
77
from datetime import datetime
88
from pathlib import Path
9+
import tempfile
910
from .common import ClientError
1011
from typing import ByteString
1112

@@ -202,7 +203,7 @@ def conflicted_copy_file_name(path, user, version):
202203
head, tail = os.path.split(os.path.normpath(path))
203204
ext = "".join(Path(tail).suffixes)
204205
file_name = tail.replace(ext, "")
205-
# in case of QGIS project files we have to add "~" (tilde) to suffix
206+
# in case of QGIS project files we TemporaryDirectoryhave to add "~" (tilde) to suffix
206207
# to avoid having several QGIS project files inside Mergin Maps project.
207208
# See https://github.com/lutraconsulting/qgis-mergin-plugin/issues/382
208209
# for more details
@@ -307,3 +308,17 @@ def get_data_checksum(data: ByteString) -> str:
307308
checksum = hashlib.sha1()
308309
checksum.update(data)
309310
return checksum.hexdigest()
311+
312+
313+
def cleanup_tmp_dir(mp, tmp_dir: tempfile.TemporaryDirectory):
314+
"""
315+
Remove temporary from tempfile.TemporaryDirectory instance
316+
This is needed beacause ignore_clanup_errors is not accepted under < Python 3.10
317+
"""
318+
319+
try:
320+
tmp_dir.cleanup()
321+
except PermissionError:
322+
mp.log.warning(f"Permission error during tmp dir cleanup: {tmp_dir.name}")
323+
except Exception as e:
324+
mp.log.error(f"Error during tmp dir cleanup: {tmp_dir.name}: {e}")

scripts/update_version.py

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

55

66
def replace_in_file(filepath, regex, sub):
7-
with open(filepath, 'r') as f:
7+
with open(filepath, "r") as f:
88
content = f.read()
99

1010
content_new = re.sub(regex, sub, content, flags=re.M)
@@ -15,14 +15,14 @@ def replace_in_file(filepath, regex, sub):
1515

1616
dir_path = os.path.dirname(os.path.realpath(__file__))
1717
parser = argparse.ArgumentParser()
18-
parser.add_argument('--version', help='version to replace')
18+
parser.add_argument("--version", help="version to replace")
1919
args = parser.parse_args()
2020
ver = args.version
2121
print("using version " + ver)
2222

2323
about_file = os.path.join(dir_path, os.pardir, "mergin", "version.py")
2424
print("patching " + about_file)
25-
replace_in_file(about_file, "__version__\s=\s\".*", "__version__ = \"" + ver + "\"")
25+
replace_in_file(about_file, '__version__\s=\s".*', '__version__ = "' + ver + '"')
2626

2727
setup_file = os.path.join(dir_path, os.pardir, "setup.py")
2828
print("patching " + setup_file)

0 commit comments

Comments
 (0)