2525from .common import CHUNK_SIZE , ClientError , DeltaChangeType , PullActionType
2626from .models import ProjectDelta , ProjectDeltaChange , PullAction
2727from .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+
83102class 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+
777831def 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
899953def 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 )
0 commit comments