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.
- ruyi/__init__.py +21 -0
- ruyi/__main__.py +98 -0
- ruyi/cli/__init__.py +5 -0
- ruyi/cli/builtin_commands.py +14 -0
- ruyi/cli/cmd.py +224 -0
- ruyi/cli/completer.py +50 -0
- ruyi/cli/completion.py +26 -0
- ruyi/cli/config_cli.py +153 -0
- ruyi/cli/main.py +111 -0
- ruyi/cli/self_cli.py +295 -0
- ruyi/cli/user_input.py +127 -0
- ruyi/cli/version_cli.py +45 -0
- ruyi/config/__init__.py +401 -0
- ruyi/config/editor.py +92 -0
- ruyi/config/errors.py +76 -0
- ruyi/config/news.py +39 -0
- ruyi/config/schema.py +197 -0
- ruyi/device/__init__.py +0 -0
- ruyi/device/provision.py +591 -0
- ruyi/device/provision_cli.py +40 -0
- ruyi/log/__init__.py +272 -0
- ruyi/mux/.gitignore +1 -0
- ruyi/mux/__init__.py +0 -0
- ruyi/mux/runtime.py +213 -0
- ruyi/mux/venv/__init__.py +12 -0
- ruyi/mux/venv/emulator_cfg.py +41 -0
- ruyi/mux/venv/maker.py +782 -0
- ruyi/mux/venv/venv_cli.py +92 -0
- ruyi/mux/venv_cfg.py +214 -0
- ruyi/pluginhost/__init__.py +0 -0
- ruyi/pluginhost/api.py +206 -0
- ruyi/pluginhost/ctx.py +222 -0
- ruyi/pluginhost/paths.py +135 -0
- ruyi/pluginhost/plugin_cli.py +37 -0
- ruyi/pluginhost/unsandboxed.py +246 -0
- ruyi/py.typed +0 -0
- ruyi/resource_bundle/__init__.py +20 -0
- ruyi/resource_bundle/__main__.py +55 -0
- ruyi/resource_bundle/data.py +26 -0
- ruyi/ruyipkg/__init__.py +0 -0
- ruyi/ruyipkg/admin_checksum.py +88 -0
- ruyi/ruyipkg/admin_cli.py +83 -0
- ruyi/ruyipkg/atom.py +184 -0
- ruyi/ruyipkg/augmented_pkg.py +212 -0
- ruyi/ruyipkg/canonical_dump.py +320 -0
- ruyi/ruyipkg/checksum.py +39 -0
- ruyi/ruyipkg/cli_completion.py +42 -0
- ruyi/ruyipkg/distfile.py +208 -0
- ruyi/ruyipkg/entity.py +387 -0
- ruyi/ruyipkg/entity_cli.py +123 -0
- ruyi/ruyipkg/entity_provider.py +273 -0
- ruyi/ruyipkg/fetch.py +271 -0
- ruyi/ruyipkg/host.py +55 -0
- ruyi/ruyipkg/install.py +554 -0
- ruyi/ruyipkg/install_cli.py +150 -0
- ruyi/ruyipkg/list.py +126 -0
- ruyi/ruyipkg/list_cli.py +79 -0
- ruyi/ruyipkg/list_filter.py +173 -0
- ruyi/ruyipkg/msg.py +99 -0
- ruyi/ruyipkg/news.py +123 -0
- ruyi/ruyipkg/news_cli.py +78 -0
- ruyi/ruyipkg/news_store.py +183 -0
- ruyi/ruyipkg/pkg_manifest.py +657 -0
- ruyi/ruyipkg/profile.py +208 -0
- ruyi/ruyipkg/profile_cli.py +33 -0
- ruyi/ruyipkg/protocols.py +55 -0
- ruyi/ruyipkg/repo.py +763 -0
- ruyi/ruyipkg/state.py +345 -0
- ruyi/ruyipkg/unpack.py +369 -0
- ruyi/ruyipkg/unpack_method.py +91 -0
- ruyi/ruyipkg/update_cli.py +54 -0
- ruyi/telemetry/__init__.py +0 -0
- ruyi/telemetry/aggregate.py +72 -0
- ruyi/telemetry/event.py +41 -0
- ruyi/telemetry/node_info.py +192 -0
- ruyi/telemetry/provider.py +411 -0
- ruyi/telemetry/scope.py +43 -0
- ruyi/telemetry/store.py +238 -0
- ruyi/telemetry/telemetry_cli.py +127 -0
- ruyi/utils/__init__.py +0 -0
- ruyi/utils/ar.py +74 -0
- ruyi/utils/ci.py +63 -0
- ruyi/utils/frontmatter.py +38 -0
- ruyi/utils/git.py +169 -0
- ruyi/utils/global_mode.py +204 -0
- ruyi/utils/l10n.py +83 -0
- ruyi/utils/markdown.py +73 -0
- ruyi/utils/nuitka.py +33 -0
- ruyi/utils/porcelain.py +51 -0
- ruyi/utils/prereqs.py +77 -0
- ruyi/utils/ssl_patch.py +170 -0
- ruyi/utils/templating.py +34 -0
- ruyi/utils/toml.py +115 -0
- ruyi/utils/url.py +7 -0
- ruyi/utils/xdg_basedir.py +80 -0
- ruyi/version.py +67 -0
- ruyi-0.39.0.dist-info/LICENSE-Apache.txt +201 -0
- ruyi-0.39.0.dist-info/METADATA +403 -0
- ruyi-0.39.0.dist-info/RECORD +101 -0
- ruyi-0.39.0.dist-info/WHEEL +4 -0
- ruyi-0.39.0.dist-info/entry_points.txt +3 -0
ruyi/config/__init__.py
ADDED
|
@@ -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
|