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

MerginMaps / mergin-py-client / 6496673826

12 Oct 2023 01:40PM UTC coverage: 76.923% (+0.2%) from 76.704%
6496673826

Pull #187

github

Jan Caha
clone using the preferred API call
Pull Request #187: Fix 180

65 of 65 new or added lines in 3 files covered. (100.0%)

2720 of 3536 relevant lines covered (76.92%)

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.option(
×
230
    "--flag",
231
    help="What kind of projects (e.g. 'created' for just my projects,"
232
    "'shared' for projects shared with me. No flag means returns all public projects.",
233
)
234
@click.option(
×
235
    "--name",
236
    help="Filter projects with name like name",
237
)
238
@click.option(
×
239
    "--namespace",
240
    help="Filter projects with namespace like namespace",
241
)
242
@click.option(
×
243
    "--order_params",
244
    help="optional attributes for sorting the list. "
245
    "It should be a comma separated attribute names "
246
    "with _asc or _desc appended for sorting direction. "
247
    'For example: "namespace_asc,disk_usage_desc". '
248
    "Available attrs: namespace, name, created, updated, disk_usage, creator",
249
)
250
@click.pass_context
×
251
def list_projects(ctx, flag, name, namespace, order_params):
×
252
    """List projects on the server."""
253
    filter_str = "(filter flag={})".format(flag) if flag is not None else "(all public)"
×
254

255
    click.echo("List of projects {}:".format(filter_str))
×
256

257
    mc = ctx.obj["client"]
×
258
    if mc is None:
×
259
        return
×
260

261
    projects_list = mc.projects_list(flag=flag, name=name, namespace=namespace, order_params=order_params)
×
262

263
    click.echo("Fetched {} projects .".format(len(projects_list)))
×
264
    for project in projects_list:
×
265
        full_name = "{} / {}".format(project["namespace"], project["name"])
×
266
        click.echo(
×
267
            "  {:40}\t{:6.1f} MB\t{}".format(full_name, project["disk_usage"] / (1024 * 1024), project["version"])
268
        )
269

270

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

302

303
@cli.command()
×
304
@click.argument("project")
×
305
@click.argument("usernames", nargs=-1)
×
306
@click.option("--permissions", help="permissions to be granted to project (reader, writer, owner)")
×
307
@click.pass_context
×
308
def share_add(ctx, project, usernames, permissions):
×
309
    """Add permissions to [users] to project."""
310
    mc = ctx.obj["client"]
×
311
    if mc is None:
×
312
        return
×
313
    usernames = list(usernames)
×
314
    mc.add_user_permissions_to_project(project, usernames, permissions)
×
315

316

317
@cli.command()
×
318
@click.argument("project")
×
319
@click.argument("usernames", nargs=-1)
×
320
@click.pass_context
×
321
def share_remove(ctx, project, usernames):
×
322
    """Remove [users] permissions from project."""
323
    mc = ctx.obj["client"]
×
324
    if mc is None:
×
325
        return
×
326
    usernames = list(usernames)
×
327
    mc.remove_user_permissions_from_project(project, usernames)
×
328

329

330
@cli.command()
×
331
@click.argument("project")
×
332
@click.pass_context
×
333
def share(ctx, project):
×
334
    """Fetch permissions to project."""
335
    mc = ctx.obj["client"]
×
336
    if mc is None:
×
337
        return
×
338
    access_list = mc.project_user_permissions(project)
×
339

340
    for username in access_list.get("owners"):
×
341
        click.echo("{:20}\t{:20}".format(username, "owner"))
×
342
    for username in access_list.get("writers"):
×
343
        if username not in access_list.get("owners"):
×
344
            click.echo("{:20}\t{:20}".format(username, "writer"))
×
345
    for username in access_list.get("readers"):
×
346
        if username not in access_list.get("writers"):
×
347
            click.echo("{:20}\t{:20}".format(username, "reader"))
×
348

349

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

382

383
def num_version(name):
×
384
    return int(name.lstrip("v"))
×
385

386

387
@cli.command()
×
388
@click.pass_context
×
389
def status(ctx):
×
390
    """Show all changes in project files - upstream and local."""
391
    mc = ctx.obj["client"]
×
392
    if mc is None:
×
393
        return
×
394
    try:
×
395
        pull_changes, push_changes, push_changes_summary = mc.project_status(os.getcwd())
×
396
    except InvalidProject as e:
×
397
        click.secho("Invalid project directory ({})".format(str(e)), fg="red")
×
398
        return
×
399
    except ClientError as e:
×
400
        click.secho("Error: " + str(e), fg="red")
×
401
        return
×
402
    except Exception as e:
×
403
        _print_unhandled_exception()
×
404
        return
×
405

406
    if mc.has_unfinished_pull(os.getcwd()):
×
407
        click.secho(
×
408
            "The previous pull has not finished completely: status "
409
            "of some files may be reported incorrectly. Use "
410
            "resolve_unfinished_pull command to try to fix that.",
411
            fg="yellow",
412
        )
413

414
    click.secho("### Server changes:", fg="magenta")
×
415
    pretty_diff(pull_changes)
×
416
    click.secho("### Local changes:", fg="magenta")
×
417
    pretty_diff(push_changes)
×
418
    click.secho("### Local changes summary ###")
×
419
    pretty_summary(push_changes_summary)
×
420

421

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

453

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

487

488
@cli.command()
×
489
@click.argument("version")
×
490
@click.pass_context
×
491
def show_version(ctx, version):
×
492
    """Displays information about a single version of a project. `version` is 'v1', 'v2', etc."""
493
    mc = ctx.obj["client"]
×
494
    if mc is None:
×
495
        return
×
496
    directory = os.getcwd()
×
497
    mp = MerginProject(directory)
×
498
    project_path = mp.project_full_name()
×
499
    # TODO: handle exception when version not found
500
    version_info_dict = mc.project_version_info(project_path, version)[0]
×
501
    click.secho("Project: " + version_info_dict["namespace"] + "/" + version_info_dict["project_name"])
×
502
    click.secho("Version: " + version_info_dict["name"] + " by " + version_info_dict["author"])
×
503
    click.secho("Time:    " + version_info_dict["created"])
×
504
    pretty_diff(version_info_dict["changes"])
×
505

506

507
@cli.command()
×
508
@click.argument("path")
×
509
@click.pass_context
×
510
def show_file_history(ctx, path):
×
511
    """Displays information about a single version of a project."""
512
    mc = ctx.obj["client"]
×
513
    if mc is None:
×
514
        return
×
515
    directory = os.getcwd()
×
516
    mp = MerginProject(directory)
×
517
    project_path = mp.project_full_name()
×
518
    info_dict = mc.project_file_history_info(project_path, path)
×
519
    # TODO: handle exception if history not found
520
    history_dict = info_dict["history"]
×
521
    click.secho("File history: " + info_dict["path"])
×
522
    click.secho("-----")
×
523
    for version, version_data in history_dict.items():
×
524
        diff_info = ""
×
525
        if "diff" in version_data:
×
526
            diff_info = "diff ({} bytes)".format(version_data["diff"]["size"])
×
527
        click.secho(" {:5} {:10} {}".format(version, version_data["change"], diff_info))
×
528

529

530
@cli.command()
×
531
@click.argument("path")
×
532
@click.argument("version")
×
533
@click.pass_context
×
534
def show_file_changeset(ctx, path, version):
×
535
    """Displays information about project changes."""
536
    mc = ctx.obj["client"]
×
537
    if mc is None:
×
538
        return
×
539
    directory = os.getcwd()
×
540
    mp = MerginProject(directory)
×
541
    project_path = mp.project_full_name()
×
542
    info_dict = mc.project_file_changeset_info(project_path, path, version)
×
543
    # TODO: handle exception if Diff not found
544
    click.secho(json.dumps(info_dict, indent=2))
×
545

546

547
@cli.command()
×
548
@click.argument("source_project_path", required=True)
×
549
@click.argument("cloned_project_name", required=True)
×
550
@click.argument("cloned_project_namespace", required=False)
×
551
@click.pass_context
×
552
def clone(ctx, source_project_path, cloned_project_name, cloned_project_namespace=None):
×
553
    """Clone project from server."""
554
    mc = ctx.obj["client"]
×
555
    if mc is None:
×
556
        return
×
557
    try:
×
558
        if cloned_project_namespace:
×
559
            click.secho(
×
560
                "The usage of `cloned_project_namespace` parameter in `mergin clone` is deprecated."
561
                "Specify `cloned_project_name` as full name (<namespace>/<name>) instead.",
562
                fg="yellow",
563
            )
564
        if cloned_project_namespace is None and "/" not in cloned_project_name:
×
565
            click.secho(
×
566
                "The use of only project name as `cloned_project_name` in `clone_project()` is deprecated."
567
                "The `cloned_project_name` should be full name (<namespace>/<name>).",
568
                fg="yellow",
569
            )
570
        if cloned_project_namespace and "/" not in cloned_project_name:
×
571
            cloned_project_name = f"{cloned_project_namespace}/{cloned_project_name}"
×
572
        mc.clone_project(source_project_path, cloned_project_name)
×
573
        click.echo("Done")
×
574
    except ClientError as e:
×
575
        click.secho("Error: " + str(e), fg="red")
×
576
    except Exception as e:
×
577
        _print_unhandled_exception()
×
578

579

580
@cli.command()
×
581
@click.argument("project", required=True)
×
582
@click.pass_context
×
583
def remove(ctx, project):
×
584
    """Remove project from server."""
585
    mc = ctx.obj["client"]
×
586
    if mc is None:
×
587
        return
×
588
    if "/" in project:
×
589
        try:
×
590
            namespace, project = project.split("/")
×
591
            assert namespace, "No namespace given"
×
592
            assert project, "No project name given"
×
593
        except (ValueError, AssertionError) as e:
×
594
            click.secho(f"Incorrect namespace/project format: {e}", fg="red")
×
595
            return
×
596
    else:
597
        # namespace not specified, use current user namespace
598
        namespace = mc.username()
×
599
    try:
×
600
        mc.delete_project(f"{namespace}/{project}")
×
601
        click.echo("Remote project removed")
×
602
    except ClientError as e:
×
603
        click.secho("Error: " + str(e), fg="red")
×
604
    except Exception as e:
×
605
        _print_unhandled_exception()
×
606

607

608
@cli.command()
×
609
@click.pass_context
×
610
def resolve_unfinished_pull(ctx):
×
611
    """Try to resolve unfinished pull."""
612
    mc = ctx.obj["client"]
×
613
    if mc is None:
×
614
        return
×
615

616
    try:
×
617
        mc.resolve_unfinished_pull(os.getcwd())
×
618
        click.echo("Unfinished pull successfully resolved")
×
619
    except InvalidProject as e:
×
620
        click.secho("Invalid project directory ({})".format(str(e)), fg="red")
×
621
    except ClientError as e:
×
622
        click.secho("Error: " + str(e), fg="red")
×
623
    except Exception as e:
×
624
        _print_unhandled_exception()
×
625

626

627
if __name__ == "__main__":
×
628
    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