snapctl 0.49.5__py3-none-any.whl → 0.53.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.

Potentially problematic release.


This version of snapctl might be problematic. Click here for more details.

snapctl/commands/byogs.py CHANGED
@@ -105,6 +105,16 @@ class ByoGs:
105
105
  pass
106
106
  return None
107
107
 
108
+ @staticmethod
109
+ def _docker_supports_buildkit():
110
+ try:
111
+ version = subprocess.check_output(
112
+ ["docker", "version", "--format", "{{.Server.Version}}"])
113
+ major, minor = map(int, version.decode().split(".")[:2])
114
+ return (major > 18) or (major == 18 and minor >= 9)
115
+ except Exception:
116
+ return False
117
+
108
118
  def _check_dependencies(self) -> None:
109
119
  progress = Progress(
110
120
  SpinnerColumn(),
@@ -200,6 +210,10 @@ class ByoGs:
200
210
  progress.add_task(
201
211
  description='Building your snap...', total=None)
202
212
  try:
213
+ env = os.environ.copy()
214
+ if ByoGs._docker_supports_buildkit():
215
+ info('Docker BuildKit is supported. Enabling it.')
216
+ env["DOCKER_BUILDKIT"] = "1"
203
217
  # image_tag = f'{ByoGs.SID}.{self.tag}'
204
218
  build_platform = ByoGs.DEFAULT_BUILD_PLATFORM
205
219
  if len(self.token_parts) == 4:
@@ -217,7 +231,7 @@ class ByoGs:
217
231
  # f"docker build --no-cache -t {tag} {path}"
218
232
  'docker', 'build', '--platform', build_platform, '-t', self.tag,
219
233
  '-f', self.docker_path_filename, self.path
220
- ], shell=True, check=False)
234
+ ], shell=True, check=False, env=env)
221
235
  # stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
222
236
  else:
223
237
  response = subprocess.run([
@@ -225,7 +239,7 @@ class ByoGs:
225
239
  "docker build --platform " +
226
240
  f"{build_platform} -t {self.tag} " +
227
241
  f"-f {self.docker_path_filename} {self.path}"
228
- ], shell=True, check=False)
242
+ ], shell=True, check=False, env=env)
229
243
  # stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
230
244
  if not response.returncode:
231
245
  return snapctl_success(
@@ -509,16 +509,19 @@ class ByoSnap:
509
509
  outfile.write(line)
510
510
  return True
511
511
  except FileNotFoundError:
512
- print(f"[ERROR] Could not find profile file: {resource_filename}")
512
+ warning(
513
+ f"[ERROR] Could not find profile file: {resource_filename}")
514
+ return False
515
+
516
+ @staticmethod
517
+ def _docker_supports_buildkit():
518
+ try:
519
+ version = subprocess.check_output(
520
+ ["docker", "version", "--format", "{{.Server.Version}}"])
521
+ major, minor = map(int, version.decode().split(".")[:2])
522
+ return (major > 18) or (major == 18 and minor >= 9)
523
+ except Exception:
513
524
  return False
514
- # @staticmethod
515
- # def _handle_output_file(input_filepath, output_filepath) -> bool:
516
- # file_written = False
517
- # with open(input_filepath, 'r') as in_file, open(output_filepath, 'w') as outfile:
518
- # for line in in_file:
519
- # outfile.write(line)
520
- # file_written = True
521
- # return file_written
522
525
 
523
526
  def _get_profile_contents(self) -> dict:
524
527
  """
@@ -588,13 +591,15 @@ class ByoSnap:
588
591
  code=SNAPCTL_INPUT_ERROR
589
592
  )
590
593
  # IMPORTANT: This is where the profile data is set and validated
594
+ #
595
+ # Update: June 2, 2025 - We removed the line that updated the self.platform_type
596
+ # self.platform_type = profile_data_obj['platform']
591
597
  self.profile_data = profile_data_obj
592
598
  ByoSnap._validate_byosnap_profile_data(self.profile_data)
593
599
  # End: IMPORTANT: This is where the profile data is set
594
600
  # Now apply the overrides
595
601
  self.name = self.profile_data['name']
596
602
  self.desc = self.profile_data['description']
597
- self.platform_type = self.profile_data['platform']
598
603
  self.language = self.profile_data['language']
599
604
  self.prefix = self.profile_data['prefix']
600
605
  # Setup the final ingress external port
@@ -716,6 +721,10 @@ class ByoSnap:
716
721
  build_platform = ByoSnap.DEFAULT_BUILD_PLATFORM
717
722
  if len(self.token_parts) == 4:
718
723
  build_platform = self.token_parts[3]
724
+ if self.platform_type is not None:
725
+ build_platform = self.platform_type
726
+ # if len(self.token_parts) == 4:
727
+ # build_platform = self.token_parts[3]
719
728
  progress = Progress(
720
729
  SpinnerColumn(),
721
730
  TextColumn("[progress.description]{task.description}"),
@@ -725,6 +734,10 @@ class ByoSnap:
725
734
  progress.add_task(
726
735
  description='Building your snap...', total=None)
727
736
  try:
737
+ env = os.environ.copy()
738
+ if ByoSnap._docker_supports_buildkit():
739
+ info('Docker BuildKit is supported. Enabling it.')
740
+ env["DOCKER_BUILDKIT"] = "1"
728
741
  # Warning check for architecture specific commands
729
742
  info(f'Building on system architecture {sys_platform.machine()}')
730
743
  check_response = check_dockerfile_architecture(
@@ -737,7 +750,7 @@ class ByoSnap:
737
750
  # f"docker build --no-cache -t {remote_tag} {path}"
738
751
  'docker', 'build', '--platform', build_platform, '-t', self.remote_tag,
739
752
  '-f', self.docker_path_filename, self.path
740
- ], shell=True, check=False)
753
+ ], shell=True, check=False, env=env)
741
754
  # stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
742
755
  else:
743
756
  response = subprocess.run([
@@ -745,7 +758,7 @@ class ByoSnap:
745
758
  "docker build --platform " +
746
759
  f"{build_platform} -t {self.remote_tag} " +
747
760
  f"-f {self.docker_path_filename} {self.path}"
748
- ], shell=True, check=False)
761
+ ], shell=True, check=False, env=env)
749
762
  # stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
750
763
  if not response.returncode:
751
764
  return snapctl_success(
@@ -943,15 +956,21 @@ class ByoSnap:
943
956
  if self.path is None and self.resources_path is None:
944
957
  snapctl_error(
945
958
  "Missing one of: path or resources-path parameter", SNAPCTL_INPUT_ERROR)
946
- if not self.tag:
947
- snapctl_error("Missing tag", SNAPCTL_INPUT_ERROR)
948
- if len(self.tag.split()) > 1 or \
949
- len(self.tag) > ByoSnap.TAG_CHARACTER_LIMIT:
950
- snapctl_error(
951
- "Tag should be a single word with maximum of " +
952
- f"{ByoSnap.TAG_CHARACTER_LIMIT} characters",
953
- SNAPCTL_INPUT_ERROR
954
- )
959
+ if not self.tag and not self.version:
960
+ snapctl_error("Missing tag or version", SNAPCTL_INPUT_ERROR)
961
+ if self.tag:
962
+ if len(self.tag.split()) > 1 or \
963
+ len(self.tag) > ByoSnap.TAG_CHARACTER_LIMIT:
964
+ snapctl_error(
965
+ "Tag should be a single word with maximum of " +
966
+ f"{ByoSnap.TAG_CHARACTER_LIMIT} characters",
967
+ SNAPCTL_INPUT_ERROR
968
+ )
969
+ if self.version:
970
+ pattern = r'^v\d+\.\d+\.\d+$'
971
+ if not re.match(pattern, self.version):
972
+ snapctl_error(message="Version should be in the format vX.X.X",
973
+ code=SNAPCTL_INPUT_ERROR)
955
974
  elif self.subcommand == 'publish-version':
956
975
  # Setup
957
976
  self._setup_token_and_token_parts(
@@ -1127,6 +1146,9 @@ class ByoSnap:
1127
1146
  progress.add_task(
1128
1147
  description='Uploading your BYOSnap Docs...', total=None)
1129
1148
  try:
1149
+ upload_tag = self.tag
1150
+ if not upload_tag:
1151
+ upload_tag = self.version
1130
1152
  if self.resources_path:
1131
1153
  base_dir = self.resources_path
1132
1154
  else:
@@ -1141,7 +1163,7 @@ class ByoSnap:
1141
1163
  info(f'Uploading swagger.json at {swagger_file}')
1142
1164
  url = (
1143
1165
  f"{self.base_url}/v1/snapser-api/byosnaps/"
1144
- f"{self.byosnap_id}/docs/{self.tag}/openapispec"
1166
+ f"{self.byosnap_id}/docs/{upload_tag}/openapispec"
1145
1167
  )
1146
1168
  test_res = requests.post(
1147
1169
  url, files={"attachment": attachment_file},
@@ -1181,7 +1203,7 @@ class ByoSnap:
1181
1203
  with open(readme_file, "rb") as attachment_file:
1182
1204
  url = (
1183
1205
  f"{self.base_url}/v1/snapser-api/byosnaps/"
1184
- f"{self.byosnap_id}/docs/{self.tag}/markdown"
1206
+ f"{self.byosnap_id}/docs/{upload_tag}/markdown"
1185
1207
  )
1186
1208
  test_res = requests.post(
1187
1209
  url, files={"attachment": attachment_file},
@@ -1213,7 +1235,7 @@ class ByoSnap:
1213
1235
  with open(file_path, "rb") as attachment_file:
1214
1236
  url = (
1215
1237
  f"{self.base_url}/v1/snapser-api/byosnaps/"
1216
- f"{self.byosnap_id}/docs/{self.tag}/tools"
1238
+ f"{self.byosnap_id}/docs/{upload_tag}/tools"
1217
1239
  )
1218
1240
  test_res = requests.post(
1219
1241
  url, files={"attachment": attachment_file},
@@ -1370,7 +1392,11 @@ class ByoSnap:
1370
1392
  "prod_template": prod_template,
1371
1393
  # Currently not supported so we are just hardcoding an empty list
1372
1394
  "egress": {"ports": []},
1395
+ # Platform override
1373
1396
  }
1397
+ if self.platform_type is not None:
1398
+ payload['platform_override'] = self.platform_type
1399
+
1374
1400
  res = requests.post(
1375
1401
  f"{self.base_url}/v1/snapser-api/byosnaps/{self.byosnap_id}/versions",
1376
1402
  json=payload, headers={'api-key': self.api_key},
@@ -1452,6 +1478,8 @@ class ByoSnap:
1452
1478
  # Currently not supported so we are just hardcoding an empty list
1453
1479
  "egress": {"ports": []},
1454
1480
  }
1481
+ if self.platform_type is not None:
1482
+ payload['platform_override'] = self.platform_type
1455
1483
  res = requests.patch(
1456
1484
  f"{self.base_url}/v1/snapser-api/byosnaps/{self.byosnap_id}/versions/{self.version}",
1457
1485
  json=payload, headers={'api-key': self.api_key},
@@ -1504,7 +1532,7 @@ class ByoSnap:
1504
1532
  "service_id": self.byosnap_id,
1505
1533
  "name": self.name,
1506
1534
  "description": self.desc,
1507
- "platform": self.platform_type,
1535
+ "platform": self.profile_data['platform'],
1508
1536
  "language": self.language,
1509
1537
  }
1510
1538
  res = requests.post(
snapctl/commands/byows.py CHANGED
@@ -20,7 +20,6 @@ from snapctl.config.constants import SERVER_CALL_TIMEOUT, SNAPCTL_INPUT_ERROR, \
20
20
  from snapctl.utils.helper import snapctl_error, snapctl_success, get_dot_snapser_dir
21
21
  from snapctl.utils.echo import info, warning
22
22
 
23
-
24
23
  class Byows:
25
24
  """
26
25
  CLI commands exposed for a Bring your own Workstation
@@ -70,6 +69,10 @@ class Byows:
70
69
  snapctl_error(
71
70
  message="Missing Input --snapend-id=$your_snapend_id",
72
71
  code=SNAPCTL_INPUT_ERROR)
72
+ if not Byows.is_valid_cluster_id(self.snapend_id):
73
+ snapctl_error(
74
+ message="Invalid value --snapend-id must be a valid Snapend ID, e.g., 'a1b2c3d4'",
75
+ code=SNAPCTL_INPUT_ERROR)
73
76
  if self.byosnap_id is None or self.byosnap_id == '':
74
77
  snapctl_error(
75
78
  message="Missing Input --byosnap-id=$your_byosnap_id",
@@ -99,12 +102,11 @@ class Byows:
99
102
  def _get_export_commands(snap_ids, port):
100
103
  '''
101
104
  Generate export commands for the given snap IDs and port.
102
- Replace hyphens with underscores in environment variable names.
103
105
  '''
104
106
  env_vars = []
105
107
 
106
108
  for snap_id in snap_ids:
107
- upper_id = snap_id.upper().replace("-", "_")
109
+ upper_id = snap_id.upper()
108
110
  env_vars.append(f"SNAPEND_{upper_id}_GRPC_URL=localhost:{port}")
109
111
  env_vars.append(
110
112
  f"SNAPEND_{upper_id}_HTTP_URL=http://localhost:{port}")
@@ -122,12 +124,11 @@ class Byows:
122
124
  def _generate_env_file(snap_ids, port):
123
125
  '''
124
126
  Generate an environment file with the given snap IDs and port.
125
- Replace hyphens with underscores in environment variable names.
126
127
  '''
127
128
  env_lines = []
128
129
 
129
130
  for snap_id in snap_ids:
130
- upper_id = snap_id.upper().replace("-", "_")
131
+ upper_id = snap_id.upper()
131
132
  env_lines.append(
132
133
  f"export SNAPEND_{upper_id}_GRPC_URL=localhost:{port}")
133
134
  env_lines.append(
@@ -392,7 +393,7 @@ class Byows:
392
393
  self._handle_signal))
393
394
  if hasattr(signal, "SIGBREAK"):
394
395
  signal.signal(signal.SIGBREAK,
395
- functools.partial(self._handle_signal))
396
+ functools.partial(self._handle_signal))
396
397
  # Set up port forward
397
398
  self._setup_port_forward(
398
399
  response_json['proxyPrivateKey'],
@@ -411,16 +412,15 @@ class Byows:
411
412
  return snapctl_success(
412
413
  message='complete', progress=progress)
413
414
  snapctl_error(
414
- message='Unable to setup port forward.',
415
+ message='Attach failed.',
415
416
  code=SNAPCTL_BYOWS_ATTACH_ERROR, progress=progress)
416
417
  except HTTPError as http_err:
417
418
  snapctl_error(
418
- message=Byows._format_portal_http_error(
419
- "Unable to setup port forward", http_err, res),
419
+ message=Byows._format_portal_http_error("Attach failed.", http_err, res),
420
420
  code=SNAPCTL_BYOWS_ATTACH_ERROR, progress=progress)
421
421
  except RequestException as e:
422
422
  snapctl_error(
423
- message=f"Exception: Unable to setup port forward {e}",
423
+ message=f"Attach failed: {e}",
424
424
  code=SNAPCTL_BYOWS_ATTACH_ERROR, progress=progress)
425
425
  finally:
426
426
  progress.stop()
@@ -465,3 +465,21 @@ class Byows:
465
465
  code=SNAPCTL_BYOWS_RESET_ERROR, progress=progress)
466
466
  finally:
467
467
  progress.stop()
468
+
469
+ @staticmethod
470
+ def is_valid_cluster_id(cluster_id: str) -> bool:
471
+ """
472
+ Check if the input is a valid cluster ID (Snapend ID).
473
+ """
474
+ import re
475
+ if not cluster_id:
476
+ return False
477
+
478
+ pattern = "^[a-z0-9]+$"
479
+ if not re.match(pattern, cluster_id):
480
+ return False
481
+
482
+ if len(cluster_id) != 8:
483
+ return False
484
+
485
+ return True
@@ -35,9 +35,15 @@ class ReleaseNotes:
35
35
  """
36
36
  print('== Releases ' + '=' * (92))
37
37
  # List all resource files in snapctl.data.releases
38
+ final_list = []
38
39
  for resource in pkg_resources.contents(snapctl.data.releases):
39
40
  if resource.endswith('.mdx'):
40
- print(resource.replace('.mdx', '').replace('.md', ''))
41
+ final_list.append(resource.replace(
42
+ '.mdx', '').replace('.md', ''))
43
+ # Sort versions in descending order
44
+ final_list.sort(reverse=True)
45
+ for version in final_list:
46
+ print(f"- {version}")
41
47
  print('=' * (104))
42
48
  snapctl_success(message="List versions")
43
49
 
@@ -22,7 +22,7 @@ from snapctl.config.constants import SERVER_CALL_TIMEOUT, SNAPCTL_INPUT_ERROR, \
22
22
  SNAPCTL_SNAPEND_APPLY_MANIFEST_MISMATCH_ERROR
23
23
  from snapctl.config.hashes import PROTOS_TYPES, CLIENT_SDK_TYPES, SERVER_SDK_TYPES, \
24
24
  SNAPEND_MANIFEST_TYPES, SDK_TYPES, SDK_ACCESS_AUTH_TYPE_LOOKUP
25
- from snapctl.utils.echo import error, success, info, warning
25
+ from snapctl.utils.echo import error, success, info
26
26
  from snapctl.utils.helper import snapctl_error, snapctl_success
27
27
 
28
28
 
@@ -85,20 +85,16 @@ class Snapend:
85
85
  self.force: bool = force
86
86
  self.category: str = category
87
87
  self.category_format: str = category_format
88
+ self.portal_category: Union[str, None] = Snapend._make_portal_category(
89
+ category, category_format)
88
90
  self.category_type: Union[str, None] = category_type
89
91
  self.category_http_lib: Union[str, None] = category_http_lib
90
- self.out_path: Union[str, None] = out_path
91
- self.snaps: Union[str, None] = snaps
92
- self.blocking: bool = blocking
93
- # Values below are derived values
94
- # portal_category: This is purely because we have simplified the input for the user
95
- # Only used for a reverse lookup to pass the category to the portal server
96
- self.portal_category: Union[str, None] = Snapend._make_portal_category(
97
- category_format)
98
- # download_types: This stores our own internal mapping of the category to the type
99
92
  self.download_types: Union[
100
93
  Dict[str, Dict[str, str]], None
101
94
  ] = Snapend._make_download_type(category)
95
+ self.out_path: Union[str, None] = out_path
96
+ self.snaps: Union[str, None] = snaps
97
+ # Values below are derived values
102
98
  self.manifest_file_name: Union[str, None] = Snapend._get_manifest_file_name(
103
99
  manifest_path_filename
104
100
  )
@@ -106,6 +102,7 @@ class Snapend:
106
102
  byosnaps) if byosnaps else None
107
103
  self.byogs_list: Union[str, None] = Snapend._make_byogs_list(
108
104
  byogs) if byogs else None
105
+ self.blocking: bool = blocking
109
106
  # Validate input
110
107
  self.validate_input()
111
108
 
@@ -120,19 +117,19 @@ class Snapend:
120
117
  return None
121
118
 
122
119
  @staticmethod
123
- def _make_portal_category(category_format: str):
120
+ def _make_portal_category(category: str, category_format: str):
124
121
  '''
125
122
  We have simplified the input for the user to only take the category as sdk
126
123
  The portal server however expects us to pass client-sdk or server-sdk
127
124
  Hence we need to do this
128
125
  '''
129
- if category_format in CLIENT_SDK_TYPES:
126
+ if category == 'sdk' and category_format in CLIENT_SDK_TYPES:
130
127
  return 'client-sdk'
131
- if category_format in SERVER_SDK_TYPES:
128
+ if category == 'sdk' and category_format in SERVER_SDK_TYPES:
132
129
  return 'server-sdk'
133
- if category_format in PROTOS_TYPES:
130
+ if category == 'protos' and category_format in PROTOS_TYPES:
134
131
  return 'protos'
135
- if category_format in SNAPEND_MANIFEST_TYPES:
132
+ if category == 'snapend-manifest' and category_format in SNAPEND_MANIFEST_TYPES:
136
133
  return 'snapend-manifest'
137
134
  return None
138
135
 
@@ -247,8 +244,6 @@ class Snapend:
247
244
  # Customize snaps
248
245
  if self.snaps:
249
246
  url += f"&snaps={self.snaps}"
250
- if "None" in url:
251
- warning('Detecting a potential issue in the download URL.')
252
247
  res = requests.get(
253
248
  url, headers={'api-key': self.api_key}, timeout=SERVER_CALL_TIMEOUT
254
249
  )
@@ -400,14 +395,8 @@ class Snapend:
400
395
  code=SNAPCTL_INPUT_ERROR)
401
396
  if self.category not in Snapend.DOWNLOAD_TYPE_NOT_REQUIRED and \
402
397
  (self.download_types is None or self.category_format not in self.download_types):
403
- message = "Invalid format. Valid formats are " + \
404
- "(req: --category sdk|protos|snapend-manifest --format " + \
405
- "sdk(" + ", ".join(SDK_TYPES.keys()) + \
406
- ") | protos(" + ", ".join(PROTOS_TYPES.keys()) + ")" + \
407
- ") | snapend-manifest(" + \
408
- ", ".join(SNAPEND_MANIFEST_TYPES.keys()) + ")"
409
398
  snapctl_error(
410
- message=message, code=SNAPCTL_INPUT_ERROR)
399
+ message="Invalid Download format.", code=SNAPCTL_INPUT_ERROR)
411
400
  # Check the Protos category
412
401
  if self.category == 'protos' and self.category_type not in Snapend.CATEGORY_TYPE_PROTOS:
413
402
  snapctl_error(
snapctl/config/app.py ADDED
@@ -0,0 +1,31 @@
1
+ '''
2
+ Configuration by environment
3
+ '''
4
+ from typing import Dict
5
+
6
+ APP_CONFIG: Dict[str, Dict[str, str]] = {
7
+ 'DEV': {
8
+ 'AMPLITUDE_REGION': 'US',
9
+ 'AMPLITUDE_API_KEY': 'ca863e91bfb3ce084e022920083f2898',
10
+ 'TELEMETRY_ACTIVE': 'false',
11
+ 'TELEMETRY_DRY_RUN': 'true',
12
+ },
13
+ 'DEV_TWO': {
14
+ 'AMPLITUDE_REGION': 'US',
15
+ 'AMPLITUDE_API_KEY': 'ca863e91bfb3ce084e022920083f2898',
16
+ 'TELEMETRY_ACTIVE': 'false',
17
+ 'TELEMETRY_DRY_RUN': 'true',
18
+ },
19
+ 'PLAYTEST': {
20
+ 'AMPLITUDE_REGION': 'US',
21
+ 'AMPLITUDE_API_KEY': 'ca863e91bfb3ce084e022920083f2898',
22
+ 'TELEMETRY_ACTIVE': 'false',
23
+ 'TELEMETRY_DRY_RUN': 'false',
24
+ },
25
+ 'PROD': {
26
+ 'AMPLITUDE_REGION': 'US',
27
+ 'AMPLITUDE_API_KEY': '31fe2221f24fc30694cda777e98bd7a1',
28
+ 'TELEMETRY_ACTIVE': 'false',
29
+ 'TELEMETRY_DRY_RUN': 'false',
30
+ }
31
+ }
@@ -2,8 +2,8 @@
2
2
  Constants used by snapctl
3
3
  """
4
4
  COMPANY_NAME = 'Snapser'
5
- VERSION_PREFIX = 'beta-'
6
- VERSION = '0.49.5'
5
+ VERSION_PREFIX = ''
6
+ VERSION = '0.53.1'
7
7
  CONFIG_FILE_MAC = '~/.snapser/config'
8
8
  CONFIG_FILE_WIN = '%homepath%\\.snapser\\config'
9
9
 
@@ -13,6 +13,10 @@ URL_KEY = 'SNAPSER_URL_KEY'
13
13
  CONFIG_PATH_KEY = 'SNAPSER_CONFIG_PATH'
14
14
  SERVER_CALL_TIMEOUT = 300
15
15
 
16
+ # Telemetry
17
+ AMPLITUDE_HTTP_US = "https://api2.amplitude.com/2/httpapi"
18
+ AMPLITUDE_HTTP_EU = "https://api.eu.amplitude.com/2/httpapi"
19
+
16
20
  # HTTP codes
17
21
  HTTP_UNAUTHORIZED = 401
18
22
  HTTP_FORBIDDEN = 403
@@ -0,0 +1,10 @@
1
+ ## beta-0.50.0
2
+ ##### June 5, 2025
3
+
4
+ ### Bug fix
5
+ - Success, info or warning messages were sometimes getting truncated in the CLI output. This has been fixed.
6
+ - Snapctl byosnap and byogs now support features that could only work with Buildkit.
7
+
8
+ ### Feature
9
+ - The `snapctl byosnap upload-docs` command now supports `--version` to keep it consistent with all the new commands like `sync` and `publish`.
10
+
@@ -0,0 +1,6 @@
1
+ ## beta-0.51.0
2
+ #### Jun 5, 2025
3
+
4
+ ### Improvements
5
+ #### BYOWS
6
+ - Improved snapend ID validation
@@ -0,0 +1,6 @@
1
+ ## beta-0.53.1
2
+ #### Aug 27, 2025
3
+
4
+ ### Bug fix
5
+ #### Snapend Download
6
+ - Fixed a bug that was preventing download of manifests and protos for a Snapend.
snapctl/main.py CHANGED
@@ -3,7 +3,6 @@
3
3
  """
4
4
  import configparser
5
5
  import os
6
- import json
7
6
  from sys import platform
8
7
  from typing import Union
9
8
  import typer
@@ -24,6 +23,7 @@ from snapctl.config.hashes import PROTOS_TYPES, SERVICE_IDS, \
24
23
  SNAPEND_MANIFEST_TYPES, SDK_TYPES
25
24
  from snapctl.utils.echo import error, success, info
26
25
  from snapctl.utils.helper import validate_api_key
26
+ from snapctl.utils.telemetry import telemetry
27
27
 
28
28
  ######### Globals #########
29
29
 
@@ -91,6 +91,21 @@ def extract_config(extract_key: str, profile: Union[str, None] = None) -> object
91
91
  return result
92
92
 
93
93
 
94
+ def get_environment(api_key_value: Union[str, None]) -> str:
95
+ """
96
+ Returns the environment based on the api_key
97
+ """
98
+ if api_key_value is None:
99
+ return 'UNKNOWN'
100
+ if api_key_value.startswith('dev_'):
101
+ return 'DEV'
102
+ if api_key_value.startswith('devtwo_'):
103
+ return 'DEV_TWO'
104
+ if api_key_value.startswith('playtest_'):
105
+ return 'PLAYTEST'
106
+ return 'PROD'
107
+
108
+
94
109
  def get_base_url(api_key: Union[str, None]) -> str:
95
110
  """
96
111
  Returns the base url based on the api_key
@@ -154,6 +169,7 @@ def default_context_callback(ctx: typer.Context):
154
169
  ctx.obj['api_key'] = api_key_obj['value']
155
170
  ctx.obj['api_key_location'] = api_key_obj['location']
156
171
  ctx.obj['profile'] = DEFAULT_PROFILE
172
+ ctx.obj['environment'] = get_environment(api_key_obj['value'])
157
173
  ctx.obj['base_url'] = get_base_url(api_key_obj['value'])
158
174
  ctx.obj['base_snapend_url'] = get_base_snapend_url(api_key_obj['value'])
159
175
 
@@ -174,6 +190,7 @@ def api_key_context_callback(
174
190
  ctx.obj['version'] = VERSION
175
191
  ctx.obj['api_key'] = api_key
176
192
  ctx.obj['api_key_location'] = 'command-line-argument'
193
+ ctx.obj['environment'] = get_environment(api_key)
177
194
  ctx.obj['base_url'] = get_base_url(api_key)
178
195
 
179
196
 
@@ -206,6 +223,7 @@ def profile_context_callback(
206
223
  ctx.obj['api_key'] = api_key_obj['value']
207
224
  ctx.obj['api_key_location'] = api_key_obj['location']
208
225
  ctx.obj['profile'] = profile if profile else DEFAULT_PROFILE
226
+ ctx.obj['environment'] = get_environment(api_key_obj['value'])
209
227
  ctx.obj['base_url'] = get_base_url(api_key_obj['value'])
210
228
 
211
229
 
@@ -237,6 +255,7 @@ def common(
237
255
 
238
256
 
239
257
  @app.command()
258
+ @telemetry("validate", subcommand_arg="subcommand")
240
259
  def validate(
241
260
  ctx: typer.Context,
242
261
  api_key: Union[str, None] = typer.Option(
@@ -256,6 +275,7 @@ def validate(
256
275
 
257
276
 
258
277
  @app.command()
278
+ @telemetry("release_notes", subcommand_arg="subcommand")
259
279
  def release_notes(
260
280
  ctx: typer.Context,
261
281
  subcommand: str = typer.Argument(
@@ -284,6 +304,7 @@ def release_notes(
284
304
 
285
305
 
286
306
  @app.command()
307
+ @telemetry("byogs", subcommand_arg="subcommand")
287
308
  def byogs(
288
309
  ctx: typer.Context,
289
310
  # Required fields
@@ -353,6 +374,7 @@ def byogs(
353
374
 
354
375
 
355
376
  @app.command()
377
+ @telemetry("byosnap", subcommand_arg="subcommand")
356
378
  def byosnap(
357
379
  ctx: typer.Context,
358
380
  # Required fields
@@ -403,7 +425,7 @@ def byosnap(
403
425
  help="(req: create) Language of your snap - " +
404
426
  ", ".join(ByoSnap.LANGUAGES) + "."
405
427
  ),
406
- # publish-image, publish-version, publish, sync
428
+ # publish-image, publish-version, publish, sync, upload-docs
407
429
  tag: str = typer.Option(
408
430
  None, "--tag", help=(
409
431
  "(req: publish-image, publish-version, upload-docs) (optional: publish, sync) Tag for your snap"
@@ -461,6 +483,7 @@ def byosnap(
461
483
 
462
484
 
463
485
  @app.command()
486
+ @telemetry("game", subcommand_arg="subcommand")
464
487
  def game(
465
488
  ctx: typer.Context,
466
489
  # Required fields
@@ -496,6 +519,7 @@ def game(
496
519
 
497
520
 
498
521
  @app.command()
522
+ @telemetry("generate", subcommand_arg="subcommand")
499
523
  def generate(
500
524
  ctx: typer.Context,
501
525
  # Required fields
@@ -542,6 +566,7 @@ def generate(
542
566
 
543
567
 
544
568
  @app.command()
569
+ @telemetry("snapend", subcommand_arg="subcommand")
545
570
  def snapend(
546
571
  ctx: typer.Context,
547
572
  # Required fields
@@ -685,6 +710,7 @@ def snapend(
685
710
 
686
711
 
687
712
  @app.command()
713
+ @telemetry("byows", subcommand_arg="subcommand")
688
714
  def byows(
689
715
  ctx: typer.Context,
690
716
  # Required fields
snapctl/utils/echo.py CHANGED
@@ -4,38 +4,49 @@ This module contains functions to print messages to the console.
4
4
  import json
5
5
  import typer
6
6
  from rich import print
7
+ from rich.markup import escape
7
8
  from snapctl.config.constants import SNAPCTL_ERROR
8
9
  from snapctl.types.definitions import ErrorResponse
9
10
  # Run `python -m rich.emoji` to get a list of all emojis that are supported
10
11
 
11
12
 
12
- def error(msg: str, code: int = SNAPCTL_ERROR, data: object = None) -> None:
13
+ def _stringify_message(msg: object) -> str:
14
+ if isinstance(msg, (dict, list)):
15
+ return json.dumps(msg, indent=2)
16
+ return str(msg)
17
+
18
+
19
+ def error(msg: object, code: int = SNAPCTL_ERROR, data: object = None) -> None:
13
20
  """
14
21
  Prints an error message to the console.
15
22
  """
23
+ msg = _stringify_message(msg)
16
24
  error_response = ErrorResponse(
17
25
  error=True, code=code, msg=msg, data=data if data else ''
18
26
  )
19
- print(f"[bold red]Error[/bold red] {msg}")
27
+ print(f"[bold red]Error[/bold red] {escape(msg)}")
20
28
  typer.echo(json.dumps(error_response.to_dict()), err=True)
21
29
 
22
30
 
23
- def warning(msg: str) -> None:
31
+ def warning(msg: object) -> None:
24
32
  """
25
33
  Prints a warning message to the console.
26
34
  """
27
- print(f"[bold yellow]Warning[/bold yellow] {msg}")
35
+ msg = _stringify_message(msg)
36
+ print(f"[bold yellow]Warning[/bold yellow] {escape(msg)}")
28
37
 
29
38
 
30
- def info(msg: str) -> None:
39
+ def info(msg: object) -> None:
31
40
  """
32
41
  Prints an info message to the console.
33
42
  """
34
- print(f"[bold blue]Info[/bold blue] {msg}")
43
+ msg = _stringify_message(msg)
44
+ print(f"[bold blue]Info[/bold blue] {escape(msg)}")
35
45
 
36
46
 
37
- def success(msg: str) -> None:
47
+ def success(msg: object) -> None:
38
48
  """
39
49
  Prints a success message to the console.
40
50
  """
41
- print(f"[bold green]Success[/bold green] {msg}")
51
+ msg = _stringify_message(msg)
52
+ print(f"[bold green]Success[/bold green] {escape(msg)}")
snapctl/utils/helper.py CHANGED
@@ -2,6 +2,7 @@
2
2
  Helper functions for snapctl
3
3
  """
4
4
  from typing import Union, Dict
5
+ from pathlib import Path
5
6
  import re
6
7
  import platform
7
8
  import os
@@ -14,7 +15,7 @@ from snapctl.config.constants import HTTP_NOT_FOUND, HTTP_FORBIDDEN, HTTP_UNAUTH
14
15
  SERVER_CALL_TIMEOUT, SNAPCTL_CONFIGURATION_ERROR, SNAPCTL_SUCCESS
15
16
  from snapctl.config.hashes import ARCHITECTURE_MAPPING
16
17
  from snapctl.utils.echo import error, success
17
- from pathlib import Path
18
+ from snapctl.config.app import APP_CONFIG
18
19
 
19
20
 
20
21
  def validate_api_key(base_url: str, api_key: Union[str, None]) -> bool:
@@ -185,10 +186,20 @@ def check_use_containerd_snapshotter() -> bool:
185
186
  except Exception:
186
187
  return False
187
188
 
189
+
188
190
  def get_dot_snapser_dir() -> Path:
189
191
  """
190
192
  Returns the .snapser configuration directory, creating it if necessary.
191
193
  """
192
194
  config_dir = Path.home() / ".snapser"
193
195
  config_dir.mkdir(parents=True, exist_ok=True)
194
- return config_dir
196
+ return config_dir
197
+
198
+
199
+ def get_config_value(environment: str, key: str) -> str:
200
+ """
201
+ Returns the config value based on the environment.
202
+ """
203
+ if environment == '' or environment not in APP_CONFIG or key not in APP_CONFIG[environment]:
204
+ return ''
205
+ return APP_CONFIG[environment][key]
@@ -0,0 +1,160 @@
1
+ '''
2
+ Telemetry utilities for snapctl
3
+ '''
4
+ from __future__ import annotations
5
+ from typing import Any, Dict, Optional
6
+ import functools
7
+ import platform
8
+ import uuid
9
+ import hashlib
10
+ import requests
11
+ import typer
12
+ from snapctl.config.constants import AMPLITUDE_HTTP_US, AMPLITUDE_HTTP_EU
13
+ from snapctl.utils.helper import get_config_value
14
+ from snapctl.utils.echo import info
15
+
16
+
17
+ def _ctx(ctx: Optional[typer.Context]) -> dict:
18
+ try:
19
+ return ctx.obj or {}
20
+ except Exception:
21
+ return {}
22
+
23
+
24
+ def _base_props(ctx: Optional[typer.Context]) -> Dict[str, Any]:
25
+ c = _ctx(ctx)
26
+ return {
27
+ "source": "snapctl",
28
+ "cli_version": c.get("version"),
29
+ "os": platform.system(),
30
+ }
31
+
32
+
33
+ def _device_id_from_ctx(ctx: Optional[typer.Context]) -> str:
34
+ """
35
+ Amplitude requires either user_id or device_id.
36
+ We derive a non-reversible device_id from the API key (if present).
37
+ """
38
+ c = _ctx(ctx)
39
+ api_key = c.get("api_key") or ""
40
+ if api_key:
41
+ # hash + truncate to keep it compact but stable
42
+ h = hashlib.sha256(f"snapctl|{api_key}".encode("utf-8")).hexdigest()
43
+ return f"dev-{h[:32]}"
44
+ # fallback: hostname or a random-ish node id
45
+ return f"host-{platform.node() or uuid.getnode()}"
46
+
47
+
48
+ def _endpoint_from_env(ctx: Optional[typer.Context]) -> str:
49
+ """
50
+ Returns the Amplitude endpoint based on environment config.
51
+ """
52
+ c = _ctx(ctx)
53
+ env = c.get("environment")
54
+ region = (get_config_value(env, "AMPLITUDE_REGION") or "US").upper()
55
+ return AMPLITUDE_HTTP_EU if region == "EU" else AMPLITUDE_HTTP_US
56
+
57
+
58
+ def _is_active(ctx: Optional[typer.Context]) -> tuple[bool, bool, Optional[str]]:
59
+ """
60
+ Returns (telemetry_active, dry_run, api_key)
61
+ """
62
+ c = _ctx(ctx)
63
+ env = c.get("environment")
64
+ api_key = get_config_value(env, "AMPLITUDE_API_KEY")
65
+ if not api_key or api_key == '':
66
+ return (False, False, None)
67
+ telemetry_active = get_config_value(env, "TELEMETRY_ACTIVE") == "true"
68
+ dry_run = get_config_value(env, "TELEMETRY_DRY_RUN") == "true"
69
+ return (telemetry_active, dry_run, api_key)
70
+
71
+
72
+ def _post_event(payload: dict, endpoint: str, timeout_s: float) -> None:
73
+ """
74
+ Post the event to Amplitude.
75
+ """
76
+ try:
77
+ requests.post(endpoint, json=payload, timeout=timeout_s)
78
+ except Exception:
79
+ # Never break the CLI
80
+ pass
81
+
82
+
83
+ def track_simple(
84
+ ctx: Optional[typer.Context],
85
+ *,
86
+ command: str,
87
+ sub: str,
88
+ result: str,
89
+ count: int = 1,
90
+ timeout_s: float = 2.0,
91
+ ) -> None:
92
+ """
93
+ Minimal Amplitude event:
94
+ event_type = action
95
+ event_properties = { category, label, count, ...tiny base props }
96
+ """
97
+ category = 'cli'
98
+ active, dry_run, api_key = _is_active(ctx)
99
+ if not active or not api_key:
100
+ return
101
+
102
+ action = f"{command}_{sub}" if sub else command
103
+ props = {**_base_props(ctx)}
104
+ if dry_run:
105
+ info(
106
+ f"[telemetry:DRY-RUN] category={category} action={action} label={result} "
107
+ f"count={count} props={props}")
108
+ return
109
+
110
+ payload = {
111
+ "api_key": api_key,
112
+ "events": [{
113
+ "event_type": action,
114
+ "device_id": _device_id_from_ctx(ctx),
115
+ "event_properties": props,
116
+ }]
117
+ }
118
+ _post_event(payload, _endpoint_from_env(ctx), timeout_s)
119
+
120
+ # -------- Decorator to auto-track per-command result --------
121
+
122
+
123
+ def telemetry(command_name: str, subcommand_arg: Optional[str] = None):
124
+ """
125
+ Decorator to track telemetry for a command function.
126
+ """
127
+ def deco(fn):
128
+ @functools.wraps(fn)
129
+ def wrapper(*args, **kwargs):
130
+ ctx: Optional[typer.Context] = kwargs.get("ctx")
131
+ sub = (kwargs.get(subcommand_arg) if subcommand_arg else None)
132
+ label = "success" # default unless we see a failure
133
+ should_track_run = True
134
+ try:
135
+ result = fn(*args, **kwargs)
136
+ return result
137
+ except typer.Exit as e:
138
+ code = getattr(e, "exit_code", None)
139
+ # treat Exit(0/None) as success, anything else as failure
140
+ label = "success" if (code == 0 or code is None) else "failure"
141
+ # Now we only want to track if it was a success
142
+ # typer.Exit is called by us on user failure.
143
+ # If we start tracking this, bad actors can spam our telemetry.
144
+ should_track_run = not label == 'failure'
145
+ raise
146
+ except SystemExit as e:
147
+ # print('#1')
148
+ code = getattr(e, "code", None)
149
+ label = "success" if (code == 0 or code is None) else "failure"
150
+ raise
151
+ except Exception:
152
+ # print('#2')
153
+ label = "failure"
154
+ raise
155
+ finally:
156
+ if should_track_run:
157
+ track_simple(ctx, command=command_name,
158
+ sub=sub, result=label, count=1)
159
+ return wrapper
160
+ return deco
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: snapctl
3
- Version: 0.49.5
3
+ Version: 0.53.1
4
4
  Summary: Snapser CLI Tool
5
5
  Author: Ajinkya Apte
6
6
  Author-email: aj@snapser.com
@@ -1,15 +1,16 @@
1
1
  snapctl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  snapctl/__main__.py,sha256=43jKoTk8b85hk_MT6499N3ruHdEfM8WBImd_-3VzjI8,116
3
3
  snapctl/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- snapctl/commands/byogs.py,sha256=WumLZRriCVF0SSnMn8TUL--iWhh3Yub3yQ-jz30faI0,19486
5
- snapctl/commands/byosnap.py,sha256=9Xe75VAi-KPFALSleUyMJsrvDJEG6cxBiGiF267PSJA,76897
6
- snapctl/commands/byows.py,sha256=xmvTsWGjp3CkIvMj3LdVJO02P5W0-HfWqsP2pf9tsXE,18519
4
+ snapctl/commands/byogs.py,sha256=fwXM7X_IqkVPAByGWWxwNzJeZlDTsh_ApWNH3IhyVVM,20076
5
+ snapctl/commands/byosnap.py,sha256=TAITdbB_FhGk7K94bPDDjykXbU9XwO9opW__qdeg0SQ,78187
6
+ snapctl/commands/byows.py,sha256=wbfCGbjigZo37mryvvE_VJ9bJWjIjcRBLAS7IT6VNrI,18906
7
7
  snapctl/commands/game.py,sha256=lAABIWIibrwcqvpKvTy_7UzzLtbiP6gdk4_qPBHFKOI,5248
8
8
  snapctl/commands/generate.py,sha256=9-NlZVQllBT2LZT_t9S3ztwtHzTbM-C8_x0f6z70U3g,5606
9
- snapctl/commands/release_notes.py,sha256=QlhBlywLiRTKBs3SlaxHf-0E0OEU_47Xjld9gGtj4Zw,1943
10
- snapctl/commands/snapend.py,sha256=lcg9HlgQ9YedP-GmNEZQz8UeIPmfa49KtLRgeeHyaCo,40310
9
+ snapctl/commands/release_notes.py,sha256=EMl-NK9-MSdRxiVyA0MLX4CBG0zijOctddEODQa5PZ4,2151
10
+ snapctl/commands/snapend.py,sha256=-Z4GjVvJ39H9XCCTx6SUlAUWOwam5Nc0V6JdbR2IY4c,39675
11
11
  snapctl/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- snapctl/config/constants.py,sha256=9A7Ktr2fZ4O8ugnLMKUnMV01znnrKlfXGlJU4SOpy3Q,3716
12
+ snapctl/config/app.py,sha256=QXSy10Yrw9yhV7VlAwxxdUCRrphoP9-2-sEaaL2SZa4,889
13
+ snapctl/config/constants.py,sha256=UOVdr_aCoGeQhOWtZIge8k1OWWh8KZ5pfCOzcBj0eS8,3844
13
14
  snapctl/config/endpoints.py,sha256=jD0n5ocJBbIkrb97F2_r9hqAHzUuddAqzqqBAPUKDTI,477
14
15
  snapctl/config/hashes.py,sha256=3OKAyOxQPnn--_hvPIzFJnhC8NVfQK4WT-RH4PHEV1w,5242
15
16
  snapctl/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -28,16 +29,18 @@ snapctl/data/releases/beta-0.49.0.mdx,sha256=pqJP7NfM3ZMxs4SoeANCdDqTxjR9IBGbBI3
28
29
  snapctl/data/releases/beta-0.49.1.mdx,sha256=4EXupEegYffnL5bu_XWxGbzIPp9xOzl7t4xbmhq52fU,200
29
30
  snapctl/data/releases/beta-0.49.2.mdx,sha256=Vv_OIQyPieEGI9FgTUT0v5Gm0PC2JWa2FqcCUhnvx6s,176
30
31
  snapctl/data/releases/beta-0.49.3.mdx,sha256=XmSfpiH-LqvKS2REJ5FGDcLgyEN-OcSzwrXNI_EhiGc,234
31
- snapctl/data/releases/beta-0.49.4.mdx,sha256=olSEvzwrBgp6TfVYehYslhGrjLXz1EcybTmbXUmtA18,161
32
- snapctl/data/releases/beta-0.49.5.mdx,sha256=I_NdMkfxtFUOhUQgCUfcnY1YraFim4eAtRYq5g0dJqs,138
33
- snapctl/main.py,sha256=6EDEQlcF3GPSqH3-Ezl7bkI33ZKaFR_c55B0Dkt0pDM,25212
32
+ snapctl/data/releases/beta-0.50.0.mdx,sha256=_w9f1HjHqukQ8IaTfUdk2W3gBcSwjMyMA8-rjuUxYTo,399
33
+ snapctl/data/releases/beta-0.51.0.mdx,sha256=5_xY29NJtH3Qa92UycCudrwDkSlUHRVNnP4GE4hF7-Y,93
34
+ snapctl/data/releases/beta-0.53.1.mdx,sha256=b__2LPkDhU3cTw5UC19Hxv1rDz6BsSLeI6fhHjOCP08,149
35
+ snapctl/main.py,sha256=BdVjXyCX64d3JIhPjDo4QEGhos6RAcQqSdY3MHBQNc4,26264
34
36
  snapctl/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
35
37
  snapctl/types/definitions.py,sha256=EQzLeiXkJ8ISRlCqHMviNVsWWpmhWjpKaOBLdlvOTmY,644
36
38
  snapctl/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
- snapctl/utils/echo.py,sha256=V0qgjqqGXRiueMkq31enmNmdqciC8S90qGRcK8UupXA,1090
38
- snapctl/utils/helper.py,sha256=7ugGs1GE7Rywf1-tQxrgqXUU97EQYFjS3JUDpk9SX_Q,6850
39
- snapctl-0.49.5.dist-info/LICENSE,sha256=6AcXm54KFSpmUI1ji9NIBd4Xl-DtjTqiyjBzfVb_CEk,2804
40
- snapctl-0.49.5.dist-info/METADATA,sha256=LTqz4qckGzXeP20E2jP_ZtiVxIrNyjoQGA4NKDGMN5Y,36275
41
- snapctl-0.49.5.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
42
- snapctl-0.49.5.dist-info/entry_points.txt,sha256=tkKW9MzmFdRs6Bgkv29G78i9WEBK4WIOWunPfe3t2Wg,44
43
- snapctl-0.49.5.dist-info/RECORD,,
39
+ snapctl/utils/echo.py,sha256=9fxrPW9kNJwiJvJBFy9RlAP4qGRcPTOHfQb3pQh3CMg,1446
40
+ snapctl/utils/helper.py,sha256=5c5jfAYD4WsvPpuCLMCz3tPvji28786_pD9TZkRSFIs,7179
41
+ snapctl/utils/telemetry.py,sha256=_VntKXUrKgJoLKCskH0Z5VhthiVo8CP572U0mcZIH4k,5028
42
+ snapctl-0.53.1.dist-info/LICENSE,sha256=6AcXm54KFSpmUI1ji9NIBd4Xl-DtjTqiyjBzfVb_CEk,2804
43
+ snapctl-0.53.1.dist-info/METADATA,sha256=vns-UuApeJGMXSEk12QjrBTaGHacAM6FcukJS3-Nxds,36275
44
+ snapctl-0.53.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
45
+ snapctl-0.53.1.dist-info/entry_points.txt,sha256=tkKW9MzmFdRs6Bgkv29G78i9WEBK4WIOWunPfe3t2Wg,44
46
+ snapctl-0.53.1.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- ## beta-0.49.4
2
- ##### May 20, 2025
3
-
4
- ### Bug Fixes
5
-
6
- #### Bring your own workstation (BYOW)
7
- - Environment exports no longer include hyphens in the variable names.
8
-
@@ -1,8 +0,0 @@
1
- ## beta-0.49.5
2
- ##### May 22, 2025
3
-
4
- ### Bug Fixes
5
-
6
- #### Snapend Download
7
- - Fixed a bug with snapend download manifest and apply manifest.
8
-