circup 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
circup/commands.py ADDED
@@ -0,0 +1,723 @@
1
+ # SPDX-FileCopyrightText: 2019 Nicholas Tollervey, 2024 Tim Cocks, written for Adafruit Industries
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """
5
+ # ----------- CLI command definitions ----------- #
6
+
7
+ The following functions have IO side effects (for instance they emit to
8
+ stdout). Ergo, these are not checked with unit tests. Most of the
9
+ functionality they provide is provided by the functions from util_functions.py,
10
+ and the respective Backends which *are* tested. Most of the logic of the following
11
+ functions is to prepare things for presentation to / interaction with the user.
12
+ """
13
+ import os
14
+ import time
15
+ import sys
16
+ import re
17
+ import logging
18
+ import update_checker
19
+ from semver import VersionInfo
20
+ import click
21
+ import requests
22
+
23
+
24
+ from circup.backends import WebBackend, DiskBackend
25
+ from circup.logging import logger, log_formatter, LOGFILE
26
+ from circup.shared import BOARDLESS_COMMANDS, get_latest_release_from_url
27
+ from circup.bundle import Bundle
28
+ from circup.command_utils import (
29
+ get_device_path,
30
+ get_circup_version,
31
+ find_modules,
32
+ get_bundles_list,
33
+ completion_for_install,
34
+ get_bundle_versions,
35
+ libraries_from_requirements,
36
+ libraries_from_code_py,
37
+ get_dependencies,
38
+ get_bundles_local_dict,
39
+ save_local_bundles,
40
+ get_bundles_dict,
41
+ completion_for_example,
42
+ get_bundle_examples,
43
+ )
44
+
45
+
46
+ @click.group()
47
+ @click.option(
48
+ "--verbose", is_flag=True, help="Comprehensive logging is sent to stdout."
49
+ )
50
+ @click.option(
51
+ "--path",
52
+ type=click.Path(exists=True, file_okay=False),
53
+ help="Path to CircuitPython directory. Overrides automatic path detection.",
54
+ )
55
+ @click.option(
56
+ "--host",
57
+ help="Hostname or IP address of a device. Overrides automatic path detection.",
58
+ )
59
+ @click.option(
60
+ "--password",
61
+ help="Password to use for authentication when --host is used."
62
+ " You can optionally set an environment variable CIRCUP_WEBWORKFLOW_PASSWORD"
63
+ " instead of passing this argument. If both exist the CLI arg takes precedent.",
64
+ )
65
+ @click.option(
66
+ "--timeout",
67
+ default=30,
68
+ help="Specify the timeout in seconds for any network operations.",
69
+ )
70
+ @click.option(
71
+ "--board-id",
72
+ default=None,
73
+ help="Manual Board ID of the CircuitPython device. If provided in combination "
74
+ "with --cpy-version, it overrides the detected board ID.",
75
+ )
76
+ @click.option(
77
+ "--cpy-version",
78
+ default=None,
79
+ help="Manual CircuitPython version. If provided in combination "
80
+ "with --board-id, it overrides the detected CPy version.",
81
+ )
82
+ @click.version_option(
83
+ prog_name="CircUp",
84
+ message="%(prog)s, A CircuitPython module updater. Version %(version)s",
85
+ )
86
+ @click.pass_context
87
+ def main( # pylint: disable=too-many-locals
88
+ ctx, verbose, path, host, password, timeout, board_id, cpy_version
89
+ ): # pragma: no cover
90
+ """
91
+ A tool to manage and update libraries on a CircuitPython device.
92
+ """
93
+ # pylint: disable=too-many-arguments,too-many-branches,too-many-statements,too-many-locals
94
+ ctx.ensure_object(dict)
95
+ ctx.obj["TIMEOUT"] = timeout
96
+
97
+ if password is None:
98
+ password = os.getenv("CIRCUP_WEBWORKFLOW_PASSWORD")
99
+
100
+ device_path = get_device_path(host, password, path)
101
+
102
+ using_webworkflow = "host" in ctx.params.keys() and ctx.params["host"] is not None
103
+
104
+ if using_webworkflow:
105
+ if host == "circuitpython.local":
106
+ click.echo("Checking versions.json on circuitpython.local to find hostname")
107
+ versions_resp = requests.get(
108
+ "http://circuitpython.local/cp/version.json", timeout=timeout
109
+ )
110
+ host = f'{versions_resp.json()["hostname"]}.local'
111
+ click.echo(f"Using hostname: {host}")
112
+ device_path = device_path.replace("circuitpython.local", host)
113
+ try:
114
+ ctx.obj["backend"] = WebBackend(
115
+ host=host,
116
+ password=password,
117
+ logger=logger,
118
+ timeout=timeout,
119
+ version_override=cpy_version,
120
+ )
121
+ except ValueError as e:
122
+ click.secho(e, fg="red")
123
+ time.sleep(0.3)
124
+ sys.exit(1)
125
+ except RuntimeError as e:
126
+ click.secho(e, fg="red")
127
+ sys.exit(1)
128
+ else:
129
+ try:
130
+ ctx.obj["backend"] = DiskBackend(
131
+ device_path,
132
+ logger,
133
+ version_override=cpy_version,
134
+ )
135
+ except ValueError as e:
136
+ print(e)
137
+
138
+ if verbose:
139
+ # Configure additional logging to stdout.
140
+ ctx.obj["verbose"] = True
141
+ verbose_handler = logging.StreamHandler(sys.stdout)
142
+ verbose_handler.setLevel(logging.INFO)
143
+ verbose_handler.setFormatter(log_formatter)
144
+ logger.addHandler(verbose_handler)
145
+ click.echo("Logging to {}\n".format(LOGFILE))
146
+ else:
147
+ ctx.obj["verbose"] = False
148
+
149
+ logger.info("### Started Circup ###")
150
+
151
+ # If a newer version of circup is available, print a message.
152
+ logger.info("Checking for a newer version of circup")
153
+ version = get_circup_version()
154
+ if version:
155
+ update_checker.update_check("circup", version)
156
+
157
+ # stop early if the command is boardless
158
+ if ctx.invoked_subcommand in BOARDLESS_COMMANDS or "--help" in sys.argv:
159
+ return
160
+
161
+ ctx.obj["DEVICE_PATH"] = device_path
162
+ latest_version = get_latest_release_from_url(
163
+ "https://github.com/adafruit/circuitpython/releases/latest", logger
164
+ )
165
+
166
+ if device_path is None or not ctx.obj["backend"].is_device_present():
167
+ click.secho("Could not find a connected CircuitPython device.", fg="red")
168
+ sys.exit(1)
169
+ else:
170
+ cpy_version, board_id = (
171
+ ctx.obj["backend"].get_circuitpython_version()
172
+ if board_id is None or cpy_version is None
173
+ else (cpy_version, board_id)
174
+ )
175
+ click.echo(
176
+ "Found device at {}, running CircuitPython {}.".format(
177
+ device_path, cpy_version
178
+ )
179
+ )
180
+ try:
181
+ if VersionInfo.parse(cpy_version) < VersionInfo.parse(latest_version):
182
+ click.secho(
183
+ "A newer version of CircuitPython ({}) is available.".format(
184
+ latest_version
185
+ ),
186
+ fg="green",
187
+ )
188
+ if board_id:
189
+ url_download = f"https://circuitpython.org/board/{board_id}"
190
+ else:
191
+ url_download = "https://circuitpython.org/downloads"
192
+ click.secho("Get it here: {}".format(url_download), fg="green")
193
+ except ValueError as ex:
194
+ logger.warning("CircuitPython has incorrect semver value.")
195
+ logger.warning(ex)
196
+
197
+
198
+ @main.command()
199
+ @click.option("-r", "--requirement", is_flag=True)
200
+ @click.pass_context
201
+ def freeze(ctx, requirement): # pragma: no cover
202
+ """
203
+ Output details of all the modules found on the connected CIRCUITPYTHON
204
+ device. Option -r saves output to requirements.txt file
205
+ """
206
+ logger.info("Freeze")
207
+ modules = find_modules(ctx.obj["backend"], get_bundles_list())
208
+ if modules:
209
+ output = []
210
+ for module in modules:
211
+ output.append("{}=={}".format(module.name, module.device_version))
212
+ for module in output:
213
+ click.echo(module)
214
+ logger.info(module)
215
+ if requirement:
216
+ cwd = os.path.abspath(os.getcwd())
217
+ for i, module in enumerate(output):
218
+ output[i] += "\n"
219
+
220
+ overwrite = None
221
+ if os.path.exists(os.path.join(cwd, "requirements.txt")):
222
+ overwrite = click.confirm(
223
+ click.style(
224
+ "\nrequirements.txt file already exists in this location.\n"
225
+ "Do you want to overwrite it?",
226
+ fg="red",
227
+ ),
228
+ default=False,
229
+ )
230
+ else:
231
+ overwrite = True
232
+
233
+ if overwrite:
234
+ with open(
235
+ cwd + "/" + "requirements.txt", "w", newline="\n", encoding="utf-8"
236
+ ) as file:
237
+ file.truncate(0)
238
+ file.writelines(output)
239
+ else:
240
+ click.echo("No modules found on the device.")
241
+
242
+
243
+ @main.command("list")
244
+ @click.pass_context
245
+ def list_cli(ctx): # pragma: no cover
246
+ """
247
+ Lists all out of date modules found on the connected CIRCUITPYTHON device.
248
+ """
249
+ logger.info("List")
250
+ # Grab out of date modules.
251
+ data = [("Module", "Version", "Latest", "Update Reason")]
252
+
253
+ modules = [
254
+ m.row
255
+ for m in find_modules(ctx.obj["backend"], get_bundles_list())
256
+ if m.outofdate
257
+ ]
258
+ if modules:
259
+ data += modules
260
+ # Nice tabular display.
261
+ col_width = [0, 0, 0, 0]
262
+ for row in data:
263
+ for i, word in enumerate(row):
264
+ col_width[i] = max(len(word) + 2, col_width[i])
265
+ dashes = tuple(("-" * (width - 1) for width in col_width))
266
+ data.insert(1, dashes)
267
+ click.echo(
268
+ "The following modules are out of date or probably need an update.\n"
269
+ "Major Updates may include breaking changes. Review before updating.\n"
270
+ "MPY Format changes from Circuitpython 8 to 9 require an update.\n"
271
+ )
272
+ for row in data:
273
+ output = ""
274
+ for index, cell in enumerate(row):
275
+ output += cell.ljust(col_width[index])
276
+ if "--verbose" not in sys.argv:
277
+ click.echo(output)
278
+ logger.info(output)
279
+ else:
280
+ click.echo("All modules found on the device are up to date.")
281
+
282
+
283
+ # pylint: disable=too-many-arguments,too-many-locals
284
+ @main.command()
285
+ @click.argument(
286
+ "modules", required=False, nargs=-1, shell_complete=completion_for_install
287
+ )
288
+ @click.option(
289
+ "pyext",
290
+ "--py",
291
+ is_flag=True,
292
+ help="Install the .py version of the module(s) instead of the mpy version.",
293
+ )
294
+ @click.option(
295
+ "-r",
296
+ "--requirement",
297
+ type=click.Path(exists=True, dir_okay=False),
298
+ help="specify a text file to install all modules listed in the text file."
299
+ " Typically requirements.txt.",
300
+ )
301
+ @click.option(
302
+ "--auto", "-a", is_flag=True, help="Install the modules imported in code.py."
303
+ )
304
+ @click.option(
305
+ "--upgrade", "-U", is_flag=True, help="Upgrade modules that are already installed."
306
+ )
307
+ @click.option(
308
+ "--auto-file",
309
+ default=None,
310
+ help="Specify the name of a file on the board to read for auto install."
311
+ " Also accepts an absolute path or a local ./ path.",
312
+ )
313
+ @click.pass_context
314
+ def install(
315
+ ctx, modules, pyext, requirement, auto, auto_file, upgrade=False
316
+ ): # pragma: no cover
317
+ """
318
+ Install a named module(s) onto the device. Multiple modules
319
+ can be installed at once by providing more than one module name, each
320
+ separated by a space. Modules can be from a Bundle or local filepaths.
321
+ """
322
+
323
+ # TODO: Ensure there's enough space on the device
324
+ available_modules = get_bundle_versions(get_bundles_list())
325
+ mod_names = {}
326
+ for module, metadata in available_modules.items():
327
+ mod_names[module.replace(".py", "").lower()] = metadata
328
+ if requirement:
329
+ with open(requirement, "r", encoding="utf-8") as rfile:
330
+ requirements_txt = rfile.read()
331
+ requested_installs = libraries_from_requirements(requirements_txt)
332
+ elif auto or auto_file:
333
+ if auto_file is None:
334
+ auto_file = "code.py"
335
+ print(f"Auto file: {auto_file}")
336
+ # pass a local file with "./" or "../"
337
+ is_relative = not isinstance(ctx.obj["backend"], WebBackend) or auto_file.split(
338
+ os.sep
339
+ )[0] in [os.path.curdir, os.path.pardir]
340
+ if not os.path.isabs(auto_file) and not is_relative:
341
+ auto_file = ctx.obj["backend"].get_file_path(auto_file or "code.py")
342
+
343
+ auto_file_path = ctx.obj["backend"].get_auto_file_path(auto_file)
344
+ print(f"Auto file path: {auto_file_path}")
345
+ if not os.path.isfile(auto_file_path):
346
+ # fell through to here when run from random folder on windows - ask backend.
347
+ new_auto_file = ctx.obj["backend"].get_file_path(auto_file)
348
+ if os.path.isfile(new_auto_file):
349
+ auto_file = new_auto_file
350
+ auto_file_path = ctx.obj["backend"].get_auto_file_path(auto_file)
351
+ print(f"Auto file path: {auto_file_path}")
352
+ else:
353
+ click.secho(f"Auto file not found: {auto_file}", fg="red")
354
+ sys.exit(1)
355
+
356
+ requested_installs = libraries_from_code_py(auto_file_path, mod_names)
357
+ else:
358
+ requested_installs = modules
359
+ requested_installs = sorted(set(requested_installs))
360
+ click.echo(f"Searching for dependencies for: {requested_installs}")
361
+ to_install = get_dependencies(requested_installs, mod_names=mod_names)
362
+ device_modules = ctx.obj["backend"].get_device_versions()
363
+ if to_install is not None:
364
+ to_install = sorted(to_install)
365
+ click.echo(f"Ready to install: {to_install}\n")
366
+ for library in to_install:
367
+ ctx.obj["backend"].install_module(
368
+ ctx.obj["DEVICE_PATH"],
369
+ device_modules,
370
+ library,
371
+ pyext,
372
+ mod_names,
373
+ upgrade,
374
+ )
375
+
376
+
377
+ @main.command()
378
+ @click.option("--overwrite", is_flag=True, help="Overwrite the file if it exists.")
379
+ @click.argument(
380
+ "examples", required=True, nargs=-1, shell_complete=completion_for_example
381
+ )
382
+ @click.pass_context
383
+ def example(ctx, examples, overwrite):
384
+ """
385
+ Copy named example(s) from a bundle onto the device. Multiple examples
386
+ can be installed at once by providing more than one example name, each
387
+ separated by a space.
388
+ """
389
+
390
+ for example_arg in examples:
391
+ available_examples = get_bundle_examples(
392
+ get_bundles_list(), avoid_download=True
393
+ )
394
+ if example_arg in available_examples:
395
+ filename = available_examples[example_arg].split(os.path.sep)[-1]
396
+
397
+ if overwrite or not ctx.obj["backend"].file_exists(filename):
398
+ click.echo(
399
+ f"{'Copying' if not overwrite else 'Overwriting'}: {filename}"
400
+ )
401
+ ctx.obj["backend"].install_module_py(
402
+ {"path": available_examples[example_arg]}, location=""
403
+ )
404
+ else:
405
+ click.secho(
406
+ f"File: {filename} already exists. Use --overwrite if you wish to replace it.",
407
+ fg="red",
408
+ )
409
+ else:
410
+ click.secho(
411
+ f"Error: {example_arg} was not found in any local bundle examples.",
412
+ fg="red",
413
+ )
414
+
415
+
416
+ # pylint: enable=too-many-arguments,too-many-locals
417
+
418
+
419
+ @main.command()
420
+ @click.argument("match", required=False, nargs=1)
421
+ def show(match): # pragma: no cover
422
+ """
423
+ Show a list of available modules in the bundle. These are modules which
424
+ *could* be installed on the device.
425
+
426
+ If MATCH is specified only matching modules will be listed.
427
+ """
428
+ available_modules = get_bundle_versions(get_bundles_list())
429
+ module_names = sorted([m.replace(".py", "") for m in available_modules])
430
+ if match is not None:
431
+ match = match.lower()
432
+ module_names = [m for m in module_names if match in m]
433
+ click.echo("\n".join(module_names))
434
+
435
+ click.echo(
436
+ "{} shown of {} packages.".format(len(module_names), len(available_modules))
437
+ )
438
+
439
+
440
+ @main.command()
441
+ @click.argument("module", nargs=-1)
442
+ @click.pass_context
443
+ def uninstall(ctx, module): # pragma: no cover
444
+ """
445
+ Uninstall a named module(s) from the connected device. Multiple modules
446
+ can be uninstalled at once by providing more than one module name, each
447
+ separated by a space.
448
+ """
449
+ device_path = ctx.obj["DEVICE_PATH"]
450
+ print(f"Uninstalling {module} from {device_path}")
451
+ for name in module:
452
+ device_modules = ctx.obj["backend"].get_device_versions()
453
+ name = name.lower()
454
+ mod_names = {}
455
+ for module_item, metadata in device_modules.items():
456
+ mod_names[module_item.replace(".py", "").lower()] = metadata
457
+ if name in mod_names:
458
+ metadata = mod_names[name]
459
+ module_path = metadata["path"]
460
+ ctx.obj["backend"].uninstall(device_path, module_path)
461
+ click.echo("Uninstalled '{}'.".format(name))
462
+ else:
463
+ click.echo("Module '{}' not found on device.".format(name))
464
+ continue
465
+
466
+
467
+ # pylint: disable=too-many-branches
468
+
469
+
470
+ @main.command(
471
+ short_help=(
472
+ "Update modules on the device. "
473
+ "Use --all to automatically update all modules without Major Version warnings."
474
+ )
475
+ )
476
+ @click.option(
477
+ "update_all",
478
+ "--all",
479
+ is_flag=True,
480
+ help="Update all modules without Major Version warnings.",
481
+ )
482
+ @click.pass_context
483
+ # pylint: disable=too-many-locals
484
+ def update(ctx, update_all): # pragma: no cover
485
+ """
486
+ Checks for out-of-date modules on the connected CIRCUITPYTHON device, and
487
+ prompts the user to confirm updating such modules.
488
+ """
489
+ logger.info("Update")
490
+ # Grab current modules.
491
+ bundles_list = get_bundles_list()
492
+ installed_modules = find_modules(ctx.obj["backend"], bundles_list)
493
+ modules_to_update = [m for m in installed_modules if m.outofdate]
494
+
495
+ if not modules_to_update:
496
+ click.echo("None of the module[s] found on the device need an update.")
497
+ return
498
+
499
+ # Process out of date modules
500
+ updated_modules = []
501
+ click.echo("Found {} module[s] needing update.".format(len(modules_to_update)))
502
+ if not update_all:
503
+ click.echo("Please indicate which module[s] you wish to update:\n")
504
+ for module in modules_to_update:
505
+ update_flag = update_all
506
+ if "--verbose" in sys.argv:
507
+ click.echo(
508
+ "Device version: {}, Bundle version: {}".format(
509
+ module.device_version, module.bundle_version
510
+ )
511
+ )
512
+ if isinstance(module.bundle_version, str) and not VersionInfo.is_valid(
513
+ module.bundle_version
514
+ ):
515
+ click.secho(
516
+ f"WARNING: Library {module.name} repo has incorrect __version__"
517
+ "\n\tmetadata. Circup will assume it needs updating."
518
+ "\n\tPlease file an issue in the library repo.",
519
+ fg="yellow",
520
+ )
521
+ if module.repo:
522
+ click.secho(f"\t{module.repo}", fg="yellow")
523
+ if not update_flag:
524
+ if module.bad_format:
525
+ click.secho(
526
+ f"WARNING: '{module.name}': module corrupted or in an"
527
+ " unknown mpy format. Updating is required.",
528
+ fg="yellow",
529
+ )
530
+ update_flag = click.confirm("Do you want to update?")
531
+ elif module.mpy_mismatch:
532
+ click.secho(
533
+ f"WARNING: '{module.name}': mpy format doesn't match the"
534
+ " device's Circuitpython version. Updating is required.",
535
+ fg="yellow",
536
+ )
537
+ update_flag = click.confirm("Do you want to update?")
538
+ elif module.major_update:
539
+ update_flag = click.confirm(
540
+ (
541
+ "'{}' is a Major Version update and may contain breaking "
542
+ "changes. Do you want to update?".format(module.name)
543
+ )
544
+ )
545
+ else:
546
+ update_flag = click.confirm("Update '{}'?".format(module.name))
547
+ if update_flag:
548
+ # pylint: disable=broad-except
549
+ try:
550
+ ctx.obj["backend"].update(module)
551
+ updated_modules.append(module.name)
552
+ click.echo("Updated {}".format(module.name))
553
+ except Exception as ex:
554
+ logger.exception(ex)
555
+ click.echo("Something went wrong, {} (check the logs)".format(str(ex)))
556
+ # pylint: enable=broad-except
557
+
558
+ if not updated_modules:
559
+ return
560
+
561
+ # We updated modules, look to see if any requirements are missing
562
+ click.echo(
563
+ "Checking {} updated module[s] for missing requirements.".format(
564
+ len(updated_modules)
565
+ )
566
+ )
567
+ available_modules = get_bundle_versions(bundles_list)
568
+ mod_names = {}
569
+ for module, metadata in available_modules.items():
570
+ mod_names[module.replace(".py", "").lower()] = metadata
571
+ missing_modules = get_dependencies(updated_modules, mod_names=mod_names)
572
+ device_modules = ctx.obj["backend"].get_device_versions()
573
+ # Process newly needed modules
574
+ if missing_modules is not None:
575
+ installed_module_names = [m.name for m in installed_modules]
576
+ missing_modules = set(missing_modules) - set(installed_module_names)
577
+ missing_modules = sorted(list(missing_modules))
578
+ click.echo(f"Ready to install: {missing_modules}\n")
579
+ for library in missing_modules:
580
+ ctx.obj["backend"].install_module(
581
+ ctx.obj["DEVICE_PATH"], device_modules, library, False, mod_names
582
+ )
583
+
584
+
585
+ # pylint: enable=too-many-branches
586
+
587
+
588
+ @main.command("bundle-show")
589
+ @click.option("--modules", is_flag=True, help="List all the modules per bundle.")
590
+ def bundle_show(modules):
591
+ """
592
+ Show the list of bundles, default and local, with URL, current version
593
+ and latest version retrieved from the web.
594
+ """
595
+ local_bundles = get_bundles_local_dict().values()
596
+ bundles = get_bundles_list()
597
+ available_modules = get_bundle_versions(bundles)
598
+
599
+ for bundle in bundles:
600
+ if bundle.key in local_bundles:
601
+ click.secho(bundle.key, fg="yellow")
602
+ else:
603
+ click.secho(bundle.key, fg="green")
604
+ click.echo(" " + bundle.url)
605
+ click.echo(" version = " + bundle.current_tag)
606
+ if modules:
607
+ click.echo("Modules:")
608
+ for name, mod in sorted(available_modules.items()):
609
+ if mod["bundle"] == bundle:
610
+ click.echo(f" {name} ({mod.get('__version__', '-')})")
611
+
612
+
613
+ @main.command("bundle-add")
614
+ @click.argument("bundle", nargs=-1)
615
+ @click.pass_context
616
+ def bundle_add(ctx, bundle):
617
+ """
618
+ Add bundles to the local bundles list, by "user/repo" github string.
619
+ A series of tests to validate that the bundle exists and at least looks
620
+ like a bundle are done before validating it. There might still be errors
621
+ when the bundle is downloaded for the first time.
622
+ """
623
+
624
+ if len(bundle) == 0:
625
+ click.secho(
626
+ "Must pass bundle argument, expecting github URL or `user/repository` string.",
627
+ fg="red",
628
+ )
629
+ return
630
+
631
+ bundles_dict = get_bundles_local_dict()
632
+ modified = False
633
+ for bundle_repo in bundle:
634
+ # cleanup in case seombody pastes the URL to the repo/releases
635
+ bundle_repo = re.sub(
636
+ r"https?://github.com/([^/]+/[^/]+)(/.*)?", r"\1", bundle_repo
637
+ )
638
+ if bundle_repo in bundles_dict.values():
639
+ click.secho("Bundle already in list.", fg="yellow")
640
+ click.secho(" " + bundle_repo, fg="yellow")
641
+ continue
642
+ try:
643
+ bundle_added = Bundle(bundle_repo)
644
+ except ValueError:
645
+ click.secho(
646
+ "Bundle string invalid, expecting github URL or `user/repository` string.",
647
+ fg="red",
648
+ )
649
+ click.secho(" " + bundle_repo, fg="red")
650
+ continue
651
+ result = requests.get(
652
+ "https://github.com/" + bundle_repo, timeout=ctx.obj["TIMEOUT"]
653
+ )
654
+ # pylint: disable=no-member
655
+ if result.status_code == requests.codes.NOT_FOUND:
656
+ click.secho("Bundle invalid, the repository doesn't exist (404).", fg="red")
657
+ click.secho(" " + bundle_repo, fg="red")
658
+ continue
659
+ # pylint: enable=no-member
660
+ if not bundle_added.validate():
661
+ click.secho(
662
+ "Bundle invalid, is the repository a valid circup bundle ?", fg="red"
663
+ )
664
+ click.secho(" " + bundle_repo, fg="red")
665
+ continue
666
+ # note: use bun as the dictionary key for uniqueness
667
+ bundles_dict[bundle_repo] = bundle_repo
668
+ modified = True
669
+ click.echo("Added " + bundle_repo)
670
+ click.echo(" " + bundle_added.url)
671
+ if modified:
672
+ # save the bundles list
673
+ save_local_bundles(bundles_dict)
674
+ # update and get the new bundles for the first time
675
+ get_bundle_versions(get_bundles_list())
676
+
677
+
678
+ @main.command("bundle-remove")
679
+ @click.argument("bundle", nargs=-1)
680
+ @click.option("--reset", is_flag=True, help="Remove all local bundles.")
681
+ def bundle_remove(bundle, reset):
682
+ """
683
+ Remove one or more bundles from the local bundles list.
684
+ """
685
+ if reset:
686
+ save_local_bundles({})
687
+ return
688
+
689
+ if len(bundle) == 0:
690
+ click.secho(
691
+ "Must pass bundle argument or --reset, expecting github URL or "
692
+ "`user/repository` string. Run circup bundle-show to see a list of bundles.",
693
+ fg="red",
694
+ )
695
+ return
696
+ bundle_config = list(get_bundles_dict().values())
697
+ bundles_local_dict = get_bundles_local_dict()
698
+ modified = False
699
+ for bun in bundle:
700
+ # cleanup in case seombody pastes the URL to the repo/releases
701
+ bun = re.sub(r"https?://github.com/([^/]+/[^/]+)(/.*)?", r"\1", bun)
702
+ found = False
703
+ for name, repo in list(bundles_local_dict.items()):
704
+ if bun in (name, repo):
705
+ found = True
706
+ click.secho(f"Bundle {repo}")
707
+ do_it = click.confirm("Do you want to remove that bundle ?")
708
+ if do_it:
709
+ click.secho("Removing the bundle from the local list", fg="yellow")
710
+ click.secho(f" {bun}", fg="yellow")
711
+ modified = True
712
+ del bundles_local_dict[name]
713
+ if not found:
714
+ if bun in bundle_config:
715
+ click.secho("Cannot remove built-in module:" "\n " + bun, fg="red")
716
+ else:
717
+ click.secho(
718
+ "Bundle not found in the local list, nothing removed:"
719
+ "\n " + bun,
720
+ fg="red",
721
+ )
722
+ if modified:
723
+ save_local_bundles(bundles_local_dict)