Skip to content

Commit 34bef79

Browse files
authored
Merge pull request #319 from MerginMaps/sparse_checkout
Add sparse checkout support
2 parents d75a967 + c84c053 commit 34bef79

9 files changed

Lines changed: 447 additions & 10 deletions

File tree

mergin/cli.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,16 +249,20 @@ def list_projects(ctx, name, namespace, order_params):
249249
@click.argument("project")
250250
@click.argument("directory", type=click.Path(), required=False)
251251
@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/*'")
252254
@click.pass_context
253-
def download(ctx, project, directory, version):
255+
def download(ctx, project, directory, version, include, exclude):
254256
"""Download last version of mergin project."""
255257
mc = ctx.obj["client"]
256258
if mc is None:
257259
return
260+
if include and exclude:
261+
raise click.UsageError("--include and --exclude cannot be used together")
258262
directory = directory or os.path.basename(project)
259263
click.echo("Downloading into {}".format(directory))
260264
try:
261-
job = download_project_async(mc, project, directory, version)
265+
job = download_project_async(mc, project, directory, version, include=include, exclude=exclude)
262266
with click.progressbar(length=job.total_size) as bar:
263267
last_transferred_size = 0
264268
while download_project_is_running(job):

mergin/client.py

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

906-
def download_project(self, project_path, directory, version=None):
907+
def download_project(self, project_path, directory, version=None, include=None, exclude=None):
907908
"""
908909
Download project into given directory. If version is not specified, latest version is downloaded
909910
@@ -915,8 +916,16 @@ def download_project(self, project_path, directory, version=None):
915916
916917
:param version: Project version to download, e.g. v42
917918
: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]
918927
"""
919-
job = download_project_async(self, project_path, directory, version)
928+
job = download_project_async(self, project_path, directory, version, include=include, exclude=exclude)
920929
download_project_wait(job)
921930
download_project_finalize(job)
922931

@@ -1159,6 +1168,10 @@ def project_status(self, directory):
11591168
server_info = self.project_info(mp.project_full_name(), since=mp.version())
11601169

11611170
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()}
11621175

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

mergin/client_pull.py

Lines changed: 20 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, is_versioned_file, save_to_file
28+
from .utils import cleanup_tmp_dir, filter_files, is_path_in_scope, save_to_file, is_versioned_file
2929
from typing import List, Optional, Union
3030

3131
# status = download_project_async(...)
@@ -261,12 +261,18 @@ def _cleanup_failed_download(mergin_project: MerginProject = None):
261261
return dest_path
262262

263263

264-
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):
265265
"""
266266
Starts project download in background and returns handle to the pending project download.
267267
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.
268272
"""
269273

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

296302
mp.log.info(f"got project info. version {version}")
297303

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+
298310
# prepare download
299311
update_tasks = [] # stuff to do at the end of download
300312
for file in project_info["files"]:
@@ -544,6 +556,9 @@ def pull_project_async(mc, directory) -> Optional[PullJob]:
544556
mp.log.info("--- pull aborted")
545557
raise
546558

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+
547562
mp.log.info(f"got project versions: local version {local_version} / server version {server_version}")
548563

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

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+
770788
job.mp.update_metadata(job.project_info)
771789

772790
if job.mp.has_unfinished_pull():

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))

mergin/merginproject.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
unique_path_name,
2525
conflicted_copy_file_name,
2626
edit_conflict_file_name,
27+
filter_files,
28+
is_path_in_scope,
2729
)
2830
from .local_changes import FileChange
2931

@@ -211,9 +213,28 @@ def version(self) -> str:
211213
return self._metadata["version"]
212214

213215
def files(self) -> list:
214-
"""Returns project's list of files (each file being a dictionary)"""
216+
"""Returns project's list of files (each file being a dictionary), scoped to this
217+
project's file_filter() if one is set (sparse checkout)."""
215218
self._read_metadata()
216-
return self._metadata["files"]
219+
return filter_files(self._metadata["files"], **self.file_filter())
220+
221+
def file_filter(self) -> dict:
222+
"""
223+
Returns the include/exclude file filter this project was downloaded with, as a dict
224+
with "include" and "exclude" keys. Stored in its own file (.mergin/file_filter.json)
225+
"""
226+
filter_file = self.fpath_meta("file_filter.json")
227+
if not os.path.exists(filter_file):
228+
return {"include": None, "exclude": None}
229+
with open(filter_file, "r") as f:
230+
return json.load(f)
231+
232+
def write_file_filter(self, file_filter: dict) -> None:
233+
"""
234+
Persists the include/exclude file filter this project was downloaded with.
235+
"""
236+
with open(self.fpath_meta("file_filter.json"), "w") as f:
237+
json.dump(file_filter, f, indent=2)
217238

218239
@property
219240
def metadata(self) -> dict:
@@ -304,10 +325,12 @@ def ignore_file(self, file):
304325
def inspect_files(self):
305326
"""
306327
Inspect files in project directory and return metadata.
328+
Only files matching this project's file_filter() are included.
307329
308330
:returns: metadata for files in project directory in server required format
309331
:rtype: list[dict]
310332
"""
333+
file_filter = self.file_filter()
311334
files_meta = []
312335
for root, dirs, files in os.walk(self.dir, topdown=True):
313336
dirs[:] = [d for d in dirs if d not in [".mergin"]]
@@ -318,6 +341,8 @@ def inspect_files(self):
318341
abs_path = os.path.abspath(os.path.join(root, file))
319342
rel_path = os.path.relpath(abs_path, start=self.dir)
320343
proj_path = "/".join(rel_path.split(os.path.sep)) # we need posix path
344+
if not is_path_in_scope(proj_path, **file_filter):
345+
continue
321346
files_meta.append(
322347
{
323348
"path": proj_path,

0 commit comments

Comments
 (0)