Skip to content

Commit a8d81ca

Browse files
authored
Merge pull request #325 from MerginMaps/develop
Sync improvements
2 parents de6f50c + 34bef79 commit a8d81ca

10 files changed

Lines changed: 584 additions & 28 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ deps
1414
venv
1515
debug.py
1616
.vscode/
17+
.python-version

mergin/cli.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
download_project_cancel,
3030
download_file_async,
3131
download_file_finalize,
32+
download_project_file_async,
3233
download_project_finalize,
3334
download_project_is_running,
3435
)
@@ -248,16 +249,20 @@ def list_projects(ctx, name, namespace, order_params):
248249
@click.argument("project")
249250
@click.argument("directory", type=click.Path(), required=False)
250251
@click.option("--version", default=None, help="Version of project to download")
252+
@click.option("--include", multiple=True, help="Only download files matching this pattern, e.g. '*.gpkg'")
253+
@click.option("--exclude", multiple=True, help="Skip files matching this pattern, e.g. 'media/*'")
251254
@click.pass_context
252-
def download(ctx, project, directory, version):
255+
def download(ctx, project, directory, version, include, exclude):
253256
"""Download last version of mergin project."""
254257
mc = ctx.obj["client"]
255258
if mc is None:
256259
return
260+
if include and exclude:
261+
raise click.UsageError("--include and --exclude cannot be used together")
257262
directory = directory or os.path.basename(project)
258263
click.echo("Downloading into {}".format(directory))
259264
try:
260-
job = download_project_async(mc, project, directory, version)
265+
job = download_project_async(mc, project, directory, version, include=include, exclude=exclude)
261266
with click.progressbar(length=job.total_size) as bar:
262267
last_transferred_size = 0
263268
while download_project_is_running(job):
@@ -335,17 +340,33 @@ def share(ctx, project):
335340
@click.argument("filepath")
336341
@click.argument("output")
337342
@click.option("--version", help="Project version tag, for example 'v3'")
343+
@click.option(
344+
"--project",
345+
help="Full project name ('<workspace>/<project>') to download the file directly from the server. "
346+
"If not given, the current directory is used and must be an existing checked out project.",
347+
)
338348
@click.pass_context
339-
def download_file(ctx, filepath, output, version):
349+
def download_file(ctx, filepath, output, version, project):
340350
"""
341-
Download project file at specified version. `project` needs to be a combination of namespace/project.
342-
If no version is given, the latest will be fetched.
351+
Download project file at specified version. If no version is given, the latest will be fetched.
343352
"""
344353
mc = ctx.obj["client"]
345354
if mc is None:
346355
return
347356
try:
348-
job = download_file_async(mc, os.getcwd(), filepath, output, version)
357+
if project is not None:
358+
job = download_project_file_async(mc, project, filepath, output, version)
359+
else:
360+
try:
361+
MerginProject(os.getcwd()).project_full_name()
362+
except InvalidProject:
363+
click.secho(
364+
"Current directory is not a Mergin Maps project. Run this command from within a "
365+
"checked out project directory, or pass --project <workspace>/<project>.",
366+
fg="red",
367+
)
368+
return
369+
job = download_file_async(mc, os.getcwd(), filepath, output, version)
349370
with click.progressbar(length=job.total_size) as bar:
350371
last_transferred_size = 0
351372
while download_project_is_running(job):

mergin/client.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
download_file_async,
4747
download_files_async,
4848
download_files_finalize,
49+
download_project_file_async,
4950
download_diffs_async,
5051
download_project_finalize,
5152
download_project_wait,
@@ -63,6 +64,7 @@
6364
from .utils import DateTimeEncoder, get_versions_with_file_changes, int_version, is_version_acceptable
6465
from .utils import (
6566
DateTimeEncoder,
67+
filter_files,
6668
get_versions_with_file_changes,
6769
int_version,
6870
is_version_acceptable,
@@ -902,7 +904,7 @@ def project_versions(self, project_path, since=1, to=None):
902904
filtered_versions = list(filter(lambda v: (num_since <= int_version(v["name"]) <= num_to), versions))
903905
return filtered_versions
904906

905-
def download_project(self, project_path, directory, version=None):
907+
def download_project(self, project_path, directory, version=None, include=None, exclude=None):
906908
"""
907909
Download project into given directory. If version is not specified, latest version is downloaded
908910
@@ -914,8 +916,16 @@ def download_project(self, project_path, directory, version=None):
914916
915917
:param version: Project version to download, e.g. v42
916918
:type version: String
919+
920+
:param include: Optional list of glob patterns (matched against each file's project path, e.g.
921+
"media/*" or "*.gpkg") - only matching files are downloaded.
922+
:type include: List[String]
923+
924+
:param exclude: Optional list of glob patterns - matching files are skipped. Mutually exclusive
925+
with include.
926+
:type exclude: List[String]
917927
"""
918-
job = download_project_async(self, project_path, directory, version)
928+
job = download_project_async(self, project_path, directory, version, include=include, exclude=exclude)
919929
download_project_wait(job)
920930
download_project_finalize(job)
921931

@@ -1158,6 +1168,10 @@ def project_status(self, directory):
11581168
server_info = self.project_info(mp.project_full_name(), since=mp.version())
11591169

11601170
pull_changes = mp.get_pull_changes(server_info.get("files", []), server_info.get("version"))
1171+
# on a sparse checkout, don't report excluded files as pending server changes -
1172+
# they were never meant to be pulled in the first place
1173+
file_filter = mp.file_filter()
1174+
pull_changes = {change_type: filter_files(files, **file_filter) for change_type, files in pull_changes.items()}
11611175

11621176
push_changes = mp.get_push_changes()
11631177
push_changes_summary = mp.get_list_of_push_changes(push_changes)
@@ -1212,6 +1226,24 @@ def download_file(self, project_dir, file_path, output_filename, version=None):
12121226
pull_project_wait(job)
12131227
download_file_finalize(job)
12141228

1229+
def download_project_file(self, project_path, file_path, output_filename, version=None):
1230+
"""
1231+
Download a single project file at specified version directly from the server, without
1232+
needing an existing local project checkout.
1233+
1234+
:param project_path: full project name ("<workspace>/<project>")
1235+
:type project_path: String
1236+
:param file_path: relative path of file to download in the project directory
1237+
:type file_path: String
1238+
:param output_filename: full destination path for saving the downloaded file
1239+
:type output_filename: String
1240+
:param version: optional version tag for downloaded file
1241+
:type version: String
1242+
"""
1243+
job = download_project_file_async(self, project_path, file_path, output_filename, version=version)
1244+
pull_project_wait(job)
1245+
download_file_finalize(job)
1246+
12151247
def get_file_diff(self, project_dir, file_path, output_diff, version_from, version_to):
12161248
"""Create concatenated diff for project file diffs between versions version_from and version_to.
12171249

mergin/client_pull.py

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
from .common import CHUNK_SIZE, ClientError, DeltaChangeType, PullActionType
2626
from .models import ProjectDelta, ProjectDeltaChange, PullAction
2727
from .merginproject import MerginProject
28-
from .utils import cleanup_tmp_dir, save_to_file
29-
from typing import List, Optional
28+
from .utils import cleanup_tmp_dir, filter_files, is_path_in_scope, save_to_file, is_versioned_file
29+
from typing import List, Optional, Union
3030

3131
# status = download_project_async(...)
3232
#
@@ -54,7 +54,7 @@ def __init__(
5454
update_tasks,
5555
download_queue_items,
5656
tmp_dir: tempfile.TemporaryDirectory,
57-
mp,
57+
mp: Union[MerginProject, "DownloadScratchContext"],
5858
project_info,
5959
):
6060
self.project_path = project_path
@@ -64,7 +64,7 @@ def __init__(
6464
self.update_tasks = update_tasks
6565
self.download_queue_items = download_queue_items
6666
self.tmp_dir = tmp_dir
67-
self.mp = mp # MerginProject instance
67+
self.mp = mp
6868
self.is_cancelled = False
6969
self.project_info = project_info # parsed JSON with project info returned from the server
7070
self.failure_log_file = None # log file, copied from the project directory if download fails
@@ -80,6 +80,25 @@ def dump(self):
8080
print("--- END ---")
8181

8282

83+
class DownloadScratchContext:
84+
"""
85+
Minimal stand-in for MerginProject used when downloading a file directly by
86+
project name ("<workspace>/<project>") without an existing local project checkout.
87+
88+
Provides only what the shared download job code actually needs from MerginProject.
89+
"""
90+
91+
def __init__(self, mc, cache_dir: str):
92+
self.log = mc.log
93+
self.cache_dir = cache_dir
94+
# only used by _cleanup_failed_download() to look for a log file
95+
self.dir = cache_dir
96+
97+
def remove_logging_handler(self):
98+
# no-op: self.log is mc's shared logger, not owned by this throwaway context
99+
pass
100+
101+
83102
class DownloadQueueItem:
84103
"""
85104
a piece of data from a project that should be downloaded - it can be either a chunk or it can be a diff.
@@ -242,12 +261,18 @@ def _cleanup_failed_download(mergin_project: MerginProject = None):
242261
return dest_path
243262

244263

245-
def download_project_async(mc, project_path, directory, project_version=None):
264+
def download_project_async(mc, project_path, directory, project_version=None, include=None, exclude=None):
246265
"""
247266
Starts project download in background and returns handle to the pending project download.
248267
Using that object it is possible to watch progress or cancel the ongoing work.
268+
269+
`include`/`exclude` are optional lists of glob patterns (matched against each file's project
270+
path, e.g. "media/*" or "*.gpkg") to only download a subset of the project's files. They are
271+
mutually exclusive.
249272
"""
250273

274+
if include and exclude:
275+
raise ClientError("Cannot use both include and exclude filters at the same time")
251276
if "/" not in project_path:
252277
raise ClientError("Project name needs to be fully qualified, e.g. <username>/<projectname>")
253278
if os.path.exists(directory):
@@ -276,6 +301,12 @@ def download_project_async(mc, project_path, directory, project_version=None):
276301

277302
mp.log.info(f"got project info. version {version}")
278303

304+
# keep only the files matching the filter (if any)
305+
project_info["files"] = filter_files(project_info["files"], include=include, exclude=exclude)
306+
# persisted once since it must never change again for this checkout
307+
if include or exclude:
308+
mp.write_file_filter({"include": include, "exclude": exclude})
309+
279310
# prepare download
280311
update_tasks = [] # stuff to do at the end of download
281312
for file in project_info["files"]:
@@ -398,7 +429,7 @@ def __init__(
398429
self.download_queue_items = download_queue_items
399430
self.latest_version = latest_version
400431

401-
def apply(self, directory, mp):
432+
def apply(self, directory, mp: Union[MerginProject, "DownloadScratchContext"]):
402433
"""assemble downloaded chunks into a single file"""
403434

404435
if self.destination_file is None:
@@ -411,14 +442,14 @@ def apply(self, directory, mp):
411442
os.makedirs(file_dir, exist_ok=True)
412443

413444
# ignore check if we download not-latest version of gpkg file (possibly reconstructed on server on demand)
414-
check_size = self.latest_version or not mp.is_versioned_file(self.file_path)
445+
check_size = self.latest_version or not is_versioned_file(self.file_path)
415446
# merge chunks together (and delete them afterwards)
416447
file_to_merge = DownloadFile(dest_file_path, self.download_queue_items, check_size)
417448
file_to_merge.from_chunks()
418449

419450
# Make a copy of the file to meta dir only if there is no user-specified path for the file.
420-
# destination_file is None for full project download and takes a meaningful value for a single file download.
421-
if mp.is_versioned_file(self.file_path) and self.destination_file is None:
451+
# destination_file is None for full project download and takes a meaningful value for a single file download
452+
if self.destination_file is None and is_versioned_file(self.file_path):
422453
mp.geodiff.make_copy_sqlite(mp.fpath(self.file_path), mp.fpath_meta(self.file_path))
423454

424455

@@ -525,6 +556,9 @@ def pull_project_async(mc, directory) -> Optional[PullJob]:
525556
mp.log.info("--- pull aborted")
526557
raise
527558

559+
file_filter = mp.file_filter()
560+
delta.changes = [c for c in delta.changes if is_path_in_scope(c.path, **file_filter)]
561+
528562
mp.log.info(f"got project versions: local version {local_version} / server version {server_version}")
529563

530564
if local_version == server_version:
@@ -748,6 +782,9 @@ def pull_project_finalize(job: PullJob):
748782
cleanup_tmp_dir(job.mp, job.tmp_dir) # delete our temporary dir and all its content
749783
raise ClientError("Failed to apply pull actions: " + str(e))
750784

785+
# keep only in-scope files in the metadata we're about to persist
786+
job.project_info["files"] = filter_files(job.project_info["files"], **job.mp.file_filter())
787+
751788
job.mp.update_metadata(job.project_info)
752789

753790
if job.mp.has_unfinished_pull():
@@ -774,6 +811,23 @@ def download_file_finalize(job):
774811
download_files_finalize(job)
775812

776813

814+
def download_project_file_async(mc, project_path: str, file_path: str, output_file: str, version: str = None):
815+
"""
816+
Starts background download of a single project file at specified version, fetched directly
817+
from the server without needing an existing local project checkout.
818+
Returns handle to the pending download.
819+
820+
:param project_path: full project name ("<workspace>/<project>")
821+
:param output_file: destination path for the downloaded file
822+
"""
823+
if not output_file:
824+
raise ClientError("output_file must be provided when downloading a file without a local project checkout")
825+
826+
tmp_dir = tempfile.TemporaryDirectory(prefix="python-api-client-")
827+
mp = DownloadScratchContext(mc, tmp_dir.name)
828+
return _download_files_async(mc, mp, project_path, [file_path], [output_file], version, tmp_dir)
829+
830+
777831
def download_diffs_async(mc, project_directory, file_path, versions):
778832
"""
779833
Starts background download project file diffs for specified versions.
@@ -897,14 +951,29 @@ def download_diffs_finalize(job: PullJob) -> List[str]:
897951

898952

899953
def download_files_async(
900-
mc, project_dir: str, file_paths: typing.List[str], output_paths: typing.List[str], version: str
954+
mc, project_dir: str, file_paths: typing.List[str], output_paths: typing.List[str] = None, version: str = None
901955
):
902956
"""
903957
Starts background download project files at specified version.
904958
Returns handle to the pending download.
959+
960+
`project_dir` must be an existing local project directory.
905961
"""
906962
mp = MerginProject(project_dir)
907963
project_path = mp.project_full_name()
964+
tmp_dir = tempfile.TemporaryDirectory(prefix="python-api-client-")
965+
return _download_files_async(mc, mp, project_path, file_paths, output_paths, version, tmp_dir)
966+
967+
968+
def _download_files_async(
969+
mc,
970+
mp: Union[MerginProject, "DownloadScratchContext"],
971+
project_path: str,
972+
file_paths: typing.List[str],
973+
output_paths: typing.List[str],
974+
version: str,
975+
tmp_dir: tempfile.TemporaryDirectory,
976+
):
908977
ver_info = f"at version {version}" if version is not None else "at latest version"
909978
mp.log.info(f"Getting [{', '.join(file_paths)}] {ver_info}")
910979
latest_proj_info = mc.project_info(project_path)
@@ -914,9 +983,6 @@ def download_files_async(
914983
project_info = latest_proj_info
915984
mp.log.info(f"Got project info. version {project_info['version']}")
916985

917-
# set temporary directory for download
918-
tmp_dir = tempfile.TemporaryDirectory(prefix="python-api-client-")
919-
920986
if output_paths is None:
921987
output_paths = []
922988
for file in file_paths:
@@ -991,6 +1057,4 @@ def download_files_finalize(job: DownloadJob):
9911057
for task in job.update_tasks:
9921058
task.apply(job.tmp_dir, job.mp)
9931059

994-
# Remove temporary download directory
995-
if job.tmp_dir is not None and os.path.exists(job.tmp_dir.name):
996-
cleanup_tmp_dir(job.mp, job.tmp_dir)
1060+
cleanup_tmp_dir(job.mp, job.tmp_dir)

mergin/client_push.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
)
3535
from .merginproject import MerginProject, pygeodiff
3636
from .editor import filter_changes
37-
from .utils import get_data_checksum, cleanup_tmp_dir
37+
from .utils import get_data_checksum, cleanup_tmp_dir, filter_files
3838

3939
POST_JSON_HEADERS = {"Content-Type": "application/json"}
4040

@@ -458,6 +458,9 @@ def push_project_finalize(job: UploadJob):
458458
cleanup_tmp_dir(job.mp, job.tmp_dir) # delete our temporary dir and all its content
459459
raise err
460460

461+
# keep only in-scope files in the metadata we're about to persist
462+
job.server_resp["files"] = filter_files(job.server_resp["files"], **job.mp.file_filter())
463+
461464
job.mp.update_metadata(job.server_resp)
462465
try:
463466
job.mp.apply_push_changes(asdict(job.changes))

0 commit comments

Comments
 (0)