ruyi 0.42.0a20251005__tar.gz → 0.42.0b20251014__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.42.0a20251005 → ruyi-0.42.0b20251014}/PKG-INFO +17 -10
  2. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/README.md +16 -9
  3. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/pyproject.toml +1 -1
  4. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/cli/config_cli.py +20 -4
  5. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/cli/main.py +12 -7
  6. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/config/__init__.py +127 -38
  7. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/config/editor.py +34 -3
  8. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/config/errors.py +12 -0
  9. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/config/schema.py +5 -0
  10. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/device/provision.py +1 -1
  11. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/distfile.py +12 -4
  12. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/install.py +99 -32
  13. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/install_cli.py +29 -1
  14. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/profile.py +148 -1
  15. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/repo.py +3 -2
  16. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/unpack.py +14 -16
  17. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/telemetry/provider.py +65 -22
  18. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/git.py +4 -2
  19. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/toml.py +11 -0
  20. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/xdg_basedir.py +20 -13
  21. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/LICENSE-Apache.txt +0 -0
  22. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/__init__.py +0 -0
  23. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/__main__.py +0 -0
  24. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/cli/__init__.py +0 -0
  25. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/cli/builtin_commands.py +0 -0
  26. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/cli/cmd.py +0 -0
  27. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/cli/completer.py +0 -0
  28. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/cli/completion.py +0 -0
  29. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/cli/oobe.py +0 -0
  30. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/cli/self_cli.py +0 -0
  31. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/cli/user_input.py +0 -0
  32. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/cli/version_cli.py +0 -0
  33. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/config/news.py +0 -0
  34. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/device/__init__.py +0 -0
  35. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/device/provision_cli.py +0 -0
  36. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/log/__init__.py +0 -0
  37. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/mux/__init__.py +0 -0
  38. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/mux/runtime.py +0 -0
  39. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/mux/venv/__init__.py +0 -0
  40. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/mux/venv/emulator_cfg.py +0 -0
  41. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/mux/venv/maker.py +0 -0
  42. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/mux/venv/venv_cli.py +0 -0
  43. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/mux/venv_cfg.py +0 -0
  44. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/pluginhost/__init__.py +0 -0
  45. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/pluginhost/api.py +0 -0
  46. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/pluginhost/ctx.py +0 -0
  47. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/pluginhost/paths.py +0 -0
  48. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/pluginhost/plugin_cli.py +0 -0
  49. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/pluginhost/unsandboxed.py +0 -0
  50. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/py.typed +0 -0
  51. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/resource_bundle/__init__.py +0 -0
  52. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/resource_bundle/__main__.py +0 -0
  53. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/resource_bundle/data.py +0 -0
  54. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/__init__.py +0 -0
  55. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/admin_checksum.py +0 -0
  56. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/admin_cli.py +0 -0
  57. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/atom.py +0 -0
  58. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/augmented_pkg.py +0 -0
  59. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/canonical_dump.py +0 -0
  60. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/checksum.py +0 -0
  61. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/cli_completion.py +0 -0
  62. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/entity.py +0 -0
  63. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/entity_cli.py +0 -0
  64. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/entity_provider.py +0 -0
  65. /ruyi-0.42.0a20251005/ruyi/ruyipkg/fetch.py → /ruyi-0.42.0b20251014/ruyi/ruyipkg/fetcher.py +0 -0
  66. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/host.py +0 -0
  67. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/list.py +0 -0
  68. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/list_cli.py +0 -0
  69. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/list_filter.py +0 -0
  70. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/msg.py +0 -0
  71. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/news.py +0 -0
  72. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/news_cli.py +0 -0
  73. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/news_store.py +0 -0
  74. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/pkg_manifest.py +0 -0
  75. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/profile_cli.py +0 -0
  76. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/protocols.py +0 -0
  77. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/state.py +0 -0
  78. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/unpack_method.py +0 -0
  79. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/ruyipkg/update_cli.py +0 -0
  80. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/telemetry/__init__.py +0 -0
  81. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/telemetry/aggregate.py +0 -0
  82. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/telemetry/event.py +0 -0
  83. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/telemetry/node_info.py +0 -0
  84. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/telemetry/scope.py +0 -0
  85. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/telemetry/store.py +0 -0
  86. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/telemetry/telemetry_cli.py +0 -0
  87. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/__init__.py +0 -0
  88. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/ar.py +0 -0
  89. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/ci.py +0 -0
  90. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/frontmatter.py +0 -0
  91. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/global_mode.py +0 -0
  92. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/l10n.py +0 -0
  93. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/markdown.py +0 -0
  94. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/mounts.py +0 -0
  95. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/nuitka.py +0 -0
  96. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/porcelain.py +0 -0
  97. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/prereqs.py +0 -0
  98. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/ssl_patch.py +0 -0
  99. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/templating.py +0 -0
  100. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/utils/url.py +0 -0
  101. {ruyi-0.42.0a20251005 → ruyi-0.42.0b20251014}/ruyi/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ruyi
3
- Version: 0.42.0a20251005
3
+ Version: 0.42.0b20251014
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
@@ -88,6 +88,10 @@ Various aspects of `ruyi` can be configured with files or environment variables.
88
88
  look up its config accordingly. If these are not explicitly set though, as in
89
89
  typical use cases, the default config directory is most likely `~/.config/ruyi`.
90
90
 
91
+ GNU/Linux distribution packagers and system administrators will find that the
92
+ directories `/usr/share/ruyi` and `/usr/local/share/ruyi` are searched for the
93
+ config file on such systems.
94
+
91
95
  ### Config file
92
96
 
93
97
  Currently `ruyi` will look for an optional `config.toml` in its XDG config
@@ -117,9 +121,8 @@ branch = "main"
117
121
  # details.
118
122
  #
119
123
  # If unset or empty, this default value is used: data will be collected and
120
- # uploaded every week, on a random weekday determined by the installation's
121
- # anonymous ID alone.
122
- mode = "on"
124
+ # stored locally; nothing will be uploaded automatically.
125
+ mode = "local"
123
126
  # The time the user's consent is given to telemetry data uploading. If the
124
127
  # system time is later than the time given here, telemetry consent banner will
125
128
  # not be displayed any more each time `ruyi` is executed. The exact consent
@@ -163,12 +166,16 @@ There are 3 telemetry modes available:
163
166
  * `off`: data will not be collected nor uploaded.
164
167
  * `on`: data will be collected and periodically uploaded.
165
168
 
166
- By default the `on` mode is active, which means every `ruyi` invocation
167
- will record some non-sensitive information locally alongside various other
168
- states of `ruyi`, and collected data will be periodically uploaded to servers
169
- managed by the RuyiSDK team in the People's Republic of China, in a weekly fashion.
170
- The upload will happen on a random weekday which is determined by the
171
- installation's anonymous ID alone.
169
+ By default the `local` mode is active from `ruyi` 0.42.0 (inclusive) on, which
170
+ means every `ruyi` invocation will record some non-sensitive information locally
171
+ alongside various other states of `ruyi`, but collected data will not be
172
+ uploaded automatically unless you explicitly request so (for example by
173
+ switching to the `on` mode, or by executing `ruyi telemetry upload`).
174
+
175
+ In case the `on` mode is active, collected data will be periodically uploaded
176
+ to servers managed by the RuyiSDK team in the People's Republic of China, in a
177
+ weekly fashion. The upload will happen on a random weekday which is determined
178
+ by the installation's anonymous ID alone.
172
179
 
173
180
  You can change the telemetry mode by editing `ruyi`'s config file, or simply
174
181
  disable telemetry altogether by setting the `RUYI_TELEMETRY_OPTOUT` environment
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "ruyi"
7
- version = "0.42.0-alpha.20251005"
7
+ version = "0.42.0-beta.20251014"
8
8
  description = "Package manager for RuyiSDK"
9
9
  keywords = ["ruyi", "ruyisdk"]
10
10
  license = { file = "LICENSE-Apache.txt" }
@@ -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
@@ -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):
@@ -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
@@ -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]
@@ -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})"
@@ -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):
@@ -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(
@@ -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,