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/utils/l10n.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import locale
|
|
2
|
+
|
|
3
|
+
from typing import Iterable, NamedTuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LangAndRegion(NamedTuple):
|
|
7
|
+
raw: str
|
|
8
|
+
lang: str
|
|
9
|
+
region: str | None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def lang_code_to_lang_region(lang_code: str, guess_region: bool) -> LangAndRegion:
|
|
13
|
+
if not guess_region and "_" not in lang_code:
|
|
14
|
+
return LangAndRegion(lang_code, lang_code, None)
|
|
15
|
+
|
|
16
|
+
lang_region_str = locale.normalize(lang_code).split(".")[0]
|
|
17
|
+
parts = lang_region_str.split("_", 2)
|
|
18
|
+
if len(parts) == 1:
|
|
19
|
+
return LangAndRegion(lang_code, lang_region_str, None)
|
|
20
|
+
return LangAndRegion(lang_code, parts[0], parts[1])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def match_lang_code(
|
|
24
|
+
req: str,
|
|
25
|
+
avail: Iterable[str],
|
|
26
|
+
) -> str:
|
|
27
|
+
"""Returns a proper available language code based on a list of available
|
|
28
|
+
language codes, and a request."""
|
|
29
|
+
|
|
30
|
+
if not isinstance(avail, set) or not isinstance(avail, frozenset):
|
|
31
|
+
avail = set(avail)
|
|
32
|
+
|
|
33
|
+
# return the only one choice if this is the case
|
|
34
|
+
if len(avail) == 1:
|
|
35
|
+
return next(iter(avail))
|
|
36
|
+
|
|
37
|
+
# try exact match
|
|
38
|
+
if req in avail:
|
|
39
|
+
return req
|
|
40
|
+
|
|
41
|
+
return _match_lang_code_slowpath(
|
|
42
|
+
lang_code_to_lang_region(req, True),
|
|
43
|
+
[lang_code_to_lang_region(x, False) for x in avail],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _match_lang_code_slowpath(
|
|
48
|
+
req: LangAndRegion,
|
|
49
|
+
avail: list[LangAndRegion],
|
|
50
|
+
) -> str:
|
|
51
|
+
# pick one with the requested region
|
|
52
|
+
if req.region is not None:
|
|
53
|
+
for x in avail:
|
|
54
|
+
if x.region == req.region:
|
|
55
|
+
return x.raw
|
|
56
|
+
|
|
57
|
+
# if no match, pick one with the requested language
|
|
58
|
+
for x in avail:
|
|
59
|
+
if x.lang == req.lang:
|
|
60
|
+
return x.raw
|
|
61
|
+
|
|
62
|
+
# neither matches, fallback to (en_US, en, en_*, zh_CN, zh, zh_*)
|
|
63
|
+
# in that order
|
|
64
|
+
fallback_en = {x.region: x.raw for x in avail if x.lang == "en"}
|
|
65
|
+
if fallback_en:
|
|
66
|
+
if "US" in fallback_en:
|
|
67
|
+
return fallback_en["US"]
|
|
68
|
+
if None in fallback_en:
|
|
69
|
+
return fallback_en[None]
|
|
70
|
+
return fallback_en[sorted(x for x in fallback_en.keys() if x is not None)[0]]
|
|
71
|
+
|
|
72
|
+
fallback_zh = {x.region: x.raw for x in avail if x.lang == "zh"}
|
|
73
|
+
if fallback_zh:
|
|
74
|
+
if "CN" in fallback_zh:
|
|
75
|
+
return fallback_zh["CN"]
|
|
76
|
+
if None in fallback_zh:
|
|
77
|
+
return fallback_zh[None]
|
|
78
|
+
return fallback_zh[sorted(x for x in fallback_zh.keys() if x is not None)[0]]
|
|
79
|
+
|
|
80
|
+
# neither en nor zh is available (which is highly unlikely at present)
|
|
81
|
+
# pick the first available one as a last resort
|
|
82
|
+
# sort the list before picking for determinism
|
|
83
|
+
return sorted(x.raw for x in avail)[0]
|
ruyi/utils/markdown.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
2
|
+
from rich.markdown import CodeBlock, Heading, Markdown, MarkdownContext
|
|
3
|
+
from rich.syntax import Syntax
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SlimHeading(Heading):
|
|
8
|
+
def on_enter(self, context: MarkdownContext) -> None:
|
|
9
|
+
try:
|
|
10
|
+
# the heading level is indicated in the tag name in rich >= 13.2.0,
|
|
11
|
+
# e.g. self.tag == 'h1', but directly stored in earlier versions
|
|
12
|
+
# as self.level.
|
|
13
|
+
#
|
|
14
|
+
# see https://github.com/Textualize/rich/commit/a20c3d5468d02a55
|
|
15
|
+
heading_level = int(self.tag[1:]) # type: ignore[attr-defined,unused-ignore]
|
|
16
|
+
except AttributeError:
|
|
17
|
+
heading_level = self.level # type: ignore[attr-defined,unused-ignore]
|
|
18
|
+
|
|
19
|
+
context.enter_style(self.style_name)
|
|
20
|
+
self.text = Text("#" * heading_level + " ", context.current_style)
|
|
21
|
+
|
|
22
|
+
def __rich_console__(
|
|
23
|
+
self,
|
|
24
|
+
console: Console,
|
|
25
|
+
options: ConsoleOptions,
|
|
26
|
+
) -> RenderResult:
|
|
27
|
+
yield self.text
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# inspired by https://github.com/Textualize/rich/issues/3154
|
|
31
|
+
class NonWrappingCodeBlock(CodeBlock):
|
|
32
|
+
def __rich_console__(
|
|
33
|
+
self,
|
|
34
|
+
console: Console,
|
|
35
|
+
options: ConsoleOptions,
|
|
36
|
+
) -> RenderResult:
|
|
37
|
+
# re-enable non-wrapping options locally for code blocks
|
|
38
|
+
render_options = options.update(no_wrap=True, overflow="ignore")
|
|
39
|
+
|
|
40
|
+
code = str(self.text).rstrip()
|
|
41
|
+
syntax = Syntax(
|
|
42
|
+
code,
|
|
43
|
+
self.lexer_name,
|
|
44
|
+
theme=self.theme,
|
|
45
|
+
word_wrap=False,
|
|
46
|
+
# not supported in rich <= 12.4.0 (Textualize/rich#2247) but fortunately
|
|
47
|
+
# zero padding is the default anyway
|
|
48
|
+
# padding=0,
|
|
49
|
+
)
|
|
50
|
+
return syntax.highlight(code).__rich_console__(console, render_options)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RuyiStyledMarkdown(Markdown):
|
|
54
|
+
elements = Markdown.elements
|
|
55
|
+
elements["fence"] = NonWrappingCodeBlock
|
|
56
|
+
elements["heading_open"] = SlimHeading
|
|
57
|
+
|
|
58
|
+
# rich < 13.2.0
|
|
59
|
+
# see https://github.com/Textualize/rich/commit/745bd99e416c2806
|
|
60
|
+
# it doesn't hurt to just unconditionally add them like below
|
|
61
|
+
elements["code"] = NonWrappingCodeBlock
|
|
62
|
+
elements["code_block"] = NonWrappingCodeBlock
|
|
63
|
+
elements["heading"] = SlimHeading
|
|
64
|
+
|
|
65
|
+
def __rich_console__(
|
|
66
|
+
self,
|
|
67
|
+
console: Console,
|
|
68
|
+
options: ConsoleOptions,
|
|
69
|
+
) -> RenderResult:
|
|
70
|
+
# we have to undo the ruyi-global console's non-wrapping setting
|
|
71
|
+
# for proper CLI rendering of long lines
|
|
72
|
+
render_options = options.update(no_wrap=False, overflow="fold")
|
|
73
|
+
return super().__rich_console__(console, render_options)
|
ruyi/utils/nuitka.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_nuitka_self_exe() -> str:
|
|
6
|
+
try:
|
|
7
|
+
# Assume we're a Nuitka onefile build, so our parent process is the onefile
|
|
8
|
+
# bootstrap process. The onefile bootstrapper puts "our path" in the
|
|
9
|
+
# undocumented environment variable $NUITKA_ONEFILE_BINARY, which works
|
|
10
|
+
# on both Linux and Windows.
|
|
11
|
+
return os.environ["NUITKA_ONEFILE_BINARY"]
|
|
12
|
+
except KeyError:
|
|
13
|
+
# It seems we are instead launched from the extracted onefile tempdir.
|
|
14
|
+
# Assume our name is "ruyi" in this case; directory is available in
|
|
15
|
+
# Nuitka metadata.
|
|
16
|
+
import ruyi
|
|
17
|
+
|
|
18
|
+
return os.path.join(ruyi.__compiled__.containing_dir, "ruyi")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_argv0() -> str:
|
|
22
|
+
import ruyi
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
if ruyi.__compiled__.original_argv0 is not None:
|
|
26
|
+
return ruyi.__compiled__.original_argv0
|
|
27
|
+
except AttributeError:
|
|
28
|
+
# Either we're not packaged with Nuitka, or the Nuitka used is
|
|
29
|
+
# without our original_argv0 patch, in which case we cannot do any
|
|
30
|
+
# better than simply returning sys.argv[0].
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
return sys.argv[0]
|
ruyi/utils/porcelain.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from contextlib import AbstractContextManager
|
|
2
|
+
import enum
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import BinaryIO, TypedDict, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from typing_extensions import Self
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
|
|
13
|
+
class PorcelainEntityType(enum.StrEnum):
|
|
14
|
+
LogV1 = "log-v1"
|
|
15
|
+
NewsItemV1 = "newsitem-v1"
|
|
16
|
+
PkgListOutputV1 = "pkglistoutput-v1"
|
|
17
|
+
EntityListOutputV1 = "entitylistoutput-v1"
|
|
18
|
+
|
|
19
|
+
else:
|
|
20
|
+
|
|
21
|
+
class PorcelainEntityType(str, enum.Enum):
|
|
22
|
+
LogV1 = "log-v1"
|
|
23
|
+
NewsItemV1 = "newsitem-v1"
|
|
24
|
+
PkgListOutputV1 = "pkglistoutput-v1"
|
|
25
|
+
EntityListOutputV1 = "entitylistoutput-v1"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PorcelainEntity(TypedDict):
|
|
29
|
+
ty: PorcelainEntityType
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PorcelainOutput(AbstractContextManager["PorcelainOutput"]):
|
|
33
|
+
def __init__(self, out: BinaryIO | None = None) -> None:
|
|
34
|
+
self.out = sys.stdout.buffer if out is None else out
|
|
35
|
+
|
|
36
|
+
def __enter__(self) -> "Self":
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def __exit__(
|
|
40
|
+
self,
|
|
41
|
+
exc_type: type[BaseException] | None,
|
|
42
|
+
exc_value: BaseException | None,
|
|
43
|
+
traceback: TracebackType | None,
|
|
44
|
+
) -> bool | None:
|
|
45
|
+
self.out.flush()
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def emit(self, obj: PorcelainEntity) -> None:
|
|
49
|
+
s = json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
|
50
|
+
self.out.write(s.encode("utf-8"))
|
|
51
|
+
self.out.write(b"\n")
|
ruyi/utils/prereqs.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Final, Iterable, NoReturn
|
|
4
|
+
|
|
5
|
+
from ..cli.user_input import pause_before_continuing
|
|
6
|
+
from ..log import RuyiLogger, humanize_list
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def has_cmd_in_path(cmd: str) -> bool:
|
|
10
|
+
return shutil.which(cmd) is not None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_CMDS: Final = (
|
|
14
|
+
"bzip2",
|
|
15
|
+
"gunzip",
|
|
16
|
+
"lz4",
|
|
17
|
+
"tar",
|
|
18
|
+
"xz",
|
|
19
|
+
"zstd",
|
|
20
|
+
"unzip",
|
|
21
|
+
# commands used by the device provisioner
|
|
22
|
+
"sudo",
|
|
23
|
+
"dd",
|
|
24
|
+
"fastboot",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_CMD_PRESENCE_MAP: Final[dict[str, bool]] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def init_cmd_presence_map() -> None:
|
|
31
|
+
_CMD_PRESENCE_MAP.clear()
|
|
32
|
+
for cmd in _CMDS:
|
|
33
|
+
_CMD_PRESENCE_MAP[cmd] = has_cmd_in_path(cmd)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ensure_cmds(
|
|
37
|
+
logger: RuyiLogger,
|
|
38
|
+
cmds: Iterable[str],
|
|
39
|
+
interactive_retry: bool = True,
|
|
40
|
+
) -> None | NoReturn:
|
|
41
|
+
# only allow interactive retry if stdin is a TTY
|
|
42
|
+
interactive_retry = interactive_retry and sys.stdin.isatty()
|
|
43
|
+
|
|
44
|
+
while True:
|
|
45
|
+
if not _CMD_PRESENCE_MAP or interactive_retry:
|
|
46
|
+
init_cmd_presence_map()
|
|
47
|
+
|
|
48
|
+
# in case any command's availability is not cached in advance
|
|
49
|
+
for cmd in cmds:
|
|
50
|
+
if cmd not in _CMD_PRESENCE_MAP:
|
|
51
|
+
_CMD_PRESENCE_MAP[cmd] = has_cmd_in_path(cmd)
|
|
52
|
+
|
|
53
|
+
absent_cmds = sorted(
|
|
54
|
+
cmd for cmd in cmds if not _CMD_PRESENCE_MAP.get(cmd, False)
|
|
55
|
+
)
|
|
56
|
+
if not absent_cmds:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
cmds_str = humanize_list(absent_cmds, item_color="yellow")
|
|
60
|
+
prompt = f"The command(s) {cmds_str} cannot be found in PATH, which [yellow]ruyi[/] requires"
|
|
61
|
+
if not interactive_retry:
|
|
62
|
+
logger.F(prompt)
|
|
63
|
+
logger.I("please install and retry")
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
logger.W(prompt)
|
|
67
|
+
logger.I(
|
|
68
|
+
"please install them and press [green]Enter[/] to retry, or [green]Ctrl+C[/] to exit"
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
pause_before_continuing(logger)
|
|
72
|
+
except EOFError:
|
|
73
|
+
logger.I("exiting due to EOF")
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
except KeyboardInterrupt:
|
|
76
|
+
logger.I("exiting due to keyboard interrupt")
|
|
77
|
+
sys.exit(1)
|
ruyi/utils/ssl_patch.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import ctypes
|
|
2
|
+
import os
|
|
3
|
+
import ssl
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Final, NamedTuple
|
|
6
|
+
|
|
7
|
+
import certifi
|
|
8
|
+
|
|
9
|
+
from ..log import RuyiConsoleLogger, RuyiLogger
|
|
10
|
+
from .global_mode import EnvGlobalModeProvider
|
|
11
|
+
|
|
12
|
+
_orig_get_default_verify_paths: Final = ssl.get_default_verify_paths
|
|
13
|
+
_cached_paths: ssl.DefaultVerifyPaths | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_system_ssl_default_verify_paths() -> ssl.DefaultVerifyPaths:
|
|
17
|
+
global _cached_paths
|
|
18
|
+
|
|
19
|
+
if _cached_paths is None:
|
|
20
|
+
# no way to pass in the logger because of the function signature
|
|
21
|
+
# so we have to use a new logger
|
|
22
|
+
gm = EnvGlobalModeProvider(os.environ)
|
|
23
|
+
_cached_paths = _get_system_ssl_default_verify_paths(RuyiConsoleLogger(gm))
|
|
24
|
+
return _cached_paths
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_system_ssl_default_verify_paths(logger: RuyiLogger) -> ssl.DefaultVerifyPaths:
|
|
28
|
+
orig_paths = _orig_get_default_verify_paths()
|
|
29
|
+
if sys.platform != "linux":
|
|
30
|
+
return orig_paths
|
|
31
|
+
|
|
32
|
+
result: ssl.DefaultVerifyPaths | None = None
|
|
33
|
+
|
|
34
|
+
# imitate the stdlib flow but with overridden data source
|
|
35
|
+
try:
|
|
36
|
+
parts = _query_linux_system_ssl_default_cert_paths(logger)
|
|
37
|
+
if parts is None:
|
|
38
|
+
logger.W("failed to probe system libcrypto")
|
|
39
|
+
else:
|
|
40
|
+
result = to_ssl_paths(parts)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logger.D(f"cannot get system libcrypto default cert paths: {e}")
|
|
43
|
+
|
|
44
|
+
if result is None:
|
|
45
|
+
logger.D("falling back to probing hard-coded paths")
|
|
46
|
+
result = probe_fallback_verify_paths()
|
|
47
|
+
|
|
48
|
+
if result != orig_paths:
|
|
49
|
+
logger.D(
|
|
50
|
+
"get_default_verify_paths() values differ between bundled and system libssl"
|
|
51
|
+
)
|
|
52
|
+
logger.D(f"bundled: {orig_paths}")
|
|
53
|
+
logger.D(f" system: {result}")
|
|
54
|
+
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_ssl_paths(parts: tuple[str, str, str, str]) -> ssl.DefaultVerifyPaths | None:
|
|
59
|
+
cafile = os.environ.get(parts[0], parts[1])
|
|
60
|
+
capath = os.environ.get(parts[2], parts[3])
|
|
61
|
+
|
|
62
|
+
is_cafile_present = os.path.isfile(cafile)
|
|
63
|
+
is_capath_present = os.path.isdir(capath)
|
|
64
|
+
if not is_cafile_present and not is_capath_present:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
# must do "else None" like the stdlib, despite the type annotation being just "str"
|
|
68
|
+
return ssl.DefaultVerifyPaths(
|
|
69
|
+
cafile if is_cafile_present else None, # type: ignore[arg-type]
|
|
70
|
+
capath if is_capath_present else None, # type: ignore[arg-type]
|
|
71
|
+
*parts,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _decode_fsdefault_or_none(val: int | None) -> str:
|
|
76
|
+
if val is None:
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
s = ctypes.c_char_p(val)
|
|
80
|
+
if s.value is None:
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
return s.value.decode(sys.getfilesystemencoding())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _query_linux_system_ssl_default_cert_paths(
|
|
87
|
+
logger: RuyiLogger,
|
|
88
|
+
soname: str | None = None,
|
|
89
|
+
) -> tuple[str, str, str, str] | None:
|
|
90
|
+
if soname is None:
|
|
91
|
+
# check libcrypto instead of libssl, because if the system libssl is
|
|
92
|
+
# newer than the bundled one, the system libssl will depend on the
|
|
93
|
+
# bundled libcrypto that may lack newer ELF symbol version(s). The
|
|
94
|
+
# functions actually reside in libcrypto, after all.
|
|
95
|
+
for soname in ("libcrypto.so", "libcrypto.so.3", "libcrypto.so.1.1"):
|
|
96
|
+
try:
|
|
97
|
+
return _query_linux_system_ssl_default_cert_paths(logger, soname)
|
|
98
|
+
except OSError as e:
|
|
99
|
+
logger.D(f"soname {soname} not working: {e}")
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
# dlopen-ing the bare soname will get us the system library
|
|
105
|
+
lib = ctypes.CDLL(soname)
|
|
106
|
+
lib.X509_get_default_cert_file_env.restype = ctypes.c_void_p
|
|
107
|
+
lib.X509_get_default_cert_file.restype = ctypes.c_void_p
|
|
108
|
+
lib.X509_get_default_cert_dir_env.restype = ctypes.c_void_p
|
|
109
|
+
lib.X509_get_default_cert_dir.restype = ctypes.c_void_p
|
|
110
|
+
|
|
111
|
+
result = (
|
|
112
|
+
_decode_fsdefault_or_none(lib.X509_get_default_cert_file_env()),
|
|
113
|
+
_decode_fsdefault_or_none(lib.X509_get_default_cert_file()),
|
|
114
|
+
_decode_fsdefault_or_none(lib.X509_get_default_cert_dir_env()),
|
|
115
|
+
_decode_fsdefault_or_none(lib.X509_get_default_cert_dir()),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
logger.D(f"got defaults from system libcrypto {soname}")
|
|
119
|
+
logger.D(f"X509_get_default_cert_file_env() = {result[0]}")
|
|
120
|
+
logger.D(f"X509_get_default_cert_file() = {result[1]}")
|
|
121
|
+
logger.D(f"X509_get_default_cert_dir_env() = {result[2]}")
|
|
122
|
+
logger.D(f"X509_get_default_cert_dir() = {result[3]}")
|
|
123
|
+
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class WellKnownCALocation(NamedTuple):
|
|
128
|
+
cafile: str
|
|
129
|
+
capath: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
WELL_KNOWN_CA_LOCATIONS: Final[list[WellKnownCALocation]] = [
|
|
133
|
+
# Debian-based distros
|
|
134
|
+
WellKnownCALocation("/usr/lib/ssl/cert.pem", "/usr/lib/ssl/certs"),
|
|
135
|
+
# RPM-based distros
|
|
136
|
+
WellKnownCALocation("/etc/pki/tls/cert.pem", "/etc/pki/tls/certs"),
|
|
137
|
+
# Most others
|
|
138
|
+
WellKnownCALocation("/etc/ssl/cert.pem", "/etc/ssl/certs"),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def probe_fallback_verify_paths() -> ssl.DefaultVerifyPaths:
|
|
143
|
+
for loc in WELL_KNOWN_CA_LOCATIONS:
|
|
144
|
+
is_file_present = os.path.isfile(loc.cafile)
|
|
145
|
+
is_dir_present = os.path.isdir(loc.capath)
|
|
146
|
+
if not is_file_present and not is_dir_present:
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
return ssl.DefaultVerifyPaths(
|
|
150
|
+
loc.cafile if is_file_present else None, # type: ignore[arg-type]
|
|
151
|
+
loc.capath if is_dir_present else None, # type: ignore[arg-type]
|
|
152
|
+
"SSL_CERT_FILE",
|
|
153
|
+
loc.cafile,
|
|
154
|
+
"SSL_CERT_DIR",
|
|
155
|
+
loc.capath,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# fall back to certifi
|
|
159
|
+
cafile = certifi.where()
|
|
160
|
+
return ssl.DefaultVerifyPaths(
|
|
161
|
+
cafile,
|
|
162
|
+
None, # type: ignore[arg-type]
|
|
163
|
+
"SSL_CERT_FILE",
|
|
164
|
+
cafile,
|
|
165
|
+
"SSL_CERT_DIR",
|
|
166
|
+
"/etc/ssl/certs",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
ssl.get_default_verify_paths = get_system_ssl_default_verify_paths
|
ruyi/utils/templating.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
from typing import Any, Final, Callable, Tuple
|
|
3
|
+
|
|
4
|
+
from jinja2 import BaseLoader, Environment, TemplateNotFound
|
|
5
|
+
|
|
6
|
+
from ..resource_bundle import get_template_str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EmbeddedLoader(BaseLoader):
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
def get_source(
|
|
14
|
+
self,
|
|
15
|
+
environment: Environment,
|
|
16
|
+
template: str,
|
|
17
|
+
) -> Tuple[str, str | None, Callable[[], bool] | None]:
|
|
18
|
+
if payload := get_template_str(template):
|
|
19
|
+
return payload, None, None
|
|
20
|
+
raise TemplateNotFound(template)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_JINJA_ENV: Final = Environment(
|
|
24
|
+
loader=EmbeddedLoader(),
|
|
25
|
+
autoescape=False, # we're not producing HTML
|
|
26
|
+
auto_reload=False, # we're serving statically embedded assets
|
|
27
|
+
keep_trailing_newline=True, # to make shells happy
|
|
28
|
+
)
|
|
29
|
+
_JINJA_ENV.filters["sh"] = shlex.quote
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_template_str(template_name: str, data: dict[str, Any]) -> str:
|
|
33
|
+
tmpl = _JINJA_ENV.get_template(template_name)
|
|
34
|
+
return tmpl.render(data)
|
ruyi/utils/toml.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from contextlib import AbstractContextManager
|
|
2
|
+
from types import TracebackType
|
|
3
|
+
from typing import Iterable
|
|
4
|
+
|
|
5
|
+
import tomlkit
|
|
6
|
+
from tomlkit.container import Container
|
|
7
|
+
from tomlkit.items import Array, Comment, InlineTable, Item, Table, Trivia, Whitespace
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def with_indent(item: Item, spaces: int = 2) -> Item:
|
|
11
|
+
item.indent(spaces)
|
|
12
|
+
return item
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def inline_table_with_spaces() -> "InlineTableWithSpaces":
|
|
16
|
+
return InlineTableWithSpaces(Container(), Trivia(), new=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InlineTableWithSpaces(InlineTable, AbstractContextManager[InlineTable]):
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
value: Container,
|
|
23
|
+
trivia: Trivia,
|
|
24
|
+
new: bool = False,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(value, trivia, new)
|
|
27
|
+
|
|
28
|
+
def __enter__(self) -> InlineTable:
|
|
29
|
+
self.add(tomlkit.ws(" "))
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def __exit__(
|
|
33
|
+
self,
|
|
34
|
+
exc_type: type[BaseException] | None,
|
|
35
|
+
exc_value: BaseException | None,
|
|
36
|
+
traceback: TracebackType | None,
|
|
37
|
+
) -> bool | None:
|
|
38
|
+
self.add(tomlkit.ws(" "))
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _into_item(x: Item | str) -> Item:
|
|
43
|
+
if isinstance(x, Item):
|
|
44
|
+
return x
|
|
45
|
+
return tomlkit.string(x)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def str_array(
|
|
49
|
+
args: Iterable[Item | str],
|
|
50
|
+
*,
|
|
51
|
+
multiline: bool = False,
|
|
52
|
+
indent: int = 2,
|
|
53
|
+
) -> Array:
|
|
54
|
+
items = [_into_item(i).indent(indent) for i in args]
|
|
55
|
+
return Array(items, Trivia(), multiline=multiline)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sorted_table(x: dict[str, str]) -> Table:
|
|
59
|
+
y = tomlkit.table()
|
|
60
|
+
for k in sorted(x.keys()):
|
|
61
|
+
y.add(k, x[k])
|
|
62
|
+
return y
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def extract_header_comments(
|
|
66
|
+
doc: Container,
|
|
67
|
+
) -> list[str]:
|
|
68
|
+
comments: list[str] = []
|
|
69
|
+
|
|
70
|
+
# ignore leading whitespaces
|
|
71
|
+
is_skipping_leading_ws = True
|
|
72
|
+
for _key, item in doc.body:
|
|
73
|
+
if isinstance(item, Whitespace):
|
|
74
|
+
if is_skipping_leading_ws:
|
|
75
|
+
continue
|
|
76
|
+
# this is part of the header comments
|
|
77
|
+
comments.append(item.as_string())
|
|
78
|
+
elif isinstance(item, Comment):
|
|
79
|
+
is_skipping_leading_ws = False
|
|
80
|
+
comments.append(item.as_string())
|
|
81
|
+
else:
|
|
82
|
+
# we reached the first non-comment item
|
|
83
|
+
break
|
|
84
|
+
return comments
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def extract_footer_comments(
|
|
88
|
+
doc: Container,
|
|
89
|
+
) -> list[str]:
|
|
90
|
+
comments: list[str] = []
|
|
91
|
+
|
|
92
|
+
# ignore trailing whitespaces
|
|
93
|
+
is_skipping_trailing_ws = True
|
|
94
|
+
for _key, item in reversed(doc.body):
|
|
95
|
+
if isinstance(item, Whitespace):
|
|
96
|
+
if is_skipping_trailing_ws:
|
|
97
|
+
continue
|
|
98
|
+
# this is part of the footer comments
|
|
99
|
+
comments.append(item.as_string())
|
|
100
|
+
elif isinstance(item, Comment):
|
|
101
|
+
is_skipping_trailing_ws = False
|
|
102
|
+
comments.append(item.as_string())
|
|
103
|
+
else:
|
|
104
|
+
# we reached the first non-comment item
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
# if the footer comment was preceded by a table, then the comment would be
|
|
108
|
+
# nested inside the table and invisible in top-level doc.body, so we would
|
|
109
|
+
# have to check the last item as well
|
|
110
|
+
if not comments:
|
|
111
|
+
last_elem = doc.body[-1][1].value
|
|
112
|
+
if isinstance(last_elem, Container):
|
|
113
|
+
return extract_footer_comments(last_elem)
|
|
114
|
+
|
|
115
|
+
return list(reversed(comments))
|