Skip to content

Commit 17c8ee0

Browse files
authored
extend 502/504 failure logging (#268)
* feat(push): extend 502/504 failure logging with upload summary and file list; include gpkg diff size and change count via safe helper; add real-diff test; no change to success path * eddited after rewiew: client_push.py removed unnecessary code, specified exceptions, added type hints test_push_logging.py changeed to use real job * changes based on review: try-except should catch more geodiff errors and using real MerginProject in test_push_logging.py * black
1 parent fe833f1 commit 17c8ee0

File tree

4 files changed

+168
-31
lines changed

4 files changed

+168
-31
lines changed

mergin/client_push.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
import tempfile
1616
import concurrent.futures
1717
import os
18+
from pygeodiff import (
19+
GeoDiff,
20+
GeoDiffLibError,
21+
GeoDiffLibConflictError,
22+
GeoDiffLibUnsupportedChangeError,
23+
GeoDiffLibVersionError,
24+
)
1825

1926
from .common import UPLOAD_CHUNK_SIZE, ClientError
2027
from .merginproject import MerginProject
@@ -268,14 +275,31 @@ def push_project_finalize(job):
268275
resp = job.mc.post("/v1/project/push/finish/%s" % job.transaction_id)
269276
job.server_resp = json.load(resp)
270277
except ClientError as err:
271-
# Log additional metadata on server error 502 or 504
272-
if hasattr(err, "http_error") and err.http_error in (502, 504):
278+
# Log additional metadata on server error 502 or 504 (extended logging only)
279+
http_code = getattr(err, "http_error", None)
280+
if http_code in (502, 504):
273281
job.mp.log.error(
274-
f"Push failed with HTTP error {err.http_error}. "
282+
f"Push failed with HTTP error {http_code}. "
275283
f"Upload details: {len(job.upload_queue_items)} file chunks, total size {job.total_size} bytes."
276284
)
285+
job.mp.log.error("Files:")
286+
for f in job.changes.get("added", []) + job.changes.get("updated", []):
287+
path = f.get("path", "<unknown>")
288+
size = f.get("size", "?")
289+
if "diff" in f:
290+
diff_info = f.get("diff", {})
291+
diff_size = diff_info.get("size", "?")
292+
# best-effort: number of geodiff changes (if available)
293+
changes_cnt = _geodiff_changes_count(job.mp, diff_info.get("path"))
294+
if changes_cnt is None:
295+
job.mp.log.error(f" - {path}, size={size}, diff_size={diff_size}, changes=n/a")
296+
else:
297+
job.mp.log.error(f" - {path}, size={size}, diff_size={diff_size}, changes={changes_cnt}")
298+
else:
299+
job.mp.log.error(f" - {path}, size={size}")
300+
277301
# server returns various error messages with filename or something generic
278-
# it would be better if it returned list of failed files (and reasons) whenever possible
302+
# it would be better if it returned list of failed files (and reasons) whenever possible
279303
job.mp.log.error("--- push finish failed! " + str(err))
280304

281305
# if push finish fails, the transaction is not killed, so we
@@ -303,6 +327,25 @@ def push_project_finalize(job):
303327
job.mp.log.info("--- push finished - new project version " + job.server_resp["version"])
304328

305329

330+
def _geodiff_changes_count(mp: MerginProject, diff_rel_path: str):
331+
"""
332+
Best-effort: return number of changes in the .gpkg diff (int) or None.
333+
Never raises – diagnostics/logging must not fail.
334+
"""
335+
336+
diff_abs = mp.fpath_meta(diff_rel_path)
337+
try:
338+
return GeoDiff().changes_count(diff_abs)
339+
except (
340+
GeoDiffLibError,
341+
GeoDiffLibConflictError,
342+
GeoDiffLibUnsupportedChangeError,
343+
GeoDiffLibVersionError,
344+
FileNotFoundError,
345+
):
346+
return None
347+
348+
306349
def push_project_cancel(job):
307350
"""
308351
To be called (from main thread) to cancel a job that has uploads in progress.

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)

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)

setup.py

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,31 @@
44
from setuptools import setup, find_packages
55

66
setup(
7-
name='mergin-client',
8-
version='0.10.5',
9-
url='https://github.com/MerginMaps/python-api-client',
10-
license='MIT',
11-
author='Lutra Consulting Ltd.',
12-
author_email='info@merginmaps.com',
13-
description='Mergin Maps utils and client',
14-
long_description='Mergin Maps utils and client',
15-
7+
name="mergin-client",
8+
version="0.10.5",
9+
url="https://github.com/MerginMaps/python-api-client",
10+
license="MIT",
11+
author="Lutra Consulting Ltd.",
12+
author_email="info@merginmaps.com",
13+
description="Mergin Maps utils and client",
14+
long_description="Mergin Maps utils and client",
1615
packages=find_packages(),
17-
18-
platforms='any',
16+
platforms="any",
1917
install_requires=[
20-
'python-dateutil==2.8.2',
21-
'pygeodiff==2.0.4',
22-
'pytz==2022.1',
23-
'click==8.1.3',
18+
"python-dateutil==2.8.2",
19+
"pygeodiff==2.0.4",
20+
"pytz==2022.1",
21+
"click==8.1.3",
2422
],
25-
2623
entry_points={
27-
'console_scripts': ['mergin=mergin.cli:cli'],
24+
"console_scripts": ["mergin=mergin.cli:cli"],
2825
},
29-
3026
classifiers=[
31-
'Development Status :: 5 - Production/Stable',
32-
'Intended Audience :: Developers',
33-
'License :: OSI Approved :: MIT License',
34-
'Operating System :: OS Independent',
35-
'Programming Language :: Python :: 3'
27+
"Development Status :: 5 - Production/Stable",
28+
"Intended Audience :: Developers",
29+
"License :: OSI Approved :: MIT License",
30+
"Operating System :: OS Independent",
31+
"Programming Language :: Python :: 3",
3632
],
37-
package_data={'mergin': ['cert.pem']}
33+
package_data={"mergin": ["cert.pem"]},
3834
)

0 commit comments

Comments
 (0)