ruyi 0.41.0b20250926__py3-none-any.whl → 0.42.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -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,
ruyi/cli/oobe.py CHANGED
@@ -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():
ruyi/cli/self_cli.py CHANGED
@@ -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:
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,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
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
@@ -31,7 +31,7 @@ class InvalidConfigValueTypeError(TypeError):
31
31
  self,
32
32
  key: str | Sequence[str],
33
33
  val: object | None,
34
- expected: type,
34
+ expected: type | Sequence[type],
35
35
  ) -> None:
36
36
  super().__init__()
37
37
  self._key = key
@@ -48,20 +48,24 @@ class InvalidConfigValueTypeError(TypeError):
48
48
  class InvalidConfigValueError(ValueError):
49
49
  def __init__(
50
50
  self,
51
- key: str | Sequence[str] | type,
51
+ key: str | Sequence[str] | None,
52
52
  val: object | None,
53
+ typ: type | Sequence[type],
53
54
  ) -> None:
54
55
  super().__init__()
55
56
  self._key = key
56
57
  self._val = val
58
+ self._typ = typ
57
59
 
58
60
  def __str__(self) -> str:
59
- if isinstance(self._key, type):
60
- return f"invalid config value for type {self._key}: {self._val}"
61
- return f"invalid config value for key {self._key}: {self._val}"
61
+ return (
62
+ f"invalid config value for key {self._key} (type {self._typ}): {self._val}"
63
+ )
62
64
 
63
65
  def __repr__(self) -> str:
64
- return f"InvalidConfigValueError({self._key:!r}, {self._val:!r})"
66
+ return (
67
+ f"InvalidConfigValueError({self._key:!r}, {self._val:!r}, {self._typ:!r})"
68
+ )
65
69
 
66
70
 
67
71
  class MalformedConfigFileError(Exception):
@@ -74,3 +78,15 @@ class MalformedConfigFileError(Exception):
74
78
 
75
79
  def __repr__(self) -> str:
76
80
  return f"MalformedConfigFileError({self._path:!r})"
81
+
82
+
83
+ class ProtectedGlobalConfigError(Exception):
84
+ def __init__(self, key: str | Sequence[str]) -> None:
85
+ super().__init__()
86
+ self._key = key
87
+
88
+ def __str__(self) -> str:
89
+ return f"attempt to modify protected global config key: {self._key}"
90
+
91
+ def __repr__(self) -> str:
92
+ return f"ProtectedGlobalConfigError({self._key!r})"