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/backends.py ADDED
@@ -0,0 +1,957 @@
1
+ # SPDX-FileCopyrightText: 2019 Nicholas Tollervey, written for Adafruit Industries
2
+ # SPDX-FileCopyrightText: 2023 Tim Cocks, written for Adafruit Industries
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+ """
6
+ Backend classes that represent interfaces to physical devices.
7
+ """
8
+ import os
9
+ import shutil
10
+ import sys
11
+ import socket
12
+ import tempfile
13
+ from urllib.parse import urlparse, urljoin
14
+ import click
15
+ import requests
16
+ from requests.adapters import HTTPAdapter
17
+ from requests.auth import HTTPBasicAuth
18
+
19
+ from circup.shared import DATA_DIR, BAD_FILE_FORMAT, extract_metadata, _get_modules_file
20
+
21
+ #: The location to store a local copy of code.py for use with --auto and
22
+ # web workflow
23
+ LOCAL_CODE_PY_COPY = os.path.join(DATA_DIR, "code.tmp.py")
24
+
25
+
26
+ class Backend:
27
+ """
28
+ Backend parent class to be extended for workflow specific
29
+ implementations
30
+ """
31
+
32
+ def __init__(self, logger, version_override=None):
33
+ self.device_location = None
34
+ self.LIB_DIR_PATH = None
35
+ self.version_override = version_override
36
+ self.logger = logger
37
+
38
+ def get_circuitpython_version(self):
39
+ """
40
+ Must be overridden by subclass for implementation!
41
+
42
+ Returns the version number of CircuitPython running on the board connected
43
+ via ``device_url``, along with the board ID.
44
+
45
+ :param str device_location: http based device URL or local file path.
46
+ :return: A tuple with the version string for CircuitPython and the board ID string.
47
+ """
48
+ raise NotImplementedError
49
+
50
+ def _get_modules(self, device_lib_path):
51
+ """
52
+ To be overridden by subclass
53
+ """
54
+ raise NotImplementedError
55
+
56
+ def get_modules(self, device_url):
57
+ """
58
+ Get a dictionary containing metadata about all the Python modules found in
59
+ the referenced path.
60
+
61
+ :param str device_url: URL to be used to find modules.
62
+ :return: A dictionary containing metadata about the found modules.
63
+ """
64
+ return self._get_modules(device_url)
65
+
66
+ def get_device_versions(self):
67
+ """
68
+ Returns a dictionary of metadata from modules on the connected device.
69
+
70
+ :param str device_url: URL for the device.
71
+ :return: A dictionary of metadata about the modules available on the
72
+ connected device.
73
+ """
74
+ return self.get_modules(os.path.join(self.device_location, self.LIB_DIR_PATH))
75
+
76
+ def _create_library_directory(self, device_path, library_path):
77
+ """
78
+ To be overridden by subclass
79
+ """
80
+ raise NotImplementedError
81
+
82
+ def install_module_py(self, metadata, location=None):
83
+ """
84
+ To be overridden by subclass
85
+ """
86
+ raise NotImplementedError
87
+
88
+ def install_module_mpy(self, bundle, metadata):
89
+ """
90
+ To be overridden by subclass
91
+ """
92
+ raise NotImplementedError
93
+
94
+ def copy_file(self, target_file, location_to_paste):
95
+ """Paste a copy of the specified file at the location given
96
+ To be overridden by subclass
97
+ """
98
+ raise NotImplementedError
99
+
100
+ # pylint: disable=too-many-locals,too-many-branches,too-many-arguments,too-many-nested-blocks,too-many-statements
101
+ def install_module(
102
+ self, device_path, device_modules, name, pyext, mod_names, upgrade=False
103
+ ): # pragma: no cover
104
+ """
105
+ Finds a connected device and installs a given module name if it
106
+ is available in the current module bundle and is not already
107
+ installed on the device.
108
+ TODO: There is currently no check for the version.
109
+
110
+ :param str device_path: The path to the connected board.
111
+ :param list(dict) device_modules: List of module metadata from the device.
112
+ :param str name: Name of module to install
113
+ :param bool pyext: Boolean to specify if the module should be installed from
114
+ source or from a pre-compiled module
115
+ :param mod_names: Dictionary of metadata from modules that can be generated
116
+ with get_bundle_versions()
117
+ :param bool upgrade: Upgrade the specified modules if they're already installed.
118
+ """
119
+ local_path = None
120
+ if os.path.exists(name):
121
+ # local file exists use that.
122
+ local_path = name
123
+ name = local_path.split(os.path.sep)[-1]
124
+ name = name.replace(".py", "").replace(".mpy", "")
125
+ click.echo(f"Installing from local path: {local_path}")
126
+
127
+ if not name:
128
+ click.echo("No module name(s) provided.")
129
+ return
130
+ if name in mod_names or local_path is not None:
131
+
132
+ # Grab device modules to check if module already installed
133
+ if name in device_modules:
134
+ if not upgrade:
135
+ # skip already installed modules if no -upgrade flag
136
+ click.echo("'{}' is already installed.".format(name))
137
+ return
138
+
139
+ # uninstall the module before installing
140
+ name = name.lower()
141
+ _mod_names = {}
142
+ for module_item, _metadata in device_modules.items():
143
+ _mod_names[module_item.replace(".py", "").lower()] = _metadata
144
+ if name in _mod_names:
145
+ _metadata = _mod_names[name]
146
+ module_path = _metadata["path"]
147
+ self.uninstall(device_path, module_path)
148
+
149
+ new_module_size = 0
150
+ library_path = (
151
+ os.path.join(device_path, self.LIB_DIR_PATH)
152
+ if not isinstance(self, WebBackend)
153
+ else urljoin(device_path, self.LIB_DIR_PATH)
154
+ )
155
+ if local_path is None:
156
+ metadata = mod_names[name]
157
+ bundle = metadata["bundle"]
158
+ else:
159
+ metadata = {"path": local_path}
160
+
161
+ new_module_size = os.path.getsize(metadata["path"])
162
+ if os.path.isdir(metadata["path"]):
163
+ # pylint: disable=unused-variable
164
+ for dirpath, dirnames, filenames in os.walk(metadata["path"]):
165
+ for f in filenames:
166
+ fp = os.path.join(dirpath, f)
167
+ try:
168
+ if not os.path.islink(fp): # Ignore symbolic links
169
+ new_module_size += os.path.getsize(fp)
170
+ else:
171
+ self.logger.warning(
172
+ f"Skipping symbolic link in space calculation: {fp}"
173
+ )
174
+ except OSError as e:
175
+ self.logger.error(
176
+ f"Error: {e} - Skipping file in space calculation: {fp}"
177
+ )
178
+
179
+ if self.get_free_space() < new_module_size:
180
+ self.logger.error(
181
+ f"Aborted installing module {name} - "
182
+ f"not enough free space ({new_module_size} < {self.get_free_space()})"
183
+ )
184
+ click.secho(
185
+ f"Aborted installing module {name} - "
186
+ f"not enough free space ({new_module_size} < {self.get_free_space()})",
187
+ fg="red",
188
+ )
189
+ return
190
+
191
+ # Create the library directory first.
192
+ self._create_library_directory(device_path, library_path)
193
+ if local_path is None:
194
+ if pyext:
195
+ # Use Python source for module.
196
+ self.install_module_py(metadata)
197
+ else:
198
+ # Use pre-compiled mpy modules.
199
+ self.install_module_mpy(bundle, metadata)
200
+ else:
201
+ self.copy_file(metadata["path"], "lib")
202
+ click.echo("Installed '{}'.".format(name))
203
+ else:
204
+ click.echo("Unknown module named, '{}'.".format(name))
205
+
206
+ # def libraries_from_imports(self, code_py, mod_names):
207
+ # """
208
+ # To be overridden by subclass
209
+ # """
210
+ # raise NotImplementedError
211
+
212
+ def uninstall(self, device_path, module_path):
213
+ """
214
+ To be overridden by subclass
215
+ """
216
+ raise NotImplementedError
217
+
218
+ def update(self, module):
219
+ """
220
+ To be overridden by subclass
221
+ """
222
+ raise NotImplementedError
223
+
224
+ def get_file_path(self, filename):
225
+ """
226
+ To be overridden by subclass
227
+ """
228
+ raise NotImplementedError
229
+
230
+ def get_free_space(self):
231
+ """
232
+ To be overridden by subclass
233
+ """
234
+ raise NotImplementedError
235
+
236
+ def is_device_present(self):
237
+ """
238
+ To be overriden by subclass
239
+ """
240
+ raise NotImplementedError
241
+
242
+ @staticmethod
243
+ def parse_boot_out_file(boot_out_contents):
244
+ """
245
+ Parse the contents of boot_out.txt
246
+ Returns: circuitpython version and board id
247
+ """
248
+ lines = boot_out_contents.split("\n")
249
+ version_line = lines[0]
250
+ circuit_python = version_line.split(";")[0].split(" ")[-3]
251
+ board_line = lines[1]
252
+ if board_line.startswith("Board ID:"):
253
+ board_id = board_line[9:].strip()
254
+ else:
255
+ board_id = ""
256
+ return circuit_python, board_id
257
+
258
+ def file_exists(self, filepath):
259
+ """
260
+ To be overriden by subclass
261
+ """
262
+ raise NotImplementedError
263
+
264
+
265
+ def _writeable_error():
266
+ click.secho(
267
+ "CircuitPython Web Workflow Device not writable\n - "
268
+ "Remount storage as writable to device (not PC)",
269
+ fg="red",
270
+ )
271
+ sys.exit(1)
272
+
273
+
274
+ class WebBackend(Backend):
275
+ """
276
+ Backend for interacting with a device via Web Workflow
277
+ """
278
+
279
+ def __init__( # pylint: disable=too-many-arguments
280
+ self, host, password, logger, timeout=10, version_override=None
281
+ ):
282
+ super().__init__(logger)
283
+ if password is None:
284
+ raise ValueError("--host needs --password")
285
+
286
+ # pylint: disable=no-member
287
+ # verify hostname/address
288
+ try:
289
+ socket.getaddrinfo(host, 80, proto=socket.IPPROTO_TCP)
290
+ except socket.gaierror as exc:
291
+ raise RuntimeError(
292
+ "Invalid host: {}.".format(host) + " You should remove the 'http://'"
293
+ if "http://" in host or "https://" in host
294
+ else "Could not find or connect to specified device"
295
+ ) from exc
296
+
297
+ self.FS_PATH = "fs/"
298
+ self.LIB_DIR_PATH = f"{self.FS_PATH}lib/"
299
+ self.host = host
300
+ self.password = password
301
+ self.device_location = f"http://:{self.password}@{self.host}"
302
+
303
+ self.session = requests.Session()
304
+ self.session.mount(self.device_location, HTTPAdapter(max_retries=5))
305
+ self.library_path = self.device_location + "/" + self.LIB_DIR_PATH
306
+ self.timeout = timeout
307
+ self.version_override = version_override
308
+
309
+ def install_file_http(self, source, location=None):
310
+ """
311
+ Install file to device using web workflow.
312
+ :param source source file.
313
+ :param location the location on the device to copy the source
314
+ directory in to. If omitted is CIRCUITPY/lib/ used.
315
+ """
316
+ file_name = source.split(os.path.sep)
317
+ file_name = file_name[-2] if file_name[-1] == "" else file_name[-1]
318
+
319
+ if location is None:
320
+ target = self.device_location + "/" + self.LIB_DIR_PATH + file_name
321
+ else:
322
+ target = self.device_location + "/" + self.FS_PATH + location + file_name
323
+
324
+ auth = HTTPBasicAuth("", self.password)
325
+
326
+ with open(source, "rb") as fp:
327
+ r = self.session.put(target, fp.read(), auth=auth, timeout=self.timeout)
328
+ if r.status_code == 409:
329
+ _writeable_error()
330
+ r.raise_for_status()
331
+
332
+ def install_dir_http(self, source, location=None):
333
+ """
334
+ Install directory to device using web workflow.
335
+ :param source source directory.
336
+ :param location the location on the device to copy the source
337
+ directory in to. If omitted is CIRCUITPY/lib/ used.
338
+ """
339
+ mod_name = source.split(os.path.sep)
340
+ mod_name = mod_name[-2] if mod_name[-1] == "" else mod_name[-1]
341
+ if location is None:
342
+ target = self.device_location + "/" + self.LIB_DIR_PATH + mod_name
343
+ else:
344
+ target = self.device_location + "/" + self.FS_PATH + location + mod_name
345
+ target = target + "/" if target[:-1] != "/" else target
346
+ url = urlparse(target)
347
+ auth = HTTPBasicAuth("", url.password)
348
+
349
+ # Create the top level directory.
350
+ with self.session.put(target, auth=auth, timeout=self.timeout) as r:
351
+ if r.status_code == 409:
352
+ _writeable_error()
353
+ r.raise_for_status()
354
+
355
+ # Traverse the directory structure and create the directories/files.
356
+ for root, dirs, files in os.walk(source):
357
+ rel_path = os.path.relpath(root, source)
358
+ if rel_path == ".":
359
+ rel_path = ""
360
+ for name in dirs:
361
+ path_to_create = (
362
+ urljoin(
363
+ urljoin(target, rel_path + "/", allow_fragments=False),
364
+ name,
365
+ allow_fragments=False,
366
+ )
367
+ if rel_path != ""
368
+ else urljoin(target, name, allow_fragments=False)
369
+ )
370
+ path_to_create = (
371
+ path_to_create + "/"
372
+ if path_to_create[:-1] != "/"
373
+ else path_to_create
374
+ )
375
+
376
+ with self.session.put(
377
+ path_to_create, auth=auth, timeout=self.timeout
378
+ ) as r:
379
+ if r.status_code == 409:
380
+ _writeable_error()
381
+ r.raise_for_status()
382
+ for name in files:
383
+ with open(os.path.join(root, name), "rb") as fp:
384
+ path_to_create = (
385
+ urljoin(
386
+ urljoin(target, rel_path + "/", allow_fragments=False),
387
+ name,
388
+ allow_fragments=False,
389
+ )
390
+ if rel_path != ""
391
+ else urljoin(target, name, allow_fragments=False)
392
+ )
393
+ with self.session.put(
394
+ path_to_create, fp.read(), auth=auth, timeout=self.timeout
395
+ ) as r:
396
+ if r.status_code == 409:
397
+ _writeable_error()
398
+ r.raise_for_status()
399
+
400
+ def get_circuitpython_version(self):
401
+ """
402
+ Returns the version number of CircuitPython running on the board connected
403
+ via ``device_path``, along with the board ID. This is obtained using
404
+ RESTful API from the /cp/version.json URL.
405
+
406
+ :return: A tuple with the version string for CircuitPython and the board ID string.
407
+ """
408
+ if self.version_override is not None:
409
+ return self.version_override
410
+
411
+ # pylint: disable=arguments-renamed
412
+ with self.session.get(
413
+ self.device_location + "/cp/version.json", timeout=self.timeout
414
+ ) as r:
415
+ # pylint: disable=no-member
416
+ if r.status_code != requests.codes.ok:
417
+ click.secho(
418
+ f" Unable to get version from {self.device_location}: {r.status_code}",
419
+ fg="red",
420
+ )
421
+ sys.exit(1)
422
+ # pylint: enable=no-member
423
+ ver_json = r.json()
424
+ return ver_json.get("version"), ver_json.get("board_id")
425
+
426
+ def _get_modules(self, device_lib_path):
427
+ return self._get_modules_http(device_lib_path)
428
+
429
+ def _get_modules_http(self, url):
430
+ """
431
+ Get a dictionary containing metadata about all the Python modules found using
432
+ the referenced URL.
433
+
434
+ :param str url: URL for the modules.
435
+ :return: A dictionary containing metadata about the found modules.
436
+ """
437
+ result = {}
438
+ u = urlparse(url)
439
+ auth = HTTPBasicAuth("", u.password)
440
+ with self.session.get(
441
+ url, auth=auth, headers={"Accept": "application/json"}, timeout=self.timeout
442
+ ) as r:
443
+ r.raise_for_status()
444
+
445
+ directory_mods = []
446
+ single_file_mods = []
447
+
448
+ for entry in r.json()["files"]:
449
+
450
+ entry_name = entry.get("name")
451
+ if entry.get("directory"):
452
+ directory_mods.append(entry_name)
453
+ else:
454
+ if entry_name.endswith(".py") or entry_name.endswith(".mpy"):
455
+ single_file_mods.append(entry_name)
456
+
457
+ self._get_modules_http_single_mods(auth, result, single_file_mods, url)
458
+ self._get_modules_http_dir_mods(auth, directory_mods, result, url)
459
+
460
+ return result
461
+
462
+ def _get_modules_http_dir_mods(self, auth, directory_mods, result, url):
463
+ # pylint: disable=too-many-locals
464
+ """
465
+ Builds result dictionary with keys containing module names and values containing a
466
+ dictionary with metadata bout the module like version, compatibility, mpy or not etc.
467
+
468
+ :param auth HTTP authentication.
469
+ :param directory_mods list of modules.
470
+ :param result dictionary for the result.
471
+ :param url: URL of the device.
472
+ """
473
+ for dm in directory_mods:
474
+ if str(urlparse(dm).scheme).lower() not in ("http", "https"):
475
+ dm_url = url + dm + "/"
476
+ else:
477
+ dm_url = dm
478
+
479
+ with self.session.get(
480
+ dm_url,
481
+ auth=auth,
482
+ headers={"Accept": "application/json"},
483
+ timeout=self.timeout,
484
+ ) as r:
485
+ r.raise_for_status()
486
+ mpy = False
487
+
488
+ for entry in r.json()["files"]:
489
+ entry_name = entry.get("name")
490
+ if not entry.get("directory") and (
491
+ entry_name.endswith(".py") or entry_name.endswith(".mpy")
492
+ ):
493
+ if entry_name.endswith(".mpy"):
494
+ mpy = True
495
+
496
+ with self.session.get(
497
+ dm_url + entry_name, auth=auth, timeout=self.timeout
498
+ ) as rr:
499
+ rr.raise_for_status()
500
+ idx = entry_name.rfind(".")
501
+ with tempfile.NamedTemporaryFile(
502
+ prefix=entry_name[:idx] + "-",
503
+ suffix=entry_name[idx:],
504
+ delete=False,
505
+ ) as fp:
506
+ fp.write(rr.content)
507
+ tmp_name = fp.name
508
+ metadata = extract_metadata(tmp_name, self.logger)
509
+ os.remove(tmp_name)
510
+ if "__version__" in metadata:
511
+ metadata["path"] = dm_url
512
+ result[dm] = metadata
513
+ # break now if any of the submodules has a bad format
514
+ if metadata["__version__"] == BAD_FILE_FORMAT:
515
+ break
516
+
517
+ if result.get(dm) is None:
518
+ result[dm] = {"path": dm_url, "mpy": mpy}
519
+
520
+ def _get_modules_http_single_mods(self, auth, result, single_file_mods, url):
521
+ """
522
+ :param auth HTTP authentication.
523
+ :param single_file_mods list of modules.
524
+ :param result dictionary for the result.
525
+ :param url: URL of the device.
526
+ """
527
+ for sfm in single_file_mods:
528
+ if str(urlparse(sfm).scheme).lower() not in ("http", "https"):
529
+ sfm_url = url + sfm
530
+ else:
531
+ sfm_url = sfm
532
+ with self.session.get(sfm_url, auth=auth, timeout=self.timeout) as r:
533
+ r.raise_for_status()
534
+ idx = sfm.rfind(".")
535
+ with tempfile.NamedTemporaryFile(
536
+ prefix=sfm[:idx] + "-", suffix=sfm[idx:], delete=False
537
+ ) as fp:
538
+ fp.write(r.content)
539
+ tmp_name = fp.name
540
+ metadata = extract_metadata(tmp_name, self.logger)
541
+ os.remove(tmp_name)
542
+ metadata["path"] = sfm_url
543
+ result[sfm[:idx]] = metadata
544
+
545
+ def _create_library_directory(self, device_path, library_path):
546
+ url = urlparse(device_path)
547
+ auth = HTTPBasicAuth("", url.password)
548
+ with self.session.put(library_path, auth=auth, timeout=self.timeout) as r:
549
+ if r.status_code == 409:
550
+ _writeable_error()
551
+ r.raise_for_status()
552
+
553
+ def copy_file(self, target_file, location_to_paste):
554
+ if os.path.isdir(target_file):
555
+ create_directory_url = urljoin(
556
+ self.device_location,
557
+ "/".join(("fs", location_to_paste, target_file, "")),
558
+ )
559
+ self._create_library_directory(self.device_location, create_directory_url)
560
+ self.install_dir_http(target_file)
561
+ else:
562
+ self.install_file_http(target_file)
563
+
564
+ def install_module_mpy(self, bundle, metadata):
565
+ """
566
+ :param bundle library bundle.
567
+ :param library_path library path
568
+ :param metadata dictionary.
569
+ """
570
+ module_name = os.path.basename(metadata["path"]).replace(".py", ".mpy")
571
+ if not module_name:
572
+ # Must be a directory based module.
573
+ module_name = os.path.basename(os.path.dirname(metadata["path"]))
574
+ major_version = self.get_circuitpython_version()[0].split(".")[0]
575
+ bundle_platform = "{}mpy".format(major_version)
576
+ bundle_path = os.path.join(bundle.lib_dir(bundle_platform), module_name)
577
+ if os.path.isdir(bundle_path):
578
+
579
+ self.install_dir_http(bundle_path)
580
+
581
+ elif os.path.isfile(bundle_path):
582
+ self.install_file_http(bundle_path)
583
+
584
+ else:
585
+ raise IOError("Cannot find compiled version of module.")
586
+
587
+ # pylint: enable=too-many-locals,too-many-branches
588
+ def install_module_py(self, metadata, location=None):
589
+ """
590
+ :param library_path library path
591
+ :param metadata dictionary.
592
+ """
593
+
594
+ source_path = metadata["path"] # Path to Python source version.
595
+ if os.path.isdir(source_path):
596
+ self.install_dir_http(source_path, location=location)
597
+
598
+ else:
599
+ self.install_file_http(source_path, location=location)
600
+
601
+ def get_auto_file_path(self, auto_file_path):
602
+ """
603
+ Make a local temp copy of the --auto file from the device.
604
+ Returns the path to the local copy.
605
+ """
606
+ url = auto_file_path
607
+ auth = HTTPBasicAuth("", self.password)
608
+ with self.session.get(url, auth=auth, timeout=self.timeout) as r:
609
+ r.raise_for_status()
610
+ with open(LOCAL_CODE_PY_COPY, "w", encoding="utf-8") as f:
611
+ f.write(r.text)
612
+ return LOCAL_CODE_PY_COPY
613
+
614
+ def uninstall(self, device_path, module_path):
615
+ """
616
+ Uninstall given module on device using REST API.
617
+ """
618
+ url = urlparse(device_path)
619
+ auth = HTTPBasicAuth("", url.password)
620
+ with self.session.delete(module_path, auth=auth, timeout=self.timeout) as r:
621
+ if r.status_code == 409:
622
+ _writeable_error()
623
+ r.raise_for_status()
624
+
625
+ def update(self, module):
626
+ """
627
+ Delete the module on the device, then copy the module from the bundle
628
+ back onto the device.
629
+
630
+ The caller is expected to handle any exceptions raised.
631
+ """
632
+ self._update_http(module)
633
+
634
+ def file_exists(self, filepath):
635
+ """
636
+ return True if the file exists, otherwise False.
637
+ """
638
+ auth = HTTPBasicAuth("", self.password)
639
+ resp = requests.get(
640
+ self.get_file_path(filepath), auth=auth, timeout=self.timeout
641
+ )
642
+ if resp.status_code == 200:
643
+ return True
644
+ return False
645
+
646
+ def _update_http(self, module):
647
+ """
648
+ Update the module using web workflow.
649
+ """
650
+ if module.file:
651
+ # Copy the file (will overwrite).
652
+ self.install_file_http(module.bundle_path)
653
+ else:
654
+ # Delete the directory (recursive) first.
655
+ url = urlparse(module.path)
656
+ auth = HTTPBasicAuth("", url.password)
657
+ with self.session.delete(module.path, auth=auth, timeout=self.timeout) as r:
658
+ if r.status_code == 409:
659
+ _writeable_error()
660
+ r.raise_for_status()
661
+ self.install_dir_http(module.bundle_path)
662
+
663
+ def get_file_path(self, filename):
664
+ """
665
+ retuns the full path on the device to a given file name.
666
+ """
667
+ return urljoin(
668
+ urljoin(self.device_location, "fs/", allow_fragments=False),
669
+ filename,
670
+ allow_fragments=False,
671
+ )
672
+
673
+ def is_device_present(self):
674
+ """
675
+ returns True if the device is currently connected and running supported version
676
+ """
677
+ try:
678
+ with self.session.get(f"{self.device_location}/cp/version.json") as r:
679
+ r.raise_for_status()
680
+ web_api_version = r.json().get("web_api_version")
681
+ if web_api_version is None:
682
+ self.logger.error("Unable to get web API version from device.")
683
+ click.secho("Unable to get web API version from device.", fg="red")
684
+ return False
685
+
686
+ if web_api_version < 4:
687
+ self.logger.error(
688
+ f"Device running unsupported web API version {web_api_version} < 4."
689
+ )
690
+ click.secho(
691
+ f"Device running unsupported web API version {web_api_version} < 4.",
692
+ fg="red",
693
+ )
694
+ return False
695
+ except requests.exceptions.ConnectionError:
696
+ return False
697
+
698
+ return True
699
+
700
+ def get_device_versions(self):
701
+ """
702
+ Returns a dictionary of metadata from modules on the connected device.
703
+
704
+ :param str device_url: URL for the device.
705
+ :return: A dictionary of metadata about the modules available on the
706
+ connected device.
707
+ """
708
+ return self.get_modules(urljoin(self.device_location, self.LIB_DIR_PATH))
709
+
710
+ def get_free_space(self):
711
+ """
712
+ Returns the free space on the device in bytes.
713
+ """
714
+ auth = HTTPBasicAuth("", self.password)
715
+ with self.session.get(
716
+ urljoin(self.device_location, "fs/"),
717
+ auth=auth,
718
+ headers={"Accept": "application/json"},
719
+ timeout=self.timeout,
720
+ ) as r:
721
+ r.raise_for_status()
722
+ if r.json().get("free") is None:
723
+ self.logger.error("Unable to get free block count from device.")
724
+ click.secho("Unable to get free block count from device.", fg="red")
725
+ elif r.json().get("block_size") is None:
726
+ self.logger.error("Unable to get block size from device.")
727
+ click.secho("Unable to get block size from device.", fg="red")
728
+ elif r.json().get("writable") is None or r.json().get("writable") is False:
729
+ self.logger.error(
730
+ "CircuitPython Web Workflow Device not writable\n - "
731
+ "Remount storage as writable to device (not PC)"
732
+ )
733
+ click.secho(
734
+ "CircuitPython Web Workflow Device not writable\n - "
735
+ "Remount storage as writable to device (not PC)",
736
+ fg="red",
737
+ )
738
+ else:
739
+ return r.json()["free"] * r.json()["block_size"] # bytes
740
+ sys.exit(1)
741
+
742
+
743
+ class DiskBackend(Backend):
744
+ """
745
+ Backend for interacting with a device via USB Workflow
746
+
747
+ :param String device_location: Path to the device
748
+ :param logger: logger to use for outputting messages
749
+ :param String boot_out: Optional mock contents of a boot_out.txt file
750
+ to use for version information.
751
+ :param String version_override: Optional mock version to use.
752
+ """
753
+
754
+ def __init__(self, device_location, logger, boot_out=None, version_override=None):
755
+ if device_location is None:
756
+ raise ValueError(
757
+ "Auto locating USB Disk based device failed. "
758
+ "Please specify --path argument or ensure your device "
759
+ "is connected and mounted under the name CIRCUITPY."
760
+ )
761
+ super().__init__(logger)
762
+ self.LIB_DIR_PATH = "lib"
763
+ self.device_location = device_location
764
+ self.library_path = os.path.join(self.device_location, self.LIB_DIR_PATH)
765
+ self.version_info = None
766
+ if boot_out is not None:
767
+ self.version_info = self.parse_boot_out_file(boot_out)
768
+ self.version_override = version_override
769
+
770
+ def get_circuitpython_version(self):
771
+ """
772
+ Returns the version number of CircuitPython running on the board connected
773
+ via ``device_path``, along with the board ID. This is obtained from the
774
+ ``boot_out.txt`` file on the device, whose first line will start with
775
+ something like this::
776
+
777
+ Adafruit CircuitPython 4.1.0 on 2019-08-02;
778
+
779
+ While the second line is::
780
+
781
+ Board ID:raspberry_pi_pico
782
+
783
+ :return: A tuple with the version string for CircuitPython and the board ID string.
784
+ """
785
+ if self.version_override is not None:
786
+ return self.version_override
787
+
788
+ if not self.version_info:
789
+ try:
790
+ with open(
791
+ os.path.join(self.device_location, "boot_out.txt"),
792
+ "r",
793
+ encoding="utf-8",
794
+ ) as boot:
795
+ boot_out_contents = boot.read()
796
+ circuit_python, board_id = self.parse_boot_out_file(
797
+ boot_out_contents
798
+ )
799
+ except FileNotFoundError:
800
+ click.secho(
801
+ "Missing file boot_out.txt on the device: wrong path or drive corrupted.",
802
+ fg="red",
803
+ )
804
+ self.logger.error("boot_out.txt not found.")
805
+ sys.exit(1)
806
+ return circuit_python, board_id
807
+
808
+ return self.version_info
809
+
810
+ def _get_modules(self, device_lib_path):
811
+ """
812
+ Get a dictionary containing metadata about all the Python modules found in
813
+ the referenced path.
814
+
815
+ :param str device_lib_path: URL to be used to find modules.
816
+ :return: A dictionary containing metadata about the found modules.
817
+ """
818
+ return _get_modules_file(device_lib_path, self.logger)
819
+
820
+ def _create_library_directory(self, device_path, library_path):
821
+ if not os.path.exists(library_path): # pragma: no cover
822
+ os.makedirs(library_path)
823
+
824
+ def copy_file(self, target_file, location_to_paste):
825
+ target_filename = target_file.split(os.path.sep)[-1]
826
+ if os.path.isdir(target_file):
827
+ shutil.copytree(
828
+ target_file,
829
+ os.path.join(self.device_location, location_to_paste, target_filename),
830
+ )
831
+ else:
832
+ shutil.copyfile(
833
+ target_file,
834
+ os.path.join(self.device_location, location_to_paste, target_filename),
835
+ )
836
+
837
+ def install_module_mpy(self, bundle, metadata):
838
+ """
839
+ :param bundle library bundle.
840
+ :param metadata dictionary.
841
+ """
842
+ module_name = os.path.basename(metadata["path"]).replace(".py", ".mpy")
843
+ if not module_name:
844
+ # Must be a directory based module.
845
+ module_name = os.path.basename(os.path.dirname(metadata["path"]))
846
+
847
+ major_version = self.get_circuitpython_version()[0].split(".")[0]
848
+ bundle_platform = "{}mpy".format(major_version)
849
+ bundle_path = os.path.join(bundle.lib_dir(bundle_platform), module_name)
850
+ if os.path.isdir(bundle_path):
851
+ target_path = os.path.join(self.library_path, module_name)
852
+ # Copy the directory.
853
+ shutil.copytree(bundle_path, target_path)
854
+ elif os.path.isfile(bundle_path):
855
+
856
+ target = os.path.basename(bundle_path)
857
+
858
+ target_path = os.path.join(self.library_path, target)
859
+
860
+ # Copy file.
861
+ shutil.copyfile(bundle_path, target_path)
862
+ else:
863
+ raise IOError("Cannot find compiled version of module.")
864
+
865
+ # pylint: enable=too-many-locals,too-many-branches
866
+ def install_module_py(self, metadata, location=None):
867
+ """
868
+ :param metadata dictionary.
869
+ :param location the location on the device to copy the py module to.
870
+ If omitted is CIRCUITPY/lib/ used.
871
+ """
872
+ if location is None:
873
+ location = self.library_path
874
+ else:
875
+ location = os.path.join(self.device_location, location)
876
+
877
+ source_path = metadata["path"] # Path to Python source version.
878
+ if os.path.isdir(source_path):
879
+ target = os.path.basename(os.path.dirname(source_path))
880
+ target_path = os.path.join(location, target)
881
+ # Copy the directory.
882
+ shutil.copytree(source_path, target_path)
883
+ else:
884
+ target = os.path.basename(source_path)
885
+ target_path = os.path.join(location, target)
886
+ # Copy file.
887
+ shutil.copyfile(source_path, target_path)
888
+
889
+ def get_auto_file_path(self, auto_file_path):
890
+ """
891
+ Returns the path on the device to the file to be read for --auto.
892
+ """
893
+ return auto_file_path
894
+
895
+ def uninstall(self, device_path, module_path):
896
+ """
897
+ Uninstall module using local file system.
898
+ """
899
+ library_path = os.path.join(device_path, "lib")
900
+ if os.path.isdir(module_path):
901
+ target = os.path.basename(os.path.dirname(module_path))
902
+ target_path = os.path.join(library_path, target)
903
+ # Remove the directory.
904
+ shutil.rmtree(target_path)
905
+ else:
906
+ target = os.path.basename(module_path)
907
+ target_path = os.path.join(library_path, target)
908
+ # Remove file
909
+ os.remove(target_path)
910
+
911
+ def update(self, module):
912
+ """
913
+ Delete the module on the device, then copy the module from the bundle
914
+ back onto the device.
915
+
916
+ The caller is expected to handle any exceptions raised.
917
+ """
918
+ self._update_file(module)
919
+
920
+ def _update_file(self, module):
921
+ """
922
+ Update the module using file system.
923
+ """
924
+ if os.path.isdir(module.path):
925
+ # Delete and copy the directory.
926
+ shutil.rmtree(module.path, ignore_errors=True)
927
+ shutil.copytree(module.bundle_path, module.path)
928
+ else:
929
+ # Delete and copy file.
930
+ os.remove(module.path)
931
+ shutil.copyfile(module.bundle_path, module.path)
932
+
933
+ def file_exists(self, filepath):
934
+ """
935
+ return True if the file exists, otherwise False.
936
+ """
937
+ return os.path.exists(os.path.join(self.device_location, filepath))
938
+
939
+ def get_file_path(self, filename):
940
+ """
941
+ returns the full path on the device to a given file name.
942
+ """
943
+ return os.path.join(self.device_location, filename)
944
+
945
+ def is_device_present(self):
946
+ """
947
+ returns True if the device is currently connected
948
+ """
949
+ return os.path.exists(self.device_location)
950
+
951
+ def get_free_space(self):
952
+ """
953
+ Returns the free space on the device in bytes.
954
+ """
955
+ # pylint: disable=unused-variable
956
+ _, total, free = shutil.disk_usage(self.device_location)
957
+ return free