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/__init__.py +26 -0
- circup/backends.py +957 -0
- circup/bundle.py +170 -0
- circup/command_utils.py +627 -0
- circup/commands.py +723 -0
- circup/config/bundle_config.json +5 -0
- circup/config/bundle_config.json.license +3 -0
- circup/logging.py +33 -0
- circup/module.py +209 -0
- circup/shared.py +218 -0
- circup-2.0.1.dist-info/LICENSE +21 -0
- circup-2.0.1.dist-info/METADATA +357 -0
- circup-2.0.1.dist-info/RECORD +16 -0
- circup-2.0.1.dist-info/WHEEL +5 -0
- circup-2.0.1.dist-info/entry_points.txt +2 -0
- circup-2.0.1.dist-info/top_level.txt +1 -0
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)
|