Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
13b8ccb
Initial dirty version of push v2
MarcelGeo Aug 8, 2025
216c3bd
tweak _do_upload running outside of class to prevent threads mix
MarcelGeo Aug 8, 2025
57c6bf4
add local changes
MarcelGeo Aug 14, 2025
88f00ea
make tests runnable with checking current version - Please update you…
MarcelGeo Aug 14, 2025
a2cfe62
Cleanup and fix getting diff from dictionary to be more safe
MarcelGeo Aug 14, 2025
054a818
fixes for missing schema
MarcelGeo Aug 15, 2025
7ba0971
cleanup
MarcelGeo Aug 15, 2025
42c34a2
Retry sync job using
MarcelGeo Aug 19, 2025
0128a06
Address comstic comments from @varmar05 and some from @wonder-sk
MarcelGeo Sep 3, 2025
df88376
fix uploading 0 size files
MarcelGeo Sep 3, 2025
28aa757
handle project info directly from v2 projects versions
MarcelGeo Sep 3, 2025
efd7ed6
Add unit tests and tunning of retry mechanism
MarcelGeo Sep 3, 2025
29292da
Update test calling count measurement
MarcelGeo Sep 5, 2025
c6dafca
add test also for v1
MarcelGeo Sep 5, 2025
e54bde8
Drop versions and permissions check
MarcelGeo Sep 9, 2025
57e854b
get rid of project info from editor
MarcelGeo Sep 9, 2025
747ea46
black
MarcelGeo Sep 9, 2025
39cea8b
Merge remote-tracking branch 'origin/master' into push-v2-integration
MarcelGeo Sep 10, 2025
b6cfa3e
temp_dir fix
MarcelGeo Sep 10, 2025
a045095
add max of 100 files
MarcelGeo Sep 11, 2025
50b6224
black
MarcelGeo Sep 11, 2025
0fc2a9e
Find just one file over limit in transaction
MarcelGeo Sep 12, 2025
607e148
black swan
MarcelGeo Sep 12, 2025
77b82e9
Close connection to make runnable test in new python and skip test fo…
MarcelGeo Sep 12, 2025
85387a5
Merge remote-tracking branch 'origin/push-v2-integration' into change…
MarcelGeo Sep 15, 2025
3041e75
Move validation to post_init
MarcelGeo Sep 16, 2025
350aedc
get rid of glob.glob
MarcelGeo Sep 16, 2025
e36ae8d
address comments without progress bar @wonder-sk
MarcelGeo Sep 17, 2025
0119b7d
First version for sync progress bar
MarcelGeo Sep 18, 2025
cf232a6
Merge remote-tracking branch 'origin/push-v2-integration' into change…
MarcelGeo Sep 18, 2025
7dbed14
fix typo and some docstrings for localProjectChanges
MarcelGeo Sep 18, 2025
92d5340
Merge remote-tracking branch 'origin/push-v2-integration' into change…
MarcelGeo Sep 18, 2025
0b585ee
move file size to consts
MarcelGeo Sep 18, 2025
2e38b84
create generator yielding function for sync project
MarcelGeo Sep 18, 2025
3d00631
Introduce generator for getting progress
MarcelGeo Sep 18, 2025
4fc877c
Merge remote-tracking branch 'origin/push-v2-integration' into change…
MarcelGeo Sep 18, 2025
7440f3f
Update docstring for sync method
MarcelGeo Sep 18, 2025
cac700e
Merge pull request #274 from MerginMaps/changes_limits
MarcelGeo Sep 24, 2025
06e815e
Reloading MerginProject instance to get fresh changes
MarcelGeo Sep 25, 2025
9486830
Cosmetic upgrades v1
MarcelGeo Sep 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/autotests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:

- uses: actions/setup-python@v2
with:
python-version: '3.x'
python-version: '3.8'

- name: Install python package dependencies
run: |
Expand Down
52 changes: 52 additions & 0 deletions mergin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,58 @@ def pull(ctx):
_print_unhandled_exception()


@cli.command()
@click.pass_context
def sync(ctx):
"""Synchronize the project. Pull latest project version from the server and push split changes."""
mc = ctx.obj["client"]
if mc is None:
return
directory = os.getcwd()
current_job = None
current_bar = None
try:
# Iterate over the generator to get updates
for size_change, job in mc.sync_project_generator(directory):
# Check if this is a new job (a new push operation)
if job and job != current_job:
# If a previous bar exists, close it
if current_bar:
current_bar.finish()

# A new push job has started. Initialize a new progress bar.
click.echo(f"\nStarting upload")
current_job = job

# The length of the progress bar should be the total size of the job
# You'll need to get this from your job object (e.g., job.total_size)
total_size = job.total_size
current_bar = click.progressbar(
length=total_size,
label=f"Uploading project",
)

# Update the current progress bar with the size increment
current_bar.update(size_change)

# After the loop finishes, make sure to close the final progress bar
if current_bar:
current_bar.finish()
click.secho("\nProject synced successfully", fg="green")

except InvalidProject as e:
click.secho("Invalid project directory ({})".format(str(e)), fg="red")
except ClientError as e:
click.secho("Error: " + str(e), fg="red")
return
except KeyboardInterrupt:
click.secho("Cancelling...")
if current_job:
push_project_cancel(current_job)
except Exception as e:
_print_unhandled_exception()


@cli.command()
@click.argument("version")
@click.pass_context
Expand Down
89 changes: 83 additions & 6 deletions mergin/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@
import re
import typing
import warnings
from time import sleep

from .common import (
SYNC_ATTEMPT_WAIT,
SYNC_ATTEMPTS,
SYNC_CALLBACK_WAIT,
ClientError,
LoginError,
WorkspaceRole,
Expand All @@ -38,7 +42,14 @@
download_diffs_finalize,
)
from .client_pull import pull_project_async, pull_project_wait, pull_project_finalize
from .client_push import push_project_async, push_project_wait, push_project_finalize
from .client_push import (
get_push_changes_batch,
push_project_async,
push_project_is_running,
push_project_wait,
push_project_finalize,
UploadChunksCache,
)
from .utils import DateTimeEncoder, get_versions_with_file_changes, int_version, is_version_acceptable
from .version import __version__

Expand Down Expand Up @@ -119,6 +130,8 @@ def __init__(
self._user_info = None
self._server_type = None
self._server_version = None
self._server_features = {}
self.upload_chunks_cache = UploadChunksCache()
self.client_version = "Python-client/" + __version__
if plugin_version is not None: # this could be e.g. "Plugin/2020.1 QGIS/3.14"
self.client_version += " " + plugin_version
Expand Down Expand Up @@ -378,8 +391,7 @@ def server_type(self):
"""
if not self._server_type:
try:
resp = self.get("/config", validate_auth=False)
config = json.load(resp)
config = self.server_config()
if config["server_type"] == "ce":
self._server_type = ServerType.CE
elif config["server_type"] == "ee":
Expand All @@ -400,14 +412,26 @@ def server_version(self):
"""
if self._server_version is None:
try:
resp = self.get("/config", validate_auth=False)
config = json.load(resp)
config = self.server_config()
self._server_version = config["version"]
except (ClientError, KeyError):
self._server_version = ""

return self._server_version

def server_features(self):
"""
Returns feature flags of the server.
"""
if self._server_features:
return self._server_features
config = self.server_config()
self._server_features = {
"v2_push_enabled": config.get("v2_push_enabled", False),
"v2_pull_enabled": config.get("v2_pull_enabled", False),
}
return self._server_features

def workspaces_list(self):
"""
Find all available workspaces
Expand Down Expand Up @@ -798,7 +822,7 @@ def download_project(self, project_path, directory, version=None):
def user_info(self):
server_type = self.server_type()
if server_type == ServerType.OLD:
resp = self.get("/v1/user/" + self.username())
resp = self.get(f"/v1/user/{self.username()}")
else:
resp = self.get("/v1/user/profile")
return json.load(resp)
Expand Down Expand Up @@ -1487,3 +1511,56 @@ def create_invitation(self, workspace_id: int, email: str, workspace_role: Works
params = {"email": email, "role": workspace_role.value}
ws_inv = self.post(f"v2/workspaces/{workspace_id}/invitations", params, json_headers)
return json.load(ws_inv)

def sync_project_generator(self, project_directory):
"""
Syncs project by loop with these steps:
1. Pull server version
2. Get local changes
3. Push first change batch
Repeat if there are more local changes.

:param project_directory: Project's directory
"""
mp = MerginProject(project_directory)
has_changes = True
server_conflict_attempts = 0
while has_changes:
self.pull_project(project_directory)
try:
job = push_project_async(self, project_directory)
if not job:
break
# waiting for progress
last_size = 0
while push_project_is_running(job):
sleep(SYNC_CALLBACK_WAIT)
current_size = job.transferred_size
yield (current_size - last_size, job) # Yields the size change and the job object
last_size = current_size
push_project_finalize(job)
_, has_changes = get_push_changes_batch(self, project_directory)
server_conflict_attempts = 0
except ClientError as e:
if e.is_retryable_sync() and server_conflict_attempts < SYNC_ATTEMPTS - 1:
# retry on conflict, e.g. when server has changes that we do not have yet
mp.log.info(
f"Restarting sync process (conflict on server) - {server_conflict_attempts + 1}/{SYNC_ATTEMPTS}"
)
server_conflict_attempts += 1
sleep(SYNC_ATTEMPT_WAIT)
continue
raise e

def sync_project(self, project_directory):
"""
Syncs project by pulling server changes and pushing local changes. There is intorduced retry mechanism
for handling server conflicts (when server has changes that we do not have yet or somebody else is syncing).
See description of _sync_project_generator().

:param project_directory: Project's directory
"""
# walk through the generator to perform the sync
# in this method we do not yield anything to the caller
for _ in self.sync_project_generator(project_directory):
pass
6 changes: 3 additions & 3 deletions mergin/client_pull.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def download_project_async(mc, project_path, directory, project_version=None):
mp.log.info("--- version: " + mc.user_agent_info())
mp.log.info(f"--- start download {project_path}")

tmp_dir = tempfile.TemporaryDirectory(prefix="python-api-client-", ignore_cleanup_errors=True, delete=True)
tmp_dir = tempfile.TemporaryDirectory(prefix="python-api-client-")

try:
# check whether we download the latest version or not
Expand Down Expand Up @@ -384,7 +384,7 @@ def dump(self):
print("--- END ---")


def pull_project_async(mc, directory):
def pull_project_async(mc, directory) -> PullJob:
"""
Starts project pull in background and returns handle to the pending job.
Using that object it is possible to watch progress or cancel the ongoing work.
Expand Down Expand Up @@ -424,7 +424,7 @@ def pull_project_async(mc, directory):
# then we just download the whole file
_pulling_file_with_diffs = lambda f: "diffs" in f and len(f["diffs"]) != 0

tmp_dir = tempfile.TemporaryDirectory(prefix="mm-pull-", ignore_cleanup_errors=True, delete=True)
tmp_dir = tempfile.TemporaryDirectory(prefix="mm-pull-")
pull_changes = mp.get_pull_changes(server_info["files"])
mp.log.debug("pull changes:\n" + pprint.pformat(pull_changes))
fetch_files = []
Expand Down
Loading