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.
Files changed (101) hide show
  1. ruyi/__init__.py +21 -0
  2. ruyi/__main__.py +98 -0
  3. ruyi/cli/__init__.py +5 -0
  4. ruyi/cli/builtin_commands.py +14 -0
  5. ruyi/cli/cmd.py +224 -0
  6. ruyi/cli/completer.py +50 -0
  7. ruyi/cli/completion.py +26 -0
  8. ruyi/cli/config_cli.py +153 -0
  9. ruyi/cli/main.py +111 -0
  10. ruyi/cli/self_cli.py +295 -0
  11. ruyi/cli/user_input.py +127 -0
  12. ruyi/cli/version_cli.py +45 -0
  13. ruyi/config/__init__.py +401 -0
  14. ruyi/config/editor.py +92 -0
  15. ruyi/config/errors.py +76 -0
  16. ruyi/config/news.py +39 -0
  17. ruyi/config/schema.py +197 -0
  18. ruyi/device/__init__.py +0 -0
  19. ruyi/device/provision.py +591 -0
  20. ruyi/device/provision_cli.py +40 -0
  21. ruyi/log/__init__.py +272 -0
  22. ruyi/mux/.gitignore +1 -0
  23. ruyi/mux/__init__.py +0 -0
  24. ruyi/mux/runtime.py +213 -0
  25. ruyi/mux/venv/__init__.py +12 -0
  26. ruyi/mux/venv/emulator_cfg.py +41 -0
  27. ruyi/mux/venv/maker.py +782 -0
  28. ruyi/mux/venv/venv_cli.py +92 -0
  29. ruyi/mux/venv_cfg.py +214 -0
  30. ruyi/pluginhost/__init__.py +0 -0
  31. ruyi/pluginhost/api.py +206 -0
  32. ruyi/pluginhost/ctx.py +222 -0
  33. ruyi/pluginhost/paths.py +135 -0
  34. ruyi/pluginhost/plugin_cli.py +37 -0
  35. ruyi/pluginhost/unsandboxed.py +246 -0
  36. ruyi/py.typed +0 -0
  37. ruyi/resource_bundle/__init__.py +20 -0
  38. ruyi/resource_bundle/__main__.py +55 -0
  39. ruyi/resource_bundle/data.py +26 -0
  40. ruyi/ruyipkg/__init__.py +0 -0
  41. ruyi/ruyipkg/admin_checksum.py +88 -0
  42. ruyi/ruyipkg/admin_cli.py +83 -0
  43. ruyi/ruyipkg/atom.py +184 -0
  44. ruyi/ruyipkg/augmented_pkg.py +212 -0
  45. ruyi/ruyipkg/canonical_dump.py +320 -0
  46. ruyi/ruyipkg/checksum.py +39 -0
  47. ruyi/ruyipkg/cli_completion.py +42 -0
  48. ruyi/ruyipkg/distfile.py +208 -0
  49. ruyi/ruyipkg/entity.py +387 -0
  50. ruyi/ruyipkg/entity_cli.py +123 -0
  51. ruyi/ruyipkg/entity_provider.py +273 -0
  52. ruyi/ruyipkg/fetch.py +271 -0
  53. ruyi/ruyipkg/host.py +55 -0
  54. ruyi/ruyipkg/install.py +554 -0
  55. ruyi/ruyipkg/install_cli.py +150 -0
  56. ruyi/ruyipkg/list.py +126 -0
  57. ruyi/ruyipkg/list_cli.py +79 -0
  58. ruyi/ruyipkg/list_filter.py +173 -0
  59. ruyi/ruyipkg/msg.py +99 -0
  60. ruyi/ruyipkg/news.py +123 -0
  61. ruyi/ruyipkg/news_cli.py +78 -0
  62. ruyi/ruyipkg/news_store.py +183 -0
  63. ruyi/ruyipkg/pkg_manifest.py +657 -0
  64. ruyi/ruyipkg/profile.py +208 -0
  65. ruyi/ruyipkg/profile_cli.py +33 -0
  66. ruyi/ruyipkg/protocols.py +55 -0
  67. ruyi/ruyipkg/repo.py +763 -0
  68. ruyi/ruyipkg/state.py +345 -0
  69. ruyi/ruyipkg/unpack.py +369 -0
  70. ruyi/ruyipkg/unpack_method.py +91 -0
  71. ruyi/ruyipkg/update_cli.py +54 -0
  72. ruyi/telemetry/__init__.py +0 -0
  73. ruyi/telemetry/aggregate.py +72 -0
  74. ruyi/telemetry/event.py +41 -0
  75. ruyi/telemetry/node_info.py +192 -0
  76. ruyi/telemetry/provider.py +411 -0
  77. ruyi/telemetry/scope.py +43 -0
  78. ruyi/telemetry/store.py +238 -0
  79. ruyi/telemetry/telemetry_cli.py +127 -0
  80. ruyi/utils/__init__.py +0 -0
  81. ruyi/utils/ar.py +74 -0
  82. ruyi/utils/ci.py +63 -0
  83. ruyi/utils/frontmatter.py +38 -0
  84. ruyi/utils/git.py +169 -0
  85. ruyi/utils/global_mode.py +204 -0
  86. ruyi/utils/l10n.py +83 -0
  87. ruyi/utils/markdown.py +73 -0
  88. ruyi/utils/nuitka.py +33 -0
  89. ruyi/utils/porcelain.py +51 -0
  90. ruyi/utils/prereqs.py +77 -0
  91. ruyi/utils/ssl_patch.py +170 -0
  92. ruyi/utils/templating.py +34 -0
  93. ruyi/utils/toml.py +115 -0
  94. ruyi/utils/url.py +7 -0
  95. ruyi/utils/xdg_basedir.py +80 -0
  96. ruyi/version.py +67 -0
  97. ruyi-0.39.0.dist-info/LICENSE-Apache.txt +201 -0
  98. ruyi-0.39.0.dist-info/METADATA +403 -0
  99. ruyi-0.39.0.dist-info/RECORD +101 -0
  100. ruyi-0.39.0.dist-info/WHEEL +4 -0
  101. 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
@@ -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
@@ -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
+ )