• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MerginMaps / mergin-py-client / 6496779582

12 Oct 2023 02:07PM UTC coverage: 76.77% (+0.07%) from 76.704%
6496779582

Pull #188

github

Jan Caha
drop flag option
Pull Request #188: change namespace from option to argument

3 of 3 new or added lines in 1 file covered. (100.0%)

2667 of 3474 relevant lines covered (76.77%)

0.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/mergin/cli.py
1
#!/usr/bin/env python3
2

3
"""
×
4
Command line interface for the Mergin Maps client module. When installed with pip, this script
5
is installed as 'mergin' command line tool (defined in setup.py). If you have installed the module
6
but the tool is not available, you may need to fix your PATH (e.g. add ~/.local/bin where
7
pip puts these tools).
8
"""
9

10
from datetime import datetime, timezone
×
11
import click
×
12
import json
×
13
import os
×
14
import platform
×
15
import sys
×
16
import time
×
17
import traceback
×
18

19
from mergin import (
×
20
    ClientError,
21
    InvalidProject,
22
    LoginError,
23
    MerginClient,
24
    MerginProject,
25
    __version__,
26
)
27
from mergin.client_pull import (
×
28
    download_project_async,
29
    download_project_cancel,
30
    download_file_async,
31
    download_file_finalize,
32
    download_project_finalize,
33
    download_project_is_running,
34
)
35
from mergin.client_pull import pull_project_async, pull_project_is_running, pull_project_finalize, pull_project_cancel
×
36
from mergin.client_push import push_project_async, push_project_is_running, push_project_finalize, push_project_cancel
×
37

38

39
from pygeodiff import GeoDiff
×
40

41

42
class OptionPasswordIfUser(click.Option):
×
43
    """Custom option class for getting a password only if the --username option was specified."""
44

45
    def handle_parse_result(self, ctx, opts, args):
×
46
        self.has_username = "username" in opts
×
47
        return super(OptionPasswordIfUser, self).handle_parse_result(ctx, opts, args)
×
48

49
    def prompt_for_value(self, ctx):
×
50
        if self.has_username:
×
51
            return super(OptionPasswordIfUser, self).prompt_for_value(ctx)
×
52
        return None
×
53

54

55
def get_changes_count(diff):
×
56
    attrs = ["added", "removed", "updated"]
×
57
    return sum([len(diff[attr]) for attr in attrs])
×
58

59

60
def pretty_diff(diff):
×
61
    added = diff["added"]
×
62
    removed = diff["removed"]
×
63
    updated = diff["updated"]
×
64
    if removed:
×
65
        click.secho("\n>>> Removed:", fg="cyan")
×
66
        click.secho("\n".join("- " + f["path"] for f in removed), fg="red")
×
67
    if added:
×
68
        click.secho("\n>>> Added:", fg="cyan")
×
69
        click.secho("\n".join("+ " + f["path"] for f in added), fg="green")
×
70
    if updated:
×
71
        click.secho("\n>>> Modified:", fg="cyan")
×
72
        click.secho("\n".join("M " + f["path"] for f in updated), fg="yellow")
×
73

74

75
def pretty_summary(summary):
×
76
    for k, v in summary.items():
×
77
        click.secho("Details " + k)
×
78
        click.secho(
×
79
            "".join(
80
                "layer name - "
81
                + d["table"]
82
                + ": inserted: "
83
                + str(d["insert"])
84
                + ", modified: "
85
                + str(d["update"])
86
                + ", deleted: "
87
                + str(d["delete"])
88
                + "\n"
89
                for d in v["geodiff_summary"]
90
                if d["table"] != "gpkg_contents"
91
            )
92
        )
93

94

95
def get_token(url, username, password):
×
96
    """Get authorization token for given user and password."""
97
    mc = MerginClient(url)
×
98
    if not mc.is_server_compatible():
×
99
        click.secho(str("This client version is incompatible with server, try to upgrade"), fg="red")
×
100
        return None
×
101
    try:
×
102
        session = mc.login(username, password)
×
103
    except LoginError as e:
×
104
        click.secho("Unable to log in: " + str(e), fg="red")
×
105
        return None
×
106
    return session["token"]
×
107

108

109
def get_client(url=None, auth_token=None, username=None, password=None):
×
110
    """Return Mergin Maps client."""
111
    if auth_token is not None:
×
112
        try:
×
113
            mc = MerginClient(url, auth_token=auth_token)
×
114
        except ClientError as e:
×
115
            click.secho(str(e), fg="red")
×
116
            return None
×
117
        # Check if the token has expired or is just about to expire
118
        delta = mc._auth_session["expire"] - datetime.now(timezone.utc)
×
119
        if delta.total_seconds() > 5:
×
120
            return mc
×
121
    if username and password:
×
122
        auth_token = get_token(url, username, password)
×
123
        if auth_token is None:
×
124
            return None
×
125
        mc = MerginClient(url, auth_token=f"Bearer {auth_token}")
×
126
    else:
127
        click.secho(
×
128
            "Missing authorization data.\n"
129
            "Either set environment variables (MERGIN_USERNAME and MERGIN_PASSWORD) "
130
            "or specify --username / --password options.\n"
131
            "Note: if --username is specified but password is missing you will be prompted for password.",
132
            fg="red",
133
        )
134
        return None
×
135
    return mc
×
136

137

138
def _print_unhandled_exception():
×
139
    """Outputs details of an unhandled exception that is being handled right now"""
140
    click.secho("Unhandled exception!", fg="red")
×
141
    for line in traceback.format_exception(*sys.exc_info()):
×
142
        click.echo(line)
×
143

144

145
@click.group(
×
146
    epilog=f"Copyright (C) 2019-2021 Lutra Consulting\n\n(mergin-py-client v{__version__} / pygeodiff v{GeoDiff().version()})"
147
)
148
@click.option(
×
149
    "--url",
150
    envvar="MERGIN_URL",
151
    default=MerginClient.default_url(),
152
    help=f"Mergin Maps server URL. Default is: {MerginClient.default_url()}",
153
)
154
@click.option("--auth-token", envvar="MERGIN_AUTH", help="Mergin Maps authentication token string")
×
155
@click.option("--username", envvar="MERGIN_USERNAME")
×
156
@click.option("--password", cls=OptionPasswordIfUser, prompt=True, hide_input=True, envvar="MERGIN_PASSWORD")
×
157
@click.pass_context
×
158
def cli(ctx, url, auth_token, username, password):
×
159
    """
160
    Command line interface for the Mergin Maps client module.
161
    For user authentication on server there are two options:
162

163
     1. authorization token environment variable (MERGIN_AUTH) is defined, or
164
     2. username and password need to be given either as environment variables (MERGIN_USERNAME, MERGIN_PASSWORD),
165
     or as command options (--username, --password).
166

167
    Run `mergin --username <your_user> login` to see how to set the token variable manually.
168
    """
169
    mc = get_client(url=url, auth_token=auth_token, username=username, password=password)
×
170
    ctx.obj = {"client": mc}
×
171

172

173
@cli.command()
×
174
@click.pass_context
×
175
def login(ctx):
×
176
    """Login to the service and see how to set the token environment variable."""
177
    mc = ctx.obj["client"]
×
178
    if mc is not None:
×
179
        click.secho("Login successful!", fg="green")
×
180
        token = mc._auth_session["token"]
×
181
        if platform.system() == "Windows":
×
182
            hint = f"To set the MERGIN_AUTH variable run:\nset MERGIN_AUTH={token}"
×
183
        else:
184
            hint = f'To set the MERGIN_AUTH variable run:\nexport MERGIN_AUTH="{token}"'
×
185
        click.secho(hint)
×
186

187

188
@cli.command()
×
189
@click.argument("project")
×
190
@click.option("--public", is_flag=True, default=False, help="Public project, visible to everyone")
×
191
@click.option(
×
192
    "--from-dir",
193
    default=None,
194
    help="Content of the directory will be uploaded to the newly created project. "
195
    "The directory will get assigned to the project.",
196
)
197
@click.pass_context
×
198
def create(ctx, project, public, from_dir):
×
199
    """Create a new project on Mergin Maps server. `project` needs to be a combination of namespace/project."""
200
    mc = ctx.obj["client"]
×
201
    if mc is None:
×
202
        return
×
203
    if "/" in project:
×
204
        try:
×
205
            namespace, project = project.split("/")
×
206
            assert namespace, "No namespace given"
×
207
            assert project, "No project name given"
×
208
        except (ValueError, AssertionError) as e:
×
209
            click.secho(f"Incorrect namespace/project format: {e}", fg="red")
×
210
            return
×
211
    else:
212
        # namespace not specified, use current user namespace
213
        namespace = mc.username()
×
214
    try:
×
215
        if from_dir is None:
×
216
            mc.create_project(project, is_public=public, namespace=namespace)
×
217
            click.echo("Created project " + project)
×
218
        else:
219
            mc.create_project_and_push(project, from_dir, is_public=public, namespace=namespace)
×
220
            click.echo("Created project " + project + " and pushed content from directory " + from_dir)
×
221
    except ClientError as e:
×
222
        click.secho("Error: " + str(e), fg="red")
×
223
        return
×
224
    except Exception as e:
×
225
        _print_unhandled_exception()
×
226

227

228
@cli.command()
×
229
@click.argument("namespace")
×
230
@click.option(
×
231
    "--name",
232
    help="Filter projects with name like name",
233
)
234
@click.option(
×
235
    "--order_params",
236
    help="optional attributes for sorting the list. "
237
    "It should be a comma separated attribute names "
238
    "with _asc or _desc appended for sorting direction. "
239
    'For example: "namespace_asc,disk_usage_desc". '
240
    "Available attrs: namespace, name, created, updated, disk_usage, creator",
241
)
242
@click.pass_context
×
243
def list_projects(ctx, name, namespace, order_params):
×
244
    """List projects on the server."""
245

246
    mc = ctx.obj["client"]
×
247
    if mc is None:
×
248
        return
×
249

250
    projects_list = mc.projects_list(name=name, namespace=namespace, order_params=order_params)
×
251

252
    click.echo("Fetched {} projects .".format(len(projects_list)))
×
253
    for project in projects_list:
×
254
        full_name = "{} / {}".format(project["namespace"], project["name"])
×
255
        click.echo(
×
256
            "  {:40}\t{:6.1f} MB\t{}".format(full_name, project["disk_usage"] / (1024 * 1024), project["version"])
257
        )
258

259

260
@cli.command()
×
261
@click.argument("project")
×
262
@click.argument("directory", type=click.Path(), required=False)
×
263
@click.option("--version", default=None, help="Version of project to download")
×
264
@click.pass_context
×
265
def download(ctx, project, directory, version):
×
266
    """Download last version of mergin project."""
267
    mc = ctx.obj["client"]
×
268
    if mc is None:
×
269
        return
×
270
    directory = directory or os.path.basename(project)
×
271
    click.echo("Downloading into {}".format(directory))
×
272
    try:
×
273
        job = download_project_async(mc, project, directory, version)
×
274
        with click.progressbar(length=job.total_size) as bar:
×
275
            last_transferred_size = 0
×
276
            while download_project_is_running(job):
×
277
                time.sleep(1 / 10)  # 100ms
×
278
                new_transferred_size = job.transferred_size
×
279
                bar.update(new_transferred_size - last_transferred_size)  # the update() needs increment only
×
280
                last_transferred_size = new_transferred_size
×
281
        download_project_finalize(job)
×
282
        click.echo("Done")
×
283
    except KeyboardInterrupt:
×
284
        click.secho("Cancelling...")
×
285
        download_project_cancel(job)
×
286
    except ClientError as e:
×
287
        click.secho("Error: " + str(e), fg="red")
×
288
    except Exception as e:
×
289
        _print_unhandled_exception()
×
290

291

292
@cli.command()
×
293
@click.argument("project")
×
294
@click.argument("usernames", nargs=-1)
×
295
@click.option("--permissions", help="permissions to be granted to project (reader, writer, owner)")
×
296
@click.pass_context
×
297
def share_add(ctx, project, usernames, permissions):
×
298
    """Add permissions to [users] to project."""
299
    mc = ctx.obj["client"]
×
300
    if mc is None:
×
301
        return
×
302
    usernames = list(usernames)
×
303
    mc.add_user_permissions_to_project(project, usernames, permissions)
×
304

305

306
@cli.command()
×
307
@click.argument("project")
×
308
@click.argument("usernames", nargs=-1)
×
309
@click.pass_context
×
310
def share_remove(ctx, project, usernames):
×
311
    """Remove [users] permissions from project."""
312
    mc = ctx.obj["client"]
×
313
    if mc is None:
×
314
        return
×
315
    usernames = list(usernames)
×
316
    mc.remove_user_permissions_from_project(project, usernames)
×
317

318

319
@cli.command()
×
320
@click.argument("project")
×
321
@click.pass_context
×
322
def share(ctx, project):
×
323
    """Fetch permissions to project."""
324
    mc = ctx.obj["client"]
×
325
    if mc is None:
×
326
        return
×
327
    access_list = mc.project_user_permissions(project)
×
328

329
    for username in access_list.get("owners"):
×
330
        click.echo("{:20}\t{:20}".format(username, "owner"))
×
331
    for username in access_list.get("writers"):
×
332
        if username not in access_list.get("owners"):
×
333
            click.echo("{:20}\t{:20}".format(username, "writer"))
×
334
    for username in access_list.get("readers"):
×
335
        if username not in access_list.get("writers"):
×
336
            click.echo("{:20}\t{:20}".format(username, "reader"))
×
337

338

339
@cli.command()
×
340
@click.argument("filepath")
×
341
@click.argument("output")
×
342
@click.option("--version", help="Project version tag, for example 'v3'")
×
343
@click.pass_context
×
344
def download_file(ctx, filepath, output, version):
×
345
    """
346
    Download project file at specified version. `project` needs to be a combination of namespace/project.
347
    If no version is given, the latest will be fetched.
348
    """
349
    mc = ctx.obj["client"]
×
350
    if mc is None:
×
351
        return
×
352
    try:
×
353
        job = download_file_async(mc, os.getcwd(), filepath, output, version)
×
354
        with click.progressbar(length=job.total_size) as bar:
×
355
            last_transferred_size = 0
×
356
            while download_project_is_running(job):
×
357
                time.sleep(1 / 10)  # 100ms
×
358
                new_transferred_size = job.transferred_size
×
359
                bar.update(new_transferred_size - last_transferred_size)  # the update() needs increment only
×
360
                last_transferred_size = new_transferred_size
×
361
        download_file_finalize(job)
×
362
        click.echo("Done")
×
363
    except KeyboardInterrupt:
×
364
        click.secho("Cancelling...")
×
365
        download_project_cancel(job)
×
366
    except ClientError as e:
×
367
        click.secho("Error: " + str(e), fg="red")
×
368
    except Exception as e:
×
369
        _print_unhandled_exception()
×
370

371

372
def num_version(name):
×
373
    return int(name.lstrip("v"))
×
374

375

376
@cli.command()
×
377
@click.pass_context
×
378
def status(ctx):
×
379
    """Show all changes in project files - upstream and local."""
380
    mc = ctx.obj["client"]
×
381
    if mc is None:
×
382
        return
×
383
    try:
×
384
        pull_changes, push_changes, push_changes_summary = mc.project_status(os.getcwd())
×
385
    except InvalidProject as e:
×
386
        click.secho("Invalid project directory ({})".format(str(e)), fg="red")
×
387
        return
×
388
    except ClientError as e:
×
389
        click.secho("Error: " + str(e), fg="red")
×
390
        return
×
391
    except Exception as e:
×
392
        _print_unhandled_exception()
×
393
        return
×
394

395
    if mc.has_unfinished_pull(os.getcwd()):
×
396
        click.secho(
×
397
            "The previous pull has not finished completely: status "
398
            "of some files may be reported incorrectly. Use "
399
            "resolve_unfinished_pull command to try to fix that.",
400
            fg="yellow",
401
        )
402

403
    click.secho("### Server changes:", fg="magenta")
×
404
    pretty_diff(pull_changes)
×
405
    click.secho("### Local changes:", fg="magenta")
×
406
    pretty_diff(push_changes)
×
407
    click.secho("### Local changes summary ###")
×
408
    pretty_summary(push_changes_summary)
×
409

410

411
@cli.command()
×
412
@click.pass_context
×
413
def push(ctx):
×
414
    """Upload local changes into Mergin Maps repository."""
415
    mc = ctx.obj["client"]
×
416
    if mc is None:
×
417
        return
×
418
    directory = os.getcwd()
×
419
    try:
×
420
        job = push_project_async(mc, directory)
×
421
        if job is not None:  # if job is none, we don't upload any files, and the transaction is finished already
×
422
            with click.progressbar(length=job.total_size) as bar:
×
423
                last_transferred_size = 0
×
424
                while push_project_is_running(job):
×
425
                    time.sleep(1 / 10)  # 100ms
×
426
                    new_transferred_size = job.transferred_size
×
427
                    bar.update(new_transferred_size - last_transferred_size)  # the update() needs increment only
×
428
                    last_transferred_size = new_transferred_size
×
429
            push_project_finalize(job)
×
430
        click.echo("Done")
×
431
    except InvalidProject as e:
×
432
        click.secho("Invalid project directory ({})".format(str(e)), fg="red")
×
433
    except ClientError as e:
×
434
        click.secho("Error: " + str(e), fg="red")
×
435
        return
×
436
    except KeyboardInterrupt:
×
437
        click.secho("Cancelling...")
×
438
        push_project_cancel(job)
×
439
    except Exception as e:
×
440
        _print_unhandled_exception()
×
441

442

443
@cli.command()
×
444
@click.pass_context
×
445
def pull(ctx):
×
446
    """Fetch changes from Mergin Maps repository."""
447
    mc = ctx.obj["client"]
×
448
    if mc is None:
×
449
        return
×
450
    directory = os.getcwd()
×
451
    try:
×
452
        job = pull_project_async(mc, directory)
×
453
        if job is None:
×
454
            click.echo("Project is up to date")
×
455
            return
×
456
        with click.progressbar(length=job.total_size) as bar:
×
457
            last_transferred_size = 0
×
458
            while pull_project_is_running(job):
×
459
                time.sleep(1 / 10)  # 100ms
×
460
                new_transferred_size = job.transferred_size
×
461
                bar.update(new_transferred_size - last_transferred_size)  # the update() needs increment only
×
462
                last_transferred_size = new_transferred_size
×
463
        pull_project_finalize(job)
×
464
        click.echo("Done")
×
465
    except InvalidProject as e:
×
466
        click.secho("Invalid project directory ({})".format(str(e)), fg="red")
×
467
    except ClientError as e:
×
468
        click.secho("Error: " + str(e), fg="red")
×
469
        return
×
470
    except KeyboardInterrupt:
×
471
        click.secho("Cancelling...")
×
472
        pull_project_cancel(job)
×
473
    except Exception as e:
×
474
        _print_unhandled_exception()
×
475

476

477
@cli.command()
×
478
@click.argument("version")
×
479
@click.pass_context
×
480
def show_version(ctx, version):
×
481
    """Displays information about a single version of a project. `version` is 'v1', 'v2', etc."""
482
    mc = ctx.obj["client"]
×
483
    if mc is None:
×
484
        return
×
485
    directory = os.getcwd()
×
486
    mp = MerginProject(directory)
×
487
    project_path = mp.project_full_name()
×
488
    # TODO: handle exception when version not found
489
    version_info_dict = mc.project_version_info(project_path, version)[0]
×
490
    click.secho("Project: " + version_info_dict["namespace"] + "/" + version_info_dict["project_name"])
×
491
    click.secho("Version: " + version_info_dict["name"] + " by " + version_info_dict["author"])
×
492
    click.secho("Time:    " + version_info_dict["created"])
×
493
    pretty_diff(version_info_dict["changes"])
×
494

495

496
@cli.command()
×
497
@click.argument("path")
×
498
@click.pass_context
×
499
def show_file_history(ctx, path):
×
500
    """Displays information about a single version of a project."""
501
    mc = ctx.obj["client"]
×
502
    if mc is None:
×
503
        return
×
504
    directory = os.getcwd()
×
505
    mp = MerginProject(directory)
×
506
    project_path = mp.project_full_name()
×
507
    info_dict = mc.project_file_history_info(project_path, path)
×
508
    # TODO: handle exception if history not found
509
    history_dict = info_dict["history"]
×
510
    click.secho("File history: " + info_dict["path"])
×
511
    click.secho("-----")
×
512
    for version, version_data in history_dict.items():
×
513
        diff_info = ""
×
514
        if "diff" in version_data:
×
515
            diff_info = "diff ({} bytes)".format(version_data["diff"]["size"])
×
516
        click.secho(" {:5} {:10} {}".format(version, version_data["change"], diff_info))
×
517

518

519
@cli.command()
×
520
@click.argument("path")
×
521
@click.argument("version")
×
522
@click.pass_context
×
523
def show_file_changeset(ctx, path, version):
×
524
    """Displays information about project changes."""
525
    mc = ctx.obj["client"]
×
526
    if mc is None:
×
527
        return
×
528
    directory = os.getcwd()
×
529
    mp = MerginProject(directory)
×
530
    project_path = mp.project_full_name()
×
531
    info_dict = mc.project_file_changeset_info(project_path, path, version)
×
532
    # TODO: handle exception if Diff not found
533
    click.secho(json.dumps(info_dict, indent=2))
×
534

535

536
@cli.command()
×
537
@click.argument("source_project_path", required=True)
×
538
@click.argument("cloned_project_name", required=True)
×
539
@click.argument("cloned_project_namespace", required=False)
×
540
@click.pass_context
×
541
def clone(ctx, source_project_path, cloned_project_name, cloned_project_namespace=None):
×
542
    """Clone project from server."""
543
    mc = ctx.obj["client"]
×
544
    if mc is None:
×
545
        return
×
546
    try:
×
547
        mc.clone_project(source_project_path, cloned_project_name, cloned_project_namespace)
×
548
        click.echo("Done")
×
549
    except ClientError as e:
×
550
        click.secho("Error: " + str(e), fg="red")
×
551
    except Exception as e:
×
552
        _print_unhandled_exception()
×
553

554

555
@cli.command()
×
556
@click.argument("project", required=True)
×
557
@click.pass_context
×
558
def remove(ctx, project):
×
559
    """Remove project from server."""
560
    mc = ctx.obj["client"]
×
561
    if mc is None:
×
562
        return
×
563
    if "/" in project:
×
564
        try:
×
565
            namespace, project = project.split("/")
×
566
            assert namespace, "No namespace given"
×
567
            assert project, "No project name given"
×
568
        except (ValueError, AssertionError) as e:
×
569
            click.secho(f"Incorrect namespace/project format: {e}", fg="red")
×
570
            return
×
571
    else:
572
        # namespace not specified, use current user namespace
573
        namespace = mc.username()
×
574
    try:
×
575
        mc.delete_project(f"{namespace}/{project}")
×
576
        click.echo("Remote project removed")
×
577
    except ClientError as e:
×
578
        click.secho("Error: " + str(e), fg="red")
×
579
    except Exception as e:
×
580
        _print_unhandled_exception()
×
581

582

583
@cli.command()
×
584
@click.pass_context
×
585
def resolve_unfinished_pull(ctx):
×
586
    """Try to resolve unfinished pull."""
587
    mc = ctx.obj["client"]
×
588
    if mc is None:
×
589
        return
×
590

591
    try:
×
592
        mc.resolve_unfinished_pull(os.getcwd())
×
593
        click.echo("Unfinished pull successfully resolved")
×
594
    except InvalidProject as e:
×
595
        click.secho("Invalid project directory ({})".format(str(e)), fg="red")
×
596
    except ClientError as e:
×
597
        click.secho("Error: " + str(e), fg="red")
×
598
    except Exception as e:
×
599
        _print_unhandled_exception()
×
600

601

602
if __name__ == "__main__":
×
603
    cli()
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc