ruyi 0.39.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. ruyi/__init__.py +21 -0
  2. ruyi/__main__.py +98 -0
  3. ruyi/cli/__init__.py +5 -0
  4. ruyi/cli/builtin_commands.py +14 -0
  5. ruyi/cli/cmd.py +224 -0
  6. ruyi/cli/completer.py +50 -0
  7. ruyi/cli/completion.py +26 -0
  8. ruyi/cli/config_cli.py +153 -0
  9. ruyi/cli/main.py +111 -0
  10. ruyi/cli/self_cli.py +295 -0
  11. ruyi/cli/user_input.py +127 -0
  12. ruyi/cli/version_cli.py +45 -0
  13. ruyi/config/__init__.py +401 -0
  14. ruyi/config/editor.py +92 -0
  15. ruyi/config/errors.py +76 -0
  16. ruyi/config/news.py +39 -0
  17. ruyi/config/schema.py +197 -0
  18. ruyi/device/__init__.py +0 -0
  19. ruyi/device/provision.py +591 -0
  20. ruyi/device/provision_cli.py +40 -0
  21. ruyi/log/__init__.py +272 -0
  22. ruyi/mux/.gitignore +1 -0
  23. ruyi/mux/__init__.py +0 -0
  24. ruyi/mux/runtime.py +213 -0
  25. ruyi/mux/venv/__init__.py +12 -0
  26. ruyi/mux/venv/emulator_cfg.py +41 -0
  27. ruyi/mux/venv/maker.py +782 -0
  28. ruyi/mux/venv/venv_cli.py +92 -0
  29. ruyi/mux/venv_cfg.py +214 -0
  30. ruyi/pluginhost/__init__.py +0 -0
  31. ruyi/pluginhost/api.py +206 -0
  32. ruyi/pluginhost/ctx.py +222 -0
  33. ruyi/pluginhost/paths.py +135 -0
  34. ruyi/pluginhost/plugin_cli.py +37 -0
  35. ruyi/pluginhost/unsandboxed.py +246 -0
  36. ruyi/py.typed +0 -0
  37. ruyi/resource_bundle/__init__.py +20 -0
  38. ruyi/resource_bundle/__main__.py +55 -0
  39. ruyi/resource_bundle/data.py +26 -0
  40. ruyi/ruyipkg/__init__.py +0 -0
  41. ruyi/ruyipkg/admin_checksum.py +88 -0
  42. ruyi/ruyipkg/admin_cli.py +83 -0
  43. ruyi/ruyipkg/atom.py +184 -0
  44. ruyi/ruyipkg/augmented_pkg.py +212 -0
  45. ruyi/ruyipkg/canonical_dump.py +320 -0
  46. ruyi/ruyipkg/checksum.py +39 -0
  47. ruyi/ruyipkg/cli_completion.py +42 -0
  48. ruyi/ruyipkg/distfile.py +208 -0
  49. ruyi/ruyipkg/entity.py +387 -0
  50. ruyi/ruyipkg/entity_cli.py +123 -0
  51. ruyi/ruyipkg/entity_provider.py +273 -0
  52. ruyi/ruyipkg/fetch.py +271 -0
  53. ruyi/ruyipkg/host.py +55 -0
  54. ruyi/ruyipkg/install.py +554 -0
  55. ruyi/ruyipkg/install_cli.py +150 -0
  56. ruyi/ruyipkg/list.py +126 -0
  57. ruyi/ruyipkg/list_cli.py +79 -0
  58. ruyi/ruyipkg/list_filter.py +173 -0
  59. ruyi/ruyipkg/msg.py +99 -0
  60. ruyi/ruyipkg/news.py +123 -0
  61. ruyi/ruyipkg/news_cli.py +78 -0
  62. ruyi/ruyipkg/news_store.py +183 -0
  63. ruyi/ruyipkg/pkg_manifest.py +657 -0
  64. ruyi/ruyipkg/profile.py +208 -0
  65. ruyi/ruyipkg/profile_cli.py +33 -0
  66. ruyi/ruyipkg/protocols.py +55 -0
  67. ruyi/ruyipkg/repo.py +763 -0
  68. ruyi/ruyipkg/state.py +345 -0
  69. ruyi/ruyipkg/unpack.py +369 -0
  70. ruyi/ruyipkg/unpack_method.py +91 -0
  71. ruyi/ruyipkg/update_cli.py +54 -0
  72. ruyi/telemetry/__init__.py +0 -0
  73. ruyi/telemetry/aggregate.py +72 -0
  74. ruyi/telemetry/event.py +41 -0
  75. ruyi/telemetry/node_info.py +192 -0
  76. ruyi/telemetry/provider.py +411 -0
  77. ruyi/telemetry/scope.py +43 -0
  78. ruyi/telemetry/store.py +238 -0
  79. ruyi/telemetry/telemetry_cli.py +127 -0
  80. ruyi/utils/__init__.py +0 -0
  81. ruyi/utils/ar.py +74 -0
  82. ruyi/utils/ci.py +63 -0
  83. ruyi/utils/frontmatter.py +38 -0
  84. ruyi/utils/git.py +169 -0
  85. ruyi/utils/global_mode.py +204 -0
  86. ruyi/utils/l10n.py +83 -0
  87. ruyi/utils/markdown.py +73 -0
  88. ruyi/utils/nuitka.py +33 -0
  89. ruyi/utils/porcelain.py +51 -0
  90. ruyi/utils/prereqs.py +77 -0
  91. ruyi/utils/ssl_patch.py +170 -0
  92. ruyi/utils/templating.py +34 -0
  93. ruyi/utils/toml.py +115 -0
  94. ruyi/utils/url.py +7 -0
  95. ruyi/utils/xdg_basedir.py +80 -0
  96. ruyi/version.py +67 -0
  97. ruyi-0.39.0.dist-info/LICENSE-Apache.txt +201 -0
  98. ruyi-0.39.0.dist-info/METADATA +403 -0
  99. ruyi-0.39.0.dist-info/RECORD +101 -0
  100. ruyi-0.39.0.dist-info/WHEEL +4 -0
  101. ruyi-0.39.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,401 @@
1
+ import datetime
2
+ from functools import cached_property
3
+ import locale
4
+ import os.path
5
+ from os import PathLike
6
+ import pathlib
7
+ import sys
8
+ from typing import Any, Final, Iterable, Sequence, TypedDict, TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from typing_extensions import NotRequired, Self
12
+
13
+ from ..log import RuyiLogger
14
+ from ..ruyipkg.repo import MetadataRepo
15
+ from ..ruyipkg.state import RuyipkgGlobalStateStore
16
+ from ..telemetry.provider import TelemetryProvider
17
+ from ..utils.global_mode import ProvidesGlobalMode
18
+ from .news import NewsReadStatusStore
19
+
20
+ from . import schema
21
+
22
+
23
+ if sys.platform == "linux":
24
+ PRESET_GLOBAL_CONFIG_LOCATIONS: Final[list[str]] = [
25
+ # TODO: enable distro packagers to customize the $PREFIX to suit their
26
+ # particular FS layout if necessary.
27
+ "/usr/share/ruyi/config.toml",
28
+ "/usr/local/share/ruyi/config.toml",
29
+ ]
30
+ else:
31
+ PRESET_GLOBAL_CONFIG_LOCATIONS: Final[list[str]] = []
32
+
33
+ DEFAULT_APP_NAME: Final = "ruyi"
34
+ DEFAULT_REPO_URL: Final = "https://github.com/ruyisdk/packages-index.git"
35
+ DEFAULT_REPO_BRANCH: Final = "main"
36
+
37
+
38
+ def get_host_path_fragment_for_binary_install_dir(canonicalized_host: str) -> str:
39
+ # e.g. linux/amd64 -> amd64; "windows/amd64" -> "windows-amd64"
40
+ if canonicalized_host.startswith("linux/"):
41
+ return canonicalized_host[6:]
42
+ return canonicalized_host.replace("/", "-")
43
+
44
+
45
+ def _get_lang_code() -> str:
46
+ lang = locale.getlocale()[0]
47
+ return lang or "en_US"
48
+
49
+
50
+ class GlobalConfigPackagesType(TypedDict):
51
+ prereleases: "NotRequired[bool]"
52
+
53
+
54
+ class GlobalConfigRepoType(TypedDict):
55
+ local: "NotRequired[str]"
56
+ remote: "NotRequired[str]"
57
+ branch: "NotRequired[str]"
58
+
59
+
60
+ class GlobalConfigInstallationType(TypedDict):
61
+ # Undocumented: whether this Ruyi installation is externally managed.
62
+ #
63
+ # Can be used by distro packagers (by placing a config file in /etc/xdg/ruyi)
64
+ # to signify this status to an official Ruyi build (where IS_PACKAGED is
65
+ # True), to prevent e.g. accidental self-uninstallation.
66
+ externally_managed: "NotRequired[bool]"
67
+
68
+
69
+ class GlobalConfigTelemetryType(TypedDict):
70
+ mode: "NotRequired[str]"
71
+ upload_consent: "NotRequired[datetime.datetime | str]"
72
+ pm_telemetry_url: "NotRequired[str]"
73
+
74
+
75
+ class GlobalConfigRootType(TypedDict):
76
+ installation: "NotRequired[GlobalConfigInstallationType]"
77
+ packages: "NotRequired[GlobalConfigPackagesType]"
78
+ repo: "NotRequired[GlobalConfigRepoType]"
79
+ telemetry: "NotRequired[GlobalConfigTelemetryType]"
80
+
81
+
82
+ class GlobalConfig:
83
+ def __init__(self, gm: "ProvidesGlobalMode", logger: "RuyiLogger") -> None:
84
+ from ..utils.xdg_basedir import XDGBaseDir
85
+
86
+ self._gm = gm
87
+ self.logger = logger
88
+
89
+ # all defaults
90
+ self.override_repo_dir: str | None = None
91
+ self.override_repo_url: str | None = None
92
+ self.override_repo_branch: str | None = None
93
+ self.include_prereleases = False
94
+ self.is_installation_externally_managed = False
95
+
96
+ self._lang_code = _get_lang_code()
97
+
98
+ self._dirs = XDGBaseDir(DEFAULT_APP_NAME)
99
+
100
+ self._telemetry_mode: str | None = None
101
+ self._telemetry_upload_consent: datetime.datetime | None = None
102
+ self._telemetry_pm_telemetry_url: str | None = None
103
+
104
+ def apply_config(self, config_data: GlobalConfigRootType) -> None:
105
+ 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
+ )
110
+
111
+ if pkgs_cfg := config_data.get(schema.SECTION_PACKAGES):
112
+ self.include_prereleases = pkgs_cfg.get(
113
+ schema.KEY_PACKAGES_PRERELEASES, False
114
+ )
115
+
116
+ if repo_cfg := config_data.get(schema.SECTION_REPO):
117
+ self.override_repo_dir = repo_cfg.get(schema.KEY_REPO_LOCAL, None)
118
+ self.override_repo_url = repo_cfg.get(schema.KEY_REPO_REMOTE, None)
119
+ self.override_repo_branch = repo_cfg.get(schema.KEY_REPO_BRANCH, None)
120
+
121
+ if self.override_repo_dir:
122
+ if not pathlib.Path(self.override_repo_dir).is_absolute():
123
+ self.logger.W(
124
+ f"the local repo path '{self.override_repo_dir}' is not absolute; ignoring"
125
+ )
126
+ self.override_repo_dir = None
127
+
128
+ if tele_cfg := config_data.get(schema.SECTION_TELEMETRY):
129
+ self._telemetry_mode = tele_cfg.get(schema.KEY_TELEMETRY_MODE, None)
130
+ self._telemetry_pm_telemetry_url = tele_cfg.get(
131
+ schema.KEY_TELEMETRY_PM_TELEMETRY_URL,
132
+ None,
133
+ )
134
+
135
+ self._telemetry_upload_consent = None
136
+ if consent := tele_cfg.get(schema.KEY_TELEMETRY_UPLOAD_CONSENT, None):
137
+ if isinstance(consent, datetime.datetime):
138
+ self._telemetry_upload_consent = consent
139
+
140
+ def get_by_key(self, key: str | Sequence[str]) -> object | None:
141
+ parsed_key = schema.parse_config_key(key)
142
+ section, sel = parsed_key[0], parsed_key[1:]
143
+ if section == schema.SECTION_INSTALLATION:
144
+ return self._get_section_installation(sel)
145
+ elif section == schema.SECTION_PACKAGES:
146
+ return self._get_section_packages(sel)
147
+ elif section == schema.SECTION_REPO:
148
+ return self._get_section_repo(sel)
149
+ elif section == schema.SECTION_TELEMETRY:
150
+ return self._get_section_telemetry(sel)
151
+ else:
152
+ return None
153
+
154
+ def _get_section_installation(self, selector: list[str]) -> object | None:
155
+ if len(selector) != 1:
156
+ return None
157
+ leaf = selector[0]
158
+ if leaf == schema.KEY_INSTALLATION_EXTERNALLY_MANAGED:
159
+ return self.is_installation_externally_managed
160
+ else:
161
+ return None
162
+
163
+ def _get_section_packages(self, selector: list[str]) -> object | None:
164
+ if len(selector) != 1:
165
+ return None
166
+ leaf = selector[0]
167
+ if leaf == schema.KEY_PACKAGES_PRERELEASES:
168
+ return self.include_prereleases
169
+ else:
170
+ return None
171
+
172
+ def _get_section_repo(self, selector: list[str]) -> object | None:
173
+ if len(selector) != 1:
174
+ return None
175
+ leaf = selector[0]
176
+ if leaf == schema.KEY_REPO_BRANCH:
177
+ return self.override_repo_branch
178
+ elif leaf == schema.KEY_REPO_LOCAL:
179
+ return self.override_repo_dir
180
+ elif leaf == schema.KEY_REPO_REMOTE:
181
+ return self.override_repo_url
182
+ else:
183
+ return None
184
+
185
+ def _get_section_telemetry(self, selector: list[str]) -> object | None:
186
+ if len(selector) != 1:
187
+ return None
188
+ leaf = selector[0]
189
+ if leaf == schema.KEY_TELEMETRY_MODE:
190
+ return self.telemetry_mode
191
+ elif leaf == schema.KEY_TELEMETRY_PM_TELEMETRY_URL:
192
+ return self.override_pm_telemetry_url
193
+ elif leaf == schema.KEY_TELEMETRY_UPLOAD_CONSENT:
194
+ return self.telemetry_upload_consent_time
195
+ else:
196
+ return None
197
+
198
+ @property
199
+ def argv0(self) -> str:
200
+ return self._gm.argv0
201
+
202
+ @property
203
+ def main_file(self) -> str:
204
+ return self._gm.main_file
205
+
206
+ @property
207
+ def self_exe(self) -> str:
208
+ return self._gm.self_exe
209
+
210
+ @property
211
+ def is_debug(self) -> bool:
212
+ return self._gm.is_debug
213
+
214
+ @property
215
+ def is_experimental(self) -> bool:
216
+ return self._gm.is_experimental
217
+
218
+ @property
219
+ def is_packaged(self) -> bool:
220
+ return self._gm.is_packaged
221
+
222
+ @property
223
+ def is_porcelain(self) -> bool:
224
+ return self._gm.is_porcelain
225
+
226
+ @property
227
+ def is_telemetry_optout(self) -> bool:
228
+ return self._gm.is_telemetry_optout
229
+
230
+ @property
231
+ def is_cli_autocomplete(self) -> bool:
232
+ return self._gm.is_cli_autocomplete
233
+
234
+ @property
235
+ def venv_root(self) -> str | None:
236
+ return self._gm.venv_root
237
+
238
+ @property
239
+ def lang_code(self) -> str:
240
+ return self._lang_code
241
+
242
+ @property
243
+ def cache_root(self) -> os.PathLike[Any]:
244
+ return self._dirs.app_cache
245
+
246
+ @property
247
+ def data_root(self) -> os.PathLike[Any]:
248
+ return self._dirs.app_data
249
+
250
+ @property
251
+ def state_root(self) -> os.PathLike[Any]:
252
+ return self._dirs.app_state
253
+
254
+ @cached_property
255
+ def news_read_status(self) -> "NewsReadStatusStore":
256
+ from .news import NewsReadStatusStore
257
+
258
+ filename = os.path.join(self.ensure_state_dir(), "news.read.txt")
259
+ return NewsReadStatusStore(filename)
260
+
261
+ @property
262
+ def telemetry_root(self) -> os.PathLike[Any]:
263
+ return pathlib.Path(self.ensure_state_dir()) / "telemetry"
264
+
265
+ @cached_property
266
+ def telemetry(self) -> "TelemetryProvider | None":
267
+ from ..telemetry.provider import TelemetryProvider
268
+
269
+ return None if self.telemetry_mode == "off" else TelemetryProvider(self)
270
+
271
+ @property
272
+ def telemetry_mode(self) -> str:
273
+ return self._telemetry_mode or "on"
274
+
275
+ @property
276
+ def telemetry_upload_consent_time(self) -> datetime.datetime | None:
277
+ return self._telemetry_upload_consent
278
+
279
+ @property
280
+ def override_pm_telemetry_url(self) -> str | None:
281
+ return self._telemetry_pm_telemetry_url
282
+
283
+ def get_repo_dir(self) -> str:
284
+ return self.override_repo_dir or os.path.join(self.cache_root, "packages-index")
285
+
286
+ def get_repo_url(self) -> str:
287
+ return self.override_repo_url or DEFAULT_REPO_URL
288
+
289
+ def get_repo_branch(self) -> str:
290
+ return self.override_repo_branch or DEFAULT_REPO_BRANCH
291
+
292
+ @cached_property
293
+ def repo(self) -> "MetadataRepo":
294
+ from ..ruyipkg.repo import MetadataRepo
295
+
296
+ return MetadataRepo(self)
297
+
298
+ def ensure_distfiles_dir(self) -> str:
299
+ path = pathlib.Path(self.ensure_cache_dir()) / "distfiles"
300
+ path.mkdir(parents=True, exist_ok=True)
301
+ return str(path)
302
+
303
+ def global_binary_install_root(self, host: str, slug: str) -> str:
304
+ host_path = get_host_path_fragment_for_binary_install_dir(host)
305
+ path = pathlib.Path(self.ensure_data_dir()) / "binaries" / host_path / slug
306
+ return str(path)
307
+
308
+ def global_blob_install_root(self, slug: str) -> str:
309
+ path = pathlib.Path(self.ensure_data_dir()) / "blobs" / slug
310
+ return str(path)
311
+
312
+ def lookup_binary_install_dir(self, host: str, slug: str) -> PathLike[Any] | None:
313
+ host_path = get_host_path_fragment_for_binary_install_dir(host)
314
+ for data_dir in self._dirs.app_data_dirs:
315
+ p = data_dir / "binaries" / host_path / slug
316
+ if p.exists():
317
+ return p
318
+ return None
319
+
320
+ @property
321
+ def ruyipkg_state_root(self) -> os.PathLike[Any]:
322
+ return pathlib.Path(self.ensure_state_dir()) / "ruyipkg"
323
+
324
+ @cached_property
325
+ def ruyipkg_global_state(self) -> "RuyipkgGlobalStateStore":
326
+ from ..ruyipkg.state import RuyipkgGlobalStateStore
327
+
328
+ return RuyipkgGlobalStateStore(self.ruyipkg_state_root)
329
+
330
+ def ensure_data_dir(self) -> os.PathLike[Any]:
331
+ p = self._dirs.app_data
332
+ p.mkdir(parents=True, exist_ok=True)
333
+ return p
334
+
335
+ def ensure_cache_dir(self) -> os.PathLike[Any]:
336
+ p = self._dirs.app_cache
337
+ p.mkdir(parents=True, exist_ok=True)
338
+ return p
339
+
340
+ def ensure_config_dir(self) -> os.PathLike[Any]:
341
+ p = self._dirs.app_config
342
+ p.mkdir(parents=True, exist_ok=True)
343
+ return p
344
+
345
+ def ensure_state_dir(self) -> os.PathLike[Any]:
346
+ p = self._dirs.app_state
347
+ p.mkdir(parents=True, exist_ok=True)
348
+ return p
349
+
350
+ def iter_preset_configs(self) -> Iterable[os.PathLike[Any]]:
351
+ """
352
+ Yields possible Ruyi config files in all preset config path locations,
353
+ sorted by precedence from lowest to highest (so that each file may be
354
+ simply applied consecutively).
355
+ """
356
+
357
+ for path in PRESET_GLOBAL_CONFIG_LOCATIONS:
358
+ yield pathlib.Path(path)
359
+
360
+ def iter_xdg_configs(self) -> Iterable[os.PathLike[Any]]:
361
+ """
362
+ Yields possible Ruyi config files in all XDG config paths, sorted by precedence
363
+ from lowest to highest (so that each file may be simply applied consecutively).
364
+ """
365
+
366
+ for config_dir in reversed(list(self._dirs.app_config_dirs)):
367
+ yield config_dir / "config.toml"
368
+
369
+ @property
370
+ def local_user_config_file(self) -> pathlib.Path:
371
+ return self._dirs.app_config / "config.toml"
372
+
373
+ def try_apply_config_file(self, path: os.PathLike[Any]) -> None:
374
+ import tomlkit
375
+
376
+ try:
377
+ with open(path, "rb") as fp:
378
+ data: Any = tomlkit.load(fp)
379
+ except FileNotFoundError:
380
+ return
381
+
382
+ self.logger.D(f"applying config: {data}")
383
+ self.apply_config(data)
384
+
385
+ @classmethod
386
+ def load_from_config(cls, gm: "ProvidesGlobalMode", logger: "RuyiLogger") -> "Self":
387
+ obj = cls(gm, logger)
388
+
389
+ for config_path in obj.iter_preset_configs():
390
+ obj.logger.D(f"trying config file from preset location: {config_path}")
391
+ obj.try_apply_config_file(config_path)
392
+
393
+ for config_path in obj.iter_xdg_configs():
394
+ obj.logger.D(f"trying config file from XDG path: {config_path}")
395
+ obj.try_apply_config_file(config_path)
396
+
397
+ # let environment variable take precedence
398
+ if gm.is_telemetry_optout:
399
+ obj._telemetry_mode = "off"
400
+
401
+ return obj
ruyi/config/editor.py ADDED
@@ -0,0 +1,92 @@
1
+ from contextlib import AbstractContextManager
2
+ import pathlib
3
+ from typing import Sequence, TYPE_CHECKING, cast
4
+
5
+ if TYPE_CHECKING:
6
+ from types import TracebackType
7
+ from typing_extensions import Self
8
+
9
+ import tomlkit
10
+ from tomlkit.items import Table
11
+
12
+ from .errors import MalformedConfigFileError
13
+ from .schema import ensure_valid_config_kv, parse_config_key, validate_section
14
+
15
+ if TYPE_CHECKING:
16
+ from . import GlobalConfig
17
+
18
+
19
+ class ConfigEditor(AbstractContextManager["ConfigEditor"]):
20
+ def __init__(self, path: pathlib.Path) -> None:
21
+ self._path = path
22
+ self._touched = False
23
+ try:
24
+ with open(path) as fp:
25
+ self._content = tomlkit.load(fp)
26
+ if not isinstance(self._content, tomlkit.TOMLDocument):
27
+ raise MalformedConfigFileError(path)
28
+ except FileNotFoundError:
29
+ self._content = tomlkit.document()
30
+
31
+ self._stage = cast(tomlkit.TOMLDocument, self._content.copy())
32
+
33
+ @classmethod
34
+ def work_on_user_local_config(cls, gc: "GlobalConfig") -> "Self":
35
+ return cls(gc.local_user_config_file)
36
+
37
+ def __enter__(self) -> "Self":
38
+ return self
39
+
40
+ def __exit__(
41
+ self,
42
+ exc_type: type[BaseException] | None,
43
+ exc_value: BaseException | None,
44
+ traceback: "TracebackType | None",
45
+ ) -> bool | None:
46
+ self._commit()
47
+ return None
48
+
49
+ def _commit(self) -> None:
50
+ if not self._touched:
51
+ return
52
+
53
+ self._path.parent.mkdir(parents=True, exist_ok=True)
54
+ with open(self._path, "w", encoding="utf-8") as fp:
55
+ tomlkit.dump(self._content, fp)
56
+
57
+ def stage(self) -> None:
58
+ self._content = self._stage
59
+ self._touched = True
60
+ self._stage = cast(tomlkit.TOMLDocument, self._content.copy())
61
+
62
+ def set_value(self, key: str | Sequence[str], val: object | None) -> None:
63
+ parsed_key = parse_config_key(key)
64
+ ensure_valid_config_kv(parsed_key, check_val=True, val=val)
65
+
66
+ section, sel = parsed_key[0], parsed_key[1:]
67
+ if section in self._stage:
68
+ existing_section = self._stage[section]
69
+ if not isinstance(existing_section, Table):
70
+ raise MalformedConfigFileError(self._path)
71
+ existing_section.update({sel[0]: val})
72
+ else:
73
+ # append a section with its sole key set to val
74
+ new_section = tomlkit.table()
75
+ new_section.append(sel[0], val)
76
+ self._stage.append(section, new_section)
77
+
78
+ def unset_value(self, key: str | Sequence[str]) -> None:
79
+ parsed_key = parse_config_key(key)
80
+ ensure_valid_config_kv(parsed_key, check_val=False)
81
+
82
+ section, sel = parsed_key[0], parsed_key[1:]
83
+ if existing_section := self._stage.get(section):
84
+ if not isinstance(existing_section, Table):
85
+ raise MalformedConfigFileError(self._path)
86
+ if sel[0] in existing_section:
87
+ existing_section.pop(sel[0])
88
+
89
+ def remove_section(self, section: str) -> None:
90
+ validate_section(section)
91
+ if section in self._stage:
92
+ self._stage.pop(section)
ruyi/config/errors.py ADDED
@@ -0,0 +1,76 @@
1
+ from os import PathLike
2
+ from typing import Any, Sequence
3
+
4
+
5
+ class InvalidConfigSectionError(Exception):
6
+ def __init__(self, section: str) -> None:
7
+ super().__init__()
8
+ self._section = section
9
+
10
+ def __str__(self) -> str:
11
+ return f"invalid config section: {self._section}"
12
+
13
+ def __repr__(self) -> str:
14
+ return f"InvalidConfigSectionError({self._section!r})"
15
+
16
+
17
+ class InvalidConfigKeyError(Exception):
18
+ def __init__(self, key: str | Sequence[str]) -> None:
19
+ super().__init__()
20
+ self._key = key
21
+
22
+ def __str__(self) -> str:
23
+ return f"invalid config key: {self._key}"
24
+
25
+ def __repr__(self) -> str:
26
+ return f"InvalidConfigKeyError({self._key:!r})"
27
+
28
+
29
+ class InvalidConfigValueTypeError(TypeError):
30
+ def __init__(
31
+ self,
32
+ key: str | Sequence[str],
33
+ val: object | None,
34
+ expected: type,
35
+ ) -> None:
36
+ super().__init__()
37
+ self._key = key
38
+ self._val = val
39
+ self._expected = expected
40
+
41
+ def __str__(self) -> str:
42
+ return f"invalid value type for config key {self._key}: {type(self._val)}, expected {self._expected}"
43
+
44
+ def __repr__(self) -> str:
45
+ return f"InvalidConfigValueTypeError({self._key!r}, {self._val!r}, {self._expected:!r})"
46
+
47
+
48
+ class InvalidConfigValueError(ValueError):
49
+ def __init__(
50
+ self,
51
+ key: str | Sequence[str] | type,
52
+ val: object | None,
53
+ ) -> None:
54
+ super().__init__()
55
+ self._key = key
56
+ self._val = val
57
+
58
+ 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}"
62
+
63
+ def __repr__(self) -> str:
64
+ return f"InvalidConfigValueError({self._key:!r}, {self._val:!r})"
65
+
66
+
67
+ class MalformedConfigFileError(Exception):
68
+ def __init__(self, path: PathLike[Any]) -> None:
69
+ super().__init__()
70
+ self._path = path
71
+
72
+ def __str__(self) -> str:
73
+ return f"malformed config file: {self._path}"
74
+
75
+ def __repr__(self) -> str:
76
+ return f"MalformedConfigFileError({self._path:!r})"
ruyi/config/news.py ADDED
@@ -0,0 +1,39 @@
1
+ import os
2
+
3
+
4
+ class NewsReadStatusStore:
5
+ def __init__(self, path: str) -> None:
6
+ self._path = path
7
+ self._status: set[str] = set()
8
+ self._orig_status: set[str] = set()
9
+
10
+ def load(self) -> None:
11
+ try:
12
+ with open(self._path, "r", encoding="utf-8") as fp:
13
+ for line in fp:
14
+ self._orig_status.add(line.strip())
15
+ except FileNotFoundError:
16
+ return
17
+
18
+ self._status = self._orig_status.copy()
19
+
20
+ def __contains__(self, key: str) -> bool:
21
+ return key in self._status
22
+
23
+ def add(self, id: str) -> None:
24
+ return self._status.add(id)
25
+
26
+ def save(self) -> None:
27
+ if self._status == self._orig_status:
28
+ return
29
+
30
+ content = "".join(f"{id}\n" for id in self._status)
31
+ with open(self._path, "w", encoding="utf-8") as fp:
32
+ fp.write(content)
33
+
34
+ def remove(self) -> None:
35
+ try:
36
+ os.unlink(self._path)
37
+ except FileNotFoundError:
38
+ # nothing to remove, that's fine
39
+ pass