circup 2.3.0__py3-none-any.whl → 3.0.0__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 CHANGED
@@ -139,7 +139,7 @@ class Backend:
139
139
  if name in device_modules:
140
140
  if not upgrade:
141
141
  # skip already installed modules if no -upgrade flag
142
- click.echo("'{}' is already installed.".format(name))
142
+ click.echo(f"'{name}' is already installed.")
143
143
  return
144
144
 
145
145
  # uninstall the module before installing
@@ -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:
@@ -205,9 +207,9 @@ class Backend:
205
207
  self.install_module_mpy(bundle, metadata)
206
208
  else:
207
209
  self.copy_file(metadata["path"], "lib")
208
- click.echo("Installed '{}'.".format(name))
210
+ click.echo(f"Installed '{name}'.")
209
211
  else:
210
- click.echo("Unknown module named, '{}'.".format(name))
212
+ click.echo(f"Unknown module named, '{name}'.")
211
213
 
212
214
  # def libraries_from_imports(self, code_py, mod_names):
213
215
  # """
@@ -303,7 +305,7 @@ class WebBackend(Backend):
303
305
  socket.getaddrinfo(host, 80, proto=socket.IPPROTO_TCP)
304
306
  except socket.gaierror as exc:
305
307
  raise RuntimeError(
306
- "Invalid host: {}.".format(host) + " You should remove the 'http://'"
308
+ f"Invalid host: {host}." + " You should remove the 'http://'"
307
309
  if "http://" in host or "https://" in host
308
310
  else "Could not find or connect to specified device"
309
311
  ) from exc
@@ -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)
@@ -659,7 +659,7 @@ class WebBackend(Backend):
659
659
  self.install_file_http(bundle_path)
660
660
 
661
661
  else:
662
- raise IOError("Cannot find compiled version of module.")
662
+ raise OSError("Cannot find compiled version of module.")
663
663
 
664
664
  # pylint: enable=too-many-locals,too-many-branches
665
665
  def install_module_py(self, metadata, location=None):
@@ -862,7 +862,6 @@ class DiskBackend(Backend):
862
862
  try:
863
863
  with open(
864
864
  os.path.join(self.device_location, "boot_out.txt"),
865
- "r",
866
865
  encoding="utf-8",
867
866
  ) as boot:
868
867
  boot_out_contents = boot.read()
@@ -920,9 +919,7 @@ class DiskBackend(Backend):
920
919
  # Must be a directory based module.
921
920
  module_name = os.path.basename(os.path.dirname(metadata["path"]))
922
921
 
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)
922
+ bundle_path = os.path.join(bundle.lib_dir(), module_name)
926
923
  if os.path.isdir(bundle_path):
927
924
  target_path = os.path.join(self.library_path, module_name)
928
925
  # Copy the directory.
@@ -936,7 +933,7 @@ class DiskBackend(Backend):
936
933
  # Copy file.
937
934
  shutil.copyfile(bundle_path, target_path)
938
935
  else:
939
- raise IOError("Cannot find compiled version of module.")
936
+ raise OSError("Cannot find compiled version of module.")
940
937
 
941
938
  # pylint: enable=too-many-locals,too-many-branches
942
939
  def install_module_py(self, metadata, location=None):
circup/bundle.py CHANGED
@@ -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),
@@ -93,7 +109,7 @@ class Bundle:
93
109
  "requirements.txt" if not toml_file else "pyproject.toml",
94
110
  )
95
111
  if os.path.isfile(found_file):
96
- with open(found_file, "r", encoding="utf-8") as read_this:
112
+ with open(found_file, encoding="utf-8") as read_this:
97
113
  return read_this.read()
98
114
  return None
99
115
 
@@ -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.
circup/command_utils.py CHANGED
@@ -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):
@@ -286,7 +329,7 @@ def find_device():
286
329
  old_mode = ctypes.windll.kernel32.SetErrorMode(1)
287
330
  try:
288
331
  for disk in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
289
- path = "{}:\\".format(disk)
332
+ path = f"{disk}:\\"
290
333
  if os.path.exists(path) and get_volume_name(path) == "CIRCUITPY":
291
334
  device_dir = path
292
335
  # Report only the FIRST device found.
@@ -295,7 +338,7 @@ def find_device():
295
338
  ctypes.windll.kernel32.SetErrorMode(old_mode)
296
339
  else:
297
340
  # No support for unknown operating systems.
298
- raise NotImplementedError('OS "{}" not supported.'.format(os.name))
341
+ raise NotImplementedError(f'OS "{os.name}" not supported.')
299
342
  logger.info("Found device: %s", device_dir)
300
343
  return device_dir
301
344
 
@@ -349,45 +392,44 @@ def find_modules(backend, bundles_list):
349
392
  # If it's not possible to get the device and bundle metadata, bail out
350
393
  # with a friendly message and indication of what's gone wrong.
351
394
  logger.exception(ex)
352
- click.echo("There was a problem: {}".format(ex))
395
+ click.echo(f"There was a problem: {ex}")
353
396
  sys.exit(1)
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,12 +449,12 @@ 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
- with open(meta_saved, "r", encoding="utf-8") as f:
457
+ with open(meta_saved, encoding="utf-8") as f:
416
458
  bundle_examples = json.load(f)
417
459
  all_the_examples.update(bundle_examples)
418
460
  bundle_examples.clear()
@@ -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)
@@ -884,7 +929,7 @@ def libraries_from_auto_file(backend, auto_file, mod_names):
884
929
  # pass a local file with "./" or "../"
885
930
  is_relative = auto_file.split(os.sep)[0] in [os.path.curdir, os.path.pardir]
886
931
  if os.path.isabs(auto_file) or is_relative:
887
- with open(auto_file, "r", encoding="UTF8") as fp:
932
+ with open(auto_file, encoding="UTF8") as fp:
888
933
  auto_file_content = fp.read()
889
934
  else:
890
935
  auto_file_content = backend.get_file_content(auto_file)
@@ -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)
circup/commands.py CHANGED
@@ -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")
@@ -171,26 +196,28 @@ def main( # pylint: disable=too-many-locals
171
196
  verbose_handler.setLevel(logging.INFO)
172
197
  verbose_handler.setFormatter(log_formatter)
173
198
  logger.addHandler(verbose_handler)
174
- click.echo("Logging to {}\n".format(LOGFILE))
199
+ click.echo(f"Logging to {LOGFILE}\n")
175
200
  else:
176
201
  ctx.obj["verbose"] = False
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(f"Get it here: {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()
@@ -237,7 +291,7 @@ def freeze(ctx, requirement): # pragma: no cover
237
291
  if modules:
238
292
  output = []
239
293
  for module in modules:
240
- output.append("{}=={}".format(module.name, module.device_version))
294
+ output.append(f"{module.name}=={module.device_version}")
241
295
  for module in output:
242
296
  click.echo(module)
243
297
  logger.info(module)
@@ -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
  ]
@@ -293,7 +350,7 @@ def list_cli(ctx): # pragma: no cover
293
350
  for row in data:
294
351
  for i, word in enumerate(row):
295
352
  col_width[i] = max(len(word) + 2, col_width[i])
296
- dashes = tuple(("-" * (width - 1) for width in col_width))
353
+ dashes = tuple("-" * (width - 1) for width in col_width)
297
354
  data.insert(1, dashes)
298
355
  click.echo(
299
356
  "The following modules are out of date or probably need an update.\n"
@@ -359,12 +416,15 @@ 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
366
426
  if requirement:
367
- with open(requirement, "r", encoding="utf-8") as rfile:
427
+ with open(requirement, encoding="utf-8") as rfile:
368
428
  requirements_txt = rfile.read()
369
429
  requested_installs = libraries_from_requirements(requirements_txt)
370
430
  elif auto or auto_file:
@@ -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:
@@ -525,9 +585,7 @@ def show(ctx, match): # pragma: no cover
525
585
  module_names = [m for m in module_names if match in m]
526
586
  click.echo("\n".join(module_names))
527
587
 
528
- click.echo(
529
- "{} shown of {} packages.".format(len(module_names), len(available_modules))
530
- )
588
+ click.echo(f"{len(module_names)} shown of {len(available_modules)} packages.")
531
589
 
532
590
 
533
591
  @main.command()
@@ -551,9 +609,9 @@ def uninstall(ctx, module): # pragma: no cover
551
609
  metadata = mod_names[name]
552
610
  module_path = metadata["path"]
553
611
  ctx.obj["backend"].uninstall(device_path, module_path)
554
- click.echo("Uninstalled '{}'.".format(name))
612
+ click.echo(f"Uninstalled '{name}'.")
555
613
  else:
556
- click.echo("Module '{}' not found on device.".format(name))
614
+ click.echo(f"Module '{name}' not found on device.")
557
615
  continue
558
616
 
559
617
 
@@ -581,7 +639,9 @@ def update(ctx, update_all): # pragma: no cover
581
639
  """
582
640
  logger.info("Update")
583
641
  # Grab current modules.
584
- bundles_list = get_bundles_list(ctx.obj["BUNDLE_TAGS"])
642
+ bundles_list = get_bundles_list(
643
+ ctx.obj["BUNDLE_TAGS"], ctx.obj["DEVICE_PLATFORM_VERSION"]
644
+ )
585
645
  installed_modules = find_modules(ctx.obj["backend"], bundles_list)
586
646
  modules_to_update = [m for m in installed_modules if m.outofdate]
587
647
 
@@ -591,7 +651,7 @@ def update(ctx, update_all): # pragma: no cover
591
651
 
592
652
  # Process out of date modules
593
653
  updated_modules = []
594
- click.echo("Found {} module[s] needing update.".format(len(modules_to_update)))
654
+ click.echo(f"Found {len(modules_to_update)} module[s] needing update.")
595
655
  if not update_all:
596
656
  click.echo("Please indicate which module[s] you wish to update:\n")
597
657
  for module in modules_to_update:
@@ -630,22 +690,20 @@ def update(ctx, update_all): # pragma: no cover
630
690
  update_flag = click.confirm("Do you want to update?")
631
691
  elif module.major_update:
632
692
  update_flag = click.confirm(
633
- (
634
- "'{}' is a Major Version update and may contain breaking "
635
- "changes. Do you want to update?".format(module.name)
636
- )
693
+ "'{}' is a Major Version update and may contain breaking "
694
+ "changes. Do you want to update?".format(module.name)
637
695
  )
638
696
  else:
639
- update_flag = click.confirm("Update '{}'?".format(module.name))
697
+ update_flag = click.confirm(f"Update '{module.name}'?")
640
698
  if update_flag:
641
699
  # pylint: disable=broad-except
642
700
  try:
643
701
  ctx.obj["backend"].update(module)
644
702
  updated_modules.append(module.name)
645
- click.echo("Updated {}".format(module.name))
703
+ click.echo(f"Updated {module.name}")
646
704
  except Exception as ex:
647
705
  logger.exception(ex)
648
- click.echo("Something went wrong, {} (check the logs)".format(str(ex)))
706
+ click.echo(f"Something went wrong, {str(ex)} (check the logs)")
649
707
  # pylint: enable=broad-except
650
708
 
651
709
  if not updated_modules:
@@ -728,6 +786,10 @@ def bundle_add(ctx, bundle):
728
786
  )
729
787
  return
730
788
 
789
+ if Bundle.offline:
790
+ click.secho("Cannot add new bundle when '--offline' flag is present.", fg="red")
791
+ return
792
+
731
793
  bundles_dict = get_bundles_local_dict()
732
794
  modified = False
733
795
  for bundle_repo in bundle:
circup/lazy_metadata.py CHANGED
@@ -5,7 +5,8 @@
5
5
  Class that acts similar to a dictionary, but defers the loading of expensive
6
6
  data until that data is accessed.
7
7
  """
8
- from typing import Any, Callable
8
+ from typing import Any
9
+ from collections.abc import Callable
9
10
 
10
11
 
11
12
  class LazyMetadata:
circup/module.py CHANGED
@@ -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:
circup/shared.py CHANGED
@@ -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
@@ -131,7 +134,7 @@ def extract_metadata(path, logger):
131
134
  logger.info("%s", path)
132
135
  if path.endswith(".py"):
133
136
  result["mpy"] = False
134
- with open(path, "r", encoding="utf-8") as source_file:
137
+ with open(path, encoding="utf-8") as source_file:
135
138
  content = source_file.read()
136
139
  #: The regex used to extract ``__version__`` and ``__repo__`` assignments.
137
140
  dunder_key_val = r"""(__\w+__)(?:\s*:\s*\w+)?\s*=\s*(?:['"]|\(\s)(.+)['"]"""
@@ -115,7 +115,7 @@ def main( # pylint: disable=too-many-locals
115
115
  verbose_handler.setLevel(logging.INFO)
116
116
  verbose_handler.setFormatter(log_formatter)
117
117
  logger.addHandler(verbose_handler)
118
- click.echo("Logging to {}\n".format(LOGFILE))
118
+ click.echo(f"Logging to {LOGFILE}\n")
119
119
  else:
120
120
  ctx.obj["verbose"] = False
121
121
 
@@ -137,7 +137,7 @@ def main( # pylint: disable=too-many-locals
137
137
  click.secho("Could not find a connected CircuitPython device.", fg="red")
138
138
  sys.exit(1)
139
139
  else:
140
- click.echo("Found device at {}.".format(device_path))
140
+ click.echo(f"Found device at {device_path}.")
141
141
 
142
142
 
143
143
  @main.command("ls")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: circup
3
- Version: 2.3.0
3
+ Version: 3.0.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
@@ -42,7 +42,7 @@ Classifier: Programming Language :: Python :: 3.11
42
42
  Classifier: Topic :: Education
43
43
  Classifier: Topic :: Software Development :: Embedded Systems
44
44
  Classifier: Topic :: System :: Software Distribution
45
- Requires-Python: >=3.9
45
+ Requires-Python: >=3.10
46
46
  Description-Content-Type: text/x-rst
47
47
  License-File: LICENSE
48
48
  Requires-Dist: appdirs
@@ -0,0 +1,21 @@
1
+ circup/__init__.py,sha256=9A98U3DyA14tm-7_d88fGSlLfEKz9T_85t8kXkErqRg,662
2
+ circup/backends.py,sha256=bm90_c3twTFZw0sDEXwytdV3LoJHUh9v01IDuzWKS5E,39812
3
+ circup/bundle.py,sha256=IdRCuV8smHGKbGkh-GDVfJ_Txkhuu4IwvighUJezn8c,9437
4
+ circup/command_utils.py,sha256=-3nK98EEg2d37y0tuZwAESPrq6sroPkMcoDqOfd40Xs,37087
5
+ circup/commands.py,sha256=gPj6S2eUc6xlLcB8MPlWXZRJ5fe9CCcWqMi7qyKhR10,33885
6
+ circup/lazy_metadata.py,sha256=GJXHkctUJyEzOCycmb7EPZT1GeES7rtX0w1tCtdtT6E,3791
7
+ circup/logging.py,sha256=hu4v8ljkXo8ru-cqs0W3PU-xEVvTO_qqMKDJM18OXbQ,1115
8
+ circup/module.py,sha256=ZrgZhBKuDWHfw9FzcXKjBYx-oybbDnH_IAdek3aSGwU,7131
9
+ circup/shared.py,sha256=yaAL61iJPSA98eJfjyAuOnxVhOFYJXvVZ8yE6aDOUjk,8747
10
+ circup/config/bundle_config.json,sha256=zzpmfy0jD7TxpOOw2P_gqIISuMBG0enb_f3_VneQ5mI,135
11
+ circup/config/bundle_config.json.license,sha256=OOHNqDsViGFhmG9z8J0o98hYmub1CkYKiZB96Php6KE,80
12
+ circup/wwshell/README.rst,sha256=M_jFP0hwOcngF0RdosdeqmVOISNcPzyjTW3duzIu9A8,3617
13
+ circup/wwshell/README.rst.license,sha256=GhA0SoZGP7CReDam-JJk_UtIQIpQaZWQFzR26YSuMm4,107
14
+ circup/wwshell/__init__.py,sha256=CAPZiYrouWboyPx4KiWLBG_vf_n0MmArGqaFyTXGKWk,398
15
+ circup/wwshell/commands.py,sha256=duMirWQqUDup6EqkeAgwdjcdccTaiZYTLTYjId_RBto,7438
16
+ circup-3.0.0.dist-info/licenses/LICENSE,sha256=bVlIMmSL_pqLCqae4hzixy9pYXD808IbgsMoQXTNLBk,1076
17
+ circup-3.0.0.dist-info/METADATA,sha256=bkXUohS9dz58rQ861CBrtQwqppDlvfGKt_9vN_mt9nA,13618
18
+ circup-3.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
19
+ circup-3.0.0.dist-info/entry_points.txt,sha256=FjTmwYD_ApgLRGifUrr_Ui1voW6fEzodQc3DKNzoAPc,69
20
+ circup-3.0.0.dist-info/top_level.txt,sha256=Qx6E0eZgSBE10ciNKsLZx8-TTy_9fEVZh7NLmn24KcU,7
21
+ circup-3.0.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,21 +0,0 @@
1
- circup/__init__.py,sha256=9A98U3DyA14tm-7_d88fGSlLfEKz9T_85t8kXkErqRg,662
2
- circup/backends.py,sha256=g9Q9xCGZidwsEDL2Ga2cm50YYB54IiqlKUPcxj-pWZA,40008
3
- circup/bundle.py,sha256=eQ_PxMaDD_XKfxXMXcpzSnhHjx5oJh1YXZq00o5smiM,7075
4
- circup/command_utils.py,sha256=_DfnRKinXflkPt-CGaIg_3nBZ6Q2J9yRFS9LXC73k3U,35180
5
- circup/commands.py,sha256=vZ4iui7IpLIkeaO_LQ5JzIqCPkfcUbrbUmICcLrl9aE,31494
6
- circup/lazy_metadata.py,sha256=69VidxGGWE13QwAAtMCPNTXTsQ2q5dJvMtclw4YaqEY,3764
7
- circup/logging.py,sha256=hu4v8ljkXo8ru-cqs0W3PU-xEVvTO_qqMKDJM18OXbQ,1115
8
- circup/module.py,sha256=33_kdy5BZn6COyIjAFZMpw00rTtPiryQZWFXQkMF8FY,7435
9
- circup/shared.py,sha256=O3_iA1mIsoVPObTzv5tfPCqejJSZitTWb0RMYIEM87w,8635
10
- circup/config/bundle_config.json,sha256=zzpmfy0jD7TxpOOw2P_gqIISuMBG0enb_f3_VneQ5mI,135
11
- circup/config/bundle_config.json.license,sha256=OOHNqDsViGFhmG9z8J0o98hYmub1CkYKiZB96Php6KE,80
12
- circup/wwshell/README.rst,sha256=M_jFP0hwOcngF0RdosdeqmVOISNcPzyjTW3duzIu9A8,3617
13
- circup/wwshell/README.rst.license,sha256=GhA0SoZGP7CReDam-JJk_UtIQIpQaZWQFzR26YSuMm4,107
14
- circup/wwshell/__init__.py,sha256=CAPZiYrouWboyPx4KiWLBG_vf_n0MmArGqaFyTXGKWk,398
15
- circup/wwshell/commands.py,sha256=-I5l7XeoDmvWWuZg5wHdt9qe__SBQ1EGmKwCDTBMeus,7454
16
- circup-2.3.0.dist-info/licenses/LICENSE,sha256=bVlIMmSL_pqLCqae4hzixy9pYXD808IbgsMoQXTNLBk,1076
17
- circup-2.3.0.dist-info/METADATA,sha256=0PRlXlC9q3DFjK27uGkOPIj3tNYxbhi7ciaL8epvyKQ,13617
18
- circup-2.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
19
- circup-2.3.0.dist-info/entry_points.txt,sha256=FjTmwYD_ApgLRGifUrr_Ui1voW6fEzodQc3DKNzoAPc,69
20
- circup-2.3.0.dist-info/top_level.txt,sha256=Qx6E0eZgSBE10ciNKsLZx8-TTy_9fEVZh7NLmn24KcU,7
21
- circup-2.3.0.dist-info/RECORD,,