runpane 2.3.4__tar.gz → 2.3.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {runpane-2.3.4 → runpane-2.3.6}/PKG-INFO +6 -1
  2. {runpane-2.3.4 → runpane-2.3.6}/README.md +5 -0
  3. {runpane-2.3.4 → runpane-2.3.6}/pyproject.toml +1 -1
  4. runpane-2.3.6/src/runpane/__init__.py +1 -0
  5. {runpane-2.3.4 → runpane-2.3.6}/src/runpane/cli.py +190 -55
  6. {runpane-2.3.4 → runpane-2.3.6}/src/runpane/download.py +12 -2
  7. runpane-2.3.6/src/runpane/generated_contract.py +4 -0
  8. runpane-2.3.6/src/runpane/telemetry.py +287 -0
  9. {runpane-2.3.4 → runpane-2.3.6}/src/runpane.egg-info/PKG-INFO +6 -1
  10. {runpane-2.3.4 → runpane-2.3.6}/src/runpane.egg-info/SOURCES.txt +1 -0
  11. runpane-2.3.4/src/runpane/__init__.py +0 -1
  12. runpane-2.3.4/src/runpane/generated_contract.py +0 -4
  13. {runpane-2.3.4 → runpane-2.3.6}/setup.cfg +0 -0
  14. {runpane-2.3.4 → runpane-2.3.6}/src/runpane/__main__.py +0 -0
  15. {runpane-2.3.4 → runpane-2.3.6}/src/runpane/agent_context.py +0 -0
  16. {runpane-2.3.4 → runpane-2.3.6}/src/runpane/daemon_client.py +0 -0
  17. {runpane-2.3.4 → runpane-2.3.6}/src/runpane/doctor.py +0 -0
  18. {runpane-2.3.4 → runpane-2.3.6}/src/runpane/installers.py +0 -0
  19. {runpane-2.3.4 → runpane-2.3.6}/src/runpane/local_control.py +0 -0
  20. {runpane-2.3.4 → runpane-2.3.6}/src/runpane/platforms.py +0 -0
  21. {runpane-2.3.4 → runpane-2.3.6}/src/runpane/releases.py +0 -0
  22. {runpane-2.3.4 → runpane-2.3.6}/src/runpane/version.py +0 -0
  23. {runpane-2.3.4 → runpane-2.3.6}/src/runpane.egg-info/dependency_links.txt +0 -0
  24. {runpane-2.3.4 → runpane-2.3.6}/src/runpane.egg-info/entry_points.txt +0 -0
  25. {runpane-2.3.4 → runpane-2.3.6}/src/runpane.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runpane
3
- Version: 2.3.4
3
+ Version: 2.3.6
4
4
  Summary: Thin PyPI installer and remote setup CLI for Pane
5
5
  Author-email: Dcouple Inc <hello@dcouple.ai>
6
6
  License: AGPL-3.0
@@ -113,6 +113,11 @@ PyPI package downloads use `source=pip` when requesting release artifacts from
113
113
  `runpane.com/api/download`. If that route is unavailable, the CLI falls back to
114
114
  matching GitHub release assets and prints a warning.
115
115
 
116
+ The wrapper also sends best-effort lifecycle telemetry with a persisted
117
+ anonymous `install_id`. Count distinct wrapper users with
118
+ `count(DISTINCT properties.install_id)` on `runpane_wrapper_*` events. Set
119
+ `RUNPANE_TELEMETRY_DISABLED=1` to disable wrapper telemetry.
120
+
116
121
  ## Maintenance Notes
117
122
 
118
123
  Keep the npm and PyPI clients in sync with each Pane release. When changing
@@ -95,6 +95,11 @@ PyPI package downloads use `source=pip` when requesting release artifacts from
95
95
  `runpane.com/api/download`. If that route is unavailable, the CLI falls back to
96
96
  matching GitHub release assets and prints a warning.
97
97
 
98
+ The wrapper also sends best-effort lifecycle telemetry with a persisted
99
+ anonymous `install_id`. Count distinct wrapper users with
100
+ `count(DISTINCT properties.install_id)` on `runpane_wrapper_*` events. Set
101
+ `RUNPANE_TELEMETRY_DISABLED=1` to disable wrapper telemetry.
102
+
98
103
  ## Maintenance Notes
99
104
 
100
105
  Keep the npm and PyPI clients in sync with each Pane release. When changing
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "runpane"
7
- version = "2.3.4"
7
+ version = "2.3.6"
8
8
  description = "Thin PyPI installer and remote setup CLI for Pane"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -0,0 +1 @@
1
+ __version__ = "2.3.6"
@@ -4,7 +4,7 @@ from dataclasses import dataclass, field
4
4
  import os
5
5
  import socket
6
6
  import sys
7
- from typing import Dict, List, Optional, Tuple, TypeVar
7
+ from typing import Callable, Dict, List, Optional, Tuple, TypeVar
8
8
 
9
9
  from .agent_context import run_agent_context
10
10
  from .doctor import run_doctor
@@ -28,6 +28,14 @@ from .local_control import (
28
28
  )
29
29
  from .platforms import detect_platform
30
30
  from .releases import resolve_release
31
+ from .telemetry import (
32
+ WrapperTelemetryContext,
33
+ apply_parsed_args_to_telemetry_context,
34
+ categorize_failure,
35
+ create_initial_telemetry_context,
36
+ set_setup_selection,
37
+ track_wrapper_event,
38
+ )
31
39
  from .version import print_version
32
40
 
33
41
  SOURCE = "pip"
@@ -94,59 +102,104 @@ class ParsedArgs:
94
102
 
95
103
 
96
104
  def main(argv: Optional[List[str]] = None) -> int:
105
+ effective_argv = sys.argv[1:] if argv is None else argv
106
+ telemetry_context = create_initial_telemetry_context(effective_argv)
97
107
  try:
98
- effective_argv = sys.argv[1:] if argv is None else argv
99
108
  if not effective_argv:
100
- return run_no_args_entrypoint()
101
-
102
- parsed = parse_args(effective_argv)
103
- if parsed.command == "help":
104
- print(help_text(parsed.help_topic))
105
- return 0
106
- if parsed.command == "setup":
107
- return run_no_args_entrypoint()
108
- if parsed.command == "version":
109
- return print_version(parsed.pane_path)
110
- if parsed.command == "doctor":
111
- return run_doctor(parsed, SOURCE)
112
- if parsed.command == "agent-context":
113
- return run_agent_context(parsed)
114
- if parsed.command == "repos list":
115
- return run_repos_list(parsed)
116
- if parsed.command == "repos add":
117
- return run_repos_add(parsed)
118
- if parsed.command == "panes list":
119
- return run_panes_list(parsed)
120
- if parsed.command == "panes create":
121
- return run_panes_create(parsed)
122
- if parsed.command == "panels list":
123
- return run_panels_list(parsed)
124
- if parsed.command == "panels output":
125
- return run_panels_output(parsed)
126
- if parsed.command == "panels input":
127
- return run_panels_input(parsed)
128
- if parsed.command in {"install", "update"}:
129
- return install_or_update(parsed)
130
- print(help_text(None))
131
- return 0
109
+ return run_tracked_command(
110
+ telemetry_context,
111
+ lambda: run_no_args_entrypoint(telemetry_context),
112
+ )
113
+
114
+ try:
115
+ parsed = parse_args(effective_argv)
116
+ except Exception as error:
117
+ telemetry_context["failure_stage"] = "parse"
118
+ telemetry_context["failure_category"] = categorize_failure(error)
119
+ track_wrapper_event("runpane_wrapper_command_failed", telemetry_context)
120
+ raise
121
+ apply_parsed_args_to_telemetry_context(telemetry_context, parsed)
122
+
123
+ return run_tracked_command(
124
+ telemetry_context,
125
+ lambda: dispatch_parsed_command(parsed, telemetry_context),
126
+ )
132
127
  except Exception as error:
133
128
  print(str(error), file=sys.stderr)
134
129
  return 1
135
130
 
136
131
 
137
- def run_no_args_entrypoint() -> int:
132
+ def dispatch_parsed_command(parsed: ParsedArgs, telemetry_context: WrapperTelemetryContext) -> int:
133
+ if parsed.command == "help":
134
+ print(help_text(parsed.help_topic))
135
+ return 0
136
+ if parsed.command == "setup":
137
+ return run_no_args_entrypoint(telemetry_context)
138
+ if parsed.command == "version":
139
+ return print_version(parsed.pane_path)
140
+ if parsed.command == "doctor":
141
+ return run_doctor(parsed, SOURCE)
142
+ if parsed.command == "agent-context":
143
+ return run_agent_context(parsed)
144
+ if parsed.command == "repos list":
145
+ return run_repos_list(parsed)
146
+ if parsed.command == "repos add":
147
+ return run_repos_add(parsed)
148
+ if parsed.command == "panes list":
149
+ return run_panes_list(parsed)
150
+ if parsed.command == "panes create":
151
+ return run_panes_create(parsed)
152
+ if parsed.command == "panels list":
153
+ return run_panels_list(parsed)
154
+ if parsed.command == "panels output":
155
+ return run_panels_output(parsed)
156
+ if parsed.command == "panels input":
157
+ return run_panels_input(parsed)
158
+ if parsed.command in {"install", "update"}:
159
+ return install_or_update(parsed, telemetry_context)
160
+ print(help_text(None))
161
+ return 0
162
+
163
+
164
+ def run_tracked_command(telemetry_context: WrapperTelemetryContext, execute: Callable[[], int]) -> int:
165
+ track_wrapper_event("runpane_wrapper_command_started", telemetry_context)
166
+ try:
167
+ code = execute()
168
+ telemetry_context["exit_code"] = code
169
+ if code == 0:
170
+ track_wrapper_event("runpane_wrapper_command_succeeded", telemetry_context)
171
+ else:
172
+ telemetry_context.setdefault("failure_stage", infer_failure_stage(telemetry_context))
173
+ telemetry_context.setdefault("failure_category", "process_exit")
174
+ track_wrapper_event("runpane_wrapper_command_failed", telemetry_context)
175
+ return code
176
+ except Exception as error:
177
+ telemetry_context.setdefault("failure_stage", "unknown")
178
+ telemetry_context.setdefault("failure_category", categorize_failure(error))
179
+ track_wrapper_event("runpane_wrapper_command_failed", telemetry_context)
180
+ raise
181
+
182
+
183
+ def infer_failure_stage(telemetry_context: WrapperTelemetryContext) -> str:
184
+ if telemetry_context.get("resolved_command") == "install" and telemetry_context.get("target") == "daemon":
185
+ return "remote_setup"
186
+ return "unknown"
187
+
188
+
189
+ def run_no_args_entrypoint(telemetry_context: WrapperTelemetryContext) -> int:
138
190
  if not is_interactive_shell():
191
+ telemetry_context["resolved_command"] = "help"
139
192
  print(help_text(None))
140
193
  return 0
141
194
 
142
- return run_interactive_wizard()
195
+ return run_interactive_wizard(telemetry_context)
143
196
 
144
197
 
145
198
  def is_interactive_shell() -> bool:
146
199
  return bool(sys.stdin.isatty() and sys.stdout.isatty() and not os.environ.get("CI"))
147
200
 
148
201
 
149
- def run_interactive_wizard() -> int:
202
+ def run_interactive_wizard(telemetry_context: WrapperTelemetryContext) -> int:
150
203
  print("Pane setup")
151
204
  print("Choose what this machine should do. You can rerun setup any time.")
152
205
  print()
@@ -176,14 +229,17 @@ def run_interactive_wizard() -> int:
176
229
  if action == "client":
177
230
  print()
178
231
  print("Installing Pane desktop app on this machine...")
179
- return install_or_update(create_parsed_args("install", target="client"))
232
+ set_setup_selection(telemetry_context, "install", "client")
233
+ return install_or_update(create_parsed_args("install", target="client"), telemetry_context)
180
234
  if action == "update":
181
235
  print()
182
236
  print("Updating Pane desktop app on this machine...")
183
- return install_or_update(create_parsed_args("update", target="client"))
237
+ set_setup_selection(telemetry_context, "update", "client")
238
+ return install_or_update(create_parsed_args("update", target="client"), telemetry_context)
184
239
  if action == "doctor":
185
240
  print()
186
241
  print("Running runpane diagnostics...")
242
+ set_setup_selection(telemetry_context, "doctor")
187
243
  return run_doctor(create_parsed_args("doctor"), SOURCE)
188
244
 
189
245
  print()
@@ -223,11 +279,12 @@ def run_interactive_wizard() -> int:
223
279
  print("Setting up this machine as a Pane remote host...")
224
280
  print("When setup finishes, paste the printed pane-remote:// code into Pane or runpane.com/app.")
225
281
 
282
+ set_setup_selection(telemetry_context, "install", "daemon")
226
283
  return install_or_update(create_parsed_args(
227
284
  "install",
228
285
  target="daemon",
229
286
  remote_setup_args=remote_setup_args,
230
- ))
287
+ ), telemetry_context)
231
288
 
232
289
 
233
290
  ChoiceT = TypeVar("ChoiceT", bound=str)
@@ -454,25 +511,51 @@ def read_value(args: List[str], index: int, flag: str) -> str:
454
511
  return args[index]
455
512
 
456
513
 
457
- def install_or_update(parsed: ParsedArgs) -> int:
514
+ def install_or_update(parsed: ParsedArgs, telemetry_context: Optional[WrapperTelemetryContext] = None) -> int:
458
515
  target = "client" if parsed.command == "update" else parsed.target
516
+ context = telemetry_context if telemetry_context is not None else create_install_telemetry_context(parsed, target)
517
+ context["target"] = target
518
+ context["pane_version"] = parsed.pane_version
519
+ context["channel"] = parsed.channel
520
+ context["format"] = parsed.format
521
+ context["dry_run"] = parsed.dry_run
522
+
459
523
  if not parsed.dry_run and should_reuse_existing_pane(parsed, target):
460
524
  existing = resolve_existing_pane_path(parsed.pane_path)
461
525
  if existing:
462
526
  print(f"runpane: using existing Pane executable at {existing}")
463
527
  print("runpane: starting remote setup...")
464
- return spawn_pane(existing, ["--remote-setup", *parsed.remote_setup_args])
528
+ context["install_kind"] = "existing"
529
+ code = spawn_pane(existing, ["--remote-setup", *parsed.remote_setup_args])
530
+ context["exit_code"] = code
531
+ if code != 0:
532
+ context["failure_stage"] = "remote_setup"
533
+ context["failure_category"] = "process_exit"
534
+ return code
535
+
536
+ try:
537
+ platform = detect_platform()
538
+ context["platform"] = platform
539
+ except Exception as error:
540
+ context["failure_stage"] = "resolve_release"
541
+ context["failure_category"] = categorize_failure(error)
542
+ raise
465
543
 
466
- platform = detect_platform()
467
544
  print(f"runpane: resolving Pane release {parsed.pane_version}...")
468
- resolved = resolve_release(
469
- version=parsed.pane_version,
470
- channel=parsed.channel,
471
- source=SOURCE,
472
- platform=platform,
473
- format_name=parsed.format,
474
- target=target,
475
- )
545
+ try:
546
+ resolved = resolve_release(
547
+ version=parsed.pane_version,
548
+ channel=parsed.channel,
549
+ source=SOURCE,
550
+ platform=platform,
551
+ format_name=parsed.format,
552
+ target=target,
553
+ )
554
+ context["resolved_format"] = resolved.format
555
+ except Exception as error:
556
+ context["failure_stage"] = "resolve_release"
557
+ context["failure_category"] = categorize_failure(error)
558
+ raise
476
559
 
477
560
  if parsed.dry_run:
478
561
  print("runpane dry run")
@@ -493,23 +576,75 @@ def install_or_update(parsed: ParsedArgs) -> int:
493
576
 
494
577
  print(f"runpane: selected {resolved.artifact['name']}")
495
578
  print(f"runpane: downloading {resolved.artifact['name']}...")
496
- artifact = download_artifact(resolved, parsed.download_dir, parsed.verbose)
579
+ track_wrapper_event("runpane_wrapper_download_requested", context)
580
+
581
+ def on_fallback_used(error: Exception) -> None:
582
+ fallback_context = dict(context)
583
+ fallback_context["used_fallback"] = True
584
+ fallback_context["failure_stage"] = "download"
585
+ fallback_context["failure_category"] = categorize_failure(error)
586
+ track_wrapper_event("runpane_wrapper_github_fallback_used", fallback_context)
587
+
588
+ try:
589
+ artifact = download_artifact(
590
+ resolved,
591
+ parsed.download_dir,
592
+ parsed.verbose,
593
+ on_fallback_used=on_fallback_used,
594
+ )
595
+ context["used_fallback"] = artifact.used_fallback
596
+ track_wrapper_event("runpane_wrapper_download_succeeded", context)
597
+ except Exception as error:
598
+ failure_category = categorize_failure(error)
599
+ context["failure_stage"] = "checksum" if failure_category == "checksum" else "download"
600
+ context["failure_category"] = failure_category
601
+ track_wrapper_event("runpane_wrapper_download_failed", context)
602
+ raise
603
+
497
604
  fallback = " from GitHub fallback" if artifact.used_fallback else ""
498
605
  print(f"runpane: downloaded {artifact.file_name}{fallback}")
499
606
  print("runpane: installing Pane...")
500
- installed = install_pane_artifact(artifact, parsed, platform, resolved.format, target)
607
+ try:
608
+ installed = install_pane_artifact(artifact, parsed, platform, resolved.format, target)
609
+ context["install_kind"] = installed.install_kind
610
+ except Exception as error:
611
+ context["failure_stage"] = "install"
612
+ context["failure_category"] = categorize_failure(error)
613
+ raise
501
614
 
502
615
  if target == "daemon":
503
616
  print("runpane: starting remote setup...")
504
- return spawn_pane(installed.executable_path, ["--remote-setup", *parsed.remote_setup_args])
617
+ code = spawn_pane(installed.executable_path, ["--remote-setup", *parsed.remote_setup_args])
618
+ context["exit_code"] = code
619
+ if code != 0:
620
+ context["failure_stage"] = "remote_setup"
621
+ context["failure_category"] = "process_exit"
622
+ return code
505
623
 
506
624
  if installed.install_kind == "installed":
507
- launch_pane_client(installed.executable_path)
625
+ try:
626
+ launch_pane_client(installed.executable_path)
627
+ except Exception as error:
628
+ context["failure_stage"] = "launch"
629
+ context["failure_category"] = categorize_failure(error)
630
+ raise
508
631
 
509
632
  print(f"Pane {installed.install_kind}: {installed.executable_path}")
510
633
  return 0
511
634
 
512
635
 
636
+ def create_install_telemetry_context(parsed: ParsedArgs, target: str) -> WrapperTelemetryContext:
637
+ return {
638
+ "command": parsed.command,
639
+ "resolved_command": parsed.command if parsed.command in {"install", "update"} else None,
640
+ "target": target,
641
+ "pane_version": parsed.pane_version,
642
+ "channel": parsed.channel,
643
+ "format": parsed.format,
644
+ "dry_run": parsed.dry_run,
645
+ }
646
+
647
+
513
648
  def help_text(topic: Optional[str]) -> str:
514
649
  help_topics = RUNPANE_CONTRACT["help"]["pip"]
515
650
  return "\n".join(help_topics.get(topic or "default", help_topics["default"]))
@@ -8,7 +8,7 @@ import time
8
8
  import urllib.error
9
9
  import urllib.request
10
10
  from dataclasses import dataclass
11
- from typing import Optional
11
+ from typing import Callable, Optional
12
12
 
13
13
  from .releases import ResolvedRelease, artifact_file_name
14
14
 
@@ -20,7 +20,12 @@ class DownloadedArtifact:
20
20
  used_fallback: bool
21
21
 
22
22
 
23
- def download_artifact(resolved: ResolvedRelease, download_dir: Optional[str], verbose: bool) -> DownloadedArtifact:
23
+ def download_artifact(
24
+ resolved: ResolvedRelease,
25
+ download_dir: Optional[str],
26
+ verbose: bool,
27
+ on_fallback_used: Optional[Callable[[Exception], None]] = None,
28
+ ) -> DownloadedArtifact:
24
29
  target_dir = download_dir or os.path.join(tempfile.gettempdir(), f"runpane-{int(time.time() * 1000)}")
25
30
  os.makedirs(target_dir, exist_ok=True)
26
31
 
@@ -33,6 +38,11 @@ def download_artifact(resolved: ResolvedRelease, download_dir: Optional[str], ve
33
38
  except Exception as error:
34
39
  used_fallback = True
35
40
  print(f"runpane: website download route failed; falling back to GitHub release asset. {error}")
41
+ if on_fallback_used is not None:
42
+ try:
43
+ on_fallback_used(error)
44
+ except Exception:
45
+ pass
36
46
  download_to_file(resolved.fallback_download_url, target_path, verbose)
37
47
 
38
48
  verify_checksum_if_available(resolved, target_path, file_name)