Skip to content

Commit 27311ed

Browse files
committed
Add sparse checkout support: filter downloaded/pushed files by include/exclude pattern
1 parent de6f50c commit 27311ed

9 files changed

Lines changed: 462 additions & 10 deletions

File tree

mergin/cli.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,16 +248,20 @@ def list_projects(ctx, name, namespace, order_params):
248248
@click.argument("project")
249249
@click.argument("directory", type=click.Path(), required=False)
250250
@click.option("--version", default=None, help="Version of project to download")
251+
@click.option("--include", multiple=True, help="Only download files matching this pattern, e.g. '*.gpkg'")
252+
@click.option("--exclude", multiple=True, help="Skip files matching this pattern, e.g. 'media/*'")
251253
@click.pass_context
252-
def download(ctx, project, directory, version):
254+
def download(ctx, project, directory, version, include, exclude):
253255
"""Download last version of mergin project."""
254256
mc = ctx.obj["client"]
255257
if mc is None:
256258
return
259+
if include and exclude:
260+
raise click.UsageError("--include and --exclude cannot be used together")
257261
directory = directory or os.path.basename(project)
258262
click.echo("Downloading into {}".format(directory))
259263
try:
260-
job = download_project_async(mc, project, directory, version)
264+
job = download_project_async(mc, project, directory, version, include=include, exclude=exclude)
261265
with click.progressbar(length=job.total_size) as bar:
262266
last_transferred_size = 0
263267
while download_project_is_running(job):

mergin/client.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
from .utils import DateTimeEncoder, get_versions_with_file_changes, int_version, is_version_acceptable
6464
from .utils import (
6565
DateTimeEncoder,
66+
filter_files,
6667
get_versions_with_file_changes,
6768
int_version,
6869
is_version_acceptable,
@@ -902,7 +903,7 @@ def project_versions(self, project_path, since=1, to=None):
902903
filtered_versions = list(filter(lambda v: (num_since <= int_version(v["name"]) <= num_to), versions))
903904
return filtered_versions
904905

905-
def download_project(self, project_path, directory, version=None):
906+
def download_project(self, project_path, directory, version=None, include=None, exclude=None):
906907
"""
907908
Download project into given directory. If version is not specified, latest version is downloaded
908909
@@ -914,8 +915,16 @@ def download_project(self, project_path, directory, version=None):
914915
915916
:param version: Project version to download, e.g. v42
916917
:type version: String
918+
919+
:param include: Optional list of glob patterns (matched against each file's project path, e.g.
920+
"media/*" or "*.gpkg") - only matching files are downloaded.
921+
:type include: List[String]
922+
923+
:param exclude: Optional list of glob patterns - matching files are skipped. Mutually exclusive
924+
with include.
925+
:type exclude: List[String]
917926
"""
918-
job = download_project_async(self, project_path, directory, version)
927+
job = download_project_async(self, project_path, directory, version, include=include, exclude=exclude)
919928
download_project_wait(job)
920929
download_project_finalize(job)
921930

@@ -1158,6 +1167,10 @@ def project_status(self, directory):
11581167
server_info = self.project_info(mp.project_full_name(), since=mp.version())
11591168

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

11621175
push_changes = mp.get_push_changes()
11631176
push_changes_summary = mp.get_list_of_push_changes(push_changes)

mergin/client_pull.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
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
28+
from .utils import cleanup_tmp_dir, filter_files, path_matches_filter, save_to_file, validates_file_filter
2929
from typing import List, Optional
3030

3131
# status = download_project_async(...)
@@ -242,10 +242,15 @@ def _cleanup_failed_download(mergin_project: MerginProject = None):
242242
return dest_path
243243

244244

245-
def download_project_async(mc, project_path, directory, project_version=None):
245+
@validates_file_filter
246+
def download_project_async(mc, project_path, directory, project_version=None, include=None, exclude=None):
246247
"""
247248
Starts project download in background and returns handle to the pending project download.
248249
Using that object it is possible to watch progress or cancel the ongoing work.
250+
251+
`include`/`exclude` are optional lists of glob patterns (matched against each file's project
252+
path, e.g. "media/*" or "*.gpkg") to only download a subset of the project's files. They are
253+
mutually exclusive.
249254
"""
250255

251256
if "/" not in project_path:
@@ -276,6 +281,11 @@ def download_project_async(mc, project_path, directory, project_version=None):
276281

277282
mp.log.info(f"got project info. version {version}")
278283

284+
# keep only the files matching the filter (if any)
285+
project_info["files"] = filter_files(project_info["files"], include=include, exclude=exclude)
286+
if include or exclude:
287+
project_info["file_filter"] = {"include": include, "exclude": exclude}
288+
279289
# prepare download
280290
update_tasks = [] # stuff to do at the end of download
281291
for file in project_info["files"]:
@@ -525,6 +535,9 @@ def pull_project_async(mc, directory) -> Optional[PullJob]:
525535
mp.log.info("--- pull aborted")
526536
raise
527537

538+
file_filter = mp.file_filter()
539+
delta.changes = [c for c in delta.changes if path_matches_filter(c.path, **file_filter)]
540+
528541
mp.log.info(f"got project versions: local version {local_version} / server version {server_version}")
529542

530543
if local_version == server_version:
@@ -748,6 +761,14 @@ def pull_project_finalize(job: PullJob):
748761
cleanup_tmp_dir(job.mp, job.tmp_dir) # delete our temporary dir and all its content
749762
raise ClientError("Failed to apply pull actions: " + str(e))
750763

764+
file_filter = job.mp.file_filter()
765+
job.project_info["files"] = filter_files(job.project_info["files"], **file_filter)
766+
# keep the sparse checkout: re-apply the filter this project was downloaded with,
767+
# since job.project_info is a fresh, unfiltered response from the server
768+
# and update_metadata() replaces the whole metadata dict rather than merging into it
769+
if file_filter["include"] or file_filter["exclude"]:
770+
job.project_info["file_filter"] = file_filter
771+
751772
job.mp.update_metadata(job.project_info)
752773

753774
if job.mp.has_unfinished_pull():

mergin/client_push.py

Lines changed: 9 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,14 @@ 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 the sparse checkout: re-apply the filter this project was
462+
# downloaded with, since job.server_resp is a fresh, unfiltered response from the server
463+
# and update_metadata() replaces the whole metadata dict rather than merging into it
464+
file_filter = job.mp.file_filter()
465+
job.server_resp["files"] = filter_files(job.server_resp["files"], **file_filter)
466+
if file_filter["include"] or file_filter["exclude"]:
467+
job.server_resp["file_filter"] = file_filter
468+
461469
job.mp.update_metadata(job.server_resp)
462470
try:
463471
job.mp.apply_push_changes(asdict(job.changes))

mergin/merginproject.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
unique_path_name,
2525
conflicted_copy_file_name,
2626
edit_conflict_file_name,
27+
filter_files,
2728
)
2829
from .local_changes import FileChange
2930

@@ -215,6 +216,13 @@ def files(self) -> list:
215216
self._read_metadata()
216217
return self._metadata["files"]
217218

219+
def file_filter(self) -> dict:
220+
"""
221+
Returns the include/exclude file filter this project was downloaded with, as a dict with "include" and "exclude" keys.
222+
"""
223+
self._read_metadata()
224+
return self._metadata.get("file_filter", {"include": None, "exclude": None})
225+
218226
@property
219227
def metadata(self) -> dict:
220228
"""Gets raw access to metadata. Kept only for backwards compatibility and will be removed."""
@@ -566,7 +574,8 @@ def get_local_delta(self, diff_directory: str) -> List[ProjectDeltaChange]:
566574
:rtype: List[ProjectDeltaItem]
567575
"""
568576
result = []
569-
changes = self.compare_file_sets(self.files(), self.inspect_files())
577+
current_files = filter_files(self.inspect_files(), **self.file_filter())
578+
changes = self.compare_file_sets(self.files(), current_files)
570579
added = changes.get("added", [])
571580
removed = changes.get("removed", [])
572581
updated = changes.get("updated", [])
@@ -655,7 +664,8 @@ def get_push_changes(self):
655664
:returns: changes metadata for files to be pushed to server
656665
:rtype: dict
657666
"""
658-
changes = self.compare_file_sets(self.files(), self.inspect_files())
667+
current_files = filter_files(self.inspect_files(), **self.file_filter())
668+
changes = self.compare_file_sets(self.files(), current_files)
659669
# do checkpoint to push changes from wal file to gpkg
660670
for file in changes["added"] + changes["updated"]:
661671
size, checksum = do_sqlite_checkpoint(self.fpath(file["path"]), self.log)

0 commit comments

Comments
 (0)