Skip to content

Commit d62ff99

Browse files
committed
Allow downloading a single file by project name without a local checkout
1 parent de6f50c commit d62ff99

5 files changed

Lines changed: 111 additions & 24 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: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -335,17 +335,32 @@ def share(ctx, project):
335335
@click.argument("filepath")
336336
@click.argument("output")
337337
@click.option("--version", help="Project version tag, for example 'v3'")
338+
@click.option(
339+
"--project",
340+
help="Full project name ('<workspace>/<project>') to download the file directly from the server. "
341+
"If not given, the current directory is used and must be an existing checked out project.",
342+
)
338343
@click.pass_context
339-
def download_file(ctx, filepath, output, version):
344+
def download_file(ctx, filepath, output, version, project):
340345
"""
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.
346+
Download project file at specified version. If no version is given, the latest will be fetched.
343347
"""
344348
mc = ctx.obj["client"]
345349
if mc is None:
346350
return
351+
if project is None:
352+
# no --project given, so we default to the current directory - make sure that's actually a checked out project
353+
try:
354+
MerginProject(os.getcwd()).project_full_name()
355+
except InvalidProject:
356+
click.secho(
357+
"Current directory is not a Mergin Maps project. Run this command from within a "
358+
"checked out project directory, or pass --project <workspace>/<project>.",
359+
fg="red",
360+
)
361+
return
347362
try:
348-
job = download_file_async(mc, os.getcwd(), filepath, output, version)
363+
job = download_file_async(mc, project or os.getcwd(), filepath, output, version)
349364
with click.progressbar(length=job.total_size) as bar:
350365
last_transferred_size = 0
351366
while download_project_is_running(job):

mergin/client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,7 +1199,7 @@ def download_file(self, project_dir, file_path, output_filename, version=None):
11991199
"""
12001200
Download project file at specified version. Get the latest if no version specified.
12011201
1202-
:param project_dir: project local directory
1202+
:param project_dir: project local directory or a full project name ("<workspace>/<project>")
12031203
:type project_dir: String
12041204
:param file_path: relative path of file to download in the project directory
12051205
:type file_path: String
@@ -1401,11 +1401,11 @@ def download_files(
14011401
"""
14021402
Download project files at specified version. Get the latest if no version specified.
14031403
1404-
:param project_dir: project local directory
1404+
:param project_dir: project local directory or a full project name ("<workspace>/<project>")
14051405
:type project_dir: String
14061406
:param file_path: List of relative paths of files to download in the project directory
14071407
:type file_path: List[String]
1408-
:param output_paths: List of paths for files to download to. Should be same length of as file_path. Default is `None` which means that files are downloaded into MerginProject at project_dir.
1408+
:param output_paths: List of paths for files to download to. Should be same length of as file_path. Default is `None` which means that files are downloaded into MerginProject at project_dir (only valid when project_dir is an existing local checkout).
14091409
:type output_paths: List[String]
14101410
:param version: optional version tag for downloaded file
14111411
:type version: String

mergin/client_pull.py

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@
2222
import concurrent.futures
2323

2424

25-
from .common import CHUNK_SIZE, ClientError, DeltaChangeType, PullActionType
25+
from .common import CHUNK_SIZE, ClientError, DeltaChangeType, InvalidProject, 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, is_versioned_file, save_to_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 by download_files_async() when downloading files
86+
directly by 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.
@@ -398,7 +417,7 @@ def __init__(
398417
self.download_queue_items = download_queue_items
399418
self.latest_version = latest_version
400419

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

404423
if self.destination_file is None:
@@ -411,14 +430,14 @@ def apply(self, directory, mp):
411430
os.makedirs(file_dir, exist_ok=True)
412431

413432
# 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)
433+
check_size = self.latest_version or not is_versioned_file(self.file_path)
415434
# merge chunks together (and delete them afterwards)
416435
file_to_merge = DownloadFile(dest_file_path, self.download_queue_items, check_size)
417436
file_to_merge.from_chunks()
418437

419438
# 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:
439+
# destination_file is None for full project download and takes a meaningful value for a single file download
440+
if self.destination_file is None and is_versioned_file(self.file_path):
422441
mp.geodiff.make_copy_sqlite(mp.fpath(self.file_path), mp.fpath_meta(self.file_path))
423442

424443

@@ -902,9 +921,30 @@ def download_files_async(
902921
"""
903922
Starts background download project files at specified version.
904923
Returns handle to the pending download.
924+
925+
`project_dir` can either be an existing local project directory (previously fetched with
926+
download_project()), or a full project name ("<workspace>/<project>") to download files
927+
directly from the server without needing a local checkout. In the latter case, `output_paths`
928+
must be provided explicitly, as there is no project directory to place files into by default.
905929
"""
906-
mp = MerginProject(project_dir)
907-
project_path = mp.project_full_name()
930+
# temporary directory to stage downloaded chunks in
931+
tmp_dir = tempfile.TemporaryDirectory(prefix="python-api-client-")
932+
933+
mp: Union[MerginProject, "DownloadScratchContext"]
934+
try:
935+
mp = MerginProject(project_dir)
936+
project_path = mp.project_full_name()
937+
except InvalidProject:
938+
# project_dir is not an existing local checkout - treat it as a full project name
939+
# ("<workspace>/<project>") and download straight from the server instead
940+
if output_paths is None:
941+
cleanup_tmp_dir(mc, tmp_dir)
942+
raise ClientError(
943+
"output_paths must be provided when downloading files without an existing local project checkout"
944+
)
945+
project_path = project_dir
946+
mp = DownloadScratchContext(mc, tmp_dir.name)
947+
908948
ver_info = f"at version {version}" if version is not None else "at latest version"
909949
mp.log.info(f"Getting [{', '.join(file_paths)}] {ver_info}")
910950
latest_proj_info = mc.project_info(project_path)
@@ -914,9 +954,6 @@ def download_files_async(
914954
project_info = latest_proj_info
915955
mp.log.info(f"Got project info. version {project_info['version']}")
916956

917-
# set temporary directory for download
918-
tmp_dir = tempfile.TemporaryDirectory(prefix="python-api-client-")
919-
920957
if output_paths is None:
921958
output_paths = []
922959
for file in file_paths:
@@ -991,6 +1028,4 @@ def download_files_finalize(job: DownloadJob):
9911028
for task in job.update_tasks:
9921029
task.apply(job.tmp_dir, job.mp)
9931030

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)
1031+
cleanup_tmp_dir(job.mp, job.tmp_dir)

mergin/test/test_client.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,6 +1354,42 @@ def test_download_file(mc):
13541354
mc.download_file(project_dir, f_updated, f_downloaded, version="v5")
13551355

13561356

1357+
def test_download_file_without_checkout(mc):
1358+
"""Test downloading a single file directly by project name, without an existing local checkout."""
1359+
test_project = "test_download_file_without_checkout"
1360+
project = create_project_path(test_project, mc)
1361+
project_dir = os.path.join(TMP_DIR, test_project)
1362+
f_updated = "base.gpkg"
1363+
1364+
create_versioned_project(mc, test_project, project_dir, f_updated)
1365+
1366+
# download straight from the server by "workspace/project" name into a fresh directory
1367+
# that has never been used as a project checkout
1368+
download_dir = os.path.join(TMP_DIR, test_project + "_no_checkout")
1369+
remove_folders([download_dir])
1370+
os.makedirs(download_dir, exist_ok=True)
1371+
f_downloaded = os.path.join(download_dir, f_updated)
1372+
1373+
expected_content = "inserted_1_A.gpkg"
1374+
mc.download_file(project, f_updated, f_downloaded, version="v2")
1375+
expected = os.path.join(TEST_DATA_DIR, expected_content)
1376+
assert check_gpkg_same_content(MerginProject(project_dir), f_downloaded, expected)
1377+
assert not os.path.exists(os.path.join(download_dir, ".mergin"))
1378+
1379+
# output_paths must be provided explicitly when there is no local checkout
1380+
with pytest.raises(ClientError, match="output_paths must be provided"):
1381+
mc.download_files(project, [f_updated])
1382+
1383+
# non-existent file in an existing project - same error as with a local checkout
1384+
with pytest.raises(ClientError, match=r"No \[does_not_exist\.gpkg\] exists at version v2"):
1385+
mc.download_file(project, "does_not_exist.gpkg", f_downloaded, version="v2")
1386+
1387+
# non-existent / inaccessible project should fail clearly too
1388+
nonexistent_project = create_project_path("this_project_does_not_exist", mc)
1389+
with pytest.raises(ClientError):
1390+
mc.download_file(nonexistent_project, f_updated, f_downloaded)
1391+
1392+
13571393
def test_download_diffs(mc):
13581394
"""Test download diffs for a project file between specified project versions."""
13591395
test_project = "test_download_diffs"

0 commit comments

Comments
 (0)