circup 2.3.0__tar.gz → 2.4.0__tar.gz

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.
Files changed (94) hide show
  1. {circup-2.3.0/circup.egg-info → circup-2.4.0}/PKG-INFO +1 -1
  2. {circup-2.3.0 → circup-2.4.0}/circup/backends.py +5 -7
  3. {circup-2.3.0 → circup-2.4.0}/circup/bundle.py +97 -38
  4. {circup-2.3.0 → circup-2.4.0}/circup/command_utils.py +101 -47
  5. {circup-2.3.0 → circup-2.4.0}/circup/commands.py +93 -27
  6. {circup-2.3.0 → circup-2.4.0}/circup/module.py +1 -9
  7. {circup-2.3.0 → circup-2.4.0}/circup/shared.py +4 -1
  8. {circup-2.3.0 → circup-2.4.0/circup.egg-info}/PKG-INFO +1 -1
  9. {circup-2.3.0 → circup-2.4.0}/tests/test_circup.py +40 -14
  10. {circup-2.3.0 → circup-2.4.0}/.github/ISSUE_TEMPLATE.md +0 -0
  11. {circup-2.3.0 → circup-2.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  12. {circup-2.3.0 → circup-2.4.0}/.github/workflows/build.yml +0 -0
  13. {circup-2.3.0 → circup-2.4.0}/.github/workflows/release.yml +0 -0
  14. {circup-2.3.0 → circup-2.4.0}/.gitignore +0 -0
  15. {circup-2.3.0 → circup-2.4.0}/.isort.cfg +0 -0
  16. {circup-2.3.0 → circup-2.4.0}/.pre-commit-config.yaml +0 -0
  17. {circup-2.3.0 → circup-2.4.0}/.pylintrc +0 -0
  18. {circup-2.3.0 → circup-2.4.0}/CODE_OF_CONDUCT.rst +0 -0
  19. {circup-2.3.0 → circup-2.4.0}/CODE_OF_CONDUCT.rst.license +0 -0
  20. {circup-2.3.0 → circup-2.4.0}/CONTRIBUTING.rst +0 -0
  21. {circup-2.3.0 → circup-2.4.0}/CONTRIBUTING.rst.license +0 -0
  22. {circup-2.3.0 → circup-2.4.0}/LICENSE +0 -0
  23. {circup-2.3.0 → circup-2.4.0}/LICENSES/CC-BY-4.0.txt +0 -0
  24. {circup-2.3.0 → circup-2.4.0}/LICENSES/MIT.txt +0 -0
  25. {circup-2.3.0 → circup-2.4.0}/LICENSES/Unlicense.txt +0 -0
  26. {circup-2.3.0 → circup-2.4.0}/README.rst +0 -0
  27. {circup-2.3.0 → circup-2.4.0}/README.rst.license +0 -0
  28. {circup-2.3.0 → circup-2.4.0}/circup/__init__.py +0 -0
  29. {circup-2.3.0 → circup-2.4.0}/circup/config/bundle_config.json +0 -0
  30. {circup-2.3.0 → circup-2.4.0}/circup/config/bundle_config.json.license +0 -0
  31. {circup-2.3.0 → circup-2.4.0}/circup/lazy_metadata.py +0 -0
  32. {circup-2.3.0 → circup-2.4.0}/circup/logging.py +0 -0
  33. {circup-2.3.0 → circup-2.4.0}/circup/wwshell/README.rst +0 -0
  34. {circup-2.3.0 → circup-2.4.0}/circup/wwshell/README.rst.license +0 -0
  35. {circup-2.3.0 → circup-2.4.0}/circup/wwshell/__init__.py +0 -0
  36. {circup-2.3.0 → circup-2.4.0}/circup/wwshell/commands.py +0 -0
  37. {circup-2.3.0 → circup-2.4.0}/circup.egg-info/SOURCES.txt +0 -0
  38. {circup-2.3.0 → circup-2.4.0}/circup.egg-info/dependency_links.txt +0 -0
  39. {circup-2.3.0 → circup-2.4.0}/circup.egg-info/entry_points.txt +0 -0
  40. {circup-2.3.0 → circup-2.4.0}/circup.egg-info/requires.txt +0 -0
  41. {circup-2.3.0 → circup-2.4.0}/circup.egg-info/top_level.txt +0 -0
  42. {circup-2.3.0 → circup-2.4.0}/docs/_static/favicon.ico +0 -0
  43. {circup-2.3.0 → circup-2.4.0}/docs/_static/favicon.ico.license +0 -0
  44. {circup-2.3.0 → circup-2.4.0}/docs/conf.py +0 -0
  45. {circup-2.3.0 → circup-2.4.0}/docs/index.rst +0 -0
  46. {circup-2.3.0 → circup-2.4.0}/docs/index.rst.license +0 -0
  47. {circup-2.3.0 → circup-2.4.0}/docs/logo.png +0 -0
  48. {circup-2.3.0 → circup-2.4.0}/docs/logo.png.license +0 -0
  49. {circup-2.3.0 → circup-2.4.0}/optional_requirements.txt +0 -0
  50. {circup-2.3.0 → circup-2.4.0}/optional_requirements.txt.license +0 -0
  51. {circup-2.3.0 → circup-2.4.0}/pyproject.toml +0 -0
  52. {circup-2.3.0 → circup-2.4.0}/readthedocs.yml +0 -0
  53. {circup-2.3.0 → circup-2.4.0}/requirements.txt +0 -0
  54. {circup-2.3.0 → circup-2.4.0}/requirements.txt.license +0 -0
  55. {circup-2.3.0 → circup-2.4.0}/setup.cfg +0 -0
  56. {circup-2.3.0 → circup-2.4.0}/tests/__init__.py +0 -0
  57. {circup-2.3.0 → circup-2.4.0}/tests/bad_module/__init__.py +0 -0
  58. {circup-2.3.0 → circup-2.4.0}/tests/bad_module/my_module.py +0 -0
  59. {circup-2.3.0 → circup-2.4.0}/tests/bad_python.py +0 -0
  60. {circup-2.3.0 → circup-2.4.0}/tests/bundle.json +0 -0
  61. {circup-2.3.0 → circup-2.4.0}/tests/bundle.json.license +0 -0
  62. {circup-2.3.0 → circup-2.4.0}/tests/device.json +0 -0
  63. {circup-2.3.0 → circup-2.4.0}/tests/device.json.license +0 -0
  64. {circup-2.3.0 → circup-2.4.0}/tests/dir_module/__init__.py +0 -0
  65. {circup-2.3.0 → circup-2.4.0}/tests/dir_module/my_module.py +0 -0
  66. {circup-2.3.0 → circup-2.4.0}/tests/import_styles.py +0 -0
  67. {circup-2.3.0 → circup-2.4.0}/tests/local_module.py +0 -0
  68. {circup-2.3.0 → circup-2.4.0}/tests/local_module_cp7.mpy +0 -0
  69. {circup-2.3.0 → circup-2.4.0}/tests/local_module_cp7.mpy.license +0 -0
  70. {circup-2.3.0 → circup-2.4.0}/tests/mock_device/apps/test_app/import_styles.py +0 -0
  71. {circup-2.3.0 → circup-2.4.0}/tests/mock_device/apps/test_app/import_styles_sub.py +0 -0
  72. {circup-2.3.0 → circup-2.4.0}/tests/mock_device/boot_out.txt +0 -0
  73. {circup-2.3.0 → circup-2.4.0}/tests/mock_device/boot_out.txt.license +0 -0
  74. {circup-2.3.0 → circup-2.4.0}/tests/mock_device/import_styles.py +0 -0
  75. {circup-2.3.0 → circup-2.4.0}/tests/mock_device/import_styles_sub.py +0 -0
  76. {circup-2.3.0 → circup-2.4.0}/tests/mock_device/lib/adafruit_waveform/.gitkeep +0 -0
  77. {circup-2.3.0 → circup-2.4.0}/tests/mock_device_2/.gitignore +0 -0
  78. {circup-2.3.0 → circup-2.4.0}/tests/mock_device_2/boot_out.txt +0 -0
  79. {circup-2.3.0 → circup-2.4.0}/tests/mock_device_2/boot_out.txt.license +0 -0
  80. {circup-2.3.0 → circup-2.4.0}/tests/mock_device_2/code.py +0 -0
  81. {circup-2.3.0 → circup-2.4.0}/tests/mock_device_2/import_styles_sub.py +0 -0
  82. {circup-2.3.0 → circup-2.4.0}/tests/mock_device_2/package/__init__.py +0 -0
  83. {circup-2.3.0 → circup-2.4.0}/tests/mock_device_2/package/other.py +0 -0
  84. {circup-2.3.0 → circup-2.4.0}/tests/mount_exists.txt +0 -0
  85. {circup-2.3.0 → circup-2.4.0}/tests/mount_exists.txt.license +0 -0
  86. {circup-2.3.0 → circup-2.4.0}/tests/mount_missing.txt +0 -0
  87. {circup-2.3.0 → circup-2.4.0}/tests/mount_missing.txt.license +0 -0
  88. {circup-2.3.0 → circup-2.4.0}/tests/remote_module.py +0 -0
  89. {circup-2.3.0 → circup-2.4.0}/tests/test_bundle_config.json +0 -0
  90. {circup-2.3.0 → circup-2.4.0}/tests/test_bundle_config.json.license +0 -0
  91. {circup-2.3.0 → circup-2.4.0}/tests/test_bundle_config_local.json +0 -0
  92. {circup-2.3.0 → circup-2.4.0}/tests/test_bundle_config_local.json.license +0 -0
  93. {circup-2.3.0 → circup-2.4.0}/tests/test_module.mpy +0 -0
  94. {circup-2.3.0 → circup-2.4.0}/tests/test_module.mpy.license +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: circup
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: A tool to manage/update libraries on CircuitPython devices.
5
5
  Author-email: Adafruit Industries <circuitpython@adafruit.com>
6
6
  License: MIT License
@@ -197,7 +197,9 @@ class Backend:
197
197
  # Create the library directory first.
198
198
  self.create_directory(device_path, library_path)
199
199
  if local_path is None:
200
- if pyext:
200
+ # Fallback to the source version (py) if the bundle doesn't have
201
+ # a compiled version (mpy)
202
+ if pyext or bundle.platform is None:
201
203
  # Use Python source for module.
202
204
  self.install_module_py(metadata)
203
205
  else:
@@ -648,9 +650,7 @@ class WebBackend(Backend):
648
650
  if not module_name:
649
651
  # Must be a directory based module.
650
652
  module_name = os.path.basename(os.path.dirname(metadata["path"]))
651
- major_version = self.get_circuitpython_version()[0].split(".")[0]
652
- bundle_platform = "{}mpy".format(major_version)
653
- bundle_path = os.path.join(bundle.lib_dir(bundle_platform), module_name)
653
+ bundle_path = os.path.join(bundle.lib_dir(), module_name)
654
654
  if os.path.isdir(bundle_path):
655
655
 
656
656
  self.install_dir_http(bundle_path)
@@ -920,9 +920,7 @@ class DiskBackend(Backend):
920
920
  # Must be a directory based module.
921
921
  module_name = os.path.basename(os.path.dirname(metadata["path"]))
922
922
 
923
- major_version = self.get_circuitpython_version()[0].split(".")[0]
924
- bundle_platform = "{}mpy".format(major_version)
925
- bundle_path = os.path.join(bundle.lib_dir(bundle_platform), module_name)
923
+ bundle_path = os.path.join(bundle.lib_dir(), module_name)
926
924
  if os.path.isdir(bundle_path):
927
925
  target_path = os.path.join(self.library_path, module_name)
928
926
  # Copy the directory.
@@ -10,6 +10,8 @@ import sys
10
10
  import click
11
11
  import requests
12
12
 
13
+ from semver import VersionInfo
14
+
13
15
  from circup.shared import (
14
16
  DATA_DIR,
15
17
  PLATFORMS,
@@ -20,11 +22,14 @@ from circup.shared import (
20
22
  from circup.logging import logger
21
23
 
22
24
 
23
- class Bundle:
25
+ class Bundle: # pylint: disable=too-many-instance-attributes
24
26
  """
25
27
  All the links and file names for a bundle
26
28
  """
27
29
 
30
+ #: Avoid requests to the internet
31
+ offline = False
32
+
28
33
  def __init__(self, repo):
29
34
  """
30
35
  Initialise a Bundle created from its github info.
@@ -47,29 +52,40 @@ class Bundle:
47
52
  self._latest = None
48
53
  self.pinned_tag = None
49
54
  self._available = []
55
+ #
56
+ self.platform = None
50
57
 
51
- def lib_dir(self, platform):
58
+ def lib_dir(self, source=False):
52
59
  """
53
- This bundle's lib directory for the platform.
60
+ This bundle's lib directory for the bundle's source or compiled version.
54
61
 
55
- :param str platform: The platform identifier (py/6mpy/...).
56
- :return: The path to the lib directory for the platform.
62
+ :param bool source: Whether to return the path to the source lib
63
+ directory or to :py:attr:`self.platform`'s lib directory. If `source` is
64
+ `False` but :py:attr:`self.platform` is None, the source lib directory
65
+ will be returned instead.
66
+ :return: The path to the lib directory.
57
67
  """
58
68
  tag = self.current_tag
69
+ platform = "py" if source or not self.platform else self.platform
59
70
  return os.path.join(
60
71
  self.dir.format(platform=platform),
61
72
  self.basename.format(platform=PLATFORMS[platform], tag=tag),
62
73
  "lib",
63
74
  )
64
75
 
65
- def examples_dir(self, platform):
76
+ def examples_dir(self, source=False):
66
77
  """
67
- This bundle's examples directory for the platform.
78
+ This bundle's examples directory for the bundle's source or compiled
79
+ version.
68
80
 
69
- :param str platform: The platform identifier (py/6mpy/...).
70
- :return: The path to the examples directory for the platform.
81
+ :param bool source: Whether to return the path to the source examples
82
+ directory or to :py:attr:`self.platform`'s examples directory. If
83
+ `source` is `False` but :py:attr:`self.platform` is None, the source
84
+ examples directory will be returned instead.
85
+ :return: The path to the examples directory.
71
86
  """
72
87
  tag = self.current_tag
88
+ platform = "py" if source or not self.platform else self.platform
73
89
  return os.path.join(
74
90
  self.dir.format(platform=platform),
75
91
  self.basename.format(platform=PLATFORMS[platform], tag=tag),
@@ -101,18 +117,25 @@ class Bundle:
101
117
  def current_tag(self):
102
118
  """
103
119
  The current tag for the project. If the tag hasn't been explicitly set
104
- this will be the pinned tag, if one is set. If there is no pinned tag,
105
- this will be the latest available tag that is locally available.
120
+ this will be the pinned tag, if one is set and it is available. If there
121
+ is no pinned tag, this will be the latest available tag that is locally
122
+ available.
106
123
 
107
124
  :return: The current tag value for the project.
108
125
  """
109
126
  if self._current is None:
110
- self._current = self.pinned_tag or (
111
- # This represents the latest version locally available
112
- self._available[-1]
113
- if len(self._available) > 0
114
- else None
115
- )
127
+ if self.pinned_tag:
128
+ self._current = (
129
+ self.pinned_tag if self.pinned_tag in self._available else None
130
+ )
131
+ else:
132
+ self._current = (
133
+ # This represents the latest version locally available
134
+ self._available[-1]
135
+ if len(self._available) > 0
136
+ else None
137
+ )
138
+
116
139
  return self._current
117
140
 
118
141
  @current_tag.setter
@@ -132,9 +155,12 @@ class Bundle:
132
155
  :return: The most recent tag value for the project.
133
156
  """
134
157
  if self._latest is None:
135
- self._latest = get_latest_release_from_url(
136
- self.url + "/releases/latest", logger
137
- )
158
+ if self.offline:
159
+ self._latest = self._available[-1] if len(self._available) > 0 else None
160
+ else:
161
+ self._latest = get_latest_release_from_url(
162
+ self.url + "/releases/latest", logger
163
+ )
138
164
  return self._latest
139
165
 
140
166
  @property
@@ -155,7 +181,15 @@ class Bundle:
155
181
  """
156
182
  if isinstance(tags, str):
157
183
  tags = [tags]
158
- self._available = sorted(tags)
184
+
185
+ try:
186
+ tags = sorted(tags, key=self.parse_version)
187
+ except ValueError as ex:
188
+ logger.warning(
189
+ "Bundle '%s' has invalid tags, cannot order by version.", self.key
190
+ )
191
+ logger.warning(ex)
192
+ self._available = tags
159
193
 
160
194
  def add_tag(self, tag: str) -> None:
161
195
  """
@@ -171,34 +205,59 @@ class Bundle:
171
205
  # The tag is already stored for some reason, lets not add it again
172
206
  return
173
207
 
174
- for rev_i, available_tag in enumerate(reversed(self._available)):
175
- if int(tag) > int(available_tag):
176
- i = len(self._available) - rev_i
177
- self._available.insert(i, tag)
178
- break
179
- else:
180
- self._available.insert(0, tag)
208
+ try:
209
+ version_tag = self.parse_version(tag)
210
+
211
+ for rev_i, available_tag in enumerate(reversed(self._available)):
212
+ available_version_tag = self.parse_version(available_tag)
213
+ if version_tag > available_version_tag:
214
+ i = len(self._available) - rev_i
215
+ self._available.insert(i, tag)
216
+ break
217
+ else:
218
+ self._available.insert(0, tag)
219
+ except ValueError as ex:
220
+ logger.warning(
221
+ "Bundle tag '%s' is not a valid tag, cannot order by version.", tag
222
+ )
223
+ logger.warning(ex)
224
+ self._available.append(tag)
181
225
 
182
226
  def validate(self):
183
227
  """
184
- Test the existence of the expected URLs (not their content)
228
+ Test the existence of the expected URL (not the content)
185
229
  """
186
230
  tag = self.latest_tag
187
231
  if not tag or tag == "releases":
188
232
  if "--verbose" in sys.argv:
189
233
  click.secho(f' Invalid tag "{tag}"', fg="red")
190
234
  return False
191
- for platform in PLATFORMS.values():
192
- url = self.url_format.format(platform=platform, tag=tag)
193
- r = requests.get(url, stream=True, timeout=REQUESTS_TIMEOUT)
194
- # pylint: disable=no-member
195
- if r.status_code != requests.codes.ok:
196
- if "--verbose" in sys.argv:
197
- click.secho(f" Unable to find {os.path.split(url)[1]}", fg="red")
198
- return False
199
- # pylint: enable=no-member
235
+ url = self.url_format.format(platform="py", tag=tag)
236
+ r = requests.get(url, stream=True, timeout=REQUESTS_TIMEOUT)
237
+ # pylint: disable=no-member
238
+ if r.status_code != requests.codes.ok:
239
+ if "--verbose" in sys.argv:
240
+ click.secho(f" Unable to find {os.path.split(url)[1]}", fg="red")
241
+ return False
242
+ # pylint: enable=no-member
200
243
  return True
201
244
 
245
+ @staticmethod
246
+ def parse_version(tag: str) -> VersionInfo:
247
+ """
248
+ Parse a tag to get a VersionInfo object.
249
+
250
+ `VersionInfo` objects are useful for ordering the tags from oldest to
251
+ newest in :py:attr:`self.available_tags`. The tags are stripped of a
252
+ leading 'v' (if one is present) and minor and patch components are
253
+ optional. This is to allow more flexibility with how a bundle is
254
+ versioned.
255
+
256
+ :param str tag: The tag to parse.
257
+ :return: A `VersionInfo` object parsed from the tag.
258
+ """
259
+ return VersionInfo.parse(tag.removeprefix("v"), optional_minor_and_patch=True)
260
+
202
261
  def __repr__(self):
203
262
  """
204
263
  Helps with log files.
@@ -23,6 +23,7 @@ import click
23
23
  from circup.shared import (
24
24
  PLATFORMS,
25
25
  REQUESTS_TIMEOUT,
26
+ SUPPORTED_PLATFORMS,
26
27
  _get_modules_file,
27
28
  BUNDLE_CONFIG_OVERWRITE,
28
29
  BUNDLE_CONFIG_FILE,
@@ -145,29 +146,71 @@ def ensure_bundle_tag(bundle, tag):
145
146
 
146
147
  :return: If the bundle is available.
147
148
  """
148
- do_update = False
149
+ if tag is None:
150
+ logger.warning("Bundle version requested is 'None'.")
151
+ return False
152
+
153
+ do_update_source = False
154
+ do_update_compiled = False
149
155
  if tag in bundle.available_tags:
150
- for platform in PLATFORMS:
151
- # missing directories (new platform added on an existing install
152
- # or side effect of pytest or network errors)
153
- do_update = do_update or not os.path.isdir(bundle.lib_dir(platform))
156
+ # missing directories (new platform added on an existing install
157
+ # or side effect of pytest or network errors)
158
+ # Check for the source
159
+ do_update_source = not os.path.isdir(bundle.lib_dir(source=True))
160
+ do_update_compiled = bundle.platform is not None and not os.path.isdir(
161
+ bundle.lib_dir(source=False)
162
+ )
154
163
  else:
155
- do_update = True
164
+ do_update_source = True
165
+ do_update_compiled = bundle.platform is not None
166
+
167
+ if not (do_update_source or do_update_compiled):
168
+ logger.info("Current bundle version available (%s).", tag)
169
+ return True
156
170
 
157
- if do_update:
158
- logger.info("New version available (%s).", tag)
171
+ if Bundle.offline:
172
+ if do_update_source: # pylint: disable=no-else-return
173
+ logger.info(
174
+ "Bundle version not available but skipping update in offline mode."
175
+ )
176
+ return False
177
+ else:
178
+ logger.info(
179
+ "Bundle platform not available. Falling back to source (.py) files in offline mode."
180
+ )
181
+ bundle.platform = None
182
+ return True
183
+
184
+ logger.info("New version available (%s).", tag)
185
+ if do_update_source:
159
186
  try:
160
- get_bundle(bundle, tag)
161
- tags_data_save_tags(bundle.key, bundle.available_tags)
187
+ get_bundle(bundle, tag, "py")
162
188
  except requests.exceptions.HTTPError as ex:
163
189
  click.secho(
164
- f"There was a problem downloading the {bundle.key} bundle.", fg="red"
190
+ f"There was a problem downloading the 'py' platform for the '{bundle.key}' bundle.",
191
+ fg="red",
165
192
  )
166
193
  logger.exception(ex)
167
- return False
168
- else:
169
- logger.info("Current bundle version available (%s).", tag)
170
- return True
194
+ return False # Bundle isn't available
195
+ bundle.add_tag(tag)
196
+ tags_data_save_tags(bundle.key, bundle.available_tags)
197
+
198
+ if do_update_compiled:
199
+ try:
200
+ get_bundle(bundle, tag, bundle.platform)
201
+ except requests.exceptions.HTTPError as ex:
202
+ click.secho(
203
+ (
204
+ f"There was a problem downloading the '{bundle.platform}' platform for the "
205
+ f"'{bundle.key}' bundle.\nFalling back to source (.py) files."
206
+ ),
207
+ fg="red",
208
+ )
209
+ logger.exception(ex)
210
+ bundle.platform = None # Compiled isn't available, source is good
211
+ bundle.current_tag = tag
212
+
213
+ return True # bundle is available
171
214
 
172
215
 
173
216
  def ensure_latest_bundle(bundle):
@@ -354,40 +397,39 @@ def find_modules(backend, bundles_list):
354
397
  # pylint: enable=broad-except,too-many-locals
355
398
 
356
399
 
357
- def get_bundle(bundle, tag):
400
+ def get_bundle(bundle, tag, platform):
358
401
  """
359
402
  Downloads and extracts the version of the bundle with the referenced tag.
360
403
  The resulting zip file is saved on the local filesystem.
361
404
 
362
405
  :param Bundle bundle: the target Bundle object.
363
406
  :param str tag: The GIT tag to use to download the bundle.
407
+ :param str platform: The platform string (i.e. '10mpy').
364
408
  """
365
- click.echo(f"Downloading bundles for {bundle.key} ({tag}).")
366
- for platform, github_string in PLATFORMS.items():
367
- # Report the platform: "8.x-mpy", etc.
368
- click.echo(f"{github_string}:")
369
- url = bundle.url_format.format(platform=github_string, tag=tag)
370
- logger.info("Downloading bundle: %s", url)
371
- r = requests.get(url, stream=True, timeout=REQUESTS_TIMEOUT)
372
- # pylint: disable=no-member
373
- if r.status_code != requests.codes.ok:
374
- logger.warning("Unable to connect to %s", url)
375
- r.raise_for_status()
376
- # pylint: enable=no-member
377
- total_size = int(r.headers.get("Content-Length"))
378
- temp_zip = bundle.zip.format(platform=platform)
379
- with click.progressbar(
380
- r.iter_content(1024), label="Extracting:", length=total_size
381
- ) as pbar, open(temp_zip, "wb") as zip_fp:
382
- for chunk in pbar:
383
- zip_fp.write(chunk)
384
- pbar.update(len(chunk))
385
- logger.info("Saved to %s", temp_zip)
386
- temp_dir = bundle.dir.format(platform=platform)
387
- with zipfile.ZipFile(temp_zip, "r") as zfile:
388
- zfile.extractall(temp_dir)
389
- bundle.add_tag(tag)
390
- bundle.current_tag = tag
409
+ click.echo(f"Downloading '{platform}' bundle for {bundle.key} ({tag}).")
410
+ github_string = PLATFORMS[platform]
411
+ # Report the platform: "8.x-mpy", etc.
412
+ click.echo(f"{github_string}:")
413
+ url = bundle.url_format.format(platform=github_string, tag=tag)
414
+ logger.info("Downloading bundle: %s", url)
415
+ r = requests.get(url, stream=True, timeout=REQUESTS_TIMEOUT)
416
+ # pylint: disable=no-member
417
+ if r.status_code != requests.codes.ok:
418
+ logger.warning("Unable to connect to %s", url)
419
+ r.raise_for_status()
420
+ # pylint: enable=no-member
421
+ total_size = int(r.headers.get("Content-Length"))
422
+ temp_zip = bundle.zip.format(platform=platform)
423
+ with click.progressbar(
424
+ r.iter_content(1024), label="Extracting:", length=total_size
425
+ ) as pbar, open(temp_zip, "wb") as zip_fp:
426
+ for chunk in pbar:
427
+ zip_fp.write(chunk)
428
+ pbar.update(len(chunk))
429
+ logger.info("Saved to %s", temp_zip)
430
+ temp_dir = bundle.dir.format(platform=platform)
431
+ with zipfile.ZipFile(temp_zip, "r") as zfile:
432
+ zfile.extractall(temp_dir)
391
433
  click.echo("\nOK\n")
392
434
 
393
435
 
@@ -407,9 +449,9 @@ def get_bundle_examples(bundles_list, avoid_download=False):
407
449
 
408
450
  try:
409
451
  for bundle in bundles_list:
410
- if not avoid_download or not os.path.isdir(bundle.lib_dir("py")):
452
+ if not avoid_download or not os.path.isdir(bundle.lib_dir(source=True)):
411
453
  ensure_bundle(bundle)
412
- path = bundle.examples_dir("py")
454
+ path = bundle.examples_dir(source=True)
413
455
  meta_saved = os.path.join(path, "../bundle_examples.json")
414
456
  if os.path.exists(meta_saved):
415
457
  with open(meta_saved, "r", encoding="utf-8") as f:
@@ -455,9 +497,9 @@ def get_bundle_versions(bundles_list, avoid_download=False):
455
497
  """
456
498
  all_the_modules = dict()
457
499
  for bundle in bundles_list:
458
- if not avoid_download or not os.path.isdir(bundle.lib_dir("py")):
500
+ if not avoid_download or not os.path.isdir(bundle.lib_dir(source=True)):
459
501
  ensure_bundle(bundle)
460
- path = bundle.lib_dir("py")
502
+ path = bundle.lib_dir(source=True)
461
503
  path_modules = _get_modules_file(path, logger)
462
504
  for name, module in path_modules.items():
463
505
  module["bundle"] = bundle
@@ -504,12 +546,14 @@ def get_bundles_local_dict():
504
546
  return dict()
505
547
 
506
548
 
507
- def get_bundles_list(bundle_tags):
549
+ def get_bundles_list(bundle_tags, platform_version=None):
508
550
  """
509
551
  Retrieve the list of bundles from the config dictionary.
510
552
 
511
553
  :param Dict[str,str]|None bundle_tags: Pinned bundle tags. These override
512
554
  any tags found in the pyproject.toml.
555
+ :param str platform_version: The platform version needed for the current
556
+ device.
513
557
  :return: List of supported bundles as Bundle objects.
514
558
  """
515
559
  bundle_config = get_bundles_dict()
@@ -524,6 +568,7 @@ def get_bundles_list(bundle_tags):
524
568
 
525
569
  bundles_list = [Bundle(bundle_config[b]) for b in bundle_config]
526
570
  for bundle in bundles_list:
571
+ bundle.platform = platform_version
527
572
  bundle.available_tags = tags.get(bundle.key, [])
528
573
  if pinned_tags is not None:
529
574
  bundle.pinned_tag = pinned_tags.get(bundle.key)
@@ -994,3 +1039,12 @@ def parse_cli_bundle_tags(bundle_tags_cli):
994
1039
  if len(item) == 2:
995
1040
  bundle_tags[item[0].strip()] = item[1].strip()
996
1041
  return bundle_tags if len(bundle_tags) > 0 else None
1042
+
1043
+
1044
+ def pretty_supported_cpy_versions():
1045
+ """Return a user friendly string of the supported CircuitPython versions."""
1046
+ supported_cpy = [
1047
+ PLATFORMS[platform].split("-", maxsplit=1)[0]
1048
+ for platform in SUPPORTED_PLATFORMS
1049
+ ]
1050
+ return ", ".join(supported_cpy)
@@ -24,7 +24,11 @@ import requests
24
24
 
25
25
  from circup.backends import WebBackend, DiskBackend
26
26
  from circup.logging import logger, log_formatter, LOGFILE
27
- from circup.shared import BOARDLESS_COMMANDS, get_latest_release_from_url
27
+ from circup.shared import (
28
+ BOARDLESS_COMMANDS,
29
+ SUPPORTED_PLATFORMS,
30
+ get_latest_release_from_url,
31
+ )
28
32
  from circup.bundle import Bundle
29
33
  from circup.command_utils import (
30
34
  get_device_path,
@@ -38,6 +42,7 @@ from circup.command_utils import (
38
42
  get_dependencies,
39
43
  get_bundles_local_dict,
40
44
  parse_cli_bundle_tags,
45
+ pretty_supported_cpy_versions,
41
46
  save_local_bundles,
42
47
  get_bundles_dict,
43
48
  completion_for_example,
@@ -68,6 +73,16 @@ from circup.command_utils import (
68
73
  " You can optionally set an environment variable CIRCUP_WEBWORKFLOW_PASSWORD"
69
74
  " instead of passing this argument. If both exist the CLI arg takes precedent.",
70
75
  )
76
+ @click.option(
77
+ "--offline",
78
+ is_flag=True,
79
+ help="Prevents Circup from accessing the internet for any reason. "
80
+ "Without this flag, Circup will fail with an error if it needs to access "
81
+ "the network and the network is not available. With this flag, Circup "
82
+ "will attempt to proceed without the network if possible. Circup will "
83
+ "only use bundles downloaded locally even if there might be newer "
84
+ "versions available.",
85
+ )
71
86
  @click.option(
72
87
  "--timeout",
73
88
  default=30,
@@ -95,6 +110,13 @@ from circup.command_utils import (
95
110
  "version values provided here will override any pinned values from the "
96
111
  "pyproject.toml.",
97
112
  )
113
+ @click.option(
114
+ "--allow-unsupported",
115
+ is_flag=True,
116
+ help="Allow using a device with a version of CircuitPython that is no longer "
117
+ "supported. Using an unsupported version of CircuitPython is generally not "
118
+ "recommended because libraries may not work with it.",
119
+ )
98
120
  @click.version_option(
99
121
  prog_name="Circup",
100
122
  message="%(prog)s, A CircuitPython module updater. Version %(version)s",
@@ -107,10 +129,12 @@ def main( # pylint: disable=too-many-locals
107
129
  host,
108
130
  port,
109
131
  password,
132
+ offline,
110
133
  timeout,
111
134
  board_id,
112
135
  cpy_version,
113
136
  bundle_versions,
137
+ allow_unsupported,
114
138
  ): # pragma: no cover
115
139
  """
116
140
  A tool to manage and update libraries on a CircuitPython device.
@@ -121,6 +145,7 @@ def main( # pylint: disable=too-many-locals
121
145
  ctx.obj["BUNDLE_TAGS"] = (
122
146
  parse_cli_bundle_tags(bundle_versions) if len(bundle_versions) > 0 else None
123
147
  )
148
+ Bundle.offline = offline
124
149
 
125
150
  if password is None:
126
151
  password = os.getenv("CIRCUP_WEBWORKFLOW_PASSWORD")
@@ -177,20 +202,22 @@ def main( # pylint: disable=too-many-locals
177
202
 
178
203
  logger.info("### Started Circup ###")
179
204
 
180
- # If a newer version of circup is available, print a message.
181
- logger.info("Checking for a newer version of circup")
182
- version = get_circup_version()
183
- if version:
184
- update_checker.update_check("circup", version)
205
+ if offline:
206
+ logger.info(
207
+ "'--offline' flag present, all update checks requiring the network will be skipped."
208
+ )
209
+ else:
210
+ # If a newer version of circup is available, print a message.
211
+ logger.info("Checking for a newer version of circup")
212
+ version = get_circup_version()
213
+ if version:
214
+ update_checker.update_check("circup", version)
185
215
 
186
216
  # stop early if the command is boardless
187
217
  if ctx.invoked_subcommand in BOARDLESS_COMMANDS or "--help" in sys.argv:
188
218
  return
189
219
 
190
220
  ctx.obj["DEVICE_PATH"] = device_path
191
- latest_version = get_latest_release_from_url(
192
- "https://github.com/adafruit/circuitpython/releases/latest", logger
193
- )
194
221
 
195
222
  if device_path is None or not ctx.obj["backend"].is_device_present():
196
223
  click.secho("Could not find a connected CircuitPython device.", fg="red")
@@ -201,27 +228,54 @@ def main( # pylint: disable=too-many-locals
201
228
  if board_id is None or cpy_version is None
202
229
  else (cpy_version, board_id)
203
230
  )
231
+ major_version = cpy_version.split(".")[0]
232
+ bundle_platform = "{}mpy".format(major_version)
233
+ ctx.obj["DEVICE_PLATFORM_VERSION"] = bundle_platform
204
234
  click.echo(
205
235
  "Found device {} at {}, running CircuitPython {}.".format(
206
236
  board_id, device_path, cpy_version
207
237
  )
208
238
  )
209
- try:
210
- if VersionInfo.parse(cpy_version) < VersionInfo.parse(latest_version):
239
+
240
+ if not offline:
241
+ latest_version = get_latest_release_from_url(
242
+ "https://github.com/adafruit/circuitpython/releases/latest", logger
243
+ )
244
+ try:
245
+ if VersionInfo.parse(cpy_version) < VersionInfo.parse(latest_version):
246
+ click.secho(
247
+ "A newer version of CircuitPython ({}) is available.".format(
248
+ latest_version
249
+ ),
250
+ fg="green",
251
+ )
252
+ if board_id:
253
+ url_download = f"https://circuitpython.org/board/{board_id}"
254
+ else:
255
+ url_download = "https://circuitpython.org/downloads"
256
+ click.secho("Get it here: {}".format(url_download), fg="green")
257
+ except ValueError as ex:
258
+ logger.warning("CircuitPython has incorrect semver value.")
259
+ logger.warning(ex)
260
+
261
+ if not bundle_platform in SUPPORTED_PLATFORMS:
262
+ click.secho(
263
+ "The version of CircuitPython on the device is no longer supported.",
264
+ fg="yellow" if allow_unsupported else "red",
265
+ )
266
+ if allow_unsupported:
211
267
  click.secho(
212
- "A newer version of CircuitPython ({}) is available.".format(
213
- latest_version
214
- ),
215
- fg="green",
268
+ "It is recommended to update to a supported version "
269
+ f"({pretty_supported_cpy_versions()}) to ensure compatability.",
270
+ fg="yellow",
216
271
  )
217
- if board_id:
218
- url_download = f"https://circuitpython.org/board/{board_id}"
219
- else:
220
- url_download = "https://circuitpython.org/downloads"
221
- click.secho("Get it here: {}".format(url_download), fg="green")
222
- except ValueError as ex:
223
- logger.warning("CircuitPython has incorrect semver value.")
224
- logger.warning(ex)
272
+ else:
273
+ click.echo(
274
+ f"If you would like to continue to use version {cpy_version} of CircuitPython, "
275
+ "pass the '--allow-unsupported' flag with this command. Otherwise, update to a "
276
+ f"supported version ({pretty_supported_cpy_versions()}) to ensure compatability.",
277
+ )
278
+ sys.exit(1)
225
279
 
226
280
 
227
281
  @main.command()
@@ -282,7 +336,10 @@ def list_cli(ctx): # pragma: no cover
282
336
  modules = [
283
337
  m.row
284
338
  for m in find_modules(
285
- ctx.obj["backend"], get_bundles_list(ctx.obj["BUNDLE_TAGS"])
339
+ ctx.obj["backend"],
340
+ get_bundles_list(
341
+ ctx.obj["BUNDLE_TAGS"], ctx.obj["DEVICE_PLATFORM_VERSION"]
342
+ ),
286
343
  )
287
344
  if m.outofdate
288
345
  ]
@@ -359,7 +416,10 @@ def install(
359
416
 
360
417
  # pylint: disable=too-many-branches
361
418
  # TODO: Ensure there's enough space on the device
362
- available_modules = get_bundle_versions(get_bundles_list(ctx.obj["BUNDLE_TAGS"]))
419
+ platform_version = ctx.obj["DEVICE_PLATFORM_VERSION"] if not pyext else None
420
+ available_modules = get_bundle_versions(
421
+ get_bundles_list(ctx.obj["BUNDLE_TAGS"], platform_version)
422
+ )
363
423
  mod_names = {}
364
424
  for module, metadata in available_modules.items():
365
425
  mod_names[module.replace(".py", "").lower()] = metadata
@@ -392,7 +452,7 @@ def install(
392
452
  upgrade,
393
453
  )
394
454
 
395
- if stubs:
455
+ if stubs and not Bundle.offline:
396
456
  # Check we are in a virtual environment
397
457
  if not is_virtual_env_active():
398
458
  if is_global_install_ok is None:
@@ -581,7 +641,9 @@ def update(ctx, update_all): # pragma: no cover
581
641
  """
582
642
  logger.info("Update")
583
643
  # Grab current modules.
584
- bundles_list = get_bundles_list(ctx.obj["BUNDLE_TAGS"])
644
+ bundles_list = get_bundles_list(
645
+ ctx.obj["BUNDLE_TAGS"], ctx.obj["DEVICE_PLATFORM_VERSION"]
646
+ )
585
647
  installed_modules = find_modules(ctx.obj["backend"], bundles_list)
586
648
  modules_to_update = [m for m in installed_modules if m.outofdate]
587
649
 
@@ -728,6 +790,10 @@ def bundle_add(ctx, bundle):
728
790
  )
729
791
  return
730
792
 
793
+ if Bundle.offline:
794
+ click.secho("Cannot add new bundle when '--offline' flag is present.", fg="red")
795
+ return
796
+
731
797
  bundles_dict = get_bundles_local_dict()
732
798
  modified = False
733
799
  for bundle_repo in bundle:
@@ -79,16 +79,8 @@ class Module:
79
79
  self.max_version = compatibility[1]
80
80
  # Figure out the bundle path.
81
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
82
  # module path in the bundle
91
- search_path = bundle.lib_dir(bundle_platform)
83
+ search_path = bundle.lib_dir(source=not self.mpy)
92
84
  if self.file:
93
85
  self.bundle_path = os.path.join(search_path, self.file)
94
86
  else:
@@ -22,7 +22,10 @@ BAD_FILE_FORMAT = "Invalid"
22
22
  DATA_DIR = appdirs.user_data_dir(appname="circup", appauthor="adafruit")
23
23
 
24
24
  #: Module formats list (and the other form used in github files)
25
- PLATFORMS = {"py": "py", "9mpy": "9.x-mpy", "10mpy": "10.x-mpy"}
25
+ PLATFORMS = {"py": "py", "8mpy": "8.x-mpy", "9mpy": "9.x-mpy", "10mpy": "10.x-mpy"}
26
+
27
+ #: CircuitPython platforms that are currently supported.
28
+ SUPPORTED_PLATFORMS = ["9mpy", "10mpy"]
26
29
 
27
30
  #: Timeout for requests calls like get()
28
31
  REQUESTS_TIMEOUT = 30
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: circup
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: A tool to manage/update libraries on CircuitPython devices.
5
5
  Author-email: Adafruit Industries <circuitpython@adafruit.com>
6
6
  License: MIT License
@@ -51,7 +51,6 @@ from circup.command_utils import (
51
51
  pyproject_bundle_versions,
52
52
  tags_data_load,
53
53
  )
54
- from circup.shared import PLATFORMS
55
54
  from circup.module import Module
56
55
  from circup.logging import logger
57
56
 
@@ -98,14 +97,15 @@ def test_Bundle_lib_dir():
98
97
  Check the return of Bundle.lib_dir with a test tag.
99
98
  """
100
99
  bundle = circup.Bundle(TEST_BUNDLE_NAME)
100
+ bundle.platform = "9mpy"
101
101
  with mock.patch.object(bundle, "_available", ["TESTTAG"]):
102
102
  assert bundle.current_tag == "TESTTAG"
103
- assert bundle.lib_dir("py") == (
103
+ assert bundle.lib_dir(source=True) == (
104
104
  circup.shared.DATA_DIR + "/"
105
105
  "adafruit/adafruit-circuitpython-bundle-py/"
106
106
  "adafruit-circuitpython-bundle-py-TESTTAG/lib"
107
107
  )
108
- assert bundle.lib_dir("9mpy") == (
108
+ assert bundle.lib_dir() == (
109
109
  circup.shared.DATA_DIR + "/"
110
110
  "adafruit/adafruit-circuitpython-bundle-9mpy/"
111
111
  "adafruit-circuitpython-bundle-9.x-mpy-TESTTAG/lib"
@@ -943,7 +943,7 @@ def test_ensure_latest_bundle_no_bundle_data():
943
943
  ):
944
944
  bundle = circup.Bundle(TEST_BUNDLE_NAME)
945
945
  ensure_latest_bundle(bundle)
946
- mock_gb.assert_called_once_with(bundle, "12345")
946
+ mock_gb.assert_called_once_with(bundle, "12345", "py")
947
947
  assert mock_json.dump.call_count == 1 # Current version saved to file.
948
948
 
949
949
 
@@ -968,7 +968,7 @@ def test_ensure_latest_bundle_bad_bundle_data():
968
968
  tags = tags_data_load()
969
969
  bundle.available_tags = tags.get(bundle.key, [])
970
970
  ensure_latest_bundle(bundle)
971
- mock_gb.assert_called_once_with(bundle, "12345")
971
+ mock_gb.assert_called_once_with(bundle, "12345", "py")
972
972
 
973
973
  assert mock_logger.error.call_count == 1
974
974
  assert mock_logger.exception.call_count == 1
@@ -987,7 +987,7 @@ def test_ensure_latest_bundle_to_update():
987
987
  mock_json.load.return_value = {TEST_BUNDLE_NAME: "12345"}
988
988
  bundle = circup.Bundle(TEST_BUNDLE_NAME)
989
989
  ensure_latest_bundle(bundle)
990
- mock_gb.assert_called_once_with(bundle, "54321")
990
+ mock_gb.assert_called_once_with(bundle, "54321", "py")
991
991
  assert mock_json.dump.call_count == 1 # Current version saved to file.
992
992
 
993
993
 
@@ -1015,7 +1015,7 @@ def test_ensure_latest_bundle_to_update_http_error():
1015
1015
  tags = tags_data_load()
1016
1016
  bundle.available_tags = tags.get(bundle.key, [])
1017
1017
  ensure_latest_bundle(bundle)
1018
- mock_gb.assert_called_once_with(bundle, "54321")
1018
+ mock_gb.assert_called_once_with(bundle, "54321", "py")
1019
1019
  assert bundle.current_tag == "67890"
1020
1020
  assert mock_json.dump.call_count == 0 # not saved.
1021
1021
  assert mock_click.call_count == 2 # friendly message.
@@ -1045,7 +1045,7 @@ def test_ensure_pinned_bundle_to_exit_http_error():
1045
1045
  tags = tags_data_load()
1046
1046
  bundle.available_tags = tags.get(bundle.key, [])
1047
1047
  ensure_pinned_bundle(bundle)
1048
- mock_gb.assert_called_once_with(bundle, "54321")
1048
+ mock_gb.assert_called_once_with(bundle, "54321", "py")
1049
1049
  assert mock_json.dump.call_count == 0 # not saved.
1050
1050
  assert mock_click.call_count == 2 # friendly message.
1051
1051
  mock_exit.assert_called_once_with(1)
@@ -1074,6 +1074,34 @@ def test_ensure_latest_bundle_no_update():
1074
1074
  assert mock_logger.info.call_count == 2
1075
1075
 
1076
1076
 
1077
+ def test_ensure_bundle_tag_fallback_to_source():
1078
+ """
1079
+ If a compiled platform download fails, fallback to the source version.
1080
+ """
1081
+ tags_data = {TEST_BUNDLE_NAME: ["12345"]}
1082
+ with mock.patch("circup.Bundle.latest_tag", "54321"), mock.patch(
1083
+ "circup.os.path.isfile",
1084
+ return_value=True,
1085
+ ), mock.patch("circup.command_utils.open"), mock.patch(
1086
+ "circup.command_utils.get_bundle",
1087
+ side_effect=[None, requests.exceptions.HTTPError("404")],
1088
+ ) as mock_gb, mock.patch(
1089
+ "circup.command_utils.json"
1090
+ ) as mock_json, mock.patch(
1091
+ "circup.click.secho"
1092
+ ):
1093
+ mock_json.load.return_value = tags_data
1094
+ bundle = circup.Bundle(TEST_BUNDLE_NAME)
1095
+ bundle.platform = "10mpy"
1096
+ tags = tags_data_load()
1097
+ bundle.available_tags = tags.get(bundle.key, [])
1098
+ ensure_latest_bundle(bundle)
1099
+ mock_gb.assert_called_with(bundle, "54321", "10mpy")
1100
+ assert bundle.current_tag == "54321"
1101
+ assert bundle.platform is None
1102
+ assert mock_json.dump.call_count == 1
1103
+
1104
+
1077
1105
  def test_get_bundle():
1078
1106
  """
1079
1107
  Ensure the expected calls are made to get the referenced bundle and the
@@ -1095,20 +1123,18 @@ def test_get_bundle():
1095
1123
  "circup.command_utils.zipfile"
1096
1124
  ) as mock_zipfile, mock.patch(
1097
1125
  "circup.Bundle.add_tag"
1098
- ) as mock_add_tag:
1126
+ ):
1099
1127
  mock_click.progressbar = mock_progress
1100
1128
  mock_requests.get().status_code = mock_requests.codes.ok
1101
1129
  mock_requests.get.reset_mock()
1102
1130
  tag = "12345"
1103
1131
  bundle = circup.Bundle(TEST_BUNDLE_NAME)
1104
- get_bundle(bundle, tag)
1105
- # how many bundles currently supported. i.e. 6x.mpy, 7x.mpy, py = 3 bundles
1106
- _bundle_count = len(PLATFORMS)
1132
+ get_bundle(bundle, tag, "py")
1133
+ _bundle_count = 1
1107
1134
  assert mock_requests.get.call_count == _bundle_count
1108
1135
  assert mock_open.call_count == _bundle_count
1109
1136
  assert mock_zipfile.ZipFile.call_count == _bundle_count
1110
1137
  assert mock_zipfile.ZipFile().__enter__().extractall.call_count == _bundle_count
1111
- assert mock_add_tag.call_count == 1
1112
1138
 
1113
1139
 
1114
1140
  def test_get_bundle_network_error():
@@ -1127,7 +1153,7 @@ def test_get_bundle_network_error():
1127
1153
  tag = "12345"
1128
1154
  with pytest.raises(Exception) as ex:
1129
1155
  bundle = circup.Bundle(TEST_BUNDLE_NAME)
1130
- get_bundle(bundle, tag)
1156
+ get_bundle(bundle, tag, "py")
1131
1157
  assert ex.value.args[0] == "Bang!"
1132
1158
  url = (
1133
1159
  "https://github.com/" + TEST_BUNDLE_NAME + "/releases/download"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes