ruyi 0.41.0b20250924__tar.gz → 0.42.0__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 (103) hide show
  1. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/PKG-INFO +18 -11
  2. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/README.md +16 -9
  3. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/pyproject.toml +3 -2
  4. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/cli/config_cli.py +20 -4
  5. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/cli/main.py +30 -8
  6. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/cli/oobe.py +7 -1
  7. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/cli/self_cli.py +7 -2
  8. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/config/__init__.py +121 -38
  9. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/config/editor.py +34 -3
  10. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/config/errors.py +22 -6
  11. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/config/schema.py +72 -17
  12. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/device/provision.py +1 -1
  13. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/pluginhost/api.py +1 -1
  14. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/distfile.py +12 -4
  15. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/install.py +99 -32
  16. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/install_cli.py +29 -1
  17. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/profile.py +148 -1
  18. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/repo.py +6 -3
  19. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/state.py +10 -0
  20. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/unpack.py +14 -16
  21. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/telemetry/provider.py +65 -22
  22. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/telemetry/store.py +1 -1
  23. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/git.py +4 -2
  24. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/global_mode.py +19 -1
  25. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/toml.py +11 -0
  26. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/xdg_basedir.py +20 -13
  27. ruyi-0.42.0/ruyi/version.py +20 -0
  28. ruyi-0.41.0b20250924/ruyi/mux/.gitignore +0 -1
  29. ruyi-0.41.0b20250924/ruyi/version.py +0 -67
  30. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/LICENSE-Apache.txt +0 -0
  31. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/__init__.py +0 -0
  32. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/__main__.py +0 -0
  33. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/cli/__init__.py +0 -0
  34. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/cli/builtin_commands.py +0 -0
  35. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/cli/cmd.py +0 -0
  36. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/cli/completer.py +0 -0
  37. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/cli/completion.py +0 -0
  38. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/cli/user_input.py +0 -0
  39. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/cli/version_cli.py +0 -0
  40. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/config/news.py +0 -0
  41. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/device/__init__.py +0 -0
  42. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/device/provision_cli.py +0 -0
  43. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/log/__init__.py +0 -0
  44. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/mux/__init__.py +0 -0
  45. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/mux/runtime.py +0 -0
  46. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/mux/venv/__init__.py +0 -0
  47. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/mux/venv/emulator_cfg.py +0 -0
  48. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/mux/venv/maker.py +0 -0
  49. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/mux/venv/venv_cli.py +0 -0
  50. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/mux/venv_cfg.py +0 -0
  51. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/pluginhost/__init__.py +0 -0
  52. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/pluginhost/ctx.py +0 -0
  53. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/pluginhost/paths.py +0 -0
  54. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/pluginhost/plugin_cli.py +0 -0
  55. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/pluginhost/unsandboxed.py +0 -0
  56. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/py.typed +0 -0
  57. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/resource_bundle/__init__.py +0 -0
  58. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/resource_bundle/__main__.py +0 -0
  59. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/resource_bundle/data.py +0 -0
  60. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/__init__.py +0 -0
  61. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/admin_checksum.py +0 -0
  62. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/admin_cli.py +0 -0
  63. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/atom.py +0 -0
  64. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/augmented_pkg.py +0 -0
  65. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/canonical_dump.py +0 -0
  66. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/checksum.py +0 -0
  67. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/cli_completion.py +0 -0
  68. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/entity.py +0 -0
  69. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/entity_cli.py +0 -0
  70. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/entity_provider.py +0 -0
  71. /ruyi-0.41.0b20250924/ruyi/ruyipkg/fetch.py → /ruyi-0.42.0/ruyi/ruyipkg/fetcher.py +0 -0
  72. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/host.py +0 -0
  73. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/list.py +0 -0
  74. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/list_cli.py +0 -0
  75. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/list_filter.py +0 -0
  76. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/msg.py +0 -0
  77. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/news.py +0 -0
  78. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/news_cli.py +0 -0
  79. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/news_store.py +0 -0
  80. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/pkg_manifest.py +0 -0
  81. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/profile_cli.py +0 -0
  82. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/protocols.py +0 -0
  83. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/unpack_method.py +0 -0
  84. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/ruyipkg/update_cli.py +0 -0
  85. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/telemetry/__init__.py +0 -0
  86. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/telemetry/aggregate.py +0 -0
  87. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/telemetry/event.py +0 -0
  88. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/telemetry/node_info.py +0 -0
  89. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/telemetry/scope.py +0 -0
  90. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/telemetry/telemetry_cli.py +0 -0
  91. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/__init__.py +0 -0
  92. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/ar.py +0 -0
  93. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/ci.py +0 -0
  94. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/frontmatter.py +0 -0
  95. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/l10n.py +0 -0
  96. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/markdown.py +0 -0
  97. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/mounts.py +0 -0
  98. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/nuitka.py +0 -0
  99. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/porcelain.py +0 -0
  100. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/prereqs.py +0 -0
  101. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/ssl_patch.py +0 -0
  102. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/templating.py +0 -0
  103. {ruyi-0.41.0b20250924 → ruyi-0.42.0}/ruyi/utils/url.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ruyi
3
- Version: 0.41.0b20250924
3
+ Version: 0.42.0
4
4
  Summary: Package manager for RuyiSDK
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -221,7 +221,7 @@ Classifier: Topic :: Software Development :: Build Tools
221
221
  Classifier: Topic :: Software Development :: Embedded Systems
222
222
  Classifier: Topic :: System :: Software Distribution
223
223
  Classifier: Typing :: Typed
224
- Requires-Dist: argcomplete (>=2.0.0,<4.0.0)
224
+ Requires-Dist: argcomplete (>=2.0.0)
225
225
  Requires-Dist: arpy
226
226
  Requires-Dist: fastjsonschema (>=2.15.1)
227
227
  Requires-Dist: jinja2 (>=3,<4)
@@ -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.41.0-beta.20250924"
7
+ version = "0.42.0"
8
8
  description = "Package manager for RuyiSDK"
9
9
  keywords = ["ruyi", "ruyisdk"]
10
10
  license = { file = "LICENSE-Apache.txt" }
@@ -29,6 +29,7 @@ classifiers = [
29
29
  ]
30
30
  requires-python = ">=3.10"
31
31
  dependencies = [
32
+ "argcomplete>=2.0.0",
32
33
  "arpy",
33
34
  "fastjsonschema>=2.15.1",
34
35
  "jinja2 (>=3, <4)",
@@ -41,7 +42,6 @@ dependencies = [
41
42
  "tomlkit>=0.9",
42
43
  "tomli>=1.2; python_version<'3.11'",
43
44
  "tzdata; sys_platform=='win32'",
44
- "argcomplete (>=2.0.0,<4.0.0)",
45
45
  ]
46
46
 
47
47
  [project.scripts]
@@ -57,6 +57,7 @@ repository = "https://github.com/ruyisdk/ruyi.git"
57
57
 
58
58
  [tool.poetry]
59
59
  include = ["ruyi/py.typed"]
60
+ exclude = ["**/.gitignore"]
60
61
 
61
62
  [tool.poetry.group.dev.dependencies]
62
63
  mypy = "^1.9.0"
@@ -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
@@ -6,6 +6,7 @@ from typing import Final, TYPE_CHECKING
6
6
  from ..config import GlobalConfig
7
7
  from ..telemetry.scope import TelemetryScope
8
8
  from ..utils.global_mode import GlobalModeProvider
9
+ from ..version import RUYI_SEMVER
9
10
  from . import RUYI_ENTRYPOINT_NAME
10
11
  from .oobe import OOBE
11
12
 
@@ -21,18 +22,40 @@ def is_called_as_ruyi(argv0: str) -> bool:
21
22
  return os.path.basename(argv0).lower() in ALLOWED_RUYI_ENTRYPOINT_NAMES
22
23
 
23
24
 
25
+ def should_prompt_for_renaming(argv0: str) -> bool:
26
+ # We need to allow things like "ruyi-qemu" through, to not break our mux.
27
+ # Only consider filenames starting with both our name *and* version to be
28
+ # un-renamed onefile artifacts that warrant a rename prompt.
29
+ likely_artifact_name_prefix = f"{RUYI_ENTRYPOINT_NAME}-{RUYI_SEMVER}."
30
+ return os.path.basename(argv0).lower().startswith(likely_artifact_name_prefix)
31
+
32
+
24
33
  def main(gm: GlobalModeProvider, gc: GlobalConfig, argv: list[str]) -> int:
25
- oobe = OOBE(gc)
34
+ logger = gc.logger
26
35
 
27
- if tm := gc.telemetry:
28
- tm.check_first_run_status()
29
- tm.init_installation(False)
30
- atexit.register(tm.flush)
31
- oobe.handlers.append(tm.oobe_prompt)
36
+ # do not init telemetry or OOBE on CLI auto-completion invocations, because
37
+ # our output isn't meant for humans in that case, and a "real" invocation
38
+ # will likely follow shortly after
39
+ if not gm.is_cli_autocomplete:
40
+ oobe = OOBE(gc)
32
41
 
33
- oobe.maybe_prompt()
42
+ if tm := gc.telemetry:
43
+ tm.check_first_run_status()
44
+ tm.init_installation(False)
45
+ atexit.register(tm.flush)
46
+ oobe.handlers.append(tm.oobe_prompt)
47
+
48
+ oobe.maybe_prompt()
34
49
 
35
50
  if not is_called_as_ruyi(gm.argv0):
51
+ if should_prompt_for_renaming(gm.argv0):
52
+ logger.F(
53
+ f"the {RUYI_ENTRYPOINT_NAME} executable must be named [green]'{RUYI_ENTRYPOINT_NAME}'[/] to work"
54
+ )
55
+ logger.I(f"it is now [yellow]'{gm.argv0}'[/]")
56
+ logger.I("please rename the command file and retry")
57
+ return 1
58
+
36
59
  from ..mux.runtime import mux_main
37
60
 
38
61
  # record an invocation and the command name being proxied to
@@ -54,7 +77,6 @@ def main(gm: GlobalModeProvider, gc: GlobalConfig, argv: list[str]) -> int:
54
77
  if TYPE_CHECKING:
55
78
  from .cmd import CLIEntrypoint
56
79
 
57
- logger = gc.logger
58
80
  p = RootCommand.build_argparse(gc)
59
81
 
60
82
  # We have to ensure argcomplete is only requested when it's supposed to,
@@ -41,10 +41,16 @@ class OOBE:
41
41
  def should_prompt(self) -> bool:
42
42
  from ..utils.global_mode import is_env_var_truthy
43
43
 
44
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
45
+ # This is of higher priority than even the debug override, because
46
+ # we don't want to mess up non-interactive sessions even in case of
47
+ # debugging.
48
+ return False
49
+
44
50
  if is_env_var_truthy(os.environ, "RUYI_DEBUG_FORCE_FIRST_RUN"):
45
51
  return True
46
52
 
47
- return self.is_first_run() and sys.stdin.isatty()
53
+ return self.is_first_run()
48
54
 
49
55
  def maybe_prompt(self) -> None:
50
56
  if not self.should_prompt():
@@ -131,12 +131,16 @@ class SelfCleanCommand(
131
131
  _do_reset(
132
132
  cfg,
133
133
  quiet=quiet,
134
+ # state-related
135
+ all_state=all,
136
+ news_read_status=news_read_status,
137
+ telemetry=telemetry,
138
+ # cache-related
139
+ all_cache=all,
134
140
  distfiles=distfiles,
135
141
  installed_pkgs=installed_pkgs,
136
- news_read_status=news_read_status,
137
142
  progcache=progcache,
138
143
  repo=repo,
139
- telemetry=telemetry,
140
144
  )
141
145
 
142
146
  return 0
@@ -233,6 +237,7 @@ def _do_reset(
233
237
  if installed_pkgs:
234
238
  status("removing installed packages")
235
239
  shutil.rmtree(cfg.data_root, True)
240
+ cfg.ruyipkg_global_state.purge_installation_info()
236
241
 
237
242
  # do not record any telemetry data if we're purging it
238
243
  if all_state or telemetry:
@@ -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,84 @@ 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
+ schema.ensure_valid_config_kv(key, True, value)
170
+ setattr(self, attr_name, value)
171
+
172
+ @classmethod
173
+ def _get_attr_name_by_key(cls, section: str, sel: list[str]) -> str | None:
143
174
  if section == schema.SECTION_INSTALLATION:
144
- return self._get_section_installation(sel)
175
+ return cls._get_section_installation(sel)
145
176
  elif section == schema.SECTION_PACKAGES:
146
- return self._get_section_packages(sel)
177
+ return cls._get_section_packages(sel)
147
178
  elif section == schema.SECTION_REPO:
148
- return self._get_section_repo(sel)
179
+ return cls._get_section_repo(sel)
149
180
  elif section == schema.SECTION_TELEMETRY:
150
- return self._get_section_telemetry(sel)
181
+ return cls._get_section_telemetry(sel)
151
182
  else:
152
183
  return None
153
184
 
154
- def _get_section_installation(self, selector: list[str]) -> object | None:
185
+ @classmethod
186
+ def _get_section_installation(cls, selector: list[str]) -> str | None:
155
187
  if len(selector) != 1:
156
188
  return None
157
189
  leaf = selector[0]
158
190
  if leaf == schema.KEY_INSTALLATION_EXTERNALLY_MANAGED:
159
- return self.is_installation_externally_managed
191
+ return "is_installation_externally_managed"
160
192
  else:
161
193
  return None
162
194
 
163
- def _get_section_packages(self, selector: list[str]) -> object | None:
195
+ @classmethod
196
+ def _get_section_packages(cls, selector: list[str]) -> str | None:
164
197
  if len(selector) != 1:
165
198
  return None
166
199
  leaf = selector[0]
167
200
  if leaf == schema.KEY_PACKAGES_PRERELEASES:
168
- return self.include_prereleases
201
+ return "include_prereleases"
169
202
  else:
170
203
  return None
171
204
 
172
- def _get_section_repo(self, selector: list[str]) -> object | None:
205
+ @classmethod
206
+ def _get_section_repo(cls, selector: list[str]) -> str | None:
173
207
  if len(selector) != 1:
174
208
  return None
175
209
  leaf = selector[0]
176
210
  if leaf == schema.KEY_REPO_BRANCH:
177
- return self.override_repo_branch
211
+ return "override_repo_branch"
178
212
  elif leaf == schema.KEY_REPO_LOCAL:
179
- return self.override_repo_dir
213
+ return "override_repo_dir"
180
214
  elif leaf == schema.KEY_REPO_REMOTE:
181
- return self.override_repo_url
215
+ return "override_repo_url"
182
216
  else:
183
217
  return None
184
218
 
185
- def _get_section_telemetry(self, selector: list[str]) -> object | None:
219
+ @classmethod
220
+ def _get_section_telemetry(cls, selector: list[str]) -> str | None:
186
221
  if len(selector) != 1:
187
222
  return None
188
223
  leaf = selector[0]
189
224
  if leaf == schema.KEY_TELEMETRY_MODE:
190
- return self.telemetry_mode
225
+ return "telemetry_mode"
191
226
  elif leaf == schema.KEY_TELEMETRY_PM_TELEMETRY_URL:
192
- return self.override_pm_telemetry_url
227
+ return "override_pm_telemetry_url"
193
228
  elif leaf == schema.KEY_TELEMETRY_UPLOAD_CONSENT:
194
- return self.telemetry_upload_consent_time
229
+ return "telemetry_upload_consent_time"
195
230
  else:
196
231
  return None
197
232
 
@@ -262,26 +297,63 @@ class GlobalConfig:
262
297
  def telemetry_root(self) -> os.PathLike[Any]:
263
298
  return pathlib.Path(self.ensure_state_dir()) / "telemetry"
264
299
 
265
- @cached_property
300
+ @property
266
301
  def telemetry(self) -> "TelemetryProvider | None":
302
+ return None if self.telemetry_mode == "off" else self._telemetry_provider
303
+
304
+ @cached_property
305
+ def _telemetry_provider(self) -> "TelemetryProvider | None":
306
+ """Do not access directly; use the ``telemetry`` property instead."""
307
+
267
308
  from ..telemetry.provider import TelemetryProvider
268
309
 
269
310
  return None if self.telemetry_mode == "off" else TelemetryProvider(self)
270
311
 
271
312
  @property
272
313
  def telemetry_mode(self) -> str:
273
- return self._telemetry_mode or "on"
314
+ return self._telemetry_mode or DEFAULT_TELEMETRY_MODE
315
+
316
+ @telemetry_mode.setter
317
+ def telemetry_mode(self, mode: str) -> None:
318
+ if mode not in ("off", "local", "on"):
319
+ raise ValueError("telemetry mode must be one of: off, local, on")
320
+ if self._gm.is_telemetry_optout and mode != "off":
321
+ raise ValueError(
322
+ "cannot enable telemetry when the environment variable opt-out is set"
323
+ )
324
+ self._telemetry_mode = mode
274
325
 
275
326
  @property
276
327
  def telemetry_upload_consent_time(self) -> datetime.datetime | None:
277
328
  return self._telemetry_upload_consent
278
329
 
330
+ @telemetry_upload_consent_time.setter
331
+ def telemetry_upload_consent_time(self, t: datetime.datetime | None) -> None:
332
+ self._telemetry_upload_consent = t
333
+
279
334
  @property
280
335
  def override_pm_telemetry_url(self) -> str | None:
281
336
  return self._telemetry_pm_telemetry_url
282
337
 
338
+ @override_pm_telemetry_url.setter
339
+ def override_pm_telemetry_url(self, url: str | None) -> None:
340
+ self._telemetry_pm_telemetry_url = url
341
+
342
+ @cached_property
343
+ def default_repo_dir(self) -> str:
344
+ return os.path.join(self.cache_root, "packages-index")
345
+
283
346
  def get_repo_dir(self) -> str:
284
- return self.override_repo_dir or os.path.join(self.cache_root, "packages-index")
347
+ return self.override_repo_dir or self.default_repo_dir
348
+
349
+ @cached_property
350
+ def have_overridden_repo_dir(self) -> bool:
351
+ if not self.override_repo_dir:
352
+ return False
353
+ override_path = pathlib.Path(self.override_repo_dir)
354
+ default_path = pathlib.Path(self.default_repo_dir)
355
+ # we don't use samefile() here because the path may not exist
356
+ return override_path.resolve() != default_path.resolve()
285
357
 
286
358
  def get_repo_url(self) -> str:
287
359
  return self.override_repo_url or DEFAULT_REPO_URL
@@ -312,7 +384,7 @@ class GlobalConfig:
312
384
  def lookup_binary_install_dir(self, host: str, slug: str) -> PathLike[Any] | None:
313
385
  host_path = get_host_path_fragment_for_binary_install_dir(host)
314
386
  for data_dir in self._dirs.app_data_dirs:
315
- p = data_dir / "binaries" / host_path / slug
387
+ p = data_dir.path / "binaries" / host_path / slug
316
388
  if p.exists():
317
389
  return p
318
390
  return None
@@ -347,30 +419,40 @@ class GlobalConfig:
347
419
  p.mkdir(parents=True, exist_ok=True)
348
420
  return p
349
421
 
350
- def iter_preset_configs(self) -> Iterable[os.PathLike[Any]]:
422
+ def iter_preset_configs(self) -> "Iterable[XDGPathEntry]":
351
423
  """
352
424
  Yields possible Ruyi config files in all preset config path locations,
353
425
  sorted by precedence from lowest to highest (so that each file may be
354
426
  simply applied consecutively).
355
427
  """
356
428
 
429
+ from ..utils.xdg_basedir import XDGPathEntry
430
+
357
431
  for path in PRESET_GLOBAL_CONFIG_LOCATIONS:
358
- yield pathlib.Path(path)
432
+ yield XDGPathEntry(pathlib.Path(path), True)
359
433
 
360
- def iter_xdg_configs(self) -> Iterable[os.PathLike[Any]]:
434
+ def iter_xdg_configs(self) -> "Iterable[XDGPathEntry]":
361
435
  """
362
436
  Yields possible Ruyi config files in all XDG config paths, sorted by precedence
363
437
  from lowest to highest (so that each file may be simply applied consecutively).
364
438
  """
365
439
 
366
- for config_dir in reversed(list(self._dirs.app_config_dirs)):
367
- yield config_dir / "config.toml"
440
+ from ..utils.xdg_basedir import XDGPathEntry
441
+
442
+ entries = list(self._dirs.app_config_dirs)
443
+ for e in reversed(entries):
444
+ yield XDGPathEntry(e.path / "config.toml", e.is_global)
368
445
 
369
446
  @property
370
447
  def local_user_config_file(self) -> pathlib.Path:
371
448
  return self._dirs.app_config / "config.toml"
372
449
 
373
- def try_apply_config_file(self, path: os.PathLike[Any]) -> None:
450
+ def _try_apply_config_file(
451
+ self,
452
+ path: os.PathLike[Any],
453
+ *,
454
+ is_global_scope: bool,
455
+ ) -> None:
374
456
  import tomlkit
375
457
 
376
458
  try:
@@ -379,23 +461,24 @@ class GlobalConfig:
379
461
  except FileNotFoundError:
380
462
  return
381
463
 
382
- self.logger.D(f"applying config: {data}")
383
- self.apply_config(data)
464
+ self.logger.D(f"applying config: {data}, is_global_scope={is_global_scope}")
465
+ self._apply_config(data, is_global_scope=is_global_scope)
384
466
 
385
467
  @classmethod
386
468
  def load_from_config(cls, gm: "ProvidesGlobalMode", logger: "RuyiLogger") -> "Self":
387
469
  obj = cls(gm, logger)
388
470
 
389
- for config_path in obj.iter_preset_configs():
471
+ for config_path, is_global in obj.iter_preset_configs():
390
472
  obj.logger.D(f"trying config file from preset location: {config_path}")
391
- obj.try_apply_config_file(config_path)
473
+ obj._try_apply_config_file(config_path, is_global_scope=is_global)
392
474
 
393
- for config_path in obj.iter_xdg_configs():
475
+ for config_path, is_global in obj.iter_xdg_configs():
394
476
  obj.logger.D(f"trying config file from XDG path: {config_path}")
395
- obj.try_apply_config_file(config_path)
477
+ obj._try_apply_config_file(config_path, is_global_scope=is_global)
396
478
 
397
479
  # let environment variable take precedence
398
480
  if gm.is_telemetry_optout:
399
481
  obj._telemetry_mode = "off"
482
+ obj._telemetry_upload_consent = None
400
483
 
401
484
  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]