ruyi 0.44.0a20251118__tar.gz → 0.44.0b20251219__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 (101) hide show
  1. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/PKG-INFO +3 -2
  2. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/README.md +2 -1
  3. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/pyproject.toml +1 -1
  4. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/main.py +20 -13
  5. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/oobe.py +3 -7
  6. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/self_cli.py +1 -2
  7. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/config/__init__.py +4 -8
  8. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/install.py +20 -22
  9. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/aggregate.py +5 -0
  10. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/provider.py +229 -87
  11. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/store.py +68 -15
  12. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/telemetry_cli.py +23 -5
  13. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/version.py +1 -1
  14. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/LICENSE-Apache.txt +0 -0
  15. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/__init__.py +0 -0
  16. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/__main__.py +0 -0
  17. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/__init__.py +0 -0
  18. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/builtin_commands.py +0 -0
  19. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/cmd.py +0 -0
  20. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/completer.py +0 -0
  21. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/completion.py +0 -0
  22. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/config_cli.py +0 -0
  23. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/user_input.py +0 -0
  24. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/cli/version_cli.py +0 -0
  25. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/config/editor.py +0 -0
  26. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/config/errors.py +0 -0
  27. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/config/news.py +0 -0
  28. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/config/schema.py +0 -0
  29. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/device/__init__.py +0 -0
  30. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/device/provision.py +0 -0
  31. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/device/provision_cli.py +0 -0
  32. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/log/__init__.py +0 -0
  33. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/__init__.py +0 -0
  34. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/runtime.py +0 -0
  35. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/venv/__init__.py +0 -0
  36. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/venv/emulator_cfg.py +0 -0
  37. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/venv/maker.py +0 -0
  38. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/venv/venv_cli.py +0 -0
  39. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/mux/venv_cfg.py +0 -0
  40. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/pluginhost/__init__.py +0 -0
  41. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/pluginhost/api.py +0 -0
  42. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/pluginhost/ctx.py +0 -0
  43. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/pluginhost/paths.py +0 -0
  44. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/pluginhost/plugin_cli.py +0 -0
  45. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/pluginhost/unsandboxed.py +0 -0
  46. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/py.typed +0 -0
  47. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/resource_bundle/__init__.py +0 -0
  48. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/resource_bundle/__main__.py +0 -0
  49. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/resource_bundle/data.py +0 -0
  50. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/__init__.py +0 -0
  51. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/admin_checksum.py +0 -0
  52. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/admin_cli.py +0 -0
  53. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/atom.py +0 -0
  54. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/augmented_pkg.py +0 -0
  55. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/canonical_dump.py +0 -0
  56. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/checksum.py +0 -0
  57. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/cli_completion.py +0 -0
  58. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/distfile.py +0 -0
  59. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/entity.py +0 -0
  60. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/entity_cli.py +0 -0
  61. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/entity_provider.py +0 -0
  62. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/fetcher.py +0 -0
  63. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/host.py +0 -0
  64. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/install_cli.py +0 -0
  65. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/list.py +0 -0
  66. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/list_cli.py +0 -0
  67. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/list_filter.py +0 -0
  68. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/msg.py +0 -0
  69. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/news.py +0 -0
  70. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/news_cli.py +0 -0
  71. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/news_store.py +0 -0
  72. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/pkg_manifest.py +0 -0
  73. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/profile.py +0 -0
  74. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/profile_cli.py +0 -0
  75. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/protocols.py +0 -0
  76. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/repo.py +0 -0
  77. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/state.py +0 -0
  78. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/unpack.py +0 -0
  79. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/unpack_method.py +0 -0
  80. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/ruyipkg/update_cli.py +0 -0
  81. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/__init__.py +0 -0
  82. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/event.py +0 -0
  83. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/telemetry/scope.py +0 -0
  84. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/__init__.py +0 -0
  85. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/ar.py +0 -0
  86. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/ci.py +0 -0
  87. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/frontmatter.py +0 -0
  88. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/git.py +0 -0
  89. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/global_mode.py +0 -0
  90. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/l10n.py +0 -0
  91. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/markdown.py +0 -0
  92. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/mounts.py +0 -0
  93. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/node_info.py +0 -0
  94. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/nuitka.py +0 -0
  95. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/porcelain.py +0 -0
  96. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/prereqs.py +0 -0
  97. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/ssl_patch.py +0 -0
  98. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/templating.py +0 -0
  99. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/toml.py +0 -0
  100. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/url.py +0 -0
  101. {ruyi-0.44.0a20251118 → ruyi-0.44.0b20251219}/ruyi/utils/xdg_basedir.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ruyi
3
- Version: 0.44.0a20251118
3
+ Version: 0.44.0b20251219
4
4
  Summary: Package manager for RuyiSDK
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -408,7 +408,8 @@ described below.
408
408
  There are 3 telemetry modes available:
409
409
 
410
410
  * `local`: data will be collected but not uploaded without user action.
411
- * `off`: data will not be collected nor uploaded.
411
+ * `off`: data will neither be collected nor uploaded, except for a one-time
412
+ upload of `ruyi`'s version number on first run.
412
413
  * `on`: data will be collected and periodically uploaded.
413
414
 
414
415
  By default the `local` mode is active from `ruyi` 0.42.0 (inclusive) on, which
@@ -163,7 +163,8 @@ described below.
163
163
  There are 3 telemetry modes available:
164
164
 
165
165
  * `local`: data will be collected but not uploaded without user action.
166
- * `off`: data will not be collected nor uploaded.
166
+ * `off`: data will neither be collected nor uploaded, except for a one-time
167
+ upload of `ruyi`'s version number on first run.
167
168
  * `on`: data will be collected and periodically uploaded.
168
169
 
169
170
  By default the `local` mode is active from `ruyi` 0.42.0 (inclusive) on, which
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "ruyi"
7
- version = "0.44.0-alpha.20251118"
7
+ version = "0.44.0-beta.20251219"
8
8
  description = "Package manager for RuyiSDK"
9
9
  keywords = ["ruyi", "ruyisdk"]
10
10
  license = { file = "LICENSE-Apache.txt" }
@@ -39,11 +39,11 @@ def main(gm: GlobalModeProvider, gc: GlobalConfig, argv: list[str]) -> int:
39
39
  if not gm.is_cli_autocomplete:
40
40
  oobe = OOBE(gc)
41
41
 
42
- if tm := gc.telemetry:
43
- tm.check_first_run_status()
44
- tm.init_installation(False)
45
- atexit.register(tm.flush)
46
- oobe.handlers.append(tm.oobe_prompt)
42
+ tm = gc.telemetry
43
+ tm.check_first_run_status()
44
+ tm.init_installation(False)
45
+ atexit.register(tm.flush)
46
+ oobe.handlers.append(tm.oobe_prompt)
47
47
 
48
48
  oobe.maybe_prompt()
49
49
 
@@ -59,12 +59,11 @@ def main(gm: GlobalModeProvider, gc: GlobalConfig, argv: list[str]) -> int:
59
59
  from ..mux.runtime import mux_main
60
60
 
61
61
  # record an invocation and the command name being proxied to
62
- if tm := gc.telemetry:
63
- tm.record(
64
- TelemetryScope(None),
65
- "cli:mux-invocation-v1",
66
- target=os.path.basename(gm.argv0),
67
- )
62
+ gc.telemetry.record(
63
+ TelemetryScope(None),
64
+ "cli:mux-invocation-v1",
65
+ target=os.path.basename(gm.argv0),
66
+ )
68
67
 
69
68
  return mux_main(gm, gc, argv)
70
69
 
@@ -127,8 +126,16 @@ def main(gm: GlobalModeProvider, gc: GlobalConfig, argv: list[str]) -> int:
127
126
  except AttributeError:
128
127
  pass
129
128
 
130
- if tm := gc.telemetry:
131
- tm.print_telemetry_notice()
129
+ tm = gc.telemetry
130
+ tm.print_telemetry_notice()
131
+
132
+ # Do not record `ruyi telemetry --cron-upload` invocations.
133
+ skip_recording_invocation = telemetry_key == "telemetry" and getattr(
134
+ args,
135
+ "cron_upload",
136
+ False,
137
+ )
138
+ if not skip_recording_invocation:
132
139
  tm.record(
133
140
  TelemetryScope(None),
134
141
  "cli:invocation-v1",
@@ -30,13 +30,9 @@ class OOBE:
30
30
  ]
31
31
 
32
32
  def is_first_run(self) -> bool:
33
- if tm := self._gc.telemetry:
34
- return tm.is_first_run
35
- # cannot reliably determine first run status without telemetry
36
- # we may revisit this later if it turns out users want OOBE tips even
37
- # if they know how to disable telemetry (hence more likely to be power
38
- # users)
39
- return False
33
+ # We now always have our first-run indicator because of the minimal
34
+ # telemetry mode.
35
+ return self._gc.telemetry.is_first_run
40
36
 
41
37
  def should_prompt(self) -> bool:
42
38
  from ..utils.global_mode import is_env_var_truthy
@@ -241,8 +241,7 @@ def _do_reset(
241
241
 
242
242
  # do not record any telemetry data if we're purging it
243
243
  if all_state or telemetry:
244
- if tm := cfg.telemetry:
245
- tm.discard_events(True)
244
+ cfg.telemetry.discard_events(True)
246
245
 
247
246
  if all_state:
248
247
  status("removing state data")
@@ -297,17 +297,13 @@ class GlobalConfig:
297
297
  def telemetry_root(self) -> os.PathLike[Any]:
298
298
  return pathlib.Path(self.ensure_state_dir()) / "telemetry"
299
299
 
300
- @property
301
- def telemetry(self) -> "TelemetryProvider | None":
302
- return None if self.telemetry_mode == "off" else self._telemetry_provider
303
-
304
300
  @cached_property
305
- def _telemetry_provider(self) -> "TelemetryProvider | None":
306
- """Do not access directly; use the ``telemetry`` property instead."""
307
-
301
+ def telemetry(self) -> "TelemetryProvider":
308
302
  from ..telemetry.provider import TelemetryProvider
309
303
 
310
- return None if self.telemetry_mode == "off" else TelemetryProvider(self)
304
+ # for allowing minimal uploads when telemetry is off
305
+ minimal_mode = self.telemetry_mode == "off"
306
+ return TelemetryProvider(self, minimal_mode)
311
307
 
312
308
  @property
313
309
  def telemetry_mode(self) -> str:
@@ -168,17 +168,16 @@ def do_install_atoms(
168
168
  for s in sv.render_known_issues(pm.repo.messages, config.lang_code):
169
169
  logger.I(s)
170
170
 
171
- if tm := config.telemetry:
172
- tm.record(
173
- TelemetryScope(mr.repo_id),
174
- "repo:package-install-v1",
175
- atom=a_str,
176
- host=canonicalized_host,
177
- pkg_category=pm.category,
178
- pkg_kinds=pm.kind,
179
- pkg_name=pm.name,
180
- pkg_version=pm.ver,
181
- )
171
+ config.telemetry.record(
172
+ TelemetryScope(mr.repo_id),
173
+ "repo:package-install-v1",
174
+ atom=a_str,
175
+ host=canonicalized_host,
176
+ pkg_category=pm.category,
177
+ pkg_kinds=pm.kind,
178
+ pkg_name=pm.name,
179
+ pkg_version=pm.ver,
180
+ )
182
181
 
183
182
  if pm.binary_metadata is not None:
184
183
  ret = _do_install_binary_pkg(
@@ -478,17 +477,16 @@ def do_uninstall_atoms(
478
477
  for a_str, pm in pms_to_uninstall:
479
478
  pkg_name = pm.name_for_installation
480
479
 
481
- if tm := config.telemetry:
482
- tm.record(
483
- TelemetryScope(mr.repo_id),
484
- "repo:package-uninstall-v1",
485
- atom=a_str,
486
- host=canonicalized_host,
487
- pkg_category=pm.category,
488
- pkg_kinds=pm.kind,
489
- pkg_name=pm.name,
490
- pkg_version=pm.ver,
491
- )
480
+ config.telemetry.record(
481
+ TelemetryScope(mr.repo_id),
482
+ "repo:package-uninstall-v1",
483
+ atom=a_str,
484
+ host=canonicalized_host,
485
+ pkg_category=pm.category,
486
+ pkg_kinds=pm.kind,
487
+ pkg_name=pm.name,
488
+ pkg_version=pm.ver,
489
+ )
492
490
 
493
491
  if pm.binary_metadata is not None:
494
492
  ret = _do_uninstall_binary_pkg(
@@ -18,8 +18,13 @@ class UploadPayload(TypedDict):
18
18
  fmt: int
19
19
  nonce: str
20
20
  ruyi_version: str
21
+ report_uuid: "NotRequired[str]"
22
+ """Optional field in case the client wishes to report this, and nothing
23
+ else. If `installation` is present, this field is ignored."""
21
24
  installation: "NotRequired[NodeInfo | None]"
25
+ """More detailed installation info that the client has user consent to report."""
22
26
  events: list[AggregatedTelemetryEvent]
27
+ """Aggregated telemetry events that the client has user consent to upload."""
23
28
 
24
29
 
25
30
  def stringify_param_val(v: object) -> str:
@@ -19,22 +19,27 @@ if TYPE_CHECKING:
19
19
  FALLBACK_PM_TELEMETRY_ENDPOINT = "https://api.ruyisdk.cn/telemetry/pm/"
20
20
 
21
21
  TELEMETRY_CONSENT_AND_UPLOAD_DESC = """
22
- RuyiSDK collects anonymized usage data locally to help us improve the product.
22
+ RuyiSDK collects minimal usage data in the form of just a version number of
23
+ the running [yellow]ruyi[/], to help us improve the product. With your consent,
24
+ RuyiSDK may also collect additional non-tracking usage data to be sent
25
+ periodically. The data will be recorded and processed by RuyiSDK team-managed
26
+ servers located in the Chinese mainland.
23
27
 
24
28
  [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
29
+ collection completely. Only with your explicit permission can [yellow]ruyi[/] collect and
30
+ upload more usage data. You can change this setting at any time by running
28
31
  [yellow]ruyi telemetry consent[/], [yellow]ruyi telemetry local[/], or [yellow]ruyi telemetry optout[/].
29
32
 
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!
33
+ We'll also send a one-time report from this [yellow]ruyi[/] installation so the RuyiSDK
34
+ team can better understand adoption. If you choose to opt out, this will be the
35
+ only data to be ever uploaded, without any tracking ID being generated or kept.
36
+ Thank you for helping us build a better experience!
33
37
  """
34
38
  TELEMETRY_CONSENT_AND_UPLOAD_PROMPT = (
35
- "Enable telemetry uploads and send a one-time report now?"
39
+ "Do you agree to have usage data periodically uploaded?"
36
40
  )
37
- TELEMETRY_OPTOUT_PROMPT = "\nDo you want to disable telemetry entirely?"
41
+ TELEMETRY_OPTOUT_PROMPT = "\nDo you want to opt out of telemetry entirely?"
42
+ MALFORMED_TELEMETRY_STATE_MSG = "malformed telemetry state: unable to determine upload weekday, nothing will be uploaded"
38
43
 
39
44
 
40
45
  def next_utc_weekday(wday: int, now: float | None = None) -> int:
@@ -130,7 +135,7 @@ def set_telemetry_mode(
130
135
 
131
136
 
132
137
  class TelemetryProvider:
133
- def __init__(self, gc: "GlobalConfig") -> None:
138
+ def __init__(self, gc: "GlobalConfig", minimal: bool) -> None:
134
139
  self.state_root = pathlib.Path(gc.telemetry_root)
135
140
 
136
141
  self._discard_events = False
@@ -138,6 +143,7 @@ class TelemetryProvider:
138
143
  self._is_first_run = False
139
144
  self._stores: dict[TelemetryScope, TelemetryStore] = {}
140
145
  self._upload_on_exit = False
146
+ self.minimal = minimal
141
147
 
142
148
  # create the PM store
143
149
  self.init_store(TelemetryScope(None))
@@ -154,6 +160,8 @@ class TelemetryProvider:
154
160
 
155
161
  @property
156
162
  def upload_consent_time(self) -> datetime.datetime | None:
163
+ if self.minimal or self.local_mode:
164
+ return None
157
165
  return self._gc.telemetry_upload_consent_time
158
166
 
159
167
  def store(self, scope: TelemetryScope) -> TelemetryStore | None:
@@ -203,11 +211,18 @@ class TelemetryProvider:
203
211
  def installation_file(self) -> pathlib.Path:
204
212
  return self.state_root / "installation.json"
205
213
 
214
+ @property
215
+ def minimal_installation_marker_file(self) -> pathlib.Path:
216
+ return self.state_root / "minimal-installation-marker"
217
+
206
218
  def check_first_run_status(self) -> None:
207
219
  """Check if this is the first run of the application by checking if installation file exists.
208
220
  This must be done before init_installation() is potentially called.
209
221
  """
210
- self._is_first_run = not self.installation_file.exists()
222
+ self._is_first_run = (
223
+ not self.installation_file.exists()
224
+ and not self.minimal_installation_marker_file.exists()
225
+ )
211
226
 
212
227
  @property
213
228
  def is_first_run(self) -> bool:
@@ -215,9 +230,15 @@ class TelemetryProvider:
215
230
  return self._is_first_run
216
231
 
217
232
  def init_installation(self, force_reinit: bool) -> NodeInfo | None:
233
+ if self.minimal:
234
+ # be extra safe by not reading or writing installation data at all
235
+ # in minimal mode
236
+ self._init_minimal_installation_marker(force_reinit)
237
+ return None
238
+
218
239
  installation_file = self.installation_file
219
240
  if installation_file.exists() and not force_reinit:
220
- return self.read_installation_data()
241
+ return self._read_installation_data()
221
242
 
222
243
  # either this is a fresh installation or we're forcing a refresh
223
244
  installation_id = uuid.uuid4()
@@ -232,13 +253,26 @@ class TelemetryProvider:
232
253
  fp.write(json.dumps(installation_data).encode("utf-8"))
233
254
  return installation_data
234
255
 
235
- def read_installation_data(self) -> NodeInfo | None:
256
+ def _init_minimal_installation_marker(self, force_reinit: bool) -> None:
257
+ if self.minimal_installation_marker_file.exists() and not force_reinit:
258
+ return
259
+
260
+ self.logger.D("initializing minimal installation marker file")
261
+ self.state_root.mkdir(parents=True, exist_ok=True)
262
+
263
+ # just touch the file
264
+ self.minimal_installation_marker_file.touch()
265
+
266
+ def _read_installation_data(self) -> NodeInfo | None:
236
267
  with open(self.installation_file, "rb") as fp:
237
268
  return cast(NodeInfo, json.load(fp))
238
269
 
239
- def upload_weekday(self) -> int | None:
270
+ def _upload_weekday(self) -> int | None:
271
+ if self.minimal:
272
+ return None
273
+
240
274
  try:
241
- installation_data = self.read_installation_data()
275
+ installation_data = self._read_installation_data()
242
276
  except FileNotFoundError:
243
277
  # init the node info if it's gone
244
278
  installation_data = self.init_installation(False)
@@ -253,55 +287,29 @@ class TelemetryProvider:
253
287
 
254
288
  return report_uuid_prefix % 7 # 0 is Monday
255
289
 
256
- def has_upload_consent(self, time_now: float | None = None) -> bool:
290
+ def _has_upload_consent(self, time_now: float | None = None) -> bool:
257
291
  if self.upload_consent_time is None:
258
292
  return False
259
293
  if time_now is None:
260
294
  time_now = time.time()
261
295
  return self.upload_consent_time.timestamp() <= time_now
262
296
 
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
-
297
+ def _print_upload_schedule_notice(self, upload_wday: int, now: float) -> None:
281
298
  next_upload_day_ts = next_utc_weekday(upload_wday, now)
282
299
  next_upload_day = time.localtime(next_upload_day_ts)
283
300
  next_upload_day_end = time.localtime(next_upload_day_ts + 86400)
284
301
  next_upload_day_str = time.strftime("%Y-%m-%d %H:%M:%S %z", next_upload_day)
285
302
  next_upload_day_end_str = time.strftime(
286
- "%Y-%m-%d %H:%M:%S %z", next_upload_day_end
303
+ "%Y-%m-%d %H:%M:%S %z",
304
+ next_upload_day_end,
287
305
  )
288
306
 
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:
307
+ if self._is_upload_day(now):
303
308
  for scope, store in self._stores.items():
304
- has_uploaded_today = self.has_uploaded_today(scope, now)
309
+ has_uploaded_today = self._has_uploaded_today(
310
+ store.last_upload_timestamp,
311
+ now,
312
+ )
305
313
  if has_uploaded_today:
306
314
  if last_upload_time := store.last_upload_timestamp:
307
315
  last_upload_time_str = time.strftime(
@@ -320,53 +328,165 @@ class TelemetryProvider:
320
328
  )
321
329
  else:
322
330
  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}[/]"
331
+ "the next upload will happen anytime [yellow]ruyi[/] is executed:"
324
332
  )
333
+ self.logger.I(
334
+ f" - between [bold green]{next_upload_day_str}[/] and [bold green]{next_upload_day_end_str}[/]"
335
+ )
336
+ self.logger.I(" - or if the last upload is more than a week ago")
337
+
338
+ def print_telemetry_notice(self, for_cli_verbose_output: bool = False) -> None:
339
+ if self.minimal:
340
+ if for_cli_verbose_output:
341
+ self.logger.I(
342
+ "telemetry mode is [green]off[/]: nothing is collected or uploaded after the first run"
343
+ )
344
+ return
345
+
346
+ now = time.time()
347
+ upload_wday = self._upload_weekday()
348
+ if upload_wday is None:
349
+ if for_cli_verbose_output:
350
+ self.logger.W(MALFORMED_TELEMETRY_STATE_MSG)
351
+ else:
352
+ self.logger.D(MALFORMED_TELEMETRY_STATE_MSG)
353
+ return
354
+
355
+ upload_wday_name = calendar.day_name[upload_wday]
356
+
357
+ if self.local_mode:
358
+ if for_cli_verbose_output:
359
+ self.logger.I(
360
+ "telemetry mode is [green]local[/]: local usage collection only, no usage uploads except if requested"
361
+ )
362
+ return
363
+
364
+ if self._has_upload_consent(now) and not for_cli_verbose_output:
365
+ self.logger.D("user has consented to telemetry upload")
366
+ return
367
+
368
+ if for_cli_verbose_output:
369
+ self.logger.I(
370
+ "telemetry mode is [green]on[/]: usage data is collected and periodically uploaded"
371
+ )
372
+ self.logger.I(
373
+ f"non-tracking usage information will be uploaded to RuyiSDK-managed servers [bold green]every {upload_wday_name}[/]"
374
+ )
375
+ else:
376
+ self.logger.W(
377
+ 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}[/]"
378
+ )
379
+
380
+ self._print_upload_schedule_notice(upload_wday, now)
325
381
 
326
382
  if not for_cli_verbose_output:
327
383
  self.logger.I("in order to hide this banner:")
328
384
  self.logger.I("- opt out with [yellow]ruyi telemetry optout[/]")
329
385
  self.logger.I("- or give consent with [yellow]ruyi telemetry consent[/]")
330
386
 
331
- def next_upload_day(self, time_now: float | None = None) -> int | None:
332
- upload_wday = self.upload_weekday()
387
+ def _next_upload_day(self, time_now: float | None = None) -> int | None:
388
+ upload_wday = self._upload_weekday()
333
389
  if upload_wday is None:
334
390
  return None
335
391
  return next_utc_weekday(upload_wday, time_now)
336
392
 
337
- def is_upload_day(self, time_now: float | None = None) -> bool:
393
+ def _is_upload_day(self, time_now: float | None = None) -> bool:
338
394
  if time_now is None:
339
395
  time_now = time.time()
340
- if upload_day := self.next_upload_day(time_now):
396
+ if upload_day := self._next_upload_day(time_now):
341
397
  return upload_day <= time_now
342
398
  return False
343
399
 
344
- def has_uploaded_today(
400
+ def _has_uploaded_today(
345
401
  self,
346
- scope: TelemetryScope,
402
+ last_upload_time: float | None,
347
403
  time_now: float | None = None,
348
404
  ) -> bool:
349
405
  if time_now is None:
350
406
  time_now = time.time()
351
- if upload_day := self.next_upload_day(time_now):
407
+ if upload_day := self._next_upload_day(time_now):
352
408
  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:
409
+ if last_upload_time is not None:
357
410
  return upload_day <= last_upload_time < upload_day_end
358
411
  return False
359
412
 
360
413
  def record(self, scope: TelemetryScope, kind: str, **params: object) -> None:
414
+ if self.minimal:
415
+ self.logger.D(
416
+ f"minimal telemetry mode enabled, discarding event '{kind}' for scope {scope}"
417
+ )
418
+ return
419
+
361
420
  if store := self.store(scope):
362
421
  return store.record(kind, **params)
363
- self.logger.D(f"no telemetry store for scope {scope}, discarding event")
422
+ self.logger.D(
423
+ f"no telemetry store for scope {scope}, discarding event '{kind}'"
424
+ )
364
425
 
365
426
  def discard_events(self, v: bool = True) -> None:
366
427
  self._discard_events = v
367
428
 
368
- def flush(self, *, upload_now: bool = False) -> None:
369
- now = time.time()
429
+ def _should_proceed_with_upload(
430
+ self,
431
+ scope: TelemetryScope,
432
+ explicit_request: bool,
433
+ cron_mode: bool,
434
+ now: float,
435
+ ) -> tuple[bool, str]:
436
+ # proceed to uploading if forced (explicit requested or _upload_on_exit)
437
+ # regardless of schedule
438
+ if explicit_request:
439
+ return True, "explicit request"
440
+ if self._upload_on_exit:
441
+ return True, "first-run upload on exit"
442
+
443
+ # this is not an explicitly requested upload, so only proceed if today
444
+ # is the day, or if the last upload is more than a week ago
445
+ #
446
+ # the last-upload-more-than-a-week-ago check is to avoid situations
447
+ # where the user has not run ruyi for a long time, thus missing
448
+ # the scheduled upload day.
449
+ #
450
+ # cron jobs are a mitigation, but we cannot rely on them either, because:
451
+ #
452
+ # * ruyi is more likely installed user-locally than system-wide, so
453
+ # users may not set up cron jobs for themselves;
454
+ # * telemetry data is always recorded per user so system-wide cron jobs
455
+ # cannot easily access this data.
456
+ last_upload_time: float | None = None
457
+ if store := self.store(scope):
458
+ last_upload_time = store.last_upload_timestamp
459
+
460
+ if not self._is_upload_day(now):
461
+ if last_upload_time is not None and now - last_upload_time >= 7 * 86400:
462
+ return True, "last upload more than a week ago"
463
+ return False, "not upload day"
464
+ # now we're sure today is the day
465
+
466
+ # if we're in cron mode, proceed as if it's an explicit request;
467
+ # otherwise, only proceed if mode is "on" and we haven't uploaded yet today
468
+ # for this scope
469
+ if cron_mode:
470
+ return True, "cron mode upload on upload day"
471
+
472
+ if self._gc.telemetry_mode != "on":
473
+ return False, "telemetry mode not 'on'"
474
+
475
+ if not self._has_uploaded_today(last_upload_time, now):
476
+ return True, "upload day, not yet uploaded today"
477
+ return False, "upload day, already uploaded today"
478
+
479
+ def flush(self, *, upload_now: bool = False, cron_mode: bool = False) -> None:
480
+ """
481
+ Flush collected telemetry data to persistent store, and upload if needed.
482
+
483
+ :param upload_now: Upload data right now regardless of schedule.
484
+ :type upload_now: bool
485
+ :param cron_mode: Whether this flush is called from a cron job. If true,
486
+ non-upload-day uploads will be skipped, otherwise acts just like
487
+ explicit uploads via `ruyi telemetry upload`.
488
+ :type cron_mode: bool
489
+ """
370
490
 
371
491
  # We may be self-uninstalling and purging all state data, and in this
372
492
  # case we don't want to record anything (thus re-creating directories).
@@ -374,31 +494,50 @@ class TelemetryProvider:
374
494
  self.logger.D("discarding collected telemetry data")
375
495
  return
376
496
 
377
- self.logger.D("flushing telemetry to persistent store")
497
+ now = time.time()
498
+
499
+ def should_proceed(scope: TelemetryScope) -> tuple[bool, str]:
500
+ return self._should_proceed_with_upload(
501
+ scope,
502
+ explicit_request=upload_now,
503
+ cron_mode=cron_mode,
504
+ now=now,
505
+ )
506
+
507
+ if self.minimal:
508
+ if not self._upload_on_exit:
509
+ self.logger.D("skipping upload for non-first-run in minimal mode")
510
+ return
511
+
512
+ for scope, store in self._stores.items():
513
+ go_ahead, reason = should_proceed(scope)
514
+ self.logger.D(
515
+ f"minimal telemetry upload check for scope {scope}: go_ahead={go_ahead}, reason={reason}"
516
+ )
517
+ if not go_ahead:
518
+ continue
519
+ store.upload_minimal()
520
+ return
378
521
 
379
522
  for scope, store in self._stores.items():
523
+ self.logger.D(f"flushing telemetry to persistent store for scope {scope}")
380
524
  store.persist(now)
381
525
 
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
- ):
526
+ go_ahead, reason = should_proceed(scope)
527
+ self.logger.D(
528
+ f"regular telemetry upload check for scope {scope}: go_ahead={go_ahead}, reason={reason}"
529
+ )
530
+ if not go_ahead:
392
531
  continue
393
532
 
394
- self.prepare_data_for_upload(store)
395
- self.upload_staged_payloads(store)
533
+ self._prepare_data_for_upload(store)
534
+ store.upload_staged_payloads()
396
535
 
397
- def prepare_data_for_upload(self, store: TelemetryStore) -> None:
536
+ def _prepare_data_for_upload(self, store: TelemetryStore) -> None:
398
537
  installation_data: NodeInfo | None = None
399
538
  if store.scope.is_pm:
400
539
  try:
401
- installation_data = self.read_installation_data()
540
+ installation_data = self._read_installation_data()
402
541
  except FileNotFoundError:
403
542
  # should not happen due to is_upload_day() initializing it for us
404
543
  # beforehand, but proceed without node info nonetheless
@@ -406,16 +545,20 @@ class TelemetryProvider:
406
545
 
407
546
  return store.prepare_data_for_upload(installation_data)
408
547
 
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
548
  def oobe_prompt(self) -> None:
416
549
  """Ask whether the user consents to a first-run telemetry upload, and
417
550
  persist the user's exact telemetry choice."""
418
551
 
552
+ if self._gc.is_telemetry_optout:
553
+ # user has already explicitly opted out via the environment variable,
554
+ # don't bother asking
555
+ return
556
+
557
+ # We always report installation info on first run, regardless of
558
+ # user's telemetry choice. In case the user opts out, only do a one-time
559
+ # upload now, and never upload anything again.
560
+ self._upload_on_exit = True
561
+
419
562
  from ..cli import user_input
420
563
 
421
564
  self.logger.stdout(TELEMETRY_CONSENT_AND_UPLOAD_DESC)
@@ -441,4 +584,3 @@ class TelemetryProvider:
441
584
 
442
585
  consent_time = datetime.datetime.now().astimezone()
443
586
  set_telemetry_mode(self._gc, "on", consent_time)
444
- self._upload_on_exit = True
@@ -85,6 +85,11 @@ class TelemetryStore:
85
85
  def record_upload_timestamp(self, time_now: float | None = None) -> None:
86
86
  if time_now is None:
87
87
  time_now = time.time()
88
+
89
+ # We may not have store_root existing yet if we're in minimal telemetry
90
+ # mode
91
+ self.store_root.mkdir(parents=True, exist_ok=True)
92
+
88
93
  f = self.last_upload_marker_file
89
94
  f.touch()
90
95
  os.utime(f, (time_now, time_now))
@@ -122,10 +127,6 @@ class TelemetryStore:
122
127
  f"scope {self.scope}: persisted {len(self._events)} telemetry event(s)"
123
128
  )
124
129
 
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
130
  def read_back_raw_events(self) -> Iterable[TelemetryEvent]:
130
131
  try:
131
132
  for f in self.raw_events_dir.glob("run.*.ndjson"):
@@ -177,6 +178,47 @@ class TelemetryStore:
177
178
 
178
179
  self.purge_raw_events()
179
180
 
181
+ def prepare_data_for_minimal_upload(self) -> bytes:
182
+ """Prepare a minimal upload payload with no installation data and no events.
183
+
184
+ Used when user has not consented to telemetry collection but also not
185
+ explicitly opted out, for gaining minimal insight into adoption.
186
+ """
187
+
188
+ # import ruyi.version here because this package is on the CLI startup
189
+ # critical path, and version probing is costly there
190
+ from ..version import RUYI_SEMVER
191
+
192
+ payload_nonce = uuid.uuid4().hex # for server-side dedup purposes
193
+
194
+ # We don't have installation data, and cannot have it initialized
195
+ # in this case because absence of installation data means no user
196
+ # consent. And making up a persistent installation ID is not a
197
+ # choice either, because "installation ID" resembles "advertising
198
+ # ID" a lot, which is considered personally identifiable information
199
+ # (PII) and not allowed to be collected without user consent.
200
+ #
201
+ # So, resort to re-using the completely random nonce as the report
202
+ # UUID, which does not allow for server-side correlation but at least
203
+ # allows for some insight into end-user adoption.
204
+ payload: UploadPayload = {
205
+ "fmt": 1,
206
+ "nonce": payload_nonce,
207
+ "ruyi_version": RUYI_SEMVER,
208
+ "report_uuid": payload_nonce,
209
+ "events": [],
210
+ }
211
+
212
+ return json.dumps(payload).encode("utf-8")
213
+
214
+ def upload_minimal(self) -> None:
215
+ if not self.api_url:
216
+ return
217
+
218
+ p = self.prepare_data_for_minimal_upload()
219
+ self.upload_one_staged_payload(p, self.api_url)
220
+ self.record_upload_timestamp()
221
+
180
222
  def upload_staged_payloads(self) -> None:
181
223
  if not self.api_url:
182
224
  return
@@ -198,7 +240,7 @@ class TelemetryStore:
198
240
 
199
241
  def upload_one_staged_payload(
200
242
  self,
201
- f: pathlib.Path,
243
+ f: pathlib.Path | bytes,
202
244
  endpoint: str,
203
245
  ) -> None:
204
246
  # import ruyi.version here because this package is on the CLI startup
@@ -206,13 +248,23 @@ class TelemetryStore:
206
248
  from ..version import RUYI_USER_AGENT
207
249
 
208
250
  api_path = urljoin_for_sure(endpoint, "upload-v1")
209
- self._logger.D(f"scope {self.scope}: about to upload payload {f} to {api_path}")
251
+
252
+ if isinstance(f, pathlib.Path):
253
+ self._logger.D(
254
+ f"scope {self.scope}: about to upload payload {f} to {api_path}"
255
+ )
256
+ data = f.read_bytes()
257
+ else:
258
+ self._logger.D(
259
+ f"scope {self.scope}: about to upload in-memory payload to {api_path}"
260
+ )
261
+ data = f
210
262
 
211
263
  import requests
212
264
 
213
265
  resp = requests.post(
214
266
  api_path,
215
- data=f.read_bytes(),
267
+ data=data,
216
268
  headers={"User-Agent": RUYI_USER_AGENT},
217
269
  allow_redirects=True,
218
270
  timeout=5,
@@ -228,11 +280,12 @@ class TelemetryStore:
228
280
  f"scope {self.scope}: telemetry upload ok: status code {resp.status_code}"
229
281
  )
230
282
 
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
- )
283
+ if isinstance(f, pathlib.Path):
284
+ # move to completed dir
285
+ # TODO: rotation
286
+ try:
287
+ f.rename(self.uploaded_dir / f.name)
288
+ except OSError as e:
289
+ self._logger.D(
290
+ f"scope {self.scope}: failed to move uploaded payload away: {e}"
291
+ )
@@ -13,12 +13,34 @@ if TYPE_CHECKING:
13
13
  class TelemetryCommand(
14
14
  RootCommand,
15
15
  cmd="telemetry",
16
+ has_main=True,
16
17
  has_subcommands=True,
17
18
  help="Manage your telemetry preferences",
18
19
  ):
19
20
  @classmethod
20
21
  def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None:
21
- pass
22
+ # https://github.com/python/cpython/issues/67037 prevents the registration
23
+ # of undocumented subcommands, so a preferred usage of
24
+ # "ruyi telemetry cron-upload" is not possible right now.
25
+ p.add_argument(
26
+ "--cron-upload",
27
+ action="store_true",
28
+ dest="cron_upload",
29
+ default=False,
30
+ help=argparse.SUPPRESS,
31
+ )
32
+
33
+ @classmethod
34
+ def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int:
35
+ cron_upload: bool = args.cron_upload
36
+ if not cron_upload:
37
+ args._parser.print_help() # pylint: disable=protected-access
38
+ return 0
39
+
40
+ # the rest are implementation of "--cron-upload"
41
+
42
+ cfg.telemetry.flush(cron_mode=True)
43
+ return 0
22
44
 
23
45
 
24
46
  class TelemetryConsentCommand(
@@ -117,10 +139,6 @@ class TelemetryUploadCommand(
117
139
 
118
140
  @classmethod
119
141
  def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int:
120
- if cfg.telemetry is None:
121
- cfg.logger.W("telemetry is disabled, nothing to upload")
122
- return 0
123
-
124
142
  cfg.telemetry.flush(upload_now=True)
125
143
  # disable the flush at program exit because we have just done that
126
144
  cfg.telemetry.discard_events()
@@ -1,6 +1,6 @@
1
1
  from typing import Final
2
2
 
3
- RUYI_SEMVER: Final = "0.44.0-alpha.20251118"
3
+ RUYI_SEMVER: Final = "0.44.0-beta.20251219"
4
4
  RUYI_USER_AGENT: Final = f"ruyi/{RUYI_SEMVER}"
5
5
 
6
6
  COPYRIGHT_NOTICE: Final = """\