ruyi 0.39.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ruyi/__init__.py +21 -0
- ruyi/__main__.py +98 -0
- ruyi/cli/__init__.py +5 -0
- ruyi/cli/builtin_commands.py +14 -0
- ruyi/cli/cmd.py +224 -0
- ruyi/cli/completer.py +50 -0
- ruyi/cli/completion.py +26 -0
- ruyi/cli/config_cli.py +153 -0
- ruyi/cli/main.py +111 -0
- ruyi/cli/self_cli.py +295 -0
- ruyi/cli/user_input.py +127 -0
- ruyi/cli/version_cli.py +45 -0
- ruyi/config/__init__.py +401 -0
- ruyi/config/editor.py +92 -0
- ruyi/config/errors.py +76 -0
- ruyi/config/news.py +39 -0
- ruyi/config/schema.py +197 -0
- ruyi/device/__init__.py +0 -0
- ruyi/device/provision.py +591 -0
- ruyi/device/provision_cli.py +40 -0
- ruyi/log/__init__.py +272 -0
- ruyi/mux/.gitignore +1 -0
- ruyi/mux/__init__.py +0 -0
- ruyi/mux/runtime.py +213 -0
- ruyi/mux/venv/__init__.py +12 -0
- ruyi/mux/venv/emulator_cfg.py +41 -0
- ruyi/mux/venv/maker.py +782 -0
- ruyi/mux/venv/venv_cli.py +92 -0
- ruyi/mux/venv_cfg.py +214 -0
- ruyi/pluginhost/__init__.py +0 -0
- ruyi/pluginhost/api.py +206 -0
- ruyi/pluginhost/ctx.py +222 -0
- ruyi/pluginhost/paths.py +135 -0
- ruyi/pluginhost/plugin_cli.py +37 -0
- ruyi/pluginhost/unsandboxed.py +246 -0
- ruyi/py.typed +0 -0
- ruyi/resource_bundle/__init__.py +20 -0
- ruyi/resource_bundle/__main__.py +55 -0
- ruyi/resource_bundle/data.py +26 -0
- ruyi/ruyipkg/__init__.py +0 -0
- ruyi/ruyipkg/admin_checksum.py +88 -0
- ruyi/ruyipkg/admin_cli.py +83 -0
- ruyi/ruyipkg/atom.py +184 -0
- ruyi/ruyipkg/augmented_pkg.py +212 -0
- ruyi/ruyipkg/canonical_dump.py +320 -0
- ruyi/ruyipkg/checksum.py +39 -0
- ruyi/ruyipkg/cli_completion.py +42 -0
- ruyi/ruyipkg/distfile.py +208 -0
- ruyi/ruyipkg/entity.py +387 -0
- ruyi/ruyipkg/entity_cli.py +123 -0
- ruyi/ruyipkg/entity_provider.py +273 -0
- ruyi/ruyipkg/fetch.py +271 -0
- ruyi/ruyipkg/host.py +55 -0
- ruyi/ruyipkg/install.py +554 -0
- ruyi/ruyipkg/install_cli.py +150 -0
- ruyi/ruyipkg/list.py +126 -0
- ruyi/ruyipkg/list_cli.py +79 -0
- ruyi/ruyipkg/list_filter.py +173 -0
- ruyi/ruyipkg/msg.py +99 -0
- ruyi/ruyipkg/news.py +123 -0
- ruyi/ruyipkg/news_cli.py +78 -0
- ruyi/ruyipkg/news_store.py +183 -0
- ruyi/ruyipkg/pkg_manifest.py +657 -0
- ruyi/ruyipkg/profile.py +208 -0
- ruyi/ruyipkg/profile_cli.py +33 -0
- ruyi/ruyipkg/protocols.py +55 -0
- ruyi/ruyipkg/repo.py +763 -0
- ruyi/ruyipkg/state.py +345 -0
- ruyi/ruyipkg/unpack.py +369 -0
- ruyi/ruyipkg/unpack_method.py +91 -0
- ruyi/ruyipkg/update_cli.py +54 -0
- ruyi/telemetry/__init__.py +0 -0
- ruyi/telemetry/aggregate.py +72 -0
- ruyi/telemetry/event.py +41 -0
- ruyi/telemetry/node_info.py +192 -0
- ruyi/telemetry/provider.py +411 -0
- ruyi/telemetry/scope.py +43 -0
- ruyi/telemetry/store.py +238 -0
- ruyi/telemetry/telemetry_cli.py +127 -0
- ruyi/utils/__init__.py +0 -0
- ruyi/utils/ar.py +74 -0
- ruyi/utils/ci.py +63 -0
- ruyi/utils/frontmatter.py +38 -0
- ruyi/utils/git.py +169 -0
- ruyi/utils/global_mode.py +204 -0
- ruyi/utils/l10n.py +83 -0
- ruyi/utils/markdown.py +73 -0
- ruyi/utils/nuitka.py +33 -0
- ruyi/utils/porcelain.py +51 -0
- ruyi/utils/prereqs.py +77 -0
- ruyi/utils/ssl_patch.py +170 -0
- ruyi/utils/templating.py +34 -0
- ruyi/utils/toml.py +115 -0
- ruyi/utils/url.py +7 -0
- ruyi/utils/xdg_basedir.py +80 -0
- ruyi/version.py +67 -0
- ruyi-0.39.0.dist-info/LICENSE-Apache.txt +201 -0
- ruyi-0.39.0.dist-info/METADATA +403 -0
- ruyi-0.39.0.dist-info/RECORD +101 -0
- ruyi-0.39.0.dist-info/WHEEL +4 -0
- ruyi-0.39.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import calendar
|
|
2
|
+
import datetime
|
|
3
|
+
import functools
|
|
4
|
+
import json
|
|
5
|
+
import pathlib
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from typing import Callable, TYPE_CHECKING, cast
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from ..log import RuyiLogger
|
|
12
|
+
from .node_info import NodeInfo, gather_node_info
|
|
13
|
+
from .scope import TelemetryScope
|
|
14
|
+
from .store import TelemetryStore
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
# for avoiding circular import
|
|
18
|
+
from ..config import GlobalConfig
|
|
19
|
+
|
|
20
|
+
FALLBACK_PM_TELEMETRY_ENDPOINT = "https://api.ruyisdk.cn/telemetry/pm/"
|
|
21
|
+
|
|
22
|
+
FIRST_RUN_PROMPT = """\
|
|
23
|
+
Welcome to RuyiSDK! This appears to be your first run of [yellow]ruyi[/].
|
|
24
|
+
|
|
25
|
+
By default, the RuyiSDK team collects anonymous usage data to help us improve
|
|
26
|
+
the product. No personal information or detail about your project is ever
|
|
27
|
+
collected. The data will be uploaded to RuyiSDK team-managed servers located
|
|
28
|
+
in the Chinese mainland if you agree to the uploading. You can change this
|
|
29
|
+
setting at any time by running [yellow]ruyi telemetry consent[/] or
|
|
30
|
+
[yellow]ruyi telemetry optout[/].
|
|
31
|
+
|
|
32
|
+
We would like to ask if you agree to have basic information about this [yellow]ruyi[/]
|
|
33
|
+
installation uploaded, right now, for one time. This will allow the RuyiSDK team
|
|
34
|
+
to have more precise knowledge about the product's adoption. Thank you for
|
|
35
|
+
your support!
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def next_utc_weekday(wday: int, now: float | None = None) -> int:
|
|
40
|
+
t = time.gmtime(now)
|
|
41
|
+
mday_delta = wday - t.tm_wday
|
|
42
|
+
if mday_delta < 0:
|
|
43
|
+
mday_delta += 7
|
|
44
|
+
|
|
45
|
+
next_t = (
|
|
46
|
+
t.tm_year,
|
|
47
|
+
t.tm_mon,
|
|
48
|
+
t.tm_mday + mday_delta,
|
|
49
|
+
0, # tm_hour
|
|
50
|
+
0, # tm_min
|
|
51
|
+
0, # tm_sec
|
|
52
|
+
0, # tm_wday
|
|
53
|
+
0, # tm_yday
|
|
54
|
+
-1, # tm_isdst
|
|
55
|
+
)
|
|
56
|
+
return calendar.timegm(next_t)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_telemetry_mode(
|
|
60
|
+
gc: "GlobalConfig",
|
|
61
|
+
mode: str,
|
|
62
|
+
consent_time: datetime.datetime | None = None,
|
|
63
|
+
show_cli_feedback: bool = True,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Set telemetry mode and consent time (if applicable) in the user preference."""
|
|
66
|
+
|
|
67
|
+
from ..config.editor import ConfigEditor
|
|
68
|
+
from ..config import schema
|
|
69
|
+
|
|
70
|
+
logger = gc.logger
|
|
71
|
+
|
|
72
|
+
with ConfigEditor.work_on_user_local_config(gc) as ed:
|
|
73
|
+
ed.set_value((schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_MODE), mode)
|
|
74
|
+
|
|
75
|
+
if mode == "on":
|
|
76
|
+
if consent_time is None:
|
|
77
|
+
consent_time = datetime.datetime.now().astimezone()
|
|
78
|
+
ed.set_value(
|
|
79
|
+
(schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_UPLOAD_CONSENT),
|
|
80
|
+
consent_time,
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
ed.unset_value(
|
|
84
|
+
(schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_UPLOAD_CONSENT)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
ed.stage()
|
|
88
|
+
|
|
89
|
+
if not show_cli_feedback:
|
|
90
|
+
return
|
|
91
|
+
match mode:
|
|
92
|
+
case "on":
|
|
93
|
+
logger.I("telemetry data uploading is now enabled")
|
|
94
|
+
logger.I(
|
|
95
|
+
"you can opt out at any time by running [yellow]ruyi telemetry optout[/]"
|
|
96
|
+
)
|
|
97
|
+
case "local":
|
|
98
|
+
logger.I("telemetry mode is now set to local collection only")
|
|
99
|
+
logger.I(
|
|
100
|
+
"you can re-enable telemetry data uploading at any time by running [yellow]ruyi telemetry consent[/]"
|
|
101
|
+
)
|
|
102
|
+
logger.I(
|
|
103
|
+
"or opt out at any time by running [yellow]ruyi telemetry optout[/]"
|
|
104
|
+
)
|
|
105
|
+
case "off":
|
|
106
|
+
logger.I("telemetry data collection is now disabled")
|
|
107
|
+
logger.I(
|
|
108
|
+
"you can re-enable telemetry data uploads at any time by running [yellow]ruyi telemetry consent[/]"
|
|
109
|
+
)
|
|
110
|
+
case _:
|
|
111
|
+
raise ValueError(f"invalid telemetry mode: {mode}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TelemetryProvider:
|
|
115
|
+
def __init__(self, gc: "GlobalConfig") -> None:
|
|
116
|
+
self.state_root = pathlib.Path(gc.telemetry_root)
|
|
117
|
+
self.local_mode = gc.telemetry_mode == "local"
|
|
118
|
+
self.upload_consent_time = gc.telemetry_upload_consent_time
|
|
119
|
+
|
|
120
|
+
self._discard_events = False
|
|
121
|
+
self._gc = gc
|
|
122
|
+
self._is_first_run = False
|
|
123
|
+
self._stores: dict[TelemetryScope, TelemetryStore] = {}
|
|
124
|
+
self._upload_on_exit = False
|
|
125
|
+
|
|
126
|
+
# create the PM store
|
|
127
|
+
self.init_store(TelemetryScope(None))
|
|
128
|
+
# TODO: add real multi-repo support
|
|
129
|
+
self.init_store(TelemetryScope("ruyisdk"))
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def logger(self) -> RuyiLogger:
|
|
133
|
+
return self._gc.logger
|
|
134
|
+
|
|
135
|
+
def store(self, scope: TelemetryScope) -> TelemetryStore | None:
|
|
136
|
+
return self._stores.get(scope)
|
|
137
|
+
|
|
138
|
+
def init_store(self, scope: TelemetryScope) -> None:
|
|
139
|
+
store_root = self.state_root
|
|
140
|
+
api_url_fn: Callable[[], str | None] | None = None
|
|
141
|
+
if repo_name := scope.repo_name:
|
|
142
|
+
if repo_name != "ruyisdk":
|
|
143
|
+
raise NotImplementedError("multi-repo support not implemented yet")
|
|
144
|
+
store_root = store_root / "repos" / repo_name
|
|
145
|
+
|
|
146
|
+
def _f() -> str | None:
|
|
147
|
+
# access the repo attribute lazily to speed up CLI startup
|
|
148
|
+
return self._gc.repo.get_telemetry_api_url("repo")
|
|
149
|
+
|
|
150
|
+
api_url_fn = _f
|
|
151
|
+
else:
|
|
152
|
+
# configure the PM telemetry endpoint
|
|
153
|
+
api_url_fn = functools.partial(self._detect_pm_api_url, self._gc)
|
|
154
|
+
|
|
155
|
+
store = TelemetryStore(
|
|
156
|
+
self.logger,
|
|
157
|
+
scope,
|
|
158
|
+
store_root,
|
|
159
|
+
api_url_factory=api_url_fn,
|
|
160
|
+
)
|
|
161
|
+
self._stores[scope] = store
|
|
162
|
+
|
|
163
|
+
def _detect_pm_api_url(self, gc: "GlobalConfig") -> str | None:
|
|
164
|
+
url = FALLBACK_PM_TELEMETRY_ENDPOINT
|
|
165
|
+
cfg_src = "fallback"
|
|
166
|
+
if gc.override_pm_telemetry_url is not None:
|
|
167
|
+
cfg_src = "local config"
|
|
168
|
+
url = gc.override_pm_telemetry_url
|
|
169
|
+
else:
|
|
170
|
+
if repo_provided_url := gc.repo.get_telemetry_api_url("pm"):
|
|
171
|
+
cfg_src = "repo"
|
|
172
|
+
url = repo_provided_url
|
|
173
|
+
self.logger.D(
|
|
174
|
+
f"configured PM telemetry endpoint via {cfg_src}: {url or '(n/a)'}"
|
|
175
|
+
)
|
|
176
|
+
return url
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def installation_file(self) -> pathlib.Path:
|
|
180
|
+
return self.state_root / "installation.json"
|
|
181
|
+
|
|
182
|
+
def check_first_run_status(self) -> None:
|
|
183
|
+
"""Check if this is the first run of the application by checking if installation file exists.
|
|
184
|
+
This must be done before init_installation() is potentially called.
|
|
185
|
+
"""
|
|
186
|
+
self._is_first_run = not self.installation_file.exists()
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def is_first_run(self) -> bool:
|
|
190
|
+
"""Check if this is the first run of the application."""
|
|
191
|
+
return self._is_first_run
|
|
192
|
+
|
|
193
|
+
def init_installation(self, force_reinit: bool) -> NodeInfo | None:
|
|
194
|
+
installation_file = self.installation_file
|
|
195
|
+
if installation_file.exists() and not force_reinit:
|
|
196
|
+
return self.read_installation_data()
|
|
197
|
+
|
|
198
|
+
# either this is a fresh installation or we're forcing a refresh
|
|
199
|
+
installation_id = uuid.uuid4()
|
|
200
|
+
self.logger.D(
|
|
201
|
+
f"initializing telemetry data store, installation_id={installation_id.hex}"
|
|
202
|
+
)
|
|
203
|
+
self.state_root.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
|
|
205
|
+
# (over)write installation data
|
|
206
|
+
installation_data = gather_node_info(installation_id)
|
|
207
|
+
with open(installation_file, "wb") as fp:
|
|
208
|
+
fp.write(json.dumps(installation_data).encode("utf-8"))
|
|
209
|
+
return installation_data
|
|
210
|
+
|
|
211
|
+
def read_installation_data(self) -> NodeInfo | None:
|
|
212
|
+
with open(self.installation_file, "rb") as fp:
|
|
213
|
+
return cast(NodeInfo, json.load(fp))
|
|
214
|
+
|
|
215
|
+
def upload_weekday(self) -> int | None:
|
|
216
|
+
try:
|
|
217
|
+
installation_data = self.read_installation_data()
|
|
218
|
+
except FileNotFoundError:
|
|
219
|
+
# init the node info if it's gone
|
|
220
|
+
installation_data = self.init_installation(False)
|
|
221
|
+
|
|
222
|
+
if installation_data is None:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
report_uuid_prefix = int(installation_data["report_uuid"][:8], 16)
|
|
227
|
+
except ValueError:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
return report_uuid_prefix % 7 # 0 is Monday
|
|
231
|
+
|
|
232
|
+
def has_upload_consent(self, time_now: float | None = None) -> bool:
|
|
233
|
+
if self.upload_consent_time is None:
|
|
234
|
+
return False
|
|
235
|
+
if time_now is None:
|
|
236
|
+
time_now = time.time()
|
|
237
|
+
return self.upload_consent_time.timestamp() <= time_now
|
|
238
|
+
|
|
239
|
+
def print_telemetry_notice(self, for_cli_verbose_output: bool = False) -> None:
|
|
240
|
+
if self.local_mode:
|
|
241
|
+
if for_cli_verbose_output:
|
|
242
|
+
self.logger.I(
|
|
243
|
+
"telemetry mode is [green]local[/]: local data collection only, no uploads"
|
|
244
|
+
)
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
now = time.time()
|
|
248
|
+
if self.has_upload_consent(now) and not for_cli_verbose_output:
|
|
249
|
+
self.logger.D("user has consented to telemetry upload")
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
upload_wday = self.upload_weekday()
|
|
253
|
+
if upload_wday is None:
|
|
254
|
+
return
|
|
255
|
+
upload_wday_name = calendar.day_name[upload_wday]
|
|
256
|
+
|
|
257
|
+
next_upload_day_ts = next_utc_weekday(upload_wday, now)
|
|
258
|
+
next_upload_day = time.localtime(next_upload_day_ts)
|
|
259
|
+
next_upload_day_end = time.localtime(next_upload_day_ts + 86400)
|
|
260
|
+
next_upload_day_str = time.strftime("%Y-%m-%d %H:%M:%S %z", next_upload_day)
|
|
261
|
+
next_upload_day_end_str = time.strftime(
|
|
262
|
+
"%Y-%m-%d %H:%M:%S %z", next_upload_day_end
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
today_is_upload_day = self.is_upload_day(now)
|
|
266
|
+
if for_cli_verbose_output:
|
|
267
|
+
self.logger.I(
|
|
268
|
+
"telemetry mode is [green]on[/]: data is collected and periodically uploaded"
|
|
269
|
+
)
|
|
270
|
+
self.logger.I(
|
|
271
|
+
f"non-tracking usage information will be uploaded to RuyiSDK-managed servers [bold green]every {upload_wday_name}[/]"
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
self.logger.W(
|
|
275
|
+
f"this [yellow]ruyi[/] installation has telemetry mode set to [yellow]on[/], and [bold]will upload non-tracking usage information to RuyiSDK-managed servers[/] [bold green]every {upload_wday_name}[/]"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if today_is_upload_day:
|
|
279
|
+
for scope, store in self._stores.items():
|
|
280
|
+
has_uploaded_today = self.has_uploaded_today(scope, now)
|
|
281
|
+
if has_uploaded_today:
|
|
282
|
+
if last_upload_time := store.last_upload_timestamp:
|
|
283
|
+
last_upload_time_str = time.strftime(
|
|
284
|
+
"%Y-%m-%d %H:%M:%S %z", time.localtime(last_upload_time)
|
|
285
|
+
)
|
|
286
|
+
self.logger.I(
|
|
287
|
+
f"scope {scope}: usage information has already been uploaded today at {last_upload_time_str}"
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
self.logger.I(
|
|
291
|
+
f"scope {scope}: usage information has already been uploaded sometime today"
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
self.logger.I(
|
|
295
|
+
f"scope {scope}: the next upload will happen [bold green]today[/] if not already"
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
self.logger.I(
|
|
299
|
+
f"the next upload will happen anytime [yellow]ruyi[/] is executed between [bold green]{next_upload_day_str}[/] and [bold green]{next_upload_day_end_str}[/]"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if not for_cli_verbose_output:
|
|
303
|
+
self.logger.I("in order to hide this banner:")
|
|
304
|
+
self.logger.I("- opt out with [yellow]ruyi telemetry optout[/]")
|
|
305
|
+
self.logger.I("- or give consent with [yellow]ruyi telemetry consent[/]")
|
|
306
|
+
|
|
307
|
+
def next_upload_day(self, time_now: float | None = None) -> int | None:
|
|
308
|
+
upload_wday = self.upload_weekday()
|
|
309
|
+
if upload_wday is None:
|
|
310
|
+
return None
|
|
311
|
+
return next_utc_weekday(upload_wday, time_now)
|
|
312
|
+
|
|
313
|
+
def is_upload_day(self, time_now: float | None = None) -> bool:
|
|
314
|
+
if time_now is None:
|
|
315
|
+
time_now = time.time()
|
|
316
|
+
if upload_day := self.next_upload_day(time_now):
|
|
317
|
+
return upload_day <= time_now
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
def has_uploaded_today(
|
|
321
|
+
self,
|
|
322
|
+
scope: TelemetryScope,
|
|
323
|
+
time_now: float | None = None,
|
|
324
|
+
) -> bool:
|
|
325
|
+
if time_now is None:
|
|
326
|
+
time_now = time.time()
|
|
327
|
+
if upload_day := self.next_upload_day(time_now):
|
|
328
|
+
upload_day_end = upload_day + 86400
|
|
329
|
+
store = self.store(scope)
|
|
330
|
+
if store is None:
|
|
331
|
+
return False
|
|
332
|
+
if last_upload_time := store.last_upload_timestamp:
|
|
333
|
+
return upload_day <= last_upload_time < upload_day_end
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
def record(self, scope: TelemetryScope, kind: str, **params: object) -> None:
|
|
337
|
+
if store := self.store(scope):
|
|
338
|
+
return store.record(kind, **params)
|
|
339
|
+
self.logger.D(f"no telemetry store for scope {scope}, discarding event")
|
|
340
|
+
|
|
341
|
+
def discard_events(self, v: bool = True) -> None:
|
|
342
|
+
self._discard_events = v
|
|
343
|
+
|
|
344
|
+
def flush(self, *, upload_now: bool = False) -> None:
|
|
345
|
+
now = time.time()
|
|
346
|
+
|
|
347
|
+
# We may be self-uninstalling and purging all state data, and in this
|
|
348
|
+
# case we don't want to record anything (thus re-creating directories).
|
|
349
|
+
if self._discard_events:
|
|
350
|
+
self.logger.D("discarding collected telemetry data")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
self.logger.D("flushing telemetry to persistent store")
|
|
354
|
+
|
|
355
|
+
for scope, store in self._stores.items():
|
|
356
|
+
store.persist(now)
|
|
357
|
+
|
|
358
|
+
# try to upload if forced (upload_now or _upload_on_exit), or:
|
|
359
|
+
#
|
|
360
|
+
# * we're not in local mode
|
|
361
|
+
# * today is the day
|
|
362
|
+
# * we haven't uploaded today
|
|
363
|
+
if not (upload_now or self._upload_on_exit) and (
|
|
364
|
+
self.local_mode
|
|
365
|
+
or not self.is_upload_day(now)
|
|
366
|
+
or self.has_uploaded_today(scope, now)
|
|
367
|
+
):
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
self.prepare_data_for_upload(store)
|
|
371
|
+
self.upload_staged_payloads(store)
|
|
372
|
+
|
|
373
|
+
def prepare_data_for_upload(self, store: TelemetryStore) -> None:
|
|
374
|
+
installation_data: NodeInfo | None = None
|
|
375
|
+
if store.scope.is_pm:
|
|
376
|
+
try:
|
|
377
|
+
installation_data = self.read_installation_data()
|
|
378
|
+
except FileNotFoundError:
|
|
379
|
+
# should not happen due to is_upload_day() initializing it for us
|
|
380
|
+
# beforehand, but proceed without node info nonetheless
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
return store.prepare_data_for_upload(installation_data)
|
|
384
|
+
|
|
385
|
+
def upload_staged_payloads(self, store: TelemetryStore) -> None:
|
|
386
|
+
if self.local_mode:
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
return store.upload_staged_payloads()
|
|
390
|
+
|
|
391
|
+
def maybe_prompt_for_first_run_upload(self) -> None:
|
|
392
|
+
"""
|
|
393
|
+
Ask whether the user consents to a first-run telemetry upload when
|
|
394
|
+
running for the first time (OOBE) and with an interactive stdin.
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
# Only prompt if this is first run and stdin is a TTY
|
|
398
|
+
if not (self.is_first_run and sys.stdin.isatty()):
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
from ..cli import user_input
|
|
402
|
+
|
|
403
|
+
self.logger.I(FIRST_RUN_PROMPT)
|
|
404
|
+
if not user_input.ask_for_yesno_confirmation(
|
|
405
|
+
self.logger,
|
|
406
|
+
"Do you agree?",
|
|
407
|
+
True,
|
|
408
|
+
):
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
self._upload_on_exit = True
|
ruyi/telemetry/scope.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import Literal, TypeAlias, TypeGuard
|
|
2
|
+
|
|
3
|
+
TelemetryScopeConfig: TypeAlias = Literal["pm"] | Literal["repo"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def is_telemetry_scope_config(x: object) -> TypeGuard[TelemetryScopeConfig]:
|
|
7
|
+
if not isinstance(x, str):
|
|
8
|
+
return False
|
|
9
|
+
match x:
|
|
10
|
+
case "pm" | "repo":
|
|
11
|
+
return True
|
|
12
|
+
case _:
|
|
13
|
+
return False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TelemetryScope:
|
|
17
|
+
def __init__(self, repo_name: str | None) -> None:
|
|
18
|
+
self._repo_name = repo_name
|
|
19
|
+
|
|
20
|
+
def __repr__(self) -> str:
|
|
21
|
+
return f"TelemetryScope(repo_name={self._repo_name})"
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
if self._repo_name:
|
|
25
|
+
return f"repo:{self._repo_name}"
|
|
26
|
+
return "pm"
|
|
27
|
+
|
|
28
|
+
def __hash__(self) -> int:
|
|
29
|
+
# behave like the inner field
|
|
30
|
+
return hash(self._repo_name)
|
|
31
|
+
|
|
32
|
+
def __eq__(self, value: object) -> bool:
|
|
33
|
+
if not isinstance(value, TelemetryScope):
|
|
34
|
+
return False
|
|
35
|
+
return self._repo_name == value._repo_name
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def repo_name(self) -> str | None:
|
|
39
|
+
return self._repo_name
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_pm(self) -> bool:
|
|
43
|
+
return self._repo_name is None
|
ruyi/telemetry/store.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from functools import cached_property
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from typing import Callable, Final, Iterable
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
from ..log import RuyiLogger
|
|
11
|
+
from ..utils.url import urljoin_for_sure
|
|
12
|
+
from .aggregate import UploadPayload, aggregate_events
|
|
13
|
+
from .event import TelemetryEvent, is_telemetry_event
|
|
14
|
+
from .node_info import NodeInfo
|
|
15
|
+
from .scope import TelemetryScope
|
|
16
|
+
|
|
17
|
+
# e.g. "run.202410201845.d06ca5d668e64fec833ed3e6eb926a2c.ndjson"
|
|
18
|
+
RE_RAW_EVENT_FILENAME: Final = re.compile(
|
|
19
|
+
r"^run\.(?P<time_bucket>\d{12})\.(?P<uuid>[0-9a-f]{32})\.ndjson$"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_time_bucket(timestamp: int | float | time.struct_time | None = None) -> str:
|
|
24
|
+
if timestamp is None:
|
|
25
|
+
return time.strftime("%Y%m%d%H%M")
|
|
26
|
+
elif isinstance(timestamp, float) or isinstance(timestamp, int):
|
|
27
|
+
timestamp = time.localtime(timestamp)
|
|
28
|
+
return time.strftime("%Y%m%d%H%M", timestamp)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def time_bucket_from_filename(filename: str) -> str | None:
|
|
32
|
+
if m := RE_RAW_EVENT_FILENAME.match(filename):
|
|
33
|
+
return m.group("time_bucket")
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TelemetryStore:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
logger: RuyiLogger,
|
|
41
|
+
scope: TelemetryScope,
|
|
42
|
+
store_root: pathlib.Path,
|
|
43
|
+
api_url: str | None = None,
|
|
44
|
+
api_url_factory: Callable[[], str | None] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._logger = logger
|
|
47
|
+
self.scope = scope
|
|
48
|
+
self.store_root = store_root
|
|
49
|
+
self._api_url = api_url
|
|
50
|
+
self._api_url_factory = api_url_factory
|
|
51
|
+
|
|
52
|
+
self._events: list[TelemetryEvent] = []
|
|
53
|
+
|
|
54
|
+
@cached_property
|
|
55
|
+
def api_url(self) -> str | None:
|
|
56
|
+
if u := self._api_url:
|
|
57
|
+
return u
|
|
58
|
+
if f := self._api_url_factory:
|
|
59
|
+
return f()
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def raw_events_dir(self) -> pathlib.Path:
|
|
64
|
+
return self.store_root / "raw"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def upload_stage_dir(self) -> pathlib.Path:
|
|
68
|
+
return self.store_root / "staged"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def uploaded_dir(self) -> pathlib.Path:
|
|
72
|
+
return self.store_root / "uploaded"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def last_upload_marker_file(self) -> pathlib.Path:
|
|
76
|
+
return self.store_root / ".stamp-last-upload"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def last_upload_timestamp(self) -> float | None:
|
|
80
|
+
try:
|
|
81
|
+
return self.last_upload_marker_file.stat().st_mtime
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def record_upload_timestamp(self, time_now: float | None = None) -> None:
|
|
86
|
+
if time_now is None:
|
|
87
|
+
time_now = time.time()
|
|
88
|
+
f = self.last_upload_marker_file
|
|
89
|
+
f.touch()
|
|
90
|
+
os.utime(f, (time_now, time_now))
|
|
91
|
+
|
|
92
|
+
def record(self, kind: str, **params: object) -> None:
|
|
93
|
+
self._events.append({"fmt": 1, "kind": kind, "params": params})
|
|
94
|
+
|
|
95
|
+
def discard_events(self, v: bool = True) -> None:
|
|
96
|
+
self._discard_events = v
|
|
97
|
+
|
|
98
|
+
def persist(self, now: float | None = None) -> None:
|
|
99
|
+
if not self._events:
|
|
100
|
+
self._logger.D(f"scope {self.scope}: no event to persist")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
now = time.time() if now is None else now
|
|
104
|
+
|
|
105
|
+
self._logger.D(f"scope {self.scope}: flushing telemetry to persistent store")
|
|
106
|
+
|
|
107
|
+
raw_events_dir = self.raw_events_dir
|
|
108
|
+
raw_events_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
|
|
110
|
+
# TODO: for now it is safe to not lock, because flush() is only ever
|
|
111
|
+
# called at program exit time
|
|
112
|
+
rough_time = get_time_bucket(now)
|
|
113
|
+
rand = uuid.uuid4().hex
|
|
114
|
+
batch_events_file = raw_events_dir / f"run.{rough_time}.{rand}.ndjson"
|
|
115
|
+
with open(batch_events_file, "wb") as fp:
|
|
116
|
+
for e in self._events:
|
|
117
|
+
payload = json.dumps(e)
|
|
118
|
+
fp.write(payload.encode("utf-8"))
|
|
119
|
+
fp.write(b"\n")
|
|
120
|
+
|
|
121
|
+
self._logger.D(
|
|
122
|
+
f"scope {self.scope}: persisted {len(self._events)} telemetry event(s)"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def upload(self, installation_data: NodeInfo | None = None) -> None:
|
|
126
|
+
self.prepare_data_for_upload(installation_data)
|
|
127
|
+
self.upload_staged_payloads()
|
|
128
|
+
|
|
129
|
+
def read_back_raw_events(self) -> Iterable[TelemetryEvent]:
|
|
130
|
+
try:
|
|
131
|
+
for f in self.raw_events_dir.glob("run.*.ndjson"):
|
|
132
|
+
time_bucket = time_bucket_from_filename(f.name)
|
|
133
|
+
with open(f, "r", encoding="utf-8", newline=None) as fp:
|
|
134
|
+
for line in fp:
|
|
135
|
+
try:
|
|
136
|
+
obj = json.loads(line)
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
# losing some malformed telemetry events is okay
|
|
139
|
+
continue
|
|
140
|
+
if not is_telemetry_event(obj):
|
|
141
|
+
# ditto
|
|
142
|
+
continue
|
|
143
|
+
if time_bucket is not None and "time_bucket" not in obj:
|
|
144
|
+
obj["time_bucket"] = time_bucket
|
|
145
|
+
yield obj
|
|
146
|
+
except FileNotFoundError:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
def purge_raw_events(self) -> None:
|
|
150
|
+
files = list(self.raw_events_dir.glob("run.*.ndjson"))
|
|
151
|
+
for f in files:
|
|
152
|
+
f.unlink(missing_ok=True)
|
|
153
|
+
|
|
154
|
+
def gen_upload_staging_filename(self, nonce: str) -> pathlib.Path:
|
|
155
|
+
return self.upload_stage_dir / f"staged.{nonce}.json"
|
|
156
|
+
|
|
157
|
+
def prepare_data_for_upload(self, installation_data: NodeInfo | None) -> None:
|
|
158
|
+
# import ruyi.version here because this package is on the CLI startup
|
|
159
|
+
# critical path, and version probing is costly there
|
|
160
|
+
from ..version import RUYI_SEMVER
|
|
161
|
+
|
|
162
|
+
aggregate_data = list(aggregate_events(self.read_back_raw_events()))
|
|
163
|
+
|
|
164
|
+
payload_nonce = uuid.uuid4().hex # for server-side dedup purposes
|
|
165
|
+
payload: UploadPayload = {
|
|
166
|
+
"fmt": 1,
|
|
167
|
+
"nonce": payload_nonce,
|
|
168
|
+
"ruyi_version": str(RUYI_SEMVER),
|
|
169
|
+
"events": aggregate_data,
|
|
170
|
+
}
|
|
171
|
+
if installation_data is not None:
|
|
172
|
+
payload["installation"] = installation_data
|
|
173
|
+
|
|
174
|
+
dest_path = self.gen_upload_staging_filename(payload_nonce)
|
|
175
|
+
self.upload_stage_dir.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
dest_path.write_text(json.dumps(payload), encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
self.purge_raw_events()
|
|
179
|
+
|
|
180
|
+
def upload_staged_payloads(self) -> None:
|
|
181
|
+
if not self.api_url:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
staged_payloads = list(self.upload_stage_dir.glob("staged.*.json"))
|
|
186
|
+
except FileNotFoundError:
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
self.uploaded_dir.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
except OSError:
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
for f in staged_payloads:
|
|
195
|
+
self.upload_one_staged_payload(f, self.api_url)
|
|
196
|
+
|
|
197
|
+
self.record_upload_timestamp()
|
|
198
|
+
|
|
199
|
+
def upload_one_staged_payload(
|
|
200
|
+
self,
|
|
201
|
+
f: pathlib.Path,
|
|
202
|
+
endpoint: str,
|
|
203
|
+
) -> None:
|
|
204
|
+
# import ruyi.version here because this package is on the CLI startup
|
|
205
|
+
# critical path, and version probing is costly there
|
|
206
|
+
from ..version import RUYI_USER_AGENT
|
|
207
|
+
|
|
208
|
+
api_path = urljoin_for_sure(endpoint, "upload-v1")
|
|
209
|
+
self._logger.D(f"scope {self.scope}: about to upload payload {f} to {api_path}")
|
|
210
|
+
|
|
211
|
+
import requests
|
|
212
|
+
|
|
213
|
+
resp = requests.post(
|
|
214
|
+
api_path,
|
|
215
|
+
data=f.read_bytes(),
|
|
216
|
+
headers={"User-Agent": RUYI_USER_AGENT},
|
|
217
|
+
allow_redirects=True,
|
|
218
|
+
timeout=5,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if not (200 <= resp.status_code < 300):
|
|
222
|
+
self._logger.D(
|
|
223
|
+
f"scope {self.scope}: telemetry upload failed: status code {resp.status_code}, content {resp.content.decode('utf-8', 'replace')}"
|
|
224
|
+
)
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
self._logger.D(
|
|
228
|
+
f"scope {self.scope}: telemetry upload ok: status code {resp.status_code}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# move to completed dir
|
|
232
|
+
# TODO: rotation
|
|
233
|
+
try:
|
|
234
|
+
f.rename(self.uploaded_dir / f.name)
|
|
235
|
+
except OSError as e:
|
|
236
|
+
self._logger.D(
|
|
237
|
+
f"scope {self.scope}: failed to move uploaded payload away: {e}"
|
|
238
|
+
)
|