ruyi 0.44.0a20251118__py3-none-any.whl → 0.45.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.
Files changed (55) hide show
  1. ruyi/__main__.py +16 -4
  2. ruyi/cli/cmd.py +6 -5
  3. ruyi/cli/config_cli.py +14 -11
  4. ruyi/cli/main.py +34 -17
  5. ruyi/cli/oobe.py +10 -10
  6. ruyi/cli/self_cli.py +49 -36
  7. ruyi/cli/user_input.py +42 -12
  8. ruyi/cli/version_cli.py +11 -5
  9. ruyi/config/__init__.py +30 -10
  10. ruyi/config/errors.py +19 -7
  11. ruyi/device/provision.py +116 -55
  12. ruyi/device/provision_cli.py +6 -3
  13. ruyi/i18n/__init__.py +129 -0
  14. ruyi/log/__init__.py +6 -5
  15. ruyi/mux/runtime.py +19 -6
  16. ruyi/mux/venv/maker.py +93 -35
  17. ruyi/mux/venv/venv_cli.py +13 -10
  18. ruyi/pluginhost/plugin_cli.py +4 -3
  19. ruyi/resource_bundle/__init__.py +22 -8
  20. ruyi/resource_bundle/__main__.py +6 -5
  21. ruyi/resource_bundle/data.py +13 -9
  22. ruyi/ruyipkg/admin_checksum.py +4 -1
  23. ruyi/ruyipkg/admin_cli.py +9 -6
  24. ruyi/ruyipkg/augmented_pkg.py +15 -14
  25. ruyi/ruyipkg/checksum.py +8 -2
  26. ruyi/ruyipkg/distfile.py +33 -9
  27. ruyi/ruyipkg/entity.py +12 -2
  28. ruyi/ruyipkg/entity_cli.py +20 -12
  29. ruyi/ruyipkg/entity_provider.py +11 -2
  30. ruyi/ruyipkg/fetcher.py +38 -9
  31. ruyi/ruyipkg/install.py +163 -64
  32. ruyi/ruyipkg/install_cli.py +18 -15
  33. ruyi/ruyipkg/list.py +27 -20
  34. ruyi/ruyipkg/list_cli.py +12 -7
  35. ruyi/ruyipkg/news.py +23 -11
  36. ruyi/ruyipkg/news_cli.py +10 -7
  37. ruyi/ruyipkg/profile_cli.py +8 -2
  38. ruyi/ruyipkg/repo.py +22 -8
  39. ruyi/ruyipkg/unpack.py +42 -8
  40. ruyi/ruyipkg/unpack_method.py +5 -1
  41. ruyi/ruyipkg/update_cli.py +8 -3
  42. ruyi/telemetry/aggregate.py +5 -0
  43. ruyi/telemetry/provider.py +292 -105
  44. ruyi/telemetry/store.py +68 -15
  45. ruyi/telemetry/telemetry_cli.py +32 -13
  46. ruyi/utils/git.py +18 -11
  47. ruyi/utils/prereqs.py +10 -5
  48. ruyi/utils/ssl_patch.py +2 -1
  49. ruyi/version.py +9 -3
  50. {ruyi-0.44.0a20251118.dist-info → ruyi-0.45.0.dist-info}/METADATA +4 -2
  51. ruyi-0.45.0.dist-info/RECORD +103 -0
  52. {ruyi-0.44.0a20251118.dist-info → ruyi-0.45.0.dist-info}/WHEEL +1 -1
  53. ruyi-0.44.0a20251118.dist-info/RECORD +0 -102
  54. {ruyi-0.44.0a20251118.dist-info → ruyi-0.45.0.dist-info}/entry_points.txt +0 -0
  55. {ruyi-0.44.0a20251118.dist-info → ruyi-0.45.0.dist-info}/licenses/LICENSE-Apache.txt +0 -0
@@ -7,6 +7,7 @@ import time
7
7
  from typing import Callable, TYPE_CHECKING, cast
8
8
  import uuid
9
9
 
10
+ from ..i18n import _, d_
10
11
  from ..log import RuyiLogger
11
12
  from ..utils.node_info import NodeInfo, gather_node_info
12
13
  from .scope import TelemetryScope
@@ -18,23 +19,32 @@ if TYPE_CHECKING:
18
19
 
19
20
  FALLBACK_PM_TELEMETRY_ENDPOINT = "https://api.ruyisdk.cn/telemetry/pm/"
20
21
 
21
- TELEMETRY_CONSENT_AND_UPLOAD_DESC = """
22
- RuyiSDK collects anonymized usage data locally to help us improve the product.
22
+ TELEMETRY_CONSENT_AND_UPLOAD_DESC = d_(
23
+ """
24
+ RuyiSDK collects minimal usage data in the form of just a version number of
25
+ the running [yellow]ruyi[/], to help us improve the product. With your consent,
26
+ RuyiSDK may also collect additional non-tracking usage data to be sent
27
+ periodically. The data will be recorded and processed by RuyiSDK team-managed
28
+ servers located in the Chinese mainland.
23
29
 
24
30
  [green]By default, nothing leaves your machine[/], and you can also turn off usage data
25
- collection completely. Only with your explicit permission can [yellow]ruyi[/] upload
26
- collected telemetry, periodically to RuyiSDK team-managed servers located in
27
- the Chinese mainland. You can change this setting at any time by running
31
+ collection completely. Only with your explicit permission can [yellow]ruyi[/] collect and
32
+ upload more usage data. You can change this setting at any time by running
28
33
  [yellow]ruyi telemetry consent[/], [yellow]ruyi telemetry local[/], or [yellow]ruyi telemetry optout[/].
29
34
 
30
- If you enable uploads now, we'll also send a one-time report from this [yellow]ruyi[/]
31
- installation so the RuyiSDK team can better understand adoption. Thank you for
32
- helping us build a better experience!
35
+ We'll also send a one-time report from this [yellow]ruyi[/] installation so the RuyiSDK
36
+ team can better understand adoption. If you choose to opt out, this will be the
37
+ only data to be ever uploaded, without any tracking ID being generated or kept.
38
+ Thank you for helping us build a better experience!
33
39
  """
34
- TELEMETRY_CONSENT_AND_UPLOAD_PROMPT = (
35
- "Enable telemetry uploads and send a one-time report now?"
36
40
  )
37
- TELEMETRY_OPTOUT_PROMPT = "\nDo you want to disable telemetry entirely?"
41
+ TELEMETRY_CONSENT_AND_UPLOAD_PROMPT = d_(
42
+ "Do you agree to have usage data periodically uploaded?"
43
+ )
44
+ TELEMETRY_OPTOUT_PROMPT = d_("\nDo you want to opt out of telemetry entirely?")
45
+ MALFORMED_TELEMETRY_STATE_MSG = d_(
46
+ "malformed telemetry state: unable to determine upload weekday, nothing will be uploaded"
47
+ )
38
48
 
39
49
 
40
50
  def next_utc_weekday(wday: int, now: float | None = None) -> int:
@@ -108,29 +118,35 @@ def set_telemetry_mode(
108
118
  return
109
119
  match mode:
110
120
  case "on":
111
- logger.I("telemetry data uploading is now enabled")
121
+ logger.I(_("telemetry data uploading is now enabled"))
112
122
  logger.I(
113
- "you can opt out at any time by running [yellow]ruyi telemetry optout[/]"
123
+ _(
124
+ "you can opt out at any time by running [yellow]ruyi telemetry optout[/]"
125
+ )
114
126
  )
115
127
  case "local":
116
- logger.I("telemetry mode is now set to local collection only")
128
+ logger.I(_("telemetry mode is now set to local collection only"))
117
129
  logger.I(
118
- "you can re-enable telemetry data uploading at any time by running [yellow]ruyi telemetry consent[/]"
130
+ _(
131
+ "you can re-enable telemetry data uploading at any time by running [yellow]ruyi telemetry consent[/]"
132
+ )
119
133
  )
120
134
  logger.I(
121
- "or opt out at any time by running [yellow]ruyi telemetry optout[/]"
135
+ _("or opt out at any time by running [yellow]ruyi telemetry optout[/]")
122
136
  )
123
137
  case "off":
124
- logger.I("telemetry data collection is now disabled")
138
+ logger.I(_("telemetry data collection is now disabled"))
125
139
  logger.I(
126
- "you can re-enable telemetry data uploads at any time by running [yellow]ruyi telemetry consent[/]"
140
+ _(
141
+ "you can re-enable telemetry data uploads at any time by running [yellow]ruyi telemetry consent[/]"
142
+ )
127
143
  )
128
144
  case _:
129
145
  raise ValueError(f"invalid telemetry mode: {mode}")
130
146
 
131
147
 
132
148
  class TelemetryProvider:
133
- def __init__(self, gc: "GlobalConfig") -> None:
149
+ def __init__(self, gc: "GlobalConfig", minimal: bool) -> None:
134
150
  self.state_root = pathlib.Path(gc.telemetry_root)
135
151
 
136
152
  self._discard_events = False
@@ -138,6 +154,7 @@ class TelemetryProvider:
138
154
  self._is_first_run = False
139
155
  self._stores: dict[TelemetryScope, TelemetryStore] = {}
140
156
  self._upload_on_exit = False
157
+ self.minimal = minimal
141
158
 
142
159
  # create the PM store
143
160
  self.init_store(TelemetryScope(None))
@@ -154,6 +171,8 @@ class TelemetryProvider:
154
171
 
155
172
  @property
156
173
  def upload_consent_time(self) -> datetime.datetime | None:
174
+ if self.minimal or self.local_mode:
175
+ return None
157
176
  return self._gc.telemetry_upload_consent_time
158
177
 
159
178
  def store(self, scope: TelemetryScope) -> TelemetryStore | None:
@@ -203,11 +222,18 @@ class TelemetryProvider:
203
222
  def installation_file(self) -> pathlib.Path:
204
223
  return self.state_root / "installation.json"
205
224
 
225
+ @property
226
+ def minimal_installation_marker_file(self) -> pathlib.Path:
227
+ return self.state_root / "minimal-installation-marker"
228
+
206
229
  def check_first_run_status(self) -> None:
207
230
  """Check if this is the first run of the application by checking if installation file exists.
208
231
  This must be done before init_installation() is potentially called.
209
232
  """
210
- self._is_first_run = not self.installation_file.exists()
233
+ self._is_first_run = (
234
+ not self.installation_file.exists()
235
+ and not self.minimal_installation_marker_file.exists()
236
+ )
211
237
 
212
238
  @property
213
239
  def is_first_run(self) -> bool:
@@ -215,9 +241,15 @@ class TelemetryProvider:
215
241
  return self._is_first_run
216
242
 
217
243
  def init_installation(self, force_reinit: bool) -> NodeInfo | None:
244
+ if self.minimal:
245
+ # be extra safe by not reading or writing installation data at all
246
+ # in minimal mode
247
+ self._init_minimal_installation_marker(force_reinit)
248
+ return None
249
+
218
250
  installation_file = self.installation_file
219
251
  if installation_file.exists() and not force_reinit:
220
- return self.read_installation_data()
252
+ return self._read_installation_data()
221
253
 
222
254
  # either this is a fresh installation or we're forcing a refresh
223
255
  installation_id = uuid.uuid4()
@@ -232,13 +264,26 @@ class TelemetryProvider:
232
264
  fp.write(json.dumps(installation_data).encode("utf-8"))
233
265
  return installation_data
234
266
 
235
- def read_installation_data(self) -> NodeInfo | None:
267
+ def _init_minimal_installation_marker(self, force_reinit: bool) -> None:
268
+ if self.minimal_installation_marker_file.exists() and not force_reinit:
269
+ return
270
+
271
+ self.logger.D("initializing minimal installation marker file")
272
+ self.state_root.mkdir(parents=True, exist_ok=True)
273
+
274
+ # just touch the file
275
+ self.minimal_installation_marker_file.touch()
276
+
277
+ def _read_installation_data(self) -> NodeInfo | None:
236
278
  with open(self.installation_file, "rb") as fp:
237
279
  return cast(NodeInfo, json.load(fp))
238
280
 
239
- def upload_weekday(self) -> int | None:
281
+ def _upload_weekday(self) -> int | None:
282
+ if self.minimal:
283
+ return None
284
+
240
285
  try:
241
- installation_data = self.read_installation_data()
286
+ installation_data = self._read_installation_data()
242
287
  except FileNotFoundError:
243
288
  # init the node info if it's gone
244
289
  installation_data = self.init_installation(False)
@@ -253,120 +298,240 @@ class TelemetryProvider:
253
298
 
254
299
  return report_uuid_prefix % 7 # 0 is Monday
255
300
 
256
- def has_upload_consent(self, time_now: float | None = None) -> bool:
301
+ def _has_upload_consent(self, time_now: float | None = None) -> bool:
257
302
  if self.upload_consent_time is None:
258
303
  return False
259
304
  if time_now is None:
260
305
  time_now = time.time()
261
306
  return self.upload_consent_time.timestamp() <= time_now
262
307
 
263
- def print_telemetry_notice(self, for_cli_verbose_output: bool = False) -> None:
264
- if self.local_mode:
265
- if for_cli_verbose_output:
266
- self.logger.I(
267
- "telemetry mode is [green]local[/]: local data collection only, no uploads"
268
- )
269
- return
270
-
271
- now = time.time()
272
- if self.has_upload_consent(now) and not for_cli_verbose_output:
273
- self.logger.D("user has consented to telemetry upload")
274
- return
275
-
276
- upload_wday = self.upload_weekday()
277
- if upload_wday is None:
278
- return
279
- upload_wday_name = calendar.day_name[upload_wday]
280
-
308
+ def _print_upload_schedule_notice(self, upload_wday: int, now: float) -> None:
281
309
  next_upload_day_ts = next_utc_weekday(upload_wday, now)
282
310
  next_upload_day = time.localtime(next_upload_day_ts)
283
311
  next_upload_day_end = time.localtime(next_upload_day_ts + 86400)
284
312
  next_upload_day_str = time.strftime("%Y-%m-%d %H:%M:%S %z", next_upload_day)
285
313
  next_upload_day_end_str = time.strftime(
286
- "%Y-%m-%d %H:%M:%S %z", next_upload_day_end
314
+ "%Y-%m-%d %H:%M:%S %z",
315
+ next_upload_day_end,
287
316
  )
288
317
 
289
- today_is_upload_day = self.is_upload_day(now)
290
- if for_cli_verbose_output:
291
- self.logger.I(
292
- "telemetry mode is [green]on[/]: data is collected and periodically uploaded"
293
- )
294
- self.logger.I(
295
- f"non-tracking usage information will be uploaded to RuyiSDK-managed servers [bold green]every {upload_wday_name}[/]"
296
- )
297
- else:
298
- self.logger.W(
299
- 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}[/]"
300
- )
301
-
302
- if today_is_upload_day:
318
+ if self._is_upload_day(now):
303
319
  for scope, store in self._stores.items():
304
- has_uploaded_today = self.has_uploaded_today(scope, now)
320
+ has_uploaded_today = self._has_uploaded_today(
321
+ store.last_upload_timestamp,
322
+ now,
323
+ )
305
324
  if has_uploaded_today:
306
325
  if last_upload_time := store.last_upload_timestamp:
307
326
  last_upload_time_str = time.strftime(
308
327
  "%Y-%m-%d %H:%M:%S %z", time.localtime(last_upload_time)
309
328
  )
310
329
  self.logger.I(
311
- f"scope {scope}: usage information has already been uploaded today at {last_upload_time_str}"
330
+ _(
331
+ "scope {scope}: usage information has already been uploaded today at {last_upload_time_str}"
332
+ ).format(
333
+ scope=scope,
334
+ last_upload_time_str=last_upload_time_str,
335
+ )
312
336
  )
313
337
  else:
314
338
  self.logger.I(
315
- f"scope {scope}: usage information has already been uploaded sometime today"
339
+ _(
340
+ "scope {scope}: usage information has already been uploaded sometime today"
341
+ ).format(
342
+ scope=scope,
343
+ )
316
344
  )
317
345
  else:
318
346
  self.logger.I(
319
- f"scope {scope}: the next upload will happen [bold green]today[/] if not already"
347
+ _(
348
+ "scope {scope}: the next upload will happen [bold green]today[/] if not already"
349
+ ).format(
350
+ scope=scope,
351
+ )
320
352
  )
321
353
  else:
322
354
  self.logger.I(
323
- 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}[/]"
355
+ _("the next upload will happen anytime [yellow]ruyi[/] is executed:")
356
+ )
357
+ self.logger.I(
358
+ _(
359
+ " - between [bold green]{time_start}[/] and [bold green]{time_end}[/]"
360
+ ).format(
361
+ time_start=next_upload_day_str,
362
+ time_end=next_upload_day_end_str,
363
+ )
324
364
  )
365
+ self.logger.I(_(" - or if the last upload is more than a week ago"))
366
+
367
+ def print_telemetry_notice(self, for_cli_verbose_output: bool = False) -> None:
368
+ if self.minimal:
369
+ if for_cli_verbose_output:
370
+ self.logger.I(
371
+ _(
372
+ "telemetry mode is [green]off[/]: nothing is collected or uploaded after the first run"
373
+ )
374
+ )
375
+ return
376
+
377
+ now = time.time()
378
+ upload_wday = self._upload_weekday()
379
+ if upload_wday is None:
380
+ if for_cli_verbose_output:
381
+ self.logger.W(MALFORMED_TELEMETRY_STATE_MSG)
382
+ else:
383
+ self.logger.D(MALFORMED_TELEMETRY_STATE_MSG)
384
+ return
385
+
386
+ upload_wday_name = self._gc.babel_locale.days["format"]["wide"][upload_wday]
387
+
388
+ if self.local_mode:
389
+ if for_cli_verbose_output:
390
+ self.logger.I(
391
+ _(
392
+ "telemetry mode is [green]local[/]: local usage collection only, no usage uploads except if requested"
393
+ )
394
+ )
395
+ return
396
+
397
+ if self._has_upload_consent(now) and not for_cli_verbose_output:
398
+ self.logger.D("user has consented to telemetry upload")
399
+ return
400
+
401
+ if for_cli_verbose_output:
402
+ self.logger.I(
403
+ _(
404
+ "telemetry mode is [green]on[/]: usage data is collected and periodically uploaded"
405
+ )
406
+ )
407
+ self.logger.I(
408
+ _(
409
+ "non-tracking usage information will be uploaded to RuyiSDK-managed servers [bold green]every {weekday}[/]"
410
+ ).format(
411
+ weekday=upload_wday_name,
412
+ )
413
+ )
414
+ else:
415
+ self.logger.W(
416
+ _(
417
+ "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 {weekday}[/]"
418
+ ).format(
419
+ weekday=upload_wday_name,
420
+ )
421
+ )
422
+
423
+ self._print_upload_schedule_notice(upload_wday, now)
325
424
 
326
425
  if not for_cli_verbose_output:
327
- self.logger.I("in order to hide this banner:")
328
- self.logger.I("- opt out with [yellow]ruyi telemetry optout[/]")
329
- self.logger.I("- or give consent with [yellow]ruyi telemetry consent[/]")
426
+ self.logger.I(_("in order to hide this banner:"))
427
+ self.logger.I(_(" - opt out with [yellow]ruyi telemetry optout[/]"))
428
+ self.logger.I(
429
+ _(" - or give consent with [yellow]ruyi telemetry consent[/]")
430
+ )
330
431
 
331
- def next_upload_day(self, time_now: float | None = None) -> int | None:
332
- upload_wday = self.upload_weekday()
432
+ def _next_upload_day(self, time_now: float | None = None) -> int | None:
433
+ upload_wday = self._upload_weekday()
333
434
  if upload_wday is None:
334
435
  return None
335
436
  return next_utc_weekday(upload_wday, time_now)
336
437
 
337
- def is_upload_day(self, time_now: float | None = None) -> bool:
438
+ def _is_upload_day(self, time_now: float | None = None) -> bool:
338
439
  if time_now is None:
339
440
  time_now = time.time()
340
- if upload_day := self.next_upload_day(time_now):
441
+ if upload_day := self._next_upload_day(time_now):
341
442
  return upload_day <= time_now
342
443
  return False
343
444
 
344
- def has_uploaded_today(
445
+ def _has_uploaded_today(
345
446
  self,
346
- scope: TelemetryScope,
447
+ last_upload_time: float | None,
347
448
  time_now: float | None = None,
348
449
  ) -> bool:
349
450
  if time_now is None:
350
451
  time_now = time.time()
351
- if upload_day := self.next_upload_day(time_now):
452
+ if upload_day := self._next_upload_day(time_now):
352
453
  upload_day_end = upload_day + 86400
353
- store = self.store(scope)
354
- if store is None:
355
- return False
356
- if last_upload_time := store.last_upload_timestamp:
454
+ if last_upload_time is not None:
357
455
  return upload_day <= last_upload_time < upload_day_end
358
456
  return False
359
457
 
360
458
  def record(self, scope: TelemetryScope, kind: str, **params: object) -> None:
459
+ if self.minimal:
460
+ self.logger.D(
461
+ f"minimal telemetry mode enabled, discarding event '{kind}' for scope {scope}"
462
+ )
463
+ return
464
+
361
465
  if store := self.store(scope):
362
466
  return store.record(kind, **params)
363
- self.logger.D(f"no telemetry store for scope {scope}, discarding event")
467
+ self.logger.D(
468
+ f"no telemetry store for scope {scope}, discarding event '{kind}'"
469
+ )
364
470
 
365
471
  def discard_events(self, v: bool = True) -> None:
366
472
  self._discard_events = v
367
473
 
368
- def flush(self, *, upload_now: bool = False) -> None:
369
- now = time.time()
474
+ def _should_proceed_with_upload(
475
+ self,
476
+ scope: TelemetryScope,
477
+ explicit_request: bool,
478
+ cron_mode: bool,
479
+ now: float,
480
+ ) -> tuple[bool, str]:
481
+ # proceed to uploading if forced (explicit requested or _upload_on_exit)
482
+ # regardless of schedule
483
+ if explicit_request:
484
+ return True, "explicit request"
485
+ if self._upload_on_exit:
486
+ return True, "first-run upload on exit"
487
+
488
+ # this is not an explicitly requested upload, so only proceed if today
489
+ # is the day, or if the last upload is more than a week ago
490
+ #
491
+ # the last-upload-more-than-a-week-ago check is to avoid situations
492
+ # where the user has not run ruyi for a long time, thus missing
493
+ # the scheduled upload day.
494
+ #
495
+ # cron jobs are a mitigation, but we cannot rely on them either, because:
496
+ #
497
+ # * ruyi is more likely installed user-locally than system-wide, so
498
+ # users may not set up cron jobs for themselves;
499
+ # * telemetry data is always recorded per user so system-wide cron jobs
500
+ # cannot easily access this data.
501
+ last_upload_time: float | None = None
502
+ if store := self.store(scope):
503
+ last_upload_time = store.last_upload_timestamp
504
+
505
+ if not self._is_upload_day(now):
506
+ if last_upload_time is not None and now - last_upload_time >= 7 * 86400:
507
+ return True, "last upload more than a week ago"
508
+ return False, "not upload day"
509
+ # now we're sure today is the day
510
+
511
+ # if we're in cron mode, proceed as if it's an explicit request;
512
+ # otherwise, only proceed if mode is "on" and we haven't uploaded yet today
513
+ # for this scope
514
+ if cron_mode:
515
+ return True, "cron mode upload on upload day"
516
+
517
+ if self._gc.telemetry_mode != "on":
518
+ return False, "telemetry mode not 'on'"
519
+
520
+ if not self._has_uploaded_today(last_upload_time, now):
521
+ return True, "upload day, not yet uploaded today"
522
+ return False, "upload day, already uploaded today"
523
+
524
+ def flush(self, *, upload_now: bool = False, cron_mode: bool = False) -> None:
525
+ """
526
+ Flush collected telemetry data to persistent store, and upload if needed.
527
+
528
+ :param upload_now: Upload data right now regardless of schedule.
529
+ :type upload_now: bool
530
+ :param cron_mode: Whether this flush is called from a cron job. If true,
531
+ non-upload-day uploads will be skipped, otherwise acts just like
532
+ explicit uploads via `ruyi telemetry upload`.
533
+ :type cron_mode: bool
534
+ """
370
535
 
371
536
  # We may be self-uninstalling and purging all state data, and in this
372
537
  # case we don't want to record anything (thus re-creating directories).
@@ -374,31 +539,50 @@ class TelemetryProvider:
374
539
  self.logger.D("discarding collected telemetry data")
375
540
  return
376
541
 
377
- self.logger.D("flushing telemetry to persistent store")
542
+ now = time.time()
543
+
544
+ def should_proceed(scope: TelemetryScope) -> tuple[bool, str]:
545
+ return self._should_proceed_with_upload(
546
+ scope,
547
+ explicit_request=upload_now,
548
+ cron_mode=cron_mode,
549
+ now=now,
550
+ )
551
+
552
+ if self.minimal:
553
+ if not self._upload_on_exit:
554
+ self.logger.D("skipping upload for non-first-run in minimal mode")
555
+ return
556
+
557
+ for scope, store in self._stores.items():
558
+ go_ahead, reason = should_proceed(scope)
559
+ self.logger.D(
560
+ f"minimal telemetry upload check for scope {scope}: go_ahead={go_ahead}, reason={reason}"
561
+ )
562
+ if not go_ahead:
563
+ continue
564
+ store.upload_minimal()
565
+ return
378
566
 
379
567
  for scope, store in self._stores.items():
568
+ self.logger.D(f"flushing telemetry to persistent store for scope {scope}")
380
569
  store.persist(now)
381
570
 
382
- # try to upload if forced (upload_now or _upload_on_exit), or:
383
- #
384
- # * we're not in local mode
385
- # * today is the day
386
- # * we haven't uploaded today
387
- if not (upload_now or self._upload_on_exit) and (
388
- self.local_mode
389
- or not self.is_upload_day(now)
390
- or self.has_uploaded_today(scope, now)
391
- ):
571
+ go_ahead, reason = should_proceed(scope)
572
+ self.logger.D(
573
+ f"regular telemetry upload check for scope {scope}: go_ahead={go_ahead}, reason={reason}"
574
+ )
575
+ if not go_ahead:
392
576
  continue
393
577
 
394
- self.prepare_data_for_upload(store)
395
- self.upload_staged_payloads(store)
578
+ self._prepare_data_for_upload(store)
579
+ store.upload_staged_payloads()
396
580
 
397
- def prepare_data_for_upload(self, store: TelemetryStore) -> None:
581
+ def _prepare_data_for_upload(self, store: TelemetryStore) -> None:
398
582
  installation_data: NodeInfo | None = None
399
583
  if store.scope.is_pm:
400
584
  try:
401
- installation_data = self.read_installation_data()
585
+ installation_data = self._read_installation_data()
402
586
  except FileNotFoundError:
403
587
  # should not happen due to is_upload_day() initializing it for us
404
588
  # beforehand, but proceed without node info nonetheless
@@ -406,28 +590,32 @@ class TelemetryProvider:
406
590
 
407
591
  return store.prepare_data_for_upload(installation_data)
408
592
 
409
- def upload_staged_payloads(self, store: TelemetryStore) -> None:
410
- if self.local_mode:
411
- return
412
-
413
- return store.upload_staged_payloads()
414
-
415
593
  def oobe_prompt(self) -> None:
416
594
  """Ask whether the user consents to a first-run telemetry upload, and
417
595
  persist the user's exact telemetry choice."""
418
596
 
597
+ if self._gc.is_telemetry_optout:
598
+ # user has already explicitly opted out via the environment variable,
599
+ # don't bother asking
600
+ return
601
+
602
+ # We always report installation info on first run, regardless of
603
+ # user's telemetry choice. In case the user opts out, only do a one-time
604
+ # upload now, and never upload anything again.
605
+ self._upload_on_exit = True
606
+
419
607
  from ..cli import user_input
420
608
 
421
- self.logger.stdout(TELEMETRY_CONSENT_AND_UPLOAD_DESC)
609
+ self.logger.stdout(_(TELEMETRY_CONSENT_AND_UPLOAD_DESC))
422
610
  if not user_input.ask_for_yesno_confirmation(
423
611
  self.logger,
424
- TELEMETRY_CONSENT_AND_UPLOAD_PROMPT,
612
+ _(TELEMETRY_CONSENT_AND_UPLOAD_PROMPT),
425
613
  False,
426
614
  ):
427
615
  # ask if the user wants to opt out entirely
428
616
  if user_input.ask_for_yesno_confirmation(
429
617
  self.logger,
430
- TELEMETRY_OPTOUT_PROMPT,
618
+ _(TELEMETRY_OPTOUT_PROMPT),
431
619
  False,
432
620
  ):
433
621
  set_telemetry_mode(self._gc, "off")
@@ -441,4 +629,3 @@ class TelemetryProvider:
441
629
 
442
630
  consent_time = datetime.datetime.now().astimezone()
443
631
  set_telemetry_mode(self._gc, "on", consent_time)
444
- self._upload_on_exit = True