snapctl 0.50.0__tar.gz → 0.53.1__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.

Potentially problematic release.


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

Files changed (45) hide show
  1. {snapctl-0.50.0 → snapctl-0.53.1}/PKG-INFO +1 -1
  2. {snapctl-0.50.0 → snapctl-0.53.1}/pyproject.toml +1 -1
  3. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/commands/byows.py +26 -6
  4. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/commands/release_notes.py +7 -1
  5. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/commands/snapend.py +9 -5
  6. snapctl-0.53.1/snapctl/config/app.py +31 -0
  7. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/config/constants.py +6 -2
  8. snapctl-0.53.1/snapctl/data/releases/beta-0.51.0.mdx +6 -0
  9. snapctl-0.53.1/snapctl/data/releases/beta-0.53.1.mdx +6 -0
  10. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/main.py +27 -0
  11. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/utils/helper.py +13 -2
  12. snapctl-0.53.1/snapctl/utils/telemetry.py +160 -0
  13. {snapctl-0.50.0 → snapctl-0.53.1}/LICENSE +0 -0
  14. {snapctl-0.50.0 → snapctl-0.53.1}/README.md +0 -0
  15. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/__init__.py +0 -0
  16. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/__main__.py +0 -0
  17. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/commands/__init__.py +0 -0
  18. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/commands/byogs.py +0 -0
  19. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/commands/byosnap.py +0 -0
  20. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/commands/game.py +0 -0
  21. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/commands/generate.py +0 -0
  22. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/config/__init__.py +0 -0
  23. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/config/endpoints.py +0 -0
  24. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/config/hashes.py +0 -0
  25. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/__init__.py +0 -0
  26. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/profiles/__init__.py +0 -0
  27. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/profiles/snapser-byosnap-profile.json +0 -0
  28. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/profiles/snapser-byosnap-profile.yaml +0 -0
  29. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/profiles/snapser-byosnap-profile.yml +0 -0
  30. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/releases/__init__.py +0 -0
  31. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/releases/beta-0.46.0.mdx +0 -0
  32. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/releases/beta-0.46.4.mdx +0 -0
  33. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/releases/beta-0.47.0.mdx +0 -0
  34. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/releases/beta-0.47.1.mdx +0 -0
  35. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/releases/beta-0.47.2.mdx +0 -0
  36. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/releases/beta-0.48.0.mdx +0 -0
  37. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/releases/beta-0.49.0.mdx +0 -0
  38. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/releases/beta-0.49.1.mdx +0 -0
  39. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/releases/beta-0.49.2.mdx +0 -0
  40. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/releases/beta-0.49.3.mdx +0 -0
  41. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/data/releases/beta-0.50.0.mdx +0 -0
  42. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/types/__init__.py +0 -0
  43. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/types/definitions.py +0 -0
  44. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/utils/__init__.py +0 -0
  45. {snapctl-0.50.0 → snapctl-0.53.1}/snapctl/utils/echo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: snapctl
3
- Version: 0.50.0
3
+ Version: 0.53.1
4
4
  Summary: Snapser CLI Tool
5
5
  Author: Ajinkya Apte
6
6
  Author-email: aj@snapser.com
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "snapctl"
3
- version = "0.50.0"
3
+ version = "0.53.1"
4
4
  description = "Snapser CLI Tool"
5
5
  authors = ["Ajinkya Apte <aj@snapser.com>"]
6
6
  readme = "README.md"
@@ -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",
@@ -390,7 +393,7 @@ class Byows:
390
393
  self._handle_signal))
391
394
  if hasattr(signal, "SIGBREAK"):
392
395
  signal.signal(signal.SIGBREAK,
393
- functools.partial(self._handle_signal))
396
+ functools.partial(self._handle_signal))
394
397
  # Set up port forward
395
398
  self._setup_port_forward(
396
399
  response_json['proxyPrivateKey'],
@@ -409,16 +412,15 @@ class Byows:
409
412
  return snapctl_success(
410
413
  message='complete', progress=progress)
411
414
  snapctl_error(
412
- message='Unable to setup port forward.',
415
+ message='Attach failed.',
413
416
  code=SNAPCTL_BYOWS_ATTACH_ERROR, progress=progress)
414
417
  except HTTPError as http_err:
415
418
  snapctl_error(
416
- message=Byows._format_portal_http_error(
417
- "Unable to setup port forward", http_err, res),
419
+ message=Byows._format_portal_http_error("Attach failed.", http_err, res),
418
420
  code=SNAPCTL_BYOWS_ATTACH_ERROR, progress=progress)
419
421
  except RequestException as e:
420
422
  snapctl_error(
421
- message=f"Exception: Unable to setup port forward {e}",
423
+ message=f"Attach failed: {e}",
422
424
  code=SNAPCTL_BYOWS_ATTACH_ERROR, progress=progress)
423
425
  finally:
424
426
  progress.stop()
@@ -463,3 +465,21 @@ class Byows:
463
465
  code=SNAPCTL_BYOWS_RESET_ERROR, progress=progress)
464
466
  finally:
465
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
 
@@ -84,9 +84,9 @@ class Snapend:
84
84
  self.manifest_path_filename: Union[str, None] = manifest_path_filename
85
85
  self.force: bool = force
86
86
  self.category: str = category
87
- self.portal_category: Union[str, None] = Snapend._make_portal_category(
88
- category_format)
89
87
  self.category_format: str = category_format
88
+ self.portal_category: Union[str, None] = Snapend._make_portal_category(
89
+ category, category_format)
90
90
  self.category_type: Union[str, None] = category_type
91
91
  self.category_http_lib: Union[str, None] = category_http_lib
92
92
  self.download_types: Union[
@@ -117,16 +117,20 @@ class Snapend:
117
117
  return None
118
118
 
119
119
  @staticmethod
120
- def _make_portal_category(category_format: str):
120
+ def _make_portal_category(category: str, category_format: str):
121
121
  '''
122
122
  We have simplified the input for the user to only take the category as sdk
123
123
  The portal server however expects us to pass client-sdk or server-sdk
124
124
  Hence we need to do this
125
125
  '''
126
- if category_format in CLIENT_SDK_TYPES:
126
+ if category == 'sdk' and category_format in CLIENT_SDK_TYPES:
127
127
  return 'client-sdk'
128
- if category_format in SERVER_SDK_TYPES:
128
+ if category == 'sdk' and category_format in SERVER_SDK_TYPES:
129
129
  return 'server-sdk'
130
+ if category == 'protos' and category_format in PROTOS_TYPES:
131
+ return 'protos'
132
+ if category == 'snapend-manifest' and category_format in SNAPEND_MANIFEST_TYPES:
133
+ return 'snapend-manifest'
130
134
  return None
131
135
 
132
136
  @staticmethod
@@ -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.50.0'
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,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.
@@ -23,6 +23,7 @@ from snapctl.config.hashes import PROTOS_TYPES, SERVICE_IDS, \
23
23
  SNAPEND_MANIFEST_TYPES, SDK_TYPES
24
24
  from snapctl.utils.echo import error, success, info
25
25
  from snapctl.utils.helper import validate_api_key
26
+ from snapctl.utils.telemetry import telemetry
26
27
 
27
28
  ######### Globals #########
28
29
 
@@ -90,6 +91,21 @@ def extract_config(extract_key: str, profile: Union[str, None] = None) -> object
90
91
  return result
91
92
 
92
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
+
93
109
  def get_base_url(api_key: Union[str, None]) -> str:
94
110
  """
95
111
  Returns the base url based on the api_key
@@ -153,6 +169,7 @@ def default_context_callback(ctx: typer.Context):
153
169
  ctx.obj['api_key'] = api_key_obj['value']
154
170
  ctx.obj['api_key_location'] = api_key_obj['location']
155
171
  ctx.obj['profile'] = DEFAULT_PROFILE
172
+ ctx.obj['environment'] = get_environment(api_key_obj['value'])
156
173
  ctx.obj['base_url'] = get_base_url(api_key_obj['value'])
157
174
  ctx.obj['base_snapend_url'] = get_base_snapend_url(api_key_obj['value'])
158
175
 
@@ -173,6 +190,7 @@ def api_key_context_callback(
173
190
  ctx.obj['version'] = VERSION
174
191
  ctx.obj['api_key'] = api_key
175
192
  ctx.obj['api_key_location'] = 'command-line-argument'
193
+ ctx.obj['environment'] = get_environment(api_key)
176
194
  ctx.obj['base_url'] = get_base_url(api_key)
177
195
 
178
196
 
@@ -205,6 +223,7 @@ def profile_context_callback(
205
223
  ctx.obj['api_key'] = api_key_obj['value']
206
224
  ctx.obj['api_key_location'] = api_key_obj['location']
207
225
  ctx.obj['profile'] = profile if profile else DEFAULT_PROFILE
226
+ ctx.obj['environment'] = get_environment(api_key_obj['value'])
208
227
  ctx.obj['base_url'] = get_base_url(api_key_obj['value'])
209
228
 
210
229
 
@@ -236,6 +255,7 @@ def common(
236
255
 
237
256
 
238
257
  @app.command()
258
+ @telemetry("validate", subcommand_arg="subcommand")
239
259
  def validate(
240
260
  ctx: typer.Context,
241
261
  api_key: Union[str, None] = typer.Option(
@@ -255,6 +275,7 @@ def validate(
255
275
 
256
276
 
257
277
  @app.command()
278
+ @telemetry("release_notes", subcommand_arg="subcommand")
258
279
  def release_notes(
259
280
  ctx: typer.Context,
260
281
  subcommand: str = typer.Argument(
@@ -283,6 +304,7 @@ def release_notes(
283
304
 
284
305
 
285
306
  @app.command()
307
+ @telemetry("byogs", subcommand_arg="subcommand")
286
308
  def byogs(
287
309
  ctx: typer.Context,
288
310
  # Required fields
@@ -352,6 +374,7 @@ def byogs(
352
374
 
353
375
 
354
376
  @app.command()
377
+ @telemetry("byosnap", subcommand_arg="subcommand")
355
378
  def byosnap(
356
379
  ctx: typer.Context,
357
380
  # Required fields
@@ -460,6 +483,7 @@ def byosnap(
460
483
 
461
484
 
462
485
  @app.command()
486
+ @telemetry("game", subcommand_arg="subcommand")
463
487
  def game(
464
488
  ctx: typer.Context,
465
489
  # Required fields
@@ -495,6 +519,7 @@ def game(
495
519
 
496
520
 
497
521
  @app.command()
522
+ @telemetry("generate", subcommand_arg="subcommand")
498
523
  def generate(
499
524
  ctx: typer.Context,
500
525
  # Required fields
@@ -541,6 +566,7 @@ def generate(
541
566
 
542
567
 
543
568
  @app.command()
569
+ @telemetry("snapend", subcommand_arg="subcommand")
544
570
  def snapend(
545
571
  ctx: typer.Context,
546
572
  # Required fields
@@ -684,6 +710,7 @@ def snapend(
684
710
 
685
711
 
686
712
  @app.command()
713
+ @telemetry("byows", subcommand_arg="subcommand")
687
714
  def byows(
688
715
  ctx: typer.Context,
689
716
  # Required fields
@@ -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
File without changes
File without changes
File without changes
File without changes
File without changes