ruyi 0.42.0a20251005__py3-none-any.whl → 0.42.0a20251013__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ruyi/cli/config_cli.py CHANGED
@@ -43,15 +43,23 @@ class ConfigGetCommand(
43
43
 
44
44
  @classmethod
45
45
  def main(cls, cfg: "config.GlobalConfig", args: argparse.Namespace) -> int:
46
+ from ..config.errors import InvalidConfigKeyError
46
47
  from ..config.schema import encode_value
48
+ from ..utils.toml import NoneValue
47
49
 
48
50
  key: str = args.key
49
51
 
50
- val = cfg.get_by_key(key)
51
- if val is None:
52
+ try:
53
+ val = cfg.get_by_key(key)
54
+ except InvalidConfigKeyError:
52
55
  return 1
53
56
 
54
- cfg.logger.stdout(encode_value(val))
57
+ try:
58
+ encoded_val = encode_value(val)
59
+ except NoneValue:
60
+ return 1
61
+
62
+ cfg.logger.stdout(encoded_val)
55
63
  return 0
56
64
 
57
65
 
@@ -80,6 +88,7 @@ class ConfigSetCommand(
80
88
  @classmethod
81
89
  def main(cls, cfg: "config.GlobalConfig", args: argparse.Namespace) -> int:
82
90
  from ..config.editor import ConfigEditor
91
+ from ..config.errors import ProtectedGlobalConfigError
83
92
  from ..config.schema import decode_value
84
93
 
85
94
  key: str = args.key
@@ -87,7 +96,14 @@ class ConfigSetCommand(
87
96
 
88
97
  pyval = decode_value(key, val)
89
98
  with ConfigEditor.work_on_user_local_config(cfg) as ed:
90
- ed.set_value(key, pyval)
99
+ try:
100
+ ed.set_value(key, pyval)
101
+ except ProtectedGlobalConfigError:
102
+ cfg.logger.F(
103
+ f"the config [yellow]{key}[/] is protected and not meant to be overridden by users",
104
+ )
105
+ return 2
106
+
91
107
  ed.stage()
92
108
 
93
109
  return 0
ruyi/cli/main.py CHANGED
@@ -27,15 +27,20 @@ def should_prompt_for_renaming(argv0: str) -> bool:
27
27
 
28
28
  def main(gm: GlobalModeProvider, gc: GlobalConfig, argv: list[str]) -> int:
29
29
  logger = gc.logger
30
- oobe = OOBE(gc)
31
30
 
32
- if tm := gc.telemetry:
33
- tm.check_first_run_status()
34
- tm.init_installation(False)
35
- atexit.register(tm.flush)
36
- oobe.handlers.append(tm.oobe_prompt)
31
+ # do not init telemetry or OOBE on CLI auto-completion invocations, because
32
+ # our output isn't meant for humans in that case, and a "real" invocation
33
+ # will likely follow shortly after
34
+ if not gm.is_cli_autocomplete:
35
+ oobe = OOBE(gc)
36
+
37
+ if tm := gc.telemetry:
38
+ tm.check_first_run_status()
39
+ tm.init_installation(False)
40
+ atexit.register(tm.flush)
41
+ oobe.handlers.append(tm.oobe_prompt)
37
42
 
38
- oobe.maybe_prompt()
43
+ oobe.maybe_prompt()
39
44
 
40
45
  if not is_called_as_ruyi(gm.argv0):
41
46
  if should_prompt_for_renaming(gm.argv0):
ruyi/config/__init__.py CHANGED
@@ -15,8 +15,10 @@ if TYPE_CHECKING:
15
15
  from ..ruyipkg.state import RuyipkgGlobalStateStore
16
16
  from ..telemetry.provider import TelemetryProvider
17
17
  from ..utils.global_mode import ProvidesGlobalMode
18
+ from ..utils.xdg_basedir import XDGPathEntry
18
19
  from .news import NewsReadStatusStore
19
20
 
21
+ from . import errors
20
22
  from . import schema
21
23
 
22
24
 
@@ -33,6 +35,7 @@ else:
33
35
  DEFAULT_APP_NAME: Final = "ruyi"
34
36
  DEFAULT_REPO_URL: Final = "https://github.com/ruyisdk/packages-index.git"
35
37
  DEFAULT_REPO_BRANCH: Final = "main"
38
+ DEFAULT_TELEMETRY_MODE: Final = "local" # "off", "local", "on"
36
39
 
37
40
 
38
41
  def get_host_path_fragment_for_binary_install_dir(canonicalized_host: str) -> str:
@@ -101,12 +104,21 @@ class GlobalConfig:
101
104
  self._telemetry_upload_consent: datetime.datetime | None = None
102
105
  self._telemetry_pm_telemetry_url: str | None = None
103
106
 
104
- def apply_config(self, config_data: GlobalConfigRootType) -> None:
107
+ def _apply_config(
108
+ self,
109
+ config_data: GlobalConfigRootType,
110
+ *,
111
+ is_global_scope: bool,
112
+ ) -> None:
105
113
  if ins_cfg := config_data.get(schema.SECTION_INSTALLATION):
106
- self.is_installation_externally_managed = ins_cfg.get(
107
- schema.KEY_INSTALLATION_EXTERNALLY_MANAGED,
108
- False,
109
- )
114
+ iem = ins_cfg.get(schema.KEY_INSTALLATION_EXTERNALLY_MANAGED, None)
115
+ if iem is not None and not is_global_scope:
116
+ iem_cfg_key = f"{schema.SECTION_INSTALLATION}.{schema.KEY_INSTALLATION_EXTERNALLY_MANAGED}"
117
+ self.logger.W(
118
+ f"the config key [yellow]{iem_cfg_key}[/] cannot be set from user config; ignoring",
119
+ )
120
+ else:
121
+ self.is_installation_externally_managed = bool(iem)
110
122
 
111
123
  if pkgs_cfg := config_data.get(schema.SECTION_PACKAGES):
112
124
  self.include_prereleases = pkgs_cfg.get(
@@ -137,61 +149,90 @@ class GlobalConfig:
137
149
  if isinstance(consent, datetime.datetime):
138
150
  self._telemetry_upload_consent = consent
139
151
 
140
- def get_by_key(self, key: str | Sequence[str]) -> object | None:
152
+ def get_by_key(self, key: str | Sequence[str]) -> object:
153
+ parsed_key = schema.parse_config_key(key)
154
+ section, sel = parsed_key[0], parsed_key[1:]
155
+ attr_name = self._get_attr_name_by_key(section, sel)
156
+ if attr_name is None:
157
+ raise errors.InvalidConfigKeyError(key)
158
+ return getattr(self, attr_name)
159
+
160
+ def set_by_key(self, key: str | Sequence[str], value: object) -> None:
161
+ # We don't have to check for global-only keys here because this
162
+ # method is only used for programmatic changes to the in-memory
163
+ # config, not for loading from config files.
141
164
  parsed_key = schema.parse_config_key(key)
142
165
  section, sel = parsed_key[0], parsed_key[1:]
166
+ attr_name = self._get_attr_name_by_key(section, sel)
167
+ if attr_name is None:
168
+ raise errors.InvalidConfigKeyError(key)
169
+
170
+ expected_type = schema.get_expected_type_for_config_key(key)
171
+ if not isinstance(value, expected_type):
172
+ raise TypeError(
173
+ f"expected type {expected_type.__name__} for config key '{key}', got {type(value).__name__}"
174
+ )
175
+
176
+ setattr(self, attr_name, value)
177
+
178
+ @classmethod
179
+ def _get_attr_name_by_key(cls, section: str, sel: list[str]) -> str | None:
143
180
  if section == schema.SECTION_INSTALLATION:
144
- return self._get_section_installation(sel)
181
+ return cls._get_section_installation(sel)
145
182
  elif section == schema.SECTION_PACKAGES:
146
- return self._get_section_packages(sel)
183
+ return cls._get_section_packages(sel)
147
184
  elif section == schema.SECTION_REPO:
148
- return self._get_section_repo(sel)
185
+ return cls._get_section_repo(sel)
149
186
  elif section == schema.SECTION_TELEMETRY:
150
- return self._get_section_telemetry(sel)
187
+ return cls._get_section_telemetry(sel)
151
188
  else:
152
189
  return None
153
190
 
154
- def _get_section_installation(self, selector: list[str]) -> object | None:
191
+ @classmethod
192
+ def _get_section_installation(cls, selector: list[str]) -> str | None:
155
193
  if len(selector) != 1:
156
194
  return None
157
195
  leaf = selector[0]
158
196
  if leaf == schema.KEY_INSTALLATION_EXTERNALLY_MANAGED:
159
- return self.is_installation_externally_managed
197
+ return "is_installation_externally_managed"
160
198
  else:
161
199
  return None
162
200
 
163
- def _get_section_packages(self, selector: list[str]) -> object | None:
201
+ @classmethod
202
+ def _get_section_packages(cls, selector: list[str]) -> str | None:
164
203
  if len(selector) != 1:
165
204
  return None
166
205
  leaf = selector[0]
167
206
  if leaf == schema.KEY_PACKAGES_PRERELEASES:
168
- return self.include_prereleases
207
+ return "include_prereleases"
169
208
  else:
170
209
  return None
171
210
 
172
- def _get_section_repo(self, selector: list[str]) -> object | None:
211
+ @classmethod
212
+ def _get_section_repo(cls, selector: list[str]) -> str | None:
173
213
  if len(selector) != 1:
174
214
  return None
175
215
  leaf = selector[0]
176
216
  if leaf == schema.KEY_REPO_BRANCH:
177
- return self.override_repo_branch
217
+ return "override_repo_branch"
178
218
  elif leaf == schema.KEY_REPO_LOCAL:
179
- return self.override_repo_dir
219
+ return "override_repo_dir"
180
220
  elif leaf == schema.KEY_REPO_REMOTE:
181
- return self.override_repo_url
221
+ return "override_repo_url"
182
222
  else:
183
223
  return None
184
224
 
185
- def _get_section_telemetry(self, selector: list[str]) -> object | None:
225
+ @classmethod
226
+ def _get_section_telemetry(cls, selector: list[str]) -> str | None:
186
227
  if len(selector) != 1:
187
228
  return None
188
229
  leaf = selector[0]
189
230
  if leaf == schema.KEY_TELEMETRY_MODE:
190
- return self.telemetry_mode
231
+ return "telemetry_mode"
191
232
  elif leaf == schema.KEY_TELEMETRY_PM_TELEMETRY_URL:
192
- return self.override_pm_telemetry_url
233
+ return "override_pm_telemetry_url"
193
234
  elif leaf == schema.KEY_TELEMETRY_UPLOAD_CONSENT:
194
- return self.telemetry_upload_consent_time
235
+ return "telemetry_upload_consent_time"
195
236
  else:
196
237
  return None
197
238
 
@@ -262,26 +303,63 @@ class GlobalConfig:
262
303
  def telemetry_root(self) -> os.PathLike[Any]:
263
304
  return pathlib.Path(self.ensure_state_dir()) / "telemetry"
264
305
 
265
- @cached_property
306
+ @property
266
307
  def telemetry(self) -> "TelemetryProvider | None":
308
+ return None if self.telemetry_mode == "off" else self._telemetry_provider
309
+
310
+ @cached_property
311
+ def _telemetry_provider(self) -> "TelemetryProvider | None":
312
+ """Do not access directly; use the ``telemetry`` property instead."""
313
+
267
314
  from ..telemetry.provider import TelemetryProvider
268
315
 
269
316
  return None if self.telemetry_mode == "off" else TelemetryProvider(self)
270
317
 
271
318
  @property
272
319
  def telemetry_mode(self) -> str:
273
- return self._telemetry_mode or "on"
320
+ return self._telemetry_mode or DEFAULT_TELEMETRY_MODE
321
+
322
+ @telemetry_mode.setter
323
+ def telemetry_mode(self, mode: str) -> None:
324
+ if mode not in ("off", "local", "on"):
325
+ raise ValueError("telemetry mode must be one of: off, local, on")
326
+ if self._gm.is_telemetry_optout and mode != "off":
327
+ raise ValueError(
328
+ "cannot enable telemetry when the environment variable opt-out is set"
329
+ )
330
+ self._telemetry_mode = mode
274
331
 
275
332
  @property
276
333
  def telemetry_upload_consent_time(self) -> datetime.datetime | None:
277
334
  return self._telemetry_upload_consent
278
335
 
336
+ @telemetry_upload_consent_time.setter
337
+ def telemetry_upload_consent_time(self, t: datetime.datetime | None) -> None:
338
+ self._telemetry_upload_consent = t
339
+
279
340
  @property
280
341
  def override_pm_telemetry_url(self) -> str | None:
281
342
  return self._telemetry_pm_telemetry_url
282
343
 
344
+ @override_pm_telemetry_url.setter
345
+ def override_pm_telemetry_url(self, url: str | None) -> None:
346
+ self._telemetry_pm_telemetry_url = url
347
+
348
+ @cached_property
349
+ def default_repo_dir(self) -> str:
350
+ return os.path.join(self.cache_root, "packages-index")
351
+
283
352
  def get_repo_dir(self) -> str:
284
- return self.override_repo_dir or os.path.join(self.cache_root, "packages-index")
353
+ return self.override_repo_dir or self.default_repo_dir
354
+
355
+ @cached_property
356
+ def have_overridden_repo_dir(self) -> bool:
357
+ if not self.override_repo_dir:
358
+ return False
359
+ override_path = pathlib.Path(self.override_repo_dir)
360
+ default_path = pathlib.Path(self.default_repo_dir)
361
+ # we don't use samefile() here because the path may not exist
362
+ return override_path.resolve() != default_path.resolve()
285
363
 
286
364
  def get_repo_url(self) -> str:
287
365
  return self.override_repo_url or DEFAULT_REPO_URL
@@ -312,7 +390,7 @@ class GlobalConfig:
312
390
  def lookup_binary_install_dir(self, host: str, slug: str) -> PathLike[Any] | None:
313
391
  host_path = get_host_path_fragment_for_binary_install_dir(host)
314
392
  for data_dir in self._dirs.app_data_dirs:
315
- p = data_dir / "binaries" / host_path / slug
393
+ p = data_dir.path / "binaries" / host_path / slug
316
394
  if p.exists():
317
395
  return p
318
396
  return None
@@ -347,30 +425,40 @@ class GlobalConfig:
347
425
  p.mkdir(parents=True, exist_ok=True)
348
426
  return p
349
427
 
350
- def iter_preset_configs(self) -> Iterable[os.PathLike[Any]]:
428
+ def iter_preset_configs(self) -> "Iterable[XDGPathEntry]":
351
429
  """
352
430
  Yields possible Ruyi config files in all preset config path locations,
353
431
  sorted by precedence from lowest to highest (so that each file may be
354
432
  simply applied consecutively).
355
433
  """
356
434
 
435
+ from ..utils.xdg_basedir import XDGPathEntry
436
+
357
437
  for path in PRESET_GLOBAL_CONFIG_LOCATIONS:
358
- yield pathlib.Path(path)
438
+ yield XDGPathEntry(pathlib.Path(path), True)
359
439
 
360
- def iter_xdg_configs(self) -> Iterable[os.PathLike[Any]]:
440
+ def iter_xdg_configs(self) -> "Iterable[XDGPathEntry]":
361
441
  """
362
442
  Yields possible Ruyi config files in all XDG config paths, sorted by precedence
363
443
  from lowest to highest (so that each file may be simply applied consecutively).
364
444
  """
365
445
 
366
- for config_dir in reversed(list(self._dirs.app_config_dirs)):
367
- yield config_dir / "config.toml"
446
+ from ..utils.xdg_basedir import XDGPathEntry
447
+
448
+ entries = list(self._dirs.app_config_dirs)
449
+ for e in reversed(entries):
450
+ yield XDGPathEntry(e.path / "config.toml", e.is_global)
368
451
 
369
452
  @property
370
453
  def local_user_config_file(self) -> pathlib.Path:
371
454
  return self._dirs.app_config / "config.toml"
372
455
 
373
- def try_apply_config_file(self, path: os.PathLike[Any]) -> None:
456
+ def _try_apply_config_file(
457
+ self,
458
+ path: os.PathLike[Any],
459
+ *,
460
+ is_global_scope: bool,
461
+ ) -> None:
374
462
  import tomlkit
375
463
 
376
464
  try:
@@ -379,23 +467,24 @@ class GlobalConfig:
379
467
  except FileNotFoundError:
380
468
  return
381
469
 
382
- self.logger.D(f"applying config: {data}")
383
- self.apply_config(data)
470
+ self.logger.D(f"applying config: {data}, is_global_scope={is_global_scope}")
471
+ self._apply_config(data, is_global_scope=is_global_scope)
384
472
 
385
473
  @classmethod
386
474
  def load_from_config(cls, gm: "ProvidesGlobalMode", logger: "RuyiLogger") -> "Self":
387
475
  obj = cls(gm, logger)
388
476
 
389
- for config_path in obj.iter_preset_configs():
477
+ for config_path, is_global in obj.iter_preset_configs():
390
478
  obj.logger.D(f"trying config file from preset location: {config_path}")
391
- obj.try_apply_config_file(config_path)
479
+ obj._try_apply_config_file(config_path, is_global_scope=is_global)
392
480
 
393
- for config_path in obj.iter_xdg_configs():
481
+ for config_path, is_global in obj.iter_xdg_configs():
394
482
  obj.logger.D(f"trying config file from XDG path: {config_path}")
395
- obj.try_apply_config_file(config_path)
483
+ obj._try_apply_config_file(config_path, is_global_scope=is_global)
396
484
 
397
485
  # let environment variable take precedence
398
486
  if gm.is_telemetry_optout:
399
487
  obj._telemetry_mode = "off"
488
+ obj._telemetry_upload_consent = None
400
489
 
401
490
  return obj
ruyi/config/editor.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from contextlib import AbstractContextManager
2
2
  import pathlib
3
- from typing import Sequence, TYPE_CHECKING, cast
3
+ from typing import Final, Sequence, TYPE_CHECKING, cast
4
4
 
5
5
  if TYPE_CHECKING:
6
6
  from types import TracebackType
@@ -9,13 +9,35 @@ if TYPE_CHECKING:
9
9
  import tomlkit
10
10
  from tomlkit.items import Table
11
11
 
12
- from .errors import MalformedConfigFileError
13
- from .schema import ensure_valid_config_kv, parse_config_key, validate_section
12
+ from .errors import MalformedConfigFileError, ProtectedGlobalConfigError
13
+ from .schema import (
14
+ SECTION_INSTALLATION,
15
+ KEY_INSTALLATION_EXTERNALLY_MANAGED,
16
+ ensure_valid_config_kv,
17
+ parse_config_key,
18
+ validate_section,
19
+ )
14
20
 
15
21
  if TYPE_CHECKING:
16
22
  from . import GlobalConfig
17
23
 
18
24
 
25
+ GLOBAL_ONLY_CONFIG_KEYS: Final[set[tuple[str, str]]] = {
26
+ (SECTION_INSTALLATION, KEY_INSTALLATION_EXTERNALLY_MANAGED),
27
+ }
28
+ """Settings that can only be set in global-scope config files.
29
+
30
+ Changes should be reflected in ``GlobalConfig._apply_config`` too."""
31
+
32
+
33
+ def _is_config_key_global_only(key: str | Sequence[str]) -> bool:
34
+ parsed_key = parse_config_key(key)
35
+ if len(parsed_key) != 2:
36
+ return False
37
+ section, leaf = parsed_key
38
+ return (section, leaf) in GLOBAL_ONLY_CONFIG_KEYS
39
+
40
+
19
41
  class ConfigEditor(AbstractContextManager["ConfigEditor"]):
20
42
  def __init__(self, path: pathlib.Path) -> None:
21
43
  self._path = path
@@ -63,6 +85,15 @@ class ConfigEditor(AbstractContextManager["ConfigEditor"]):
63
85
  parsed_key = parse_config_key(key)
64
86
  ensure_valid_config_kv(parsed_key, check_val=True, val=val)
65
87
 
88
+ # Gate protected settings: user-local config is not allowed to override
89
+ # global-only settings.
90
+ if _is_config_key_global_only(parsed_key):
91
+ # This mechanism is only meant for modifying user-local configs,
92
+ # because global configs are assumed to be maintained by packagers
93
+ # and/or sysadmins, who are expected to write the config file by
94
+ # hand.
95
+ raise ProtectedGlobalConfigError(parsed_key)
96
+
66
97
  section, sel = parsed_key[0], parsed_key[1:]
67
98
  if section in self._stage:
68
99
  existing_section = self._stage[section]
ruyi/config/errors.py CHANGED
@@ -74,3 +74,15 @@ class MalformedConfigFileError(Exception):
74
74
 
75
75
  def __repr__(self) -> str:
76
76
  return f"MalformedConfigFileError({self._path:!r})"
77
+
78
+
79
+ class ProtectedGlobalConfigError(Exception):
80
+ def __init__(self, key: str | Sequence[str]) -> None:
81
+ super().__init__()
82
+ self._key = key
83
+
84
+ def __str__(self) -> str:
85
+ return f"attempt to modify protected global config key: {self._key}"
86
+
87
+ def __repr__(self) -> str:
88
+ return f"ProtectedGlobalConfigError({self._key!r})"
ruyi/config/schema.py CHANGED
@@ -145,6 +145,11 @@ def encode_value(v: object) -> str:
145
145
  """Encodes the given config value into a string representation suitable for
146
146
  display or storage into TOML config files."""
147
147
 
148
+ if v is None:
149
+ from ..utils.toml import NoneValue
150
+
151
+ raise NoneValue()
152
+
148
153
  if isinstance(v, bool):
149
154
  return "true" if v else "false"
150
155
  elif isinstance(v, int):
ruyi/device/provision.py CHANGED
@@ -276,7 +276,7 @@ We are about to:
276
276
  """
277
277
  Some flashing steps require the use of fastboot, in which case you should
278
278
  ensure the target device is showing up in [yellow]fastboot devices[/] output.
279
- Please confirm it yourself before the flashing begins.
279
+ Please [bold red]confirm it yourself before continuing[/].
280
280
  """
281
281
  )
282
282
  if not user_input.ask_for_yesno_confirmation(
ruyi/ruyipkg/distfile.py CHANGED
@@ -1,10 +1,10 @@
1
1
  from functools import cached_property
2
2
  import os
3
- from typing import Final
3
+ from typing import Any, Final
4
4
 
5
5
  from ..log import RuyiLogger
6
6
  from .checksum import Checksummer
7
- from .fetch import BaseFetcher
7
+ from .fetcher import BaseFetcher
8
8
  from .pkg_manifest import DistfileDecl
9
9
  from .repo import MetadataRepo
10
10
  from .unpack import do_unpack, do_unpack_or_symlink
@@ -187,7 +187,11 @@ class Distfile:
187
187
  f"failed to fetch distfile: {self.dest} failed integrity checks"
188
188
  )
189
189
 
190
- def unpack(self, root: str | None, logger: RuyiLogger) -> None:
190
+ def unpack(
191
+ self,
192
+ root: str | os.PathLike[Any] | None,
193
+ logger: RuyiLogger,
194
+ ) -> None:
191
195
  return do_unpack(
192
196
  logger,
193
197
  self.dest,
@@ -197,7 +201,11 @@ class Distfile:
197
201
  prefixes_to_unpack=self.prefixes_to_unpack,
198
202
  )
199
203
 
200
- def unpack_or_symlink(self, root: str | None, logger: RuyiLogger) -> None:
204
+ def unpack_or_symlink(
205
+ self,
206
+ root: str | os.PathLike[Any] | None,
207
+ logger: RuyiLogger,
208
+ ) -> None:
201
209
  return do_unpack_or_symlink(
202
210
  logger,
203
211
  self.dest,
ruyi/ruyipkg/install.py CHANGED
@@ -1,7 +1,8 @@
1
- import os.path
1
+ import os
2
2
  import pathlib
3
3
  import shutil
4
4
  import tempfile
5
+ from typing import Any
5
6
 
6
7
  from ruyi.ruyipkg.state import BoundInstallationStateStore
7
8
 
@@ -29,6 +30,9 @@ def do_extract_atoms(
29
30
  atom_strs: set[str],
30
31
  *,
31
32
  canonicalized_host: str | RuyiHost,
33
+ dest_dir: os.PathLike[Any] | None, # None for CWD
34
+ extract_without_subdir: bool,
35
+ fetch_only: bool,
32
36
  ) -> int:
33
37
  logger = cfg.logger
34
38
  logger.D(f"about to extract for host {canonicalized_host}: {atom_strs}")
@@ -41,7 +45,6 @@ def do_extract_atoms(
41
45
  if pm is None:
42
46
  logger.F(f"atom {a_str} matches no package in the repository")
43
47
  return 1
44
- pkg_name = pm.name_for_installation
45
48
 
46
49
  sv = pm.service_level
47
50
  if sv.has_known_issues:
@@ -49,43 +52,92 @@ def do_extract_atoms(
49
52
  for s in sv.render_known_issues(pm.repo.messages, cfg.lang_code):
50
53
  logger.I(s)
51
54
 
52
- bm = pm.binary_metadata
53
- sm = pm.source_metadata
54
- if bm is None and sm is None:
55
- logger.F(f"don't know how to extract package [green]{pkg_name}[/]")
56
- return 2
55
+ ret = _do_extract_pkg(
56
+ cfg,
57
+ pm,
58
+ canonicalized_host=canonicalized_host,
59
+ fetch_only=fetch_only,
60
+ dest_dir=dest_dir,
61
+ extract_without_subdir=extract_without_subdir,
62
+ )
63
+ if ret != 0:
64
+ return ret
57
65
 
58
- if bm is not None and sm is not None:
59
- logger.F(
60
- f"cannot handle package [green]{pkg_name}[/]: package is both binary and source"
61
- )
62
- return 2
66
+ return 0
63
67
 
64
- distfiles_for_host: list[str] | None = None
65
- if bm is not None:
66
- distfiles_for_host = bm.get_distfile_names_for_host(canonicalized_host)
67
- elif sm is not None:
68
- distfiles_for_host = sm.get_distfile_names_for_host(canonicalized_host)
69
68
 
70
- if not distfiles_for_host:
71
- logger.F(
72
- f"package [green]{pkg_name}[/] declares no distfile for host {canonicalized_host}"
73
- )
74
- return 2
69
+ def _do_extract_pkg(
70
+ cfg: GlobalConfig,
71
+ pm: BoundPackageManifest,
72
+ *,
73
+ canonicalized_host: str | RuyiHost,
74
+ dest_dir: os.PathLike[Any] | None, # None for CWD
75
+ extract_without_subdir: bool,
76
+ fetch_only: bool,
77
+ ) -> int:
78
+ logger = cfg.logger
75
79
 
76
- dfs = pm.distfiles
80
+ pkg_name = pm.name_for_installation
77
81
 
78
- for df_name in distfiles_for_host:
79
- df_decl = dfs[df_name]
80
- ensure_unpack_cmd_for_method(logger, df_decl.unpack_method)
81
- df = Distfile(df_decl, mr)
82
- df.ensure(logger)
82
+ if not extract_without_subdir:
83
+ # extract into a subdirectory named <pkg_name>-<version>
84
+ subdir_name = pm.name_for_installation
85
+ if dest_dir is None:
86
+ dest_dir = pathlib.Path(subdir_name)
87
+ else:
88
+ dest_dir = pathlib.Path(dest_dir) / subdir_name
83
89
 
84
- logger.I(f"extracting [green]{df_name}[/] for package [green]{pkg_name}[/]")
85
- # unpack into CWD
86
- df.unpack(None, logger)
90
+ logger.D(f"about to extract {pm} to {dest_dir}")
87
91
 
88
- logger.I(f"package [green]{pkg_name}[/] extracted to current working directory")
92
+ # Make sure destination directory exists
93
+ if dest_dir is not None:
94
+ dest_dir = pathlib.Path(dest_dir)
95
+ dest_dir.mkdir(parents=True, exist_ok=True)
96
+
97
+ bm = pm.binary_metadata
98
+ sm = pm.source_metadata
99
+ if bm is None and sm is None:
100
+ logger.F(f"don't know how to extract package [green]{pkg_name}[/]")
101
+ return 2
102
+
103
+ if bm is not None and sm is not None:
104
+ logger.F(
105
+ f"cannot handle package [green]{pkg_name}[/]: package is both binary and source"
106
+ )
107
+ return 2
108
+
109
+ distfiles_for_host: list[str] | None = None
110
+ if bm is not None:
111
+ distfiles_for_host = bm.get_distfile_names_for_host(canonicalized_host)
112
+ elif sm is not None:
113
+ distfiles_for_host = sm.get_distfile_names_for_host(canonicalized_host)
114
+
115
+ if not distfiles_for_host:
116
+ logger.F(
117
+ f"package [green]{pkg_name}[/] declares no distfile for host {canonicalized_host}"
118
+ )
119
+ return 2
120
+
121
+ dfs = pm.distfiles
122
+
123
+ for df_name in distfiles_for_host:
124
+ df_decl = dfs[df_name]
125
+ ensure_unpack_cmd_for_method(logger, df_decl.unpack_method)
126
+ df = Distfile(df_decl, pm.repo)
127
+ df.ensure(logger)
128
+
129
+ if fetch_only:
130
+ logger.D("skipping extraction because [yellow]--fetch-only[/] is given")
131
+ continue
132
+
133
+ logger.I(f"extracting [green]{df_name}[/] for package [green]{pkg_name}[/]")
134
+ # unpack into destination
135
+ df.unpack(dest_dir, logger)
136
+
137
+ if not fetch_only:
138
+ logger.I(
139
+ f"package [green]{pkg_name}[/] has been extracted to {dest_dir}",
140
+ )
89
141
 
90
142
  return 0
91
143
 
@@ -147,6 +199,21 @@ def do_install_atoms(
147
199
  return ret
148
200
  continue
149
201
 
202
+ # the user may be trying to fetch a source-only package with `ruyi install --fetch-only`,
203
+ # so try that too for better UX
204
+ if fetch_only and pm.source_metadata is not None:
205
+ ret = _do_extract_pkg(
206
+ config,
207
+ pm,
208
+ canonicalized_host=canonicalized_host,
209
+ dest_dir=None, # unused in this case
210
+ extract_without_subdir=False, # unused in this case
211
+ fetch_only=fetch_only,
212
+ )
213
+ if ret != 0:
214
+ return ret
215
+ continue
216
+
150
217
  logger.F(f"don't know how to handle non-binary package [green]{pkg_name}[/]")
151
218
  return 2
152
219
 
@@ -1,4 +1,5 @@
1
1
  import argparse
2
+ import pathlib
2
3
  from typing import TYPE_CHECKING
3
4
 
4
5
  from ..cli.cmd import RootCommand
@@ -26,6 +27,25 @@ class ExtractCommand(
26
27
  if gc.is_cli_autocomplete:
27
28
  a.completer = package_completer_builder(gc)
28
29
 
30
+ p.add_argument(
31
+ "-d",
32
+ "--dest-dir",
33
+ type=str,
34
+ metavar="DESTDIR",
35
+ default=".",
36
+ help="Destination directory to extract to (default: current directory)",
37
+ )
38
+ p.add_argument(
39
+ "--extract-without-subdir",
40
+ action="store_true",
41
+ help="Extract files directly into DESTDIR instead of package-named subdirectories",
42
+ )
43
+ p.add_argument(
44
+ "-f",
45
+ "--fetch-only",
46
+ action="store_true",
47
+ help="Fetch distribution files only without installing",
48
+ )
29
49
  p.add_argument(
30
50
  "--host",
31
51
  type=str,
@@ -38,14 +58,22 @@ class ExtractCommand(
38
58
  from .host import canonicalize_host_str
39
59
  from .install import do_extract_atoms
40
60
 
41
- host: str = args.host
42
61
  atom_strs: set[str] = set(args.atom)
62
+ dest_dir_arg: str = args.dest_dir
63
+ extract_without_subdir: bool = args.extract_without_subdir
64
+ host: str = args.host
65
+ fetch_only: bool = args.fetch_only
66
+
67
+ dest_dir = None if dest_dir_arg == "." else pathlib.Path(dest_dir_arg)
43
68
 
44
69
  return do_extract_atoms(
45
70
  cfg,
46
71
  cfg.repo,
47
72
  atom_strs,
48
73
  canonicalized_host=canonicalize_host_str(host),
74
+ dest_dir=dest_dir,
75
+ extract_without_subdir=extract_without_subdir,
76
+ fetch_only=fetch_only,
49
77
  )
50
78
 
51
79
 
ruyi/ruyipkg/repo.py CHANGED
@@ -315,7 +315,7 @@ class MetadataRepo(ProvidesPackageManifests):
315
315
 
316
316
  # only manage the repo settings on the user's behalf if the user
317
317
  # has not overridden the repo directory themselves
318
- allow_auto_management = self._gc.override_repo_dir is None
318
+ allow_auto_management = not self._gc.have_overridden_repo_dir
319
319
 
320
320
  pull_ff_or_die(
321
321
  self.logger,
ruyi/ruyipkg/unpack.py CHANGED
@@ -2,7 +2,7 @@ import mmap
2
2
  import os
3
3
  import shutil
4
4
  import subprocess
5
- from typing import BinaryIO, NoReturn, Protocol
5
+ from typing import Any, BinaryIO, NoReturn, Protocol
6
6
 
7
7
  from ..log import RuyiLogger
8
8
  from ..utils import ar, prereqs
@@ -20,7 +20,7 @@ class SupportsRead(Protocol):
20
20
  def do_unpack(
21
21
  logger: RuyiLogger,
22
22
  filename: str,
23
- dest: str | None,
23
+ dest: str | os.PathLike[Any] | None,
24
24
  strip_components: int,
25
25
  unpack_method: UnpackMethod,
26
26
  stream: BinaryIO | SupportsRead | None = None,
@@ -77,7 +77,7 @@ def do_unpack(
77
77
  def do_unpack_or_symlink(
78
78
  logger: RuyiLogger,
79
79
  filename: str,
80
- dest: str | None,
80
+ dest: str | os.PathLike[Any] | None,
81
81
  strip_components: int,
82
82
  unpack_method: UnpackMethod,
83
83
  stream: BinaryIO | SupportsRead | None = None,
@@ -100,7 +100,7 @@ def do_unpack_or_symlink(
100
100
 
101
101
  def _do_copy_raw(
102
102
  src_path: str,
103
- destdir: str | None,
103
+ destdir: str | os.PathLike[Any] | None,
104
104
  ) -> None:
105
105
  src_filename = os.path.basename(src_path)
106
106
  if destdir is None:
@@ -114,7 +114,7 @@ def _do_copy_raw(
114
114
 
115
115
  def do_symlink(
116
116
  src_path: str,
117
- destdir: str | None,
117
+ destdir: str | os.PathLike[Any] | None,
118
118
  ) -> None:
119
119
  src_filename = os.path.basename(src_path)
120
120
  if destdir is None:
@@ -132,7 +132,7 @@ def do_symlink(
132
132
  def _do_unpack_tar(
133
133
  logger: RuyiLogger,
134
134
  filename: str,
135
- dest: str | None,
135
+ dest: str | os.PathLike[Any] | None,
136
136
  strip_components: int,
137
137
  unpack_method: UnpackMethod,
138
138
  stream: SupportsRead | None,
@@ -164,8 +164,6 @@ def _do_unpack_tar(
164
164
  stdin = subprocess.PIPE
165
165
 
166
166
  argv.extend(("-f", filename, f"--strip-components={strip_components}"))
167
- if dest is not None:
168
- argv.extend(("-C", dest))
169
167
  if prefixes_to_unpack:
170
168
  if any(p.startswith("-") for p in prefixes_to_unpack):
171
169
  raise ValueError(
@@ -200,11 +198,11 @@ def _do_unpack_tar(
200
198
  def _do_unpack_zip(
201
199
  logger: RuyiLogger,
202
200
  filename: str,
203
- dest: str | None,
201
+ dest: str | os.PathLike[Any] | None,
204
202
  ) -> None:
205
203
  argv = ["unzip", filename]
206
204
  if dest is not None:
207
- argv.extend(("-d", dest))
205
+ argv.extend(("-d", str(dest)))
208
206
  logger.D(f"about to call unzip: argv={argv}")
209
207
  retcode = subprocess.call(argv, cwd=dest)
210
208
  if retcode != 0:
@@ -214,7 +212,7 @@ def _do_unpack_zip(
214
212
  def _do_unpack_bare_gz(
215
213
  logger: RuyiLogger,
216
214
  filename: str,
217
- destdir: str | None,
215
+ destdir: str | os.PathLike[Any] | None,
218
216
  ) -> None:
219
217
  # the suffix may not be ".gz" so do this generically
220
218
  dest_filename = os.path.splitext(os.path.basename(filename))[0]
@@ -235,7 +233,7 @@ def _do_unpack_bare_gz(
235
233
  def _do_unpack_bare_bzip2(
236
234
  logger: RuyiLogger,
237
235
  filename: str,
238
- destdir: str | None,
236
+ destdir: str | os.PathLike[Any] | None,
239
237
  ) -> None:
240
238
  # the suffix may not be ".bz2" so do this generically
241
239
  dest_filename = os.path.splitext(os.path.basename(filename))[0]
@@ -256,7 +254,7 @@ def _do_unpack_bare_bzip2(
256
254
  def _do_unpack_bare_lz4(
257
255
  logger: RuyiLogger,
258
256
  filename: str,
259
- destdir: str | None,
257
+ destdir: str | os.PathLike[Any] | None,
260
258
  ) -> None:
261
259
  # the suffix may not be ".lz4" so do this generically
262
260
  dest_filename = os.path.splitext(os.path.basename(filename))[0]
@@ -271,7 +269,7 @@ def _do_unpack_bare_lz4(
271
269
  def _do_unpack_bare_xz(
272
270
  logger: RuyiLogger,
273
271
  filename: str,
274
- destdir: str | None,
272
+ destdir: str | os.PathLike[Any] | None,
275
273
  ) -> None:
276
274
  # the suffix may not be ".xz" so do this generically
277
275
  dest_filename = os.path.splitext(os.path.basename(filename))[0]
@@ -292,7 +290,7 @@ def _do_unpack_bare_xz(
292
290
  def _do_unpack_bare_zstd(
293
291
  logger: RuyiLogger,
294
292
  filename: str,
295
- destdir: str | None,
293
+ destdir: str | os.PathLike[Any] | None,
296
294
  ) -> None:
297
295
  # the suffix may not be ".zst" so do this generically
298
296
  dest_filename = os.path.splitext(os.path.basename(filename))[0]
@@ -307,7 +305,7 @@ def _do_unpack_bare_zstd(
307
305
  def _do_unpack_deb(
308
306
  logger: RuyiLogger,
309
307
  filename: str,
310
- destdir: str | None,
308
+ destdir: str | os.PathLike[Any] | None,
311
309
  ) -> None:
312
310
  with ar.ArpyArchiveWrapper(filename) as a:
313
311
  for f in a.infolist():
@@ -18,19 +18,23 @@ if TYPE_CHECKING:
18
18
 
19
19
  FALLBACK_PM_TELEMETRY_ENDPOINT = "https://api.ruyisdk.cn/telemetry/pm/"
20
20
 
21
- TELEMETRY_INITIAL_UPLOAD_PROMPT = """
22
- By default, the RuyiSDK team collects anonymous usage data to help us improve
23
- the product. No personal information or detail about your project is ever
24
- collected. The data will be uploaded to RuyiSDK team-managed servers located
25
- in the Chinese mainland if you agree to the uploading. You can change this
26
- setting at any time by running [yellow]ruyi telemetry consent[/] or
27
- [yellow]ruyi telemetry optout[/].
28
-
29
- We would like to ask if you agree to have basic information about this [yellow]ruyi[/]
30
- installation uploaded, right now, for one time. This will allow the RuyiSDK team
31
- to have more precise knowledge about the product's adoption. Thank you for
32
- your support!
21
+ TELEMETRY_CONSENT_AND_UPLOAD_DESC = """
22
+ RuyiSDK collects anonymized usage data locally to help us improve the product.
23
+
24
+ [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
28
+ [yellow]ruyi telemetry consent[/], [yellow]ruyi telemetry local[/], or [yellow]ruyi telemetry optout[/].
29
+
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
33
  """
34
+ TELEMETRY_CONSENT_AND_UPLOAD_PROMPT = (
35
+ "Enable telemetry uploads and send a one-time report now?"
36
+ )
37
+ TELEMETRY_OPTOUT_PROMPT = "\nDo you want to disable telemetry entirely?"
34
38
 
35
39
 
36
40
  def next_utc_weekday(wday: int, now: float | None = None) -> int:
@@ -66,12 +70,17 @@ def set_telemetry_mode(
66
70
 
67
71
  logger = gc.logger
68
72
 
73
+ if mode == "on":
74
+ if consent_time is None:
75
+ consent_time = datetime.datetime.now().astimezone()
76
+ else:
77
+ # clear any previously recorded consent time
78
+ consent_time = None
79
+
80
+ # First, persist the changes to user config
69
81
  with ConfigEditor.work_on_user_local_config(gc) as ed:
70
82
  ed.set_value((schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_MODE), mode)
71
-
72
- if mode == "on":
73
- if consent_time is None:
74
- consent_time = datetime.datetime.now().astimezone()
83
+ if consent_time is not None:
75
84
  ed.set_value(
76
85
  (schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_UPLOAD_CONSENT),
77
86
  consent_time,
@@ -83,6 +92,18 @@ def set_telemetry_mode(
83
92
 
84
93
  ed.stage()
85
94
 
95
+ # Then, apply the changes to the running instance's GlobalConfig
96
+ # TelemetryProvider instance (if any) will pick them up automatically
97
+ # because the properties are backed by GlobalConfig.
98
+ gc.set_by_key(
99
+ (schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_MODE),
100
+ mode,
101
+ )
102
+ gc.set_by_key(
103
+ (schema.SECTION_TELEMETRY, schema.KEY_TELEMETRY_UPLOAD_CONSENT),
104
+ consent_time,
105
+ )
106
+
86
107
  if not show_cli_feedback:
87
108
  return
88
109
  match mode:
@@ -111,8 +132,6 @@ def set_telemetry_mode(
111
132
  class TelemetryProvider:
112
133
  def __init__(self, gc: "GlobalConfig") -> None:
113
134
  self.state_root = pathlib.Path(gc.telemetry_root)
114
- self.local_mode = gc.telemetry_mode == "local"
115
- self.upload_consent_time = gc.telemetry_upload_consent_time
116
135
 
117
136
  self._discard_events = False
118
137
  self._gc = gc
@@ -129,6 +148,14 @@ class TelemetryProvider:
129
148
  def logger(self) -> RuyiLogger:
130
149
  return self._gc.logger
131
150
 
151
+ @property
152
+ def local_mode(self) -> bool:
153
+ return self._gc.telemetry_mode == "local"
154
+
155
+ @property
156
+ def upload_consent_time(self) -> datetime.datetime | None:
157
+ return self._gc.telemetry_upload_consent_time
158
+
132
159
  def store(self, scope: TelemetryScope) -> TelemetryStore | None:
133
160
  return self._stores.get(scope)
134
161
 
@@ -386,16 +413,32 @@ class TelemetryProvider:
386
413
  return store.upload_staged_payloads()
387
414
 
388
415
  def oobe_prompt(self) -> None:
389
- """Ask whether the user consents to a first-run telemetry upload."""
416
+ """Ask whether the user consents to a first-run telemetry upload, and
417
+ persist the user's exact telemetry choice."""
390
418
 
391
419
  from ..cli import user_input
392
420
 
393
- self.logger.stdout(TELEMETRY_INITIAL_UPLOAD_PROMPT)
421
+ self.logger.stdout(TELEMETRY_CONSENT_AND_UPLOAD_DESC)
394
422
  if not user_input.ask_for_yesno_confirmation(
395
423
  self.logger,
396
- "Do you agree?",
397
- True,
424
+ TELEMETRY_CONSENT_AND_UPLOAD_PROMPT,
425
+ False,
398
426
  ):
427
+ # ask if the user wants to opt out entirely
428
+ if user_input.ask_for_yesno_confirmation(
429
+ self.logger,
430
+ TELEMETRY_OPTOUT_PROMPT,
431
+ False,
432
+ ):
433
+ set_telemetry_mode(self._gc, "off")
434
+ return
435
+
436
+ # user wants to stay in local mode
437
+ # explicitly record the preference, so we don't have to worry about
438
+ # us potentially changing defaults yet another time
439
+ set_telemetry_mode(self._gc, "local")
399
440
  return
400
441
 
442
+ consent_time = datetime.datetime.now().astimezone()
443
+ set_telemetry_mode(self._gc, "on", consent_time)
401
444
  self._upload_on_exit = True
ruyi/utils/git.py CHANGED
@@ -119,13 +119,15 @@ def pull_ff_or_die(
119
119
  logger.I(f"repository: [yellow]{repo_path}[/]")
120
120
  logger.I(f"expected remote URL: [yellow]{remote_url}[/]")
121
121
  logger.I(f"actual remote URL: [yellow]{remote.url}[/]")
122
- logger.I("please fix the repo settings manually")
122
+ logger.I("please [bold red]fix the repo settings manually[/]")
123
123
  raise SystemExit(1)
124
124
 
125
125
  logger.D(
126
126
  f"updating url of remote {remote_name} from {remote.url} to {remote_url}"
127
127
  )
128
- repo.remotes.set_url("origin", remote_url)
128
+ repo.remotes.set_url(remote_name, remote_url)
129
+ # this needs manual refreshing
130
+ remote = repo.remotes[remote_name]
129
131
 
130
132
  logger.D("fetching")
131
133
  try:
ruyi/utils/toml.py CHANGED
@@ -7,6 +7,17 @@ from tomlkit.container import Container
7
7
  from tomlkit.items import Array, Comment, InlineTable, Item, Table, Trivia, Whitespace
8
8
 
9
9
 
10
+ class NoneValue(Exception):
11
+ """Used to indicate that a None value is to be dumped in TOML. Because TOML
12
+ does not support None natively, this means special handling is needed."""
13
+
14
+ def __str__(self) -> str:
15
+ return "NoneValue()"
16
+
17
+ def __repr__(self) -> str:
18
+ return "NoneValue()"
19
+
20
+
10
21
  def with_indent(item: Item, spaces: int = 2) -> Item:
11
22
  item.indent(spaces)
12
23
  return item
ruyi/utils/xdg_basedir.py CHANGED
@@ -4,7 +4,12 @@
4
4
 
5
5
  import os
6
6
  import pathlib
7
- from typing import Iterable
7
+ from typing import Iterable, NamedTuple
8
+
9
+
10
+ class XDGPathEntry(NamedTuple):
11
+ path: pathlib.Path
12
+ is_global: bool
8
13
 
9
14
 
10
15
  def _paths_from_env(env: str, default: str) -> Iterable[pathlib.Path]:
@@ -38,14 +43,16 @@ class XDGBaseDir:
38
43
  return pathlib.Path(v) if v else pathlib.Path.home() / ".local" / "state"
39
44
 
40
45
  @property
41
- def config_dirs(self) -> Iterable[pathlib.Path]:
46
+ def config_dirs(self) -> Iterable[XDGPathEntry]:
42
47
  # from highest precedence to lowest
43
- yield from _paths_from_env("XDG_CONFIG_DIRS", "/etc/xdg")
48
+ for p in _paths_from_env("XDG_CONFIG_DIRS", "/etc/xdg"):
49
+ yield XDGPathEntry(p, True)
44
50
 
45
51
  @property
46
- def data_dirs(self) -> Iterable[pathlib.Path]:
52
+ def data_dirs(self) -> Iterable[XDGPathEntry]:
47
53
  # from highest precedence to lowest
48
- yield from _paths_from_env("XDG_DATA_DIRS", "/usr/local/share/:/usr/share/")
54
+ for p in _paths_from_env("XDG_DATA_DIRS", "/usr/local/share/:/usr/share/"):
55
+ yield XDGPathEntry(p, True)
49
56
 
50
57
  # derived info
51
58
 
@@ -66,15 +73,15 @@ class XDGBaseDir:
66
73
  return self.state_home / self.app_name
67
74
 
68
75
  @property
69
- def app_config_dirs(self) -> Iterable[pathlib.Path]:
76
+ def app_config_dirs(self) -> Iterable[XDGPathEntry]:
70
77
  # from highest precedence to lowest
71
- yield self.app_config
72
- for p in self.config_dirs:
73
- yield p / self.app_name
78
+ yield XDGPathEntry(self.app_config, False)
79
+ for e in self.config_dirs:
80
+ yield XDGPathEntry(e.path / self.app_name, e.is_global)
74
81
 
75
82
  @property
76
- def app_data_dirs(self) -> Iterable[pathlib.Path]:
83
+ def app_data_dirs(self) -> Iterable[XDGPathEntry]:
77
84
  # from highest precedence to lowest
78
- yield self.app_data
79
- for p in self.data_dirs:
80
- yield p / self.app_name
85
+ yield XDGPathEntry(self.app_data, False)
86
+ for e in self.data_dirs:
87
+ yield XDGPathEntry(e.path / self.app_name, e.is_global)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ruyi
3
- Version: 0.42.0a20251005
3
+ Version: 0.42.0a20251013
4
4
  Summary: Package manager for RuyiSDK
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -332,6 +332,10 @@ Various aspects of `ruyi` can be configured with files or environment variables.
332
332
  look up its config accordingly. If these are not explicitly set though, as in
333
333
  typical use cases, the default config directory is most likely `~/.config/ruyi`.
334
334
 
335
+ GNU/Linux distribution packagers and system administrators will find that the
336
+ directories `/usr/share/ruyi` and `/usr/local/share/ruyi` are searched for the
337
+ config file on such systems.
338
+
335
339
  ### Config file
336
340
 
337
341
  Currently `ruyi` will look for an optional `config.toml` in its XDG config
@@ -361,9 +365,8 @@ branch = "main"
361
365
  # details.
362
366
  #
363
367
  # If unset or empty, this default value is used: data will be collected and
364
- # uploaded every week, on a random weekday determined by the installation's
365
- # anonymous ID alone.
366
- mode = "on"
368
+ # stored locally; nothing will be uploaded automatically.
369
+ mode = "local"
367
370
  # The time the user's consent is given to telemetry data uploading. If the
368
371
  # system time is later than the time given here, telemetry consent banner will
369
372
  # not be displayed any more each time `ruyi` is executed. The exact consent
@@ -407,12 +410,16 @@ There are 3 telemetry modes available:
407
410
  * `off`: data will not be collected nor uploaded.
408
411
  * `on`: data will be collected and periodically uploaded.
409
412
 
410
- By default the `on` mode is active, which means every `ruyi` invocation
411
- will record some non-sensitive information locally alongside various other
412
- states of `ruyi`, and collected data will be periodically uploaded to servers
413
- managed by the RuyiSDK team in the People's Republic of China, in a weekly fashion.
414
- The upload will happen on a random weekday which is determined by the
415
- installation's anonymous ID alone.
413
+ By default the `local` mode is active from `ruyi` 0.42.0 (inclusive) on, which
414
+ means every `ruyi` invocation will record some non-sensitive information locally
415
+ alongside various other states of `ruyi`, but collected data will not be
416
+ uploaded automatically unless you explicitly request so (for example by
417
+ switching to the `on` mode, or by executing `ruyi telemetry upload`).
418
+
419
+ In case the `on` mode is active, collected data will be periodically uploaded
420
+ to servers managed by the RuyiSDK team in the People's Republic of China, in a
421
+ weekly fashion. The upload will happen on a random weekday which is determined
422
+ by the installation's anonymous ID alone.
416
423
 
417
424
  You can change the telemetry mode by editing `ruyi`'s config file, or simply
418
425
  disable telemetry altogether by setting the `RUYI_TELEMETRY_OPTOUT` environment
@@ -5,19 +5,19 @@ ruyi/cli/builtin_commands.py,sha256=cYyPSF00DSBH1WMv6mHcMygbFRBGXObMWhbXHs5K1Mc,
5
5
  ruyi/cli/cmd.py,sha256=kR3aEiDE3AfPoP0Zr7MO-09CKoExbkLLmPvve9oKaUg,6725
6
6
  ruyi/cli/completer.py,sha256=cnOkU7veDe-jP8ROXZL2uBop2HgWfaAZl6dromnPLx8,1426
7
7
  ruyi/cli/completion.py,sha256=ffLs3Dv7pY_uinwH98wkBPohRvAjpUOGqy01OTA_Bgo,841
8
- ruyi/cli/config_cli.py,sha256=IMsUEs1iiFCvGLdGAVByMxwjNiP4IMZammkUMs0nFio,3497
9
- ruyi/cli/main.py,sha256=x3L_EQ1sb2F3JK5bY-4iH59pJUk93KWZAjyj-gAG8-g,3863
8
+ ruyi/cli/config_cli.py,sha256=9kq5W3Ir_lfwImhvrUmQ1KTKy1aRCv_UU1CmL5eGyJs,4038
9
+ ruyi/cli/main.py,sha256=X1xyD7mYcCdQAWxGU7F27XPI77LzG0oifJx_el_UOBs,4124
10
10
  ruyi/cli/oobe.py,sha256=fpzNukwpT16CSqAobClC615Fii7GFDYc5g5ArZMur1A,2463
11
11
  ruyi/cli/self_cli.py,sha256=opDHVpvgg0AgZdzi7kEY73_jwV6LRjtIpdgsFkRL1hI,8668
12
12
  ruyi/cli/user_input.py,sha256=ZJPyCAD7Aizkt37f_KDtW683aKvGZ2pL85Rg_y1LLfg,3711
13
13
  ruyi/cli/version_cli.py,sha256=L2pejZ7LIPYLUTb9NIz4t51KB8ai8igPBuE64tyqUuI,1275
14
- ruyi/config/__init__.py,sha256=VqHcXAt82bDJPsLnZKzzL-d-tNbe5X4SnLKlx9WdfRw,13501
15
- ruyi/config/editor.py,sha256=6b4VpViEPvfPHtEyrQHAjeFpJOT8-mfTla6dXbKf--g,3148
16
- ruyi/config/errors.py,sha256=EO-ezHgtkWoiH0jOjkkw3MgiTAHHLhppks6F9-p2i6I,2162
14
+ ruyi/config/__init__.py,sha256=IeRfS51U4f-d---jKVNrBZshJrlrK06u25LssMgd8sI,17075
15
+ ruyi/config/editor.py,sha256=piAJ-a68iX6IFWXy1g9OXoSs_3DardZwoaSPdStKKL0,4243
16
+ ruyi/config/errors.py,sha256=pSu9bzVmQLh5ov36i1T6SkZOYwdus1v2DoMGWV7caKY,2518
17
17
  ruyi/config/news.py,sha256=83LjQjJHsqOPdRrytG7VBFubG6pyDwJ-Mg37gpBRU20,1061
18
- ruyi/config/schema.py,sha256=j-oXLl5qeoaWNONxSzAfuaBM6be9m1Xjliez2FUEpjg,5862
18
+ ruyi/config/schema.py,sha256=btw3dwuTfNRIEVWa9JwIqrdfnxhbQ72uX_PV5Q7kCvM,5951
19
19
  ruyi/device/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
- ruyi/device/provision.py,sha256=0Lywhs9_sLMG21DRMP1DyijYFIB4k4-xYYNJikH8qcs,20902
20
+ ruyi/device/provision.py,sha256=9rUbLtybBQ8x9qRXaMwz_yAMf51vb3wmYE0qm54P5Bk,20906
21
21
  ruyi/device/provision_cli.py,sha256=sc6AF8ohWrXA-kIAYdZcD6sl1HHbj_dH2cCi-pjjOQg,1031
22
22
  ruyi/log/__init__.py,sha256=ehgUl8iY1oRfP_nJKrln5lnvJFSPPU1J-3g-PK28YWk,6516
23
23
  ruyi/mux/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -45,14 +45,14 @@ ruyi/ruyipkg/augmented_pkg.py,sha256=QbzYNHCRanFx9xwYRosN_dAEzOSxmOBxyBVli8SC9FE
45
45
  ruyi/ruyipkg/canonical_dump.py,sha256=Qu25YXwJpjWUBte0C3bmmaJxe7zyqfN-2-__u2_dJDM,9363
46
46
  ruyi/ruyipkg/checksum.py,sha256=ChYFPXl7-Y8p_bDerkXOGroRz4k6ejjWY9ViaVWuEgk,1274
47
47
  ruyi/ruyipkg/cli_completion.py,sha256=kJf7vN5rXi5zAwTI3dr4J8HdUzR3-ZXnsdaNQV6kqzo,1151
48
- ruyi/ruyipkg/distfile.py,sha256=mgEZAS8jv9zP_7SSk59B8QCZfcouUf4qZGBnL44B5zg,6895
48
+ ruyi/ruyipkg/distfile.py,sha256=wkyYVcSM2b3vD462TtoltdLcUZElCwWvcZBiPG3lTZ0,7002
49
49
  ruyi/ruyipkg/entity.py,sha256=s8h5kNaR2vsP6v_QUFIO6SZiUrt0jrb8GU11bNT6fn4,14498
50
50
  ruyi/ruyipkg/entity_cli.py,sha256=hF66i3sJX8XVLIWq376GA9vzgegfQy-6s0x0L831Irk,3802
51
51
  ruyi/ruyipkg/entity_provider.py,sha256=jDfS2Jh01PVHo0kb1XyI5WkZgL4fv21AooXLLwqK-1I,8741
52
- ruyi/ruyipkg/fetch.py,sha256=_btz2hkTz0uEUCCSAhOK7tlhAuvzCu42ZTUCP-RpU7U,9047
52
+ ruyi/ruyipkg/fetcher.py,sha256=_btz2hkTz0uEUCCSAhOK7tlhAuvzCu42ZTUCP-RpU7U,9047
53
53
  ruyi/ruyipkg/host.py,sha256=pmqgggi7koDCWgzFexwHpycv4SZ07VF6xUbi4s8FSKA,1399
54
- ruyi/ruyipkg/install.py,sha256=1RpDVEGkni8Z9z-3GVJepDYpF9ADdkh2vXTyG7BZms4,16299
55
- ruyi/ruyipkg/install_cli.py,sha256=BuhGJcrKsJqr63-8AamouY1mXAvjEe2nkxHYuAQ3U0Y,4269
54
+ ruyi/ruyipkg/install.py,sha256=RhJwIqGxMveWrGmpP-2uWpZBRnqkA75HwyHwrNTdqow,18176
55
+ ruyi/ruyipkg/install_cli.py,sha256=joIV5iY4iblDinoH2pgbHNYkMVWqMxtVNj89T0IpJuk,5267
56
56
  ruyi/ruyipkg/list.py,sha256=iO7666xFEWKTDNtJqVLaaQKsp0BfLTzgXBwFFHrqCGc,4172
57
57
  ruyi/ruyipkg/list_cli.py,sha256=9tRWWRcJv3yJqzup6Oc89E3xahGp5jIfybfEvwwd55I,2243
58
58
  ruyi/ruyipkg/list_filter.py,sha256=F64_UhwUEiaUR73EkLu91qoUBA-Yz9mEVWj8XY46MXQ,5467
@@ -64,16 +64,16 @@ ruyi/ruyipkg/pkg_manifest.py,sha256=FmksKjQyBnU4zA3MFXiHdB2EjNmhs-J9km9vE-zBQlk,
64
64
  ruyi/ruyipkg/profile.py,sha256=FVppmA6x12m3JfeAWd3Vl9osk4aBprZL4ff7akO018Q,6467
65
65
  ruyi/ruyipkg/profile_cli.py,sha256=ud9MS9JQLOtec_1tFRu669I-imVfi1DHU3AuBJym9mw,848
66
66
  ruyi/ruyipkg/protocols.py,sha256=lPKRaAcK3DY3wmkyO2tKpFvPQ_4QA4aSNNsNLvi78O8,1833
67
- ruyi/ruyipkg/repo.py,sha256=cdqEkeFgCnPieynyoZlp_19BAExBZnCWuD3kE1NQ6JA,25164
67
+ ruyi/ruyipkg/repo.py,sha256=3D_R9JjztY2IbyeQsfnrwJHNJ_Bcx3o_QZZEOk02YNo,25167
68
68
  ruyi/ruyipkg/state.py,sha256=3DRYyHvm_GOvKFVWZUV0iFVnXdK2vn6_RKBXKxFzK64,11235
69
- ruyi/ruyipkg/unpack.py,sha256=X_MKcxU1bcgIM-th1--CUfmnjQOFp-EqWWpvk27YttA,11028
69
+ ruyi/ruyipkg/unpack.py,sha256=hPd-lOnDXp1lEb4mdbLUEckT4QXblSFxz-dGAdwaAjc,11207
70
70
  ruyi/ruyipkg/unpack_method.py,sha256=MonFFvcDb7MVsi2w4yitnCeZkmWmS7nRMMY-wSt9AMs,2106
71
71
  ruyi/ruyipkg/update_cli.py,sha256=ywsAAUPctJ3_2qPDvFL2__ql9m8tGZ6ZfrwbSk30Xh4,1425
72
72
  ruyi/telemetry/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
73
  ruyi/telemetry/aggregate.py,sha256=Ybt25xrg0n62A9Ipslv18EBC-kLkyYsPeEzgguTtChg,1981
74
74
  ruyi/telemetry/event.py,sha256=GZnFj6E59Q7mjp-2VRApAZH3rT_bu4_cWb5QMCPm-Zc,982
75
75
  ruyi/telemetry/node_info.py,sha256=ix-udhoKQI-HV7qVeEbd-jy3ZyhXL62sVA9uX4fowEg,5178
76
- ruyi/telemetry/provider.py,sha256=KKYoic29hWq4u2Bk_usORkT9yQCpREinOLYZXj3eN9k,14832
76
+ ruyi/telemetry/provider.py,sha256=U88ydGga3dwYAi7mfNKmEEyfhAZ4xelF2L-jNAk29IY,16486
77
77
  ruyi/telemetry/scope.py,sha256=e45VPAvRAqSxrL0ESorN9SCnR_I6Bwi2CMPJDDshJEE,1133
78
78
  ruyi/telemetry/store.py,sha256=7ThCzKF2txT_xU3qgjotOcwRqwNhozIkIyYQ0GSUxBg,8015
79
79
  ruyi/telemetry/telemetry_cli.py,sha256=PBVMUSE3P6IBKQVMji_bueVenCdbchbOlowySXy0468,3364
@@ -81,7 +81,7 @@ ruyi/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
81
81
  ruyi/utils/ar.py,sha256=w9wiYYdbInLewr5IRTcP0TiOw2ibVDQEmnV0hHm9WlA,2271
82
82
  ruyi/utils/ci.py,sha256=66DBm4ooA7yozDtXCJFd1n2jJXTsEnxPSpkNzLfE28M,2970
83
83
  ruyi/utils/frontmatter.py,sha256=4EOohEYCZ_q6ncpDv7ktJYf9PN4WEdgFfdE9hZBV3Zg,1052
84
- ruyi/utils/git.py,sha256=SlOaS9sR4_0mhNgTXMvrmI8Q7-eh2z8pyhGq_9HuqSU,5893
84
+ ruyi/utils/git.py,sha256=YspRRkfxLXluCv4LNx6q_mjkPdoX7WSM9aR7EfudqlM,5991
85
85
  ruyi/utils/global_mode.py,sha256=A9ehY4z1ckUSMHqiDDlXmJpjPzTajCgsDvDqyTwZZj4,4979
86
86
  ruyi/utils/l10n.py,sha256=l003oQ5M8fWIKQHbYTVSc6oHzFFGU2sbKac7Hh6FNFU,2530
87
87
  ruyi/utils/markdown.py,sha256=Mpq--ClM4j9lm_-5zO53ptYePUTLI4rg0V1YshOwsf8,2654
@@ -91,12 +91,12 @@ ruyi/utils/porcelain.py,sha256=pF6ieSE2xlnC0HBADFY0m-uuwVNNME3wlbHo2jWdLFA,1403
91
91
  ruyi/utils/prereqs.py,sha256=oWAaH-smpTMQxpHt782YBxqHxrTaheataslN988-a78,2076
92
92
  ruyi/utils/ssl_patch.py,sha256=a5gf4br6nC39wTHsqiFtcJ-mGzqB-YzK6DHSeERLaHQ,5661
93
93
  ruyi/utils/templating.py,sha256=94xBJTkIfDqmUBTc9hnLO54zQoC7hwGWONGF3YbaqHk,966
94
- ruyi/utils/toml.py,sha256=ri97Ki0xhBwoDtrjngCc6hlPXuEsma1ShPFFuFMYqpA,3192
94
+ ruyi/utils/toml.py,sha256=aniIF3SGfR69_s3GWWwlnoKxW4B5IDVY2CM0eUI55_c,3501
95
95
  ruyi/utils/url.py,sha256=Wyct6syS4GmZC6mY7SK-YgBWxKl3cOOBXtp9UtvGkto,186
96
- ruyi/utils/xdg_basedir.py,sha256=vd9p-JZru2DkXvt_W3gCS1_Wj4QkuftErI1MLPaBiz4,2478
96
+ ruyi/utils/xdg_basedir.py,sha256=RwVH199jPcLVsg5ngR62RaNS5hqnMpkdt31LqkCfa1g,2751
97
97
  ruyi/version.py,sha256=KLJkvKexU07mu-GVDbYKsQvReRvwlVFYkRmcvnyfQNY,2142
98
- ruyi-0.42.0a20251005.dist-info/METADATA,sha256=6BbsE9zC6zyCzC-rlAsG2h3s5wUOBNY1TdcUvIZkjhA,23892
99
- ruyi-0.42.0a20251005.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
100
- ruyi-0.42.0a20251005.dist-info/entry_points.txt,sha256=GXSNSy7OgFrnlU5xm5dE3l3PGO92Qf6VDIUCdvQNm8E,49
101
- ruyi-0.42.0a20251005.dist-info/licenses/LICENSE-Apache.txt,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
102
- ruyi-0.42.0a20251005.dist-info/RECORD,,
98
+ ruyi-0.42.0a20251013.dist-info/METADATA,sha256=S9n6IZt93cQ9AXUc1vAdp6XAgOAtFTLOkKFjMvnhhog,24282
99
+ ruyi-0.42.0a20251013.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
100
+ ruyi-0.42.0a20251013.dist-info/entry_points.txt,sha256=GXSNSy7OgFrnlU5xm5dE3l3PGO92Qf6VDIUCdvQNm8E,49
101
+ ruyi-0.42.0a20251013.dist-info/licenses/LICENSE-Apache.txt,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
102
+ ruyi-0.42.0a20251013.dist-info/RECORD,,
File without changes