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,5 @@
1
+ {
2
+ "adafruit": "adafruit/Adafruit_CircuitPython_Bundle",
3
+ "circuitpython_community": "adafruit/CircuitPython_Community_Bundle",
4
+ "circuitpython_org": "circuitpython/CircuitPython_Org_Bundle"
5
+ }
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2021 Patrick Walters
2
+ #
3
+ # SPDX-License-Identifier: MIT
circup/logging.py ADDED
@@ -0,0 +1,33 @@
1
+ # SPDX-FileCopyrightText: 2019 Nicholas Tollervey, 2024 Tim Cocks, written for Adafruit Industries
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """
5
+ Logging utilities and configuration used by circup
6
+ """
7
+ import os
8
+ import logging
9
+ from logging.handlers import RotatingFileHandler
10
+ import appdirs
11
+
12
+ from circup.shared import DATA_DIR
13
+
14
+ #: The directory containing the utility's log file.
15
+ LOG_DIR = appdirs.user_log_dir(appname="circup", appauthor="adafruit")
16
+ #: The location of the log file for the utility.
17
+ LOGFILE = os.path.join(LOG_DIR, "circup.log")
18
+
19
+ # Ensure DATA_DIR / LOG_DIR related directories and files exist.
20
+ if not os.path.exists(DATA_DIR): # pragma: no cover
21
+ os.makedirs(DATA_DIR)
22
+ if not os.path.exists(LOG_DIR): # pragma: no cover
23
+ os.makedirs(LOG_DIR)
24
+
25
+ # Setup logging.
26
+ logger = logging.getLogger(__name__)
27
+ logger.setLevel(logging.INFO)
28
+ logfile_handler = RotatingFileHandler(LOGFILE, maxBytes=10_000_000, backupCount=0)
29
+ log_formatter = logging.Formatter(
30
+ "%(asctime)s %(levelname)s: %(message)s", datefmt="%m/%d/%Y %H:%M:%S"
31
+ )
32
+ logfile_handler.setFormatter(log_formatter)
33
+ logger.addHandler(logfile_handler)
circup/module.py ADDED
@@ -0,0 +1,209 @@
1
+ # SPDX-FileCopyrightText: 2019 Nicholas Tollervey, 2024 Tim Cocks, written for Adafruit Industries
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """
5
+ Class that represents a specific CircuitPython module on a device or in a Bundle.
6
+ """
7
+ import os
8
+ from urllib.parse import urljoin, urlparse
9
+ from semver import VersionInfo
10
+
11
+ from circup.shared import BAD_FILE_FORMAT
12
+ from circup.backends import WebBackend
13
+ from circup.logging import logger
14
+
15
+
16
+ class Module:
17
+ """
18
+ Represents a CircuitPython module.
19
+ """
20
+
21
+ # pylint: disable=too-many-arguments
22
+
23
+ def __init__(
24
+ self,
25
+ name,
26
+ backend,
27
+ repo,
28
+ device_version,
29
+ bundle_version,
30
+ mpy,
31
+ bundle,
32
+ compatibility,
33
+ ):
34
+ """
35
+ The ``self.file`` and ``self.name`` attributes are constructed from
36
+ the ``path`` value. If the path is to a directory based module, the
37
+ resulting self.file value will be None, and the name will be the
38
+ basename of the directory path.
39
+
40
+ :param str name: The file name of the module.
41
+ :param Backend backend: The backend that the module is on.
42
+ :param str repo: The URL of the Git repository for this module.
43
+ :param str device_version: The semver value for the version on device.
44
+ :param str bundle_version: The semver value for the version in bundle.
45
+ :param bool mpy: Flag to indicate if the module is byte-code compiled.
46
+ :param Bundle bundle: Bundle object where the module is located.
47
+ :param (str,str) compatibility: Min and max versions of CP compatible with the mpy.
48
+ """
49
+ self.name = name
50
+ self.backend = backend
51
+ self.path = (
52
+ urljoin(backend.library_path, name, allow_fragments=False)
53
+ if isinstance(backend, WebBackend)
54
+ else os.path.join(backend.library_path, name)
55
+ )
56
+
57
+ url = urlparse(self.path, allow_fragments=False)
58
+
59
+ if (
60
+ url.path.endswith("/")
61
+ if isinstance(backend, WebBackend)
62
+ else self.path.endswith(os.sep)
63
+ ):
64
+ self.file = None
65
+ self.name = self.path.split(
66
+ "/" if isinstance(backend, WebBackend) else os.sep
67
+ )[-2]
68
+ else:
69
+ self.file = os.path.basename(url.path)
70
+ self.name = (
71
+ os.path.basename(url.path).replace(".py", "").replace(".mpy", "")
72
+ )
73
+
74
+ self.repo = repo
75
+ self.device_version = device_version
76
+ self.bundle_version = bundle_version
77
+ self.mpy = mpy
78
+ self.min_version = compatibility[0]
79
+ self.max_version = compatibility[1]
80
+ # Figure out the bundle path.
81
+ self.bundle_path = None
82
+ if self.mpy:
83
+ # Byte compiled, now check CircuitPython version.
84
+
85
+ major_version = self.backend.get_circuitpython_version()[0].split(".")[0]
86
+ bundle_platform = "{}mpy".format(major_version)
87
+ else:
88
+ # Regular Python
89
+ bundle_platform = "py"
90
+ # module path in the bundle
91
+ search_path = bundle.lib_dir(bundle_platform)
92
+ if self.file:
93
+ self.bundle_path = os.path.join(search_path, self.file)
94
+ else:
95
+ self.bundle_path = os.path.join(search_path, self.name)
96
+ logger.info(self)
97
+
98
+ # pylint: enable=too-many-arguments
99
+
100
+ @property
101
+ def outofdate(self):
102
+ """
103
+ Returns a boolean to indicate if this module is out of date.
104
+ Treat mismatched MPY versions as out of date.
105
+
106
+ :return: Truthy indication if the module is out of date.
107
+ """
108
+ if self.mpy_mismatch:
109
+ return True
110
+ if self.device_version and self.bundle_version:
111
+ try:
112
+ return VersionInfo.parse(self.device_version) < VersionInfo.parse(
113
+ self.bundle_version
114
+ )
115
+ except ValueError as ex:
116
+ logger.warning("Module '%s' has incorrect semver value.", self.name)
117
+ logger.warning(ex)
118
+ return True # Assume out of date to try to update.
119
+
120
+ @property
121
+ def bad_format(self):
122
+ """A boolean indicating that the mpy file format could not be identified"""
123
+ return self.mpy and self.device_version == BAD_FILE_FORMAT
124
+
125
+ @property
126
+ def mpy_mismatch(self):
127
+ """
128
+ Returns a boolean to indicate if this module's MPY version is compatible
129
+ with the board's current version of Circuitpython. A min or max version
130
+ that evals to False means no limit.
131
+
132
+ :return: Boolean indicating if the MPY versions don't match.
133
+ """
134
+ if not self.mpy:
135
+ return False
136
+ try:
137
+ cpv = VersionInfo.parse(self.backend.get_circuitpython_version()[0])
138
+ except ValueError as ex:
139
+ logger.warning("CircuitPython has incorrect semver value.")
140
+ logger.warning(ex)
141
+ try:
142
+ if self.min_version and cpv < VersionInfo.parse(self.min_version):
143
+ return True # CP version too old
144
+ if self.max_version and cpv >= VersionInfo.parse(self.max_version):
145
+ return True # MPY version too old
146
+ except (TypeError, ValueError) as ex:
147
+ logger.warning(
148
+ "Module '%s' has incorrect MPY compatibility information.", self.name
149
+ )
150
+ logger.warning(ex)
151
+ return False
152
+
153
+ @property
154
+ def major_update(self):
155
+ """
156
+ Returns a boolean to indicate if this is a major version update.
157
+
158
+ :return: Boolean indicating if this is a major version upgrade
159
+ """
160
+ try:
161
+ if (
162
+ VersionInfo.parse(self.device_version).major
163
+ == VersionInfo.parse(self.bundle_version).major
164
+ ):
165
+ return False
166
+ except (TypeError, ValueError) as ex:
167
+ logger.warning("Module '%s' has incorrect semver value.", self.name)
168
+ logger.warning(ex)
169
+ return True # Assume Major Version udpate.
170
+
171
+ @property
172
+ def row(self):
173
+ """
174
+ Returns a tuple of items to display in a table row to show the module's
175
+ name, local version and remote version, and reason to update.
176
+
177
+ :return: A tuple containing the module's name, version on the connected
178
+ device, version in the latest bundle and reason to update.
179
+ """
180
+ loc = self.device_version if self.device_version else "unknown"
181
+ rem = self.bundle_version if self.bundle_version else "unknown"
182
+ if self.mpy_mismatch:
183
+ update_reason = "MPY Format"
184
+ elif self.major_update:
185
+ update_reason = "Major Version"
186
+ else:
187
+ update_reason = "Minor Version"
188
+ return (self.name, loc, rem, update_reason)
189
+
190
+ def __repr__(self):
191
+ """
192
+ Helps with log files.
193
+
194
+ :return: A repr of a dictionary containing the module's metadata.
195
+ """
196
+ return repr(
197
+ {
198
+ "path": self.path,
199
+ "file": self.file,
200
+ "name": self.name,
201
+ "repo": self.repo,
202
+ "device_version": self.device_version,
203
+ "bundle_version": self.bundle_version,
204
+ "bundle_path": self.bundle_path,
205
+ "mpy": self.mpy,
206
+ "min_version": self.min_version,
207
+ "max_version": self.max_version,
208
+ }
209
+ )
circup/shared.py ADDED
@@ -0,0 +1,218 @@
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
+ Utilities that are shared and used by both click CLI command functions
7
+ and Backend class functions.
8
+ """
9
+ import glob
10
+ import os
11
+ import re
12
+ import json
13
+ import appdirs
14
+ import pkg_resources
15
+ import requests
16
+
17
+ #: Version identifier for a bad MPY file format
18
+ BAD_FILE_FORMAT = "Invalid"
19
+
20
+ #: The location of data files used by circup (following OS conventions).
21
+ DATA_DIR = appdirs.user_data_dir(appname="circup", appauthor="adafruit")
22
+
23
+ #: Module formats list (and the other form used in github files)
24
+ PLATFORMS = {"py": "py", "8mpy": "8.x-mpy", "9mpy": "9.x-mpy"}
25
+
26
+ #: Timeout for requests calls like get()
27
+ REQUESTS_TIMEOUT = 30
28
+
29
+ #: The path to the JSON file containing the metadata about the bundles.
30
+ BUNDLE_CONFIG_FILE = pkg_resources.resource_filename(
31
+ "circup", "config/bundle_config.json"
32
+ )
33
+ #: Overwrite the bundles list with this file (only done manually)
34
+ BUNDLE_CONFIG_OVERWRITE = os.path.join(DATA_DIR, "bundle_config.json")
35
+ #: The path to the JSON file containing the local list of bundles.
36
+ BUNDLE_CONFIG_LOCAL = os.path.join(DATA_DIR, "bundle_config_local.json")
37
+ #: The path to the JSON file containing the metadata about the bundles.
38
+ BUNDLE_DATA = os.path.join(DATA_DIR, "circup.json")
39
+
40
+ #: The libraries (and blank lines) which don't go on devices
41
+ NOT_MCU_LIBRARIES = [
42
+ "",
43
+ "adafruit-blinka",
44
+ "adafruit-blinka-bleio",
45
+ "adafruit-blinka-displayio",
46
+ "adafruit-circuitpython-typing",
47
+ "circuitpython_typing",
48
+ "pyserial",
49
+ ]
50
+
51
+ #: Commands that do not require an attached board
52
+ BOARDLESS_COMMANDS = ["show", "bundle-add", "bundle-remove", "bundle-show"]
53
+
54
+
55
+ def _get_modules_file(path, logger):
56
+ """
57
+ Get a dictionary containing metadata about all the Python modules found in
58
+ the referenced file system path.
59
+
60
+ :param str path: The directory in which to find modules.
61
+ :return: A dictionary containing metadata about the found modules.
62
+ """
63
+ result = {}
64
+ if not path:
65
+ return result
66
+ single_file_py_mods = glob.glob(os.path.join(path, "*.py"))
67
+ single_file_mpy_mods = glob.glob(os.path.join(path, "*.mpy"))
68
+ package_dir_mods = [
69
+ d
70
+ for d in glob.glob(os.path.join(path, "*", ""))
71
+ if not os.path.basename(os.path.normpath(d)).startswith(".")
72
+ ]
73
+ single_file_mods = single_file_py_mods + single_file_mpy_mods
74
+ for sfm in [f for f in single_file_mods if not os.path.basename(f).startswith(".")]:
75
+ metadata = extract_metadata(sfm, logger)
76
+ metadata["path"] = sfm
77
+ result[os.path.basename(sfm).replace(".py", "").replace(".mpy", "")] = metadata
78
+ for package_path in package_dir_mods:
79
+ name = os.path.basename(os.path.dirname(package_path))
80
+ py_files = glob.glob(os.path.join(package_path, "**/*.py"), recursive=True)
81
+ mpy_files = glob.glob(os.path.join(package_path, "**/*.mpy"), recursive=True)
82
+ all_files = py_files + mpy_files
83
+ # default value
84
+ result[name] = {"path": package_path, "mpy": bool(mpy_files)}
85
+ # explore all the submodules to detect bad ones
86
+ for source in [f for f in all_files if not os.path.basename(f).startswith(".")]:
87
+ metadata = extract_metadata(source, logger)
88
+ if "__version__" in metadata:
89
+ metadata["path"] = package_path
90
+ result[name] = metadata
91
+ # break now if any of the submodules has a bad format
92
+ if metadata["__version__"] == BAD_FILE_FORMAT:
93
+ break
94
+ return result
95
+
96
+
97
+ def extract_metadata(path, logger):
98
+ # pylint: disable=too-many-locals,too-many-branches
99
+ """
100
+ Given a file path, return a dictionary containing metadata extracted from
101
+ dunder attributes found therein. Works with both .py and .mpy files.
102
+
103
+ For Python source files, such metadata assignments should be simple and
104
+ single-line. For example::
105
+
106
+ __version__ = "1.1.4"
107
+ __repo__ = "https://github.com/adafruit/SomeLibrary.git"
108
+
109
+ For byte compiled .mpy files, a brute force / backtrack approach is used
110
+ to find the __version__ number in the file -- see comments in the
111
+ code for the implementation details.
112
+
113
+ :param str path: The path to the file containing the metadata.
114
+ :return: The dunder based metadata found in the file, as a dictionary.
115
+ """
116
+ result = {}
117
+ logger.info("%s", path)
118
+ if path.endswith(".py"):
119
+ result["mpy"] = False
120
+ with open(path, "r", encoding="utf-8") as source_file:
121
+ content = source_file.read()
122
+ #: The regex used to extract ``__version__`` and ``__repo__`` assignments.
123
+ dunder_key_val = r"""(__\w+__)(?:\s*:\s*\w+)?\s*=\s*(?:['"]|\(\s)(.+)['"]"""
124
+ for match in re.findall(dunder_key_val, content):
125
+ result[match[0]] = str(match[1])
126
+ if result:
127
+ logger.info("Extracted metadata: %s", result)
128
+ elif path.endswith(".mpy"):
129
+ find_by_regexp_match = False
130
+ result["mpy"] = True
131
+ with open(path, "rb") as mpy_file:
132
+ content = mpy_file.read()
133
+ # Track the MPY version number
134
+ mpy_version = content[0:2]
135
+ compatibility = None
136
+ loc = -1
137
+ # Find the start location of the __version__
138
+ if mpy_version == b"M\x03":
139
+ # One byte for the length of "__version__"
140
+ loc = content.find(b"__version__") - 1
141
+ compatibility = (None, "7.0.0-alpha.1")
142
+ elif mpy_version == b"C\x05":
143
+ # Two bytes for the length of "__version__" in mpy version 5
144
+ loc = content.find(b"__version__") - 2
145
+ compatibility = ("7.0.0-alpha.1", "8.99.99")
146
+ elif mpy_version == b"C\x06":
147
+ # Two bytes in mpy version 6
148
+ find_by_regexp_match = True
149
+ compatibility = ("9.0.0-alpha.1", None)
150
+ if find_by_regexp_match:
151
+ # Too hard to find the version positionally.
152
+ # Find the first thing that looks like an x.y.z version number.
153
+ match = re.search(rb"([\d]+\.[\d]+\.[\d]+)\x00", content)
154
+ if match:
155
+ result["__version__"] = match.group(1).decode("utf-8")
156
+ elif loc > -1:
157
+ # Backtrack until a byte value of the offset is reached.
158
+ offset = 1
159
+ while offset < loc:
160
+ val = int(content[loc - offset])
161
+ if mpy_version == b"C\x05":
162
+ val = val // 2
163
+ if val == offset - 1: # Off by one..!
164
+ # Found version, extract the number given boundaries.
165
+ start = loc - offset + 1 # No need for prepended length.
166
+ end = loc # Up to the start of the __version__.
167
+ version = content[start:end] # Slice the version number.
168
+ # Create a string version as metadata in the result.
169
+ result["__version__"] = version.decode("utf-8")
170
+ break # Nothing more to do.
171
+ offset += 1 # ...and again but backtrack by one.
172
+ if compatibility:
173
+ result["compatibility"] = compatibility
174
+ else:
175
+ # not a valid MPY file
176
+ result["__version__"] = BAD_FILE_FORMAT
177
+ return result
178
+
179
+
180
+ def tags_data_load(logger):
181
+ """
182
+ Load the list of the version tags of the bundles on disk.
183
+
184
+ :return: a dict() of tags indexed by Bundle identifiers/keys.
185
+ """
186
+ tags_data = None
187
+ try:
188
+ with open(BUNDLE_DATA, encoding="utf-8") as data:
189
+ try:
190
+ tags_data = json.load(data)
191
+ except json.decoder.JSONDecodeError as ex:
192
+ # Sometimes (why?) the JSON file becomes corrupt. In which case
193
+ # log it and carry on as if setting up for first time.
194
+ logger.error("Could not parse %s", BUNDLE_DATA)
195
+ logger.exception(ex)
196
+ except FileNotFoundError:
197
+ pass
198
+ if not isinstance(tags_data, dict):
199
+ tags_data = {}
200
+ return tags_data
201
+
202
+
203
+ def get_latest_release_from_url(url, logger):
204
+ """
205
+ Find the tag name of the latest release by using HTTP HEAD and decoding the redirect.
206
+
207
+ :param str url: URL to the latest release page on a git repository.
208
+ :return: The most recent tag value for the release.
209
+ """
210
+
211
+ logger.info("Requesting redirect information: %s", url)
212
+ response = requests.head(url, timeout=REQUESTS_TIMEOUT)
213
+ responseurl = response.url
214
+ if response.is_redirect:
215
+ responseurl = response.headers["Location"]
216
+ tag = responseurl.rsplit("/", 1)[-1]
217
+ logger.info("Tag: '%s'", tag)
218
+ return tag
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Adafruit Industries
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.