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.
@@ -0,0 +1,627 @@
1
+ # SPDX-FileCopyrightText: 2019 Nicholas Tollervey, 2024 Tim Cocks, written for Adafruit Industries
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """
5
+ Functions called from commands in order to provide behaviors and return information.
6
+ """
7
+
8
+ import ctypes
9
+ import glob
10
+ import os
11
+
12
+ from subprocess import check_output
13
+ import sys
14
+ import shutil
15
+ import zipfile
16
+ import json
17
+ import re
18
+ import toml
19
+ import findimports
20
+ import requests
21
+ import click
22
+
23
+ from circup.shared import (
24
+ PLATFORMS,
25
+ REQUESTS_TIMEOUT,
26
+ _get_modules_file,
27
+ BUNDLE_CONFIG_OVERWRITE,
28
+ BUNDLE_CONFIG_FILE,
29
+ BUNDLE_CONFIG_LOCAL,
30
+ BUNDLE_DATA,
31
+ NOT_MCU_LIBRARIES,
32
+ tags_data_load,
33
+ )
34
+ from circup.logging import logger
35
+ from circup.module import Module
36
+ from circup.bundle import Bundle
37
+
38
+ WARNING_IGNORE_MODULES = (
39
+ "typing-extensions",
40
+ "pyasn1",
41
+ "circuitpython-typing",
42
+ )
43
+
44
+
45
+ def clean_library_name(assumed_library_name):
46
+ """
47
+ Most CP repos and library names are look like this:
48
+
49
+ repo: Adafruit_CircuitPython_LC709203F
50
+ library: adafruit_lc709203f
51
+
52
+ But some do not and this handles cleaning that up.
53
+ Also cleans up if the pypi or reponame is passed in instead of the
54
+ CP library name.
55
+
56
+ :param str assumed_library_name: An assumed name of a library from user
57
+ or requirements.txt entry
58
+ :return: str proper library name
59
+ """
60
+ not_standard_names = {
61
+ # Assumed Name : Actual Name
62
+ "adafruit_adafruitio": "adafruit_io",
63
+ "adafruit_asyncio": "asyncio",
64
+ "adafruit_busdevice": "adafruit_bus_device",
65
+ "adafruit_connectionmanager": "adafruit_connection_manager",
66
+ "adafruit_display_button": "adafruit_button",
67
+ "adafruit_neopixel": "neopixel",
68
+ "adafruit_sd": "adafruit_sdcard",
69
+ "adafruit_simpleio": "simpleio",
70
+ "pimoroni_ltr559": "pimoroni_circuitpython_ltr559",
71
+ }
72
+ if "circuitpython" in assumed_library_name:
73
+ # convert repo or pypi name to common library name
74
+ assumed_library_name = (
75
+ assumed_library_name.replace("-circuitpython-", "_")
76
+ .replace("_circuitpython_", "_")
77
+ .replace("-", "_")
78
+ )
79
+ if assumed_library_name in not_standard_names:
80
+ return not_standard_names[assumed_library_name]
81
+ return assumed_library_name
82
+
83
+
84
+ def completion_for_install(ctx, param, incomplete):
85
+ """
86
+ Returns the list of available modules for the command line tab-completion
87
+ with the ``circup install`` command.
88
+ """
89
+ # pylint: disable=unused-argument
90
+ available_modules = get_bundle_versions(get_bundles_list(), avoid_download=True)
91
+ module_names = {m.replace(".py", "") for m in available_modules}
92
+ if incomplete:
93
+ module_names = [name for name in module_names if name.startswith(incomplete)]
94
+ module_names.extend(glob.glob(f"{incomplete}*"))
95
+ return sorted(module_names)
96
+
97
+
98
+ def completion_for_example(ctx, param, incomplete):
99
+ """
100
+ Returns the list of available modules for the command line tab-completion
101
+ with the ``circup example`` command.
102
+ """
103
+ # pylint: disable=unused-argument, consider-iterating-dictionary
104
+ available_examples = get_bundle_examples(get_bundles_list(), avoid_download=True)
105
+
106
+ matching_examples = [
107
+ example_path
108
+ for example_path in available_examples.keys()
109
+ if example_path.startswith(incomplete)
110
+ ]
111
+
112
+ return sorted(matching_examples)
113
+
114
+
115
+ def ensure_latest_bundle(bundle):
116
+ """
117
+ Ensure that there's a copy of the latest library bundle available so circup
118
+ can check the metadata contained therein.
119
+
120
+ :param Bundle bundle: the target Bundle object.
121
+ """
122
+ logger.info("Checking library updates for %s.", bundle.key)
123
+ tag = bundle.latest_tag
124
+ do_update = False
125
+ if tag == bundle.current_tag:
126
+ for platform in PLATFORMS:
127
+ # missing directories (new platform added on an existing install
128
+ # or side effect of pytest or network errors)
129
+ do_update = do_update or not os.path.isdir(bundle.lib_dir(platform))
130
+ else:
131
+ do_update = True
132
+
133
+ if do_update:
134
+ logger.info("New version available (%s).", tag)
135
+ try:
136
+ get_bundle(bundle, tag)
137
+ tags_data_save_tag(bundle.key, tag)
138
+ except requests.exceptions.HTTPError as ex:
139
+ # See #20 for reason for this
140
+ click.secho(
141
+ (
142
+ "There was a problem downloading that platform bundle. "
143
+ "Skipping and using existing download if available."
144
+ ),
145
+ fg="red",
146
+ )
147
+ logger.exception(ex)
148
+ else:
149
+ logger.info("Current bundle up to date %s.", tag)
150
+
151
+
152
+ def find_device():
153
+ """
154
+ Return the location on the filesystem for the connected CircuitPython device.
155
+ This is based upon how Mu discovers this information.
156
+
157
+ :return: The path to the device on the local filesystem.
158
+ """
159
+ device_dir = None
160
+ # Attempt to find the path on the filesystem that represents the plugged in
161
+ # CIRCUITPY board.
162
+ if os.name == "posix":
163
+ # Linux / OSX
164
+ for mount_command in ["mount", "/sbin/mount"]:
165
+ try:
166
+ mount_output = check_output(mount_command).splitlines()
167
+ mounted_volumes = [x.split()[2] for x in mount_output]
168
+ for volume in mounted_volumes:
169
+ if volume.endswith(b"CIRCUITPY"):
170
+ device_dir = volume.decode("utf-8")
171
+ except FileNotFoundError:
172
+ continue
173
+ elif os.name == "nt":
174
+ # Windows
175
+
176
+ def get_volume_name(disk_name):
177
+ """
178
+ Each disk or external device connected to windows has an attribute
179
+ called "volume name". This function returns the volume name for the
180
+ given disk/device.
181
+
182
+ Based upon answer given here: http://stackoverflow.com/a/12056414
183
+ """
184
+ vol_name_buf = ctypes.create_unicode_buffer(1024)
185
+ ctypes.windll.kernel32.GetVolumeInformationW(
186
+ ctypes.c_wchar_p(disk_name),
187
+ vol_name_buf,
188
+ ctypes.sizeof(vol_name_buf),
189
+ None,
190
+ None,
191
+ None,
192
+ None,
193
+ 0,
194
+ )
195
+ return vol_name_buf.value
196
+
197
+ #
198
+ # In certain circumstances, volumes are allocated to USB
199
+ # storage devices which cause a Windows popup to raise if their
200
+ # volume contains no media. Wrapping the check in SetErrorMode
201
+ # with SEM_FAILCRITICALERRORS (1) prevents this popup.
202
+ #
203
+ old_mode = ctypes.windll.kernel32.SetErrorMode(1)
204
+ try:
205
+ for disk in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
206
+ path = "{}:\\".format(disk)
207
+ if os.path.exists(path) and get_volume_name(path) == "CIRCUITPY":
208
+ device_dir = path
209
+ # Report only the FIRST device found.
210
+ break
211
+ finally:
212
+ ctypes.windll.kernel32.SetErrorMode(old_mode)
213
+ else:
214
+ # No support for unknown operating systems.
215
+ raise NotImplementedError('OS "{}" not supported.'.format(os.name))
216
+ logger.info("Found device: %s", device_dir)
217
+ return device_dir
218
+
219
+
220
+ def find_modules(backend, bundles_list):
221
+ """
222
+ Extracts metadata from the connected device and available bundles and
223
+ returns this as a list of Module instances representing the modules on the
224
+ device.
225
+
226
+ :param Backend backend: Backend with the device connection.
227
+ :param List[Bundle] bundles_list: List of supported bundles as Bundle objects.
228
+ :return: A list of Module instances describing the current state of the
229
+ modules on the connected device.
230
+ """
231
+ # pylint: disable=broad-except,too-many-locals
232
+ try:
233
+ device_modules = backend.get_device_versions()
234
+ bundle_modules = get_bundle_versions(bundles_list)
235
+ result = []
236
+ for key, device_metadata in device_modules.items():
237
+
238
+ if key in bundle_modules:
239
+ path = device_metadata["path"]
240
+ bundle_metadata = bundle_modules[key]
241
+ repo = bundle_metadata.get("__repo__")
242
+ bundle = bundle_metadata.get("bundle")
243
+ device_version = device_metadata.get("__version__")
244
+ bundle_version = bundle_metadata.get("__version__")
245
+ mpy = device_metadata["mpy"]
246
+ compatibility = device_metadata.get("compatibility", (None, None))
247
+ module_name = (
248
+ path.split(os.sep)[-1]
249
+ if not path.endswith(os.sep)
250
+ else path[:-1].split(os.sep)[-1] + os.sep
251
+ )
252
+
253
+ m = Module(
254
+ module_name,
255
+ backend,
256
+ repo,
257
+ device_version,
258
+ bundle_version,
259
+ mpy,
260
+ bundle,
261
+ compatibility,
262
+ )
263
+ result.append(m)
264
+ return result
265
+ except Exception as ex:
266
+ # If it's not possible to get the device and bundle metadata, bail out
267
+ # with a friendly message and indication of what's gone wrong.
268
+ logger.exception(ex)
269
+ click.echo("There was a problem: {}".format(ex))
270
+ sys.exit(1)
271
+ # pylint: enable=broad-except,too-many-locals
272
+
273
+
274
+ def get_bundle(bundle, tag):
275
+ """
276
+ Downloads and extracts the version of the bundle with the referenced tag.
277
+ The resulting zip file is saved on the local filesystem.
278
+
279
+ :param Bundle bundle: the target Bundle object.
280
+ :param str tag: The GIT tag to use to download the bundle.
281
+ """
282
+ click.echo(f"Downloading latest bundles for {bundle.key} ({tag}).")
283
+ for platform, github_string in PLATFORMS.items():
284
+ # Report the platform: "8.x-mpy", etc.
285
+ click.echo(f"{github_string}:")
286
+ url = bundle.url_format.format(platform=github_string, tag=tag)
287
+ logger.info("Downloading bundle: %s", url)
288
+ r = requests.get(url, stream=True, timeout=REQUESTS_TIMEOUT)
289
+ # pylint: disable=no-member
290
+ if r.status_code != requests.codes.ok:
291
+ logger.warning("Unable to connect to %s", url)
292
+ r.raise_for_status()
293
+ # pylint: enable=no-member
294
+ total_size = int(r.headers.get("Content-Length"))
295
+ temp_zip = bundle.zip.format(platform=platform)
296
+ with click.progressbar(
297
+ r.iter_content(1024), label="Extracting:", length=total_size
298
+ ) as pbar, open(temp_zip, "wb") as zip_fp:
299
+ for chunk in pbar:
300
+ zip_fp.write(chunk)
301
+ pbar.update(len(chunk))
302
+ logger.info("Saved to %s", temp_zip)
303
+ temp_dir = bundle.dir.format(platform=platform)
304
+ if os.path.isdir(temp_dir):
305
+ shutil.rmtree(temp_dir)
306
+ with zipfile.ZipFile(temp_zip, "r") as zfile:
307
+ zfile.extractall(temp_dir)
308
+ bundle.current_tag = tag
309
+ click.echo("\nOK\n")
310
+
311
+
312
+ def get_bundle_examples(bundles_list, avoid_download=False):
313
+ """
314
+ Return a dictionary of metadata from examples in the all of the bundles
315
+ specified by bundles_list argument.
316
+
317
+ :param List[Bundle] bundles_list: List of supported bundles as Bundle objects.
318
+ :param bool avoid_download: if True, download the bundle only if missing.
319
+ :return: A dictionary of metadata about the examples available in the
320
+ library bundle.
321
+ """
322
+ # pylint: disable=too-many-nested-blocks
323
+ all_the_examples = dict()
324
+
325
+ try:
326
+ for bundle in bundles_list:
327
+ if not avoid_download or not os.path.isdir(bundle.lib_dir("py")):
328
+ ensure_latest_bundle(bundle)
329
+ path = bundle.examples_dir("py")
330
+ path_examples = _get_modules_file(path, logger)
331
+ for lib_name, lib_metadata in path_examples.items():
332
+ for _dir_level in os.walk(lib_metadata["path"]):
333
+ for _file in _dir_level[2]:
334
+ _parts = _dir_level[0].split(os.path.sep)
335
+ _lib_name_index = _parts.index(lib_name)
336
+ _dirs = _parts[_lib_name_index:]
337
+ if _dirs[-1] == "":
338
+ _dirs.pop(-1)
339
+ slug = f"{os.path.sep}".join(_dirs + [_file.replace(".py", "")])
340
+ all_the_examples[slug] = os.path.join(_dir_level[0], _file)
341
+
342
+ except NotADirectoryError:
343
+ # Bundle does not have new style examples directory
344
+ # so we cannot include its examples.
345
+ pass
346
+ return all_the_examples
347
+
348
+
349
+ def get_bundle_versions(bundles_list, avoid_download=False):
350
+ """
351
+ Returns a dictionary of metadata from modules in the latest known release
352
+ of the library bundle. Uses the Python version (rather than the compiled
353
+ version) of the library modules.
354
+
355
+ :param List[Bundle] bundles_list: List of supported bundles as Bundle objects.
356
+ :param bool avoid_download: if True, download the bundle only if missing.
357
+ :return: A dictionary of metadata about the modules available in the
358
+ library bundle.
359
+ """
360
+ all_the_modules = dict()
361
+ for bundle in bundles_list:
362
+ if not avoid_download or not os.path.isdir(bundle.lib_dir("py")):
363
+ ensure_latest_bundle(bundle)
364
+ path = bundle.lib_dir("py")
365
+ path_modules = _get_modules_file(path, logger)
366
+ for name, module in path_modules.items():
367
+ module["bundle"] = bundle
368
+ if name not in all_the_modules: # here we decide the order of priority
369
+ all_the_modules[name] = module
370
+ return all_the_modules
371
+
372
+
373
+ def get_bundles_dict():
374
+ """
375
+ Retrieve the dictionary from BUNDLE_CONFIG_FILE (JSON).
376
+ Put the local dictionary in front, so it gets priority.
377
+ It's a dictionary of bundle string identifiers.
378
+
379
+ :return: Combined dictionaries from the config files.
380
+ """
381
+ bundle_dict = get_bundles_local_dict()
382
+ try:
383
+ with open(BUNDLE_CONFIG_OVERWRITE, "rb") as bundle_config_json:
384
+ bundle_config = json.load(bundle_config_json)
385
+ except (FileNotFoundError, json.decoder.JSONDecodeError):
386
+ with open(BUNDLE_CONFIG_FILE, "rb") as bundle_config_json:
387
+ bundle_config = json.load(bundle_config_json)
388
+ for name, bundle in bundle_config.items():
389
+ if bundle not in bundle_dict.values():
390
+ bundle_dict[name] = bundle
391
+ return bundle_dict
392
+
393
+
394
+ def get_bundles_local_dict():
395
+ """
396
+ Retrieve the local bundles from BUNDLE_CONFIG_LOCAL (JSON).
397
+
398
+ :return: Raw dictionary from the config file(s).
399
+ """
400
+ try:
401
+ with open(BUNDLE_CONFIG_LOCAL, "rb") as bundle_config_json:
402
+ bundle_config = json.load(bundle_config_json)
403
+ if not isinstance(bundle_config, dict) or not bundle_config:
404
+ logger.error("Local bundle list invalid. Skipped.")
405
+ raise FileNotFoundError("Bad local bundle list")
406
+ return bundle_config
407
+ except (FileNotFoundError, json.decoder.JSONDecodeError):
408
+ return dict()
409
+
410
+
411
+ def get_bundles_list():
412
+ """
413
+ Retrieve the list of bundles from the config dictionary.
414
+
415
+ :return: List of supported bundles as Bundle objects.
416
+ """
417
+ bundle_config = get_bundles_dict()
418
+ bundles_list = [Bundle(bundle_config[b]) for b in bundle_config]
419
+ logger.info("Using bundles: %s", ", ".join(b.key for b in bundles_list))
420
+ return bundles_list
421
+
422
+
423
+ def get_circup_version():
424
+ """Return the version of circup that is running. If not available, return None.
425
+
426
+ :return: Current version of circup, or None.
427
+ """
428
+ try:
429
+ from importlib import metadata # pylint: disable=import-outside-toplevel
430
+ except ImportError:
431
+ try:
432
+ import importlib_metadata as metadata # pylint: disable=import-outside-toplevel
433
+ except ImportError:
434
+ return None
435
+ try:
436
+ return metadata.version("circup")
437
+ except metadata.PackageNotFoundError:
438
+ return None
439
+
440
+
441
+ def get_dependencies(*requested_libraries, mod_names, to_install=()):
442
+ """
443
+ Return a list of other CircuitPython libraries required by the given list
444
+ of libraries
445
+
446
+ :param tuple requested_libraries: The libraries to search for dependencies
447
+ :param object mod_names: All the modules metadata from bundle
448
+ :param list(str) to_install: Modules already selected for installation.
449
+ :return: tuple of module names to install which we build
450
+ """
451
+ # pylint: disable=too-many-branches
452
+ # Internal variables
453
+ _to_install = to_install
454
+ _requested_libraries = []
455
+ _rl = requested_libraries[0]
456
+
457
+ if not requested_libraries[0]:
458
+ # If nothing is requested, we're done
459
+ return _to_install
460
+
461
+ for lib_name in _rl:
462
+ lower_lib_name = lib_name.lower()
463
+ if lower_lib_name in NOT_MCU_LIBRARIES:
464
+ logger.info(
465
+ "Skipping %s. It is not for microcontroller installs.", lib_name
466
+ )
467
+ else:
468
+ # Canonicalize, with some exceptions:
469
+ # adafruit-circuitpython-something => adafruit_something
470
+ canonical_lib_name = clean_library_name(lower_lib_name)
471
+ try:
472
+ # Don't process any names we can't find in mod_names
473
+ mod_names[canonical_lib_name] # pylint: disable=pointless-statement
474
+ _requested_libraries.append(canonical_lib_name)
475
+ except KeyError:
476
+ if canonical_lib_name not in WARNING_IGNORE_MODULES:
477
+ if os.path.exists(canonical_lib_name):
478
+ _requested_libraries.append(canonical_lib_name)
479
+ else:
480
+ click.secho(
481
+ f"WARNING:\n\t{canonical_lib_name} "
482
+ f"is not a known CircuitPython library.",
483
+ fg="yellow",
484
+ )
485
+
486
+ if not _requested_libraries:
487
+ # If nothing is requested, we're done
488
+ return _to_install
489
+
490
+ for library in list(_requested_libraries):
491
+ if library not in _to_install:
492
+ _to_install = _to_install + (library,)
493
+ # get the requirements.txt from bundle
494
+ try:
495
+ bundle = mod_names[library]["bundle"]
496
+ requirements_txt = bundle.requirements_for(library)
497
+ if requirements_txt:
498
+ _requested_libraries.extend(
499
+ libraries_from_requirements(requirements_txt)
500
+ )
501
+
502
+ circup_dependencies = get_circup_dependencies(bundle, library)
503
+ for circup_dependency in circup_dependencies:
504
+ _requested_libraries.append(circup_dependency)
505
+ except KeyError:
506
+ # don't check local file for further dependencies
507
+ pass
508
+
509
+ # we've processed this library, remove it from the list
510
+ _requested_libraries.remove(library)
511
+
512
+ return get_dependencies(
513
+ tuple(_requested_libraries), mod_names=mod_names, to_install=_to_install
514
+ )
515
+
516
+
517
+ def get_circup_dependencies(bundle, library):
518
+ """
519
+ Get the list of circup dependencies from pyproject.toml
520
+ e.g.
521
+ [circup]
522
+ circup_dependencies = ["dependency_name_here"]
523
+
524
+ :param bundle: The Bundle to look within
525
+ :param library: The Library to find pyproject.toml for and get dependencies from
526
+
527
+ :return: The list of dependency libraries that were found
528
+ """
529
+ try:
530
+ pyproj_toml = bundle.requirements_for(library, toml_file=True)
531
+ if pyproj_toml:
532
+ pyproj_toml_data = toml.loads(pyproj_toml)
533
+ dependencies = pyproj_toml_data["circup"]["circup_dependencies"]
534
+ if isinstance(dependencies, list):
535
+ return dependencies
536
+
537
+ if isinstance(dependencies, str):
538
+ return (dependencies,)
539
+
540
+ return tuple()
541
+
542
+ except KeyError:
543
+ # no circup_dependencies in pyproject.toml
544
+ return tuple()
545
+
546
+
547
+ def libraries_from_requirements(requirements):
548
+ """
549
+ Clean up supplied requirements.txt and turn into tuple of CP libraries
550
+
551
+ :param str requirements: A string version of a requirements.txt
552
+ :return: tuple of library names
553
+ """
554
+ libraries = ()
555
+ for line in requirements.split("\n"):
556
+ line = line.lower().strip()
557
+ if line.startswith("#") or line == "":
558
+ # skip comments
559
+ pass
560
+ else:
561
+ # Remove everything after any pip style version specifiers
562
+ line = re.split("[<>=~[;]", line)[0].strip()
563
+ libraries = libraries + (line,)
564
+ return libraries
565
+
566
+
567
+ def save_local_bundles(bundles_data):
568
+ """
569
+ Save the list of local bundles to the settings.
570
+
571
+ :param str key: The bundle's identifier/key.
572
+ """
573
+ if len(bundles_data) > 0:
574
+ with open(BUNDLE_CONFIG_LOCAL, "w", encoding="utf-8") as data:
575
+ json.dump(bundles_data, data)
576
+ else:
577
+ if os.path.isfile(BUNDLE_CONFIG_LOCAL):
578
+ os.unlink(BUNDLE_CONFIG_LOCAL)
579
+
580
+
581
+ def tags_data_save_tag(key, tag):
582
+ """
583
+ Add or change the saved tag value for a bundle.
584
+
585
+ :param str key: The bundle's identifier/key.
586
+ :param str tag: The new tag for the bundle.
587
+ """
588
+ tags_data = tags_data_load(logger)
589
+ tags_data[key] = tag
590
+ with open(BUNDLE_DATA, "w", encoding="utf-8") as data:
591
+ json.dump(tags_data, data)
592
+
593
+
594
+ def libraries_from_code_py(code_py, mod_names):
595
+ """
596
+ Parse the given code.py file and return the imported libraries
597
+
598
+ :param str code_py: Full path of the code.py file
599
+ :return: sequence of library names
600
+ """
601
+ # pylint: disable=broad-except
602
+ try:
603
+ found_imports = findimports.find_imports(code_py)
604
+ except Exception as ex: # broad exception because anything could go wrong
605
+ logger.exception(ex)
606
+ click.secho('Unable to read the auto file: "{}"'.format(str(ex)), fg="red")
607
+ sys.exit(2)
608
+ # pylint: enable=broad-except
609
+ imports = [info.name.split(".", 1)[0] for info in found_imports]
610
+ return [r for r in imports if r in mod_names]
611
+
612
+
613
+ def get_device_path(host, password, path):
614
+ """
615
+ :param host Hostname or IP address.
616
+ :param password REST API password.
617
+ :param path File system path.
618
+ :return device URL or None if the device cannot be found.
619
+ """
620
+ if path:
621
+ device_path = path
622
+ elif host:
623
+ # pylint: enable=no-member
624
+ device_path = f"http://:{password}@" + host
625
+ else:
626
+ device_path = find_device()
627
+ return device_path