Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ deps
venv
debug.py
.vscode/
.python-version
23 changes: 19 additions & 4 deletions mergin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,17 +335,32 @@ def share(ctx, project):
@click.argument("filepath")
@click.argument("output")
@click.option("--version", help="Project version tag, for example 'v3'")
@click.option(
"--project",
help="Full project name ('<workspace>/<project>') to download the file directly from the server. "
"If not given, the current directory is used and must be an existing checked out project.",
)
@click.pass_context
def download_file(ctx, filepath, output, version):
def download_file(ctx, filepath, output, version, project):
"""
Download project file at specified version. `project` needs to be a combination of namespace/project.
If no version is given, the latest will be fetched.
Download project file at specified version. If no version is given, the latest will be fetched.
"""
mc = ctx.obj["client"]
if mc is None:
return
if project is None:
# no --project given, so we default to the current directory - make sure that's actually a checked out project
try:
MerginProject(os.getcwd()).project_full_name()
except InvalidProject:
click.secho(
"Current directory is not a Mergin Maps project. Run this command from within a "
"checked out project directory, or pass --project <workspace>/<project>.",
fg="red",
)
return
try:
job = download_file_async(mc, os.getcwd(), filepath, output, version)
job = download_file_async(mc, project or os.getcwd(), filepath, output, version)
with click.progressbar(length=job.total_size) as bar:
last_transferred_size = 0
while download_project_is_running(job):
Expand Down
6 changes: 3 additions & 3 deletions mergin/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1199,7 +1199,7 @@ def download_file(self, project_dir, file_path, output_filename, version=None):
"""
Download project file at specified version. Get the latest if no version specified.

:param project_dir: project local directory
:param project_dir: project local directory or a full project name ("<workspace>/<project>")
:type project_dir: String
:param file_path: relative path of file to download in the project directory
:type file_path: String
Expand Down Expand Up @@ -1401,11 +1401,11 @@ def download_files(
"""
Download project files at specified version. Get the latest if no version specified.

:param project_dir: project local directory
:param project_dir: project local directory or a full project name ("<workspace>/<project>")

@MarcelGeo MarcelGeo Sep 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably use same pattern as project name in cli. This is like mix of multiple behaviours in one arg. We could probably use also separated method in client.py download_project_file or similar.

This could be in future path like type.

:type project_dir: String
:param file_path: List of relative paths of files to download in the project directory
:type file_path: List[String]
: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.
: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).
:type output_paths: List[String]
:param version: optional version tag for downloaded file
:type version: String
Expand Down
69 changes: 52 additions & 17 deletions mergin/client_pull.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@
import concurrent.futures


from .common import CHUNK_SIZE, ClientError, DeltaChangeType, PullActionType
from .common import CHUNK_SIZE, ClientError, DeltaChangeType, InvalidProject, PullActionType
from .models import ProjectDelta, ProjectDeltaChange, PullAction
from .merginproject import MerginProject
from .utils import cleanup_tmp_dir, save_to_file
from typing import List, Optional
from .utils import cleanup_tmp_dir, is_versioned_file, save_to_file
from typing import List, Optional, Union

# status = download_project_async(...)
#
Expand Down Expand Up @@ -54,7 +54,7 @@ def __init__(
update_tasks,
download_queue_items,
tmp_dir: tempfile.TemporaryDirectory,
mp,
mp: Union[MerginProject, "DownloadScratchContext"],
project_info,
):
self.project_path = project_path
Expand All @@ -64,7 +64,7 @@ def __init__(
self.update_tasks = update_tasks
self.download_queue_items = download_queue_items
self.tmp_dir = tmp_dir
self.mp = mp # MerginProject instance
self.mp = mp
self.is_cancelled = False
self.project_info = project_info # parsed JSON with project info returned from the server
self.failure_log_file = None # log file, copied from the project directory if download fails
Expand All @@ -80,6 +80,25 @@ def dump(self):
print("--- END ---")


class DownloadScratchContext:
"""
Minimal stand-in for MerginProject, used by download_files_async() when downloading files
directly by project name ("<workspace>/<project>") without an existing local project checkout.

Provides only what the shared download job code actually needs from MerginProject.
"""

def __init__(self, mc, cache_dir: str):
self.log = mc.log
self.cache_dir = cache_dir
# only used by _cleanup_failed_download() to look for a log file
self.dir = cache_dir

def remove_logging_handler(self):
# no-op: self.log is mc's shared logger, not owned by this throwaway context
pass


class DownloadQueueItem:
"""
a piece of data from a project that should be downloaded - it can be either a chunk or it can be a diff.
Expand Down Expand Up @@ -398,7 +417,7 @@ def __init__(
self.download_queue_items = download_queue_items
self.latest_version = latest_version

def apply(self, directory, mp):
def apply(self, directory, mp: Union[MerginProject, "DownloadScratchContext"]):
"""assemble downloaded chunks into a single file"""

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

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

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


Expand Down Expand Up @@ -902,9 +921,30 @@ def download_files_async(
"""
Starts background download project files at specified version.
Returns handle to the pending download.

`project_dir` can either be an existing local project directory (previously fetched with
download_project()), or a full project name ("<workspace>/<project>") to download files
directly from the server without needing a local checkout. In the latter case, `output_paths`
must be provided explicitly, as there is no project directory to place files into by default.
"""
mp = MerginProject(project_dir)
project_path = mp.project_full_name()
# temporary directory to stage downloaded chunks in
tmp_dir = tempfile.TemporaryDirectory(prefix="python-api-client-")

mp: Union[MerginProject, "DownloadScratchContext"]
try:
mp = MerginProject(project_dir)
project_path = mp.project_full_name()
except InvalidProject:
# project_dir is not an existing local checkout - treat it as a full project name
# ("<workspace>/<project>") and download straight from the server instead
if output_paths is None:
cleanup_tmp_dir(mc, tmp_dir)
raise ClientError(
"output_paths must be provided when downloading files without an existing local project checkout"
)
project_path = project_dir
mp = DownloadScratchContext(mc, tmp_dir.name)

ver_info = f"at version {version}" if version is not None else "at latest version"
mp.log.info(f"Getting [{', '.join(file_paths)}] {ver_info}")
latest_proj_info = mc.project_info(project_path)
Expand All @@ -914,9 +954,6 @@ def download_files_async(
project_info = latest_proj_info
mp.log.info(f"Got project info. version {project_info['version']}")

# set temporary directory for download
tmp_dir = tempfile.TemporaryDirectory(prefix="python-api-client-")

if output_paths is None:
output_paths = []
for file in file_paths:
Expand Down Expand Up @@ -991,6 +1028,4 @@ def download_files_finalize(job: DownloadJob):
for task in job.update_tasks:
task.apply(job.tmp_dir, job.mp)

# Remove temporary download directory
if job.tmp_dir is not None and os.path.exists(job.tmp_dir.name):
cleanup_tmp_dir(job.mp, job.tmp_dir)
cleanup_tmp_dir(job.mp, job.tmp_dir)
36 changes: 36 additions & 0 deletions mergin/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,42 @@ def test_download_file(mc):
mc.download_file(project_dir, f_updated, f_downloaded, version="v5")


def test_download_file_without_checkout(mc):
"""Test downloading a single file directly by project name, without an existing local checkout."""
test_project = "test_download_file_without_checkout"
project = create_project_path(test_project, mc)
project_dir = os.path.join(TMP_DIR, test_project)
f_updated = "base.gpkg"

create_versioned_project(mc, test_project, project_dir, f_updated)

# download straight from the server by "workspace/project" name into a fresh directory
# that has never been used as a project checkout
download_dir = os.path.join(TMP_DIR, test_project + "_no_checkout")
remove_folders([download_dir])
os.makedirs(download_dir, exist_ok=True)
f_downloaded = os.path.join(download_dir, f_updated)

expected_content = "inserted_1_A.gpkg"
mc.download_file(project, f_updated, f_downloaded, version="v2")
expected = os.path.join(TEST_DATA_DIR, expected_content)
assert check_gpkg_same_content(MerginProject(project_dir), f_downloaded, expected)
assert not os.path.exists(os.path.join(download_dir, ".mergin"))

# output_paths must be provided explicitly when there is no local checkout
with pytest.raises(ClientError, match="output_paths must be provided"):
mc.download_files(project, [f_updated])

# non-existent file in an existing project - same error as with a local checkout
with pytest.raises(ClientError, match=r"No \[does_not_exist\.gpkg\] exists at version v2"):
mc.download_file(project, "does_not_exist.gpkg", f_downloaded, version="v2")

# non-existent / inaccessible project should fail clearly too
nonexistent_project = create_project_path("this_project_does_not_exist", mc)
with pytest.raises(ClientError):
mc.download_file(nonexistent_project, f_updated, f_downloaded)


def test_download_diffs(mc):
"""Test download diffs for a project file between specified project versions."""
test_project = "test_download_diffs"
Expand Down
Loading