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.
- {runpane-2.3.4 → runpane-2.3.6}/PKG-INFO +6 -1
- {runpane-2.3.4 → runpane-2.3.6}/README.md +5 -0
- {runpane-2.3.4 → runpane-2.3.6}/pyproject.toml +1 -1
- runpane-2.3.6/src/runpane/__init__.py +1 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane/cli.py +190 -55
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane/download.py +12 -2
- runpane-2.3.6/src/runpane/generated_contract.py +4 -0
- runpane-2.3.6/src/runpane/telemetry.py +287 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane.egg-info/PKG-INFO +6 -1
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane.egg-info/SOURCES.txt +1 -0
- runpane-2.3.4/src/runpane/__init__.py +0 -1
- runpane-2.3.4/src/runpane/generated_contract.py +0 -4
- {runpane-2.3.4 → runpane-2.3.6}/setup.cfg +0 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane/__main__.py +0 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane/agent_context.py +0 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane/daemon_client.py +0 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane/doctor.py +0 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane/installers.py +0 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane/local_control.py +0 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane/platforms.py +0 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane/releases.py +0 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane/version.py +0 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane.egg-info/dependency_links.txt +0 -0
- {runpane-2.3.4 → runpane-2.3.6}/src/runpane.egg-info/entry_points.txt +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|