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/pluginhost/ctx.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import os
|
|
3
|
+
import pathlib
|
|
4
|
+
from typing import (
|
|
5
|
+
Callable,
|
|
6
|
+
Final,
|
|
7
|
+
Generic,
|
|
8
|
+
MutableMapping,
|
|
9
|
+
Protocol,
|
|
10
|
+
TypeVar,
|
|
11
|
+
TYPE_CHECKING,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing_extensions import Self
|
|
16
|
+
|
|
17
|
+
from ..log import RuyiLogger
|
|
18
|
+
from . import api
|
|
19
|
+
from . import paths
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ENV_PLUGIN_BACKEND_KEY: Final = "RUYI_PLUGIN_BACKEND"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SupportsGetOption(Protocol):
|
|
26
|
+
def get_option(self, key: str) -> object: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SupportsEvalFunction(Protocol):
|
|
30
|
+
def eval_function(
|
|
31
|
+
self,
|
|
32
|
+
function: object,
|
|
33
|
+
*args: object,
|
|
34
|
+
**kwargs: object,
|
|
35
|
+
) -> object: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ModuleTy = TypeVar("ModuleTy", bound=SupportsGetOption, covariant=True)
|
|
39
|
+
EvalTy = TypeVar("EvalTy", bound=SupportsEvalFunction, covariant=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PluginHostContext(Generic[ModuleTy, EvalTy], metaclass=abc.ABCMeta):
|
|
43
|
+
@staticmethod
|
|
44
|
+
def new(
|
|
45
|
+
host_logger: RuyiLogger,
|
|
46
|
+
plugin_root: pathlib.Path,
|
|
47
|
+
) -> "PluginHostContext[SupportsGetOption, SupportsEvalFunction]":
|
|
48
|
+
plugin_backend = os.environ.get("RUYI_PLUGIN_BACKEND", "")
|
|
49
|
+
if not plugin_backend:
|
|
50
|
+
plugin_backend = "unsandboxed"
|
|
51
|
+
|
|
52
|
+
match plugin_backend:
|
|
53
|
+
case "unsandboxed":
|
|
54
|
+
return UnsandboxedPluginHostContext(host_logger, plugin_root)
|
|
55
|
+
case _:
|
|
56
|
+
raise RuntimeError(f"unsupported plugin backend: {plugin_backend}")
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
host_logger: RuyiLogger,
|
|
61
|
+
plugin_root: pathlib.Path,
|
|
62
|
+
) -> None:
|
|
63
|
+
self._host_logger = host_logger
|
|
64
|
+
self._plugin_root = plugin_root
|
|
65
|
+
# resolved path: finalized module
|
|
66
|
+
self._module_cache: MutableMapping[str, ModuleTy] = {}
|
|
67
|
+
# plugin id: finalized plugin module
|
|
68
|
+
self._loaded_plugins: dict[str, SupportsGetOption] = {}
|
|
69
|
+
# plugin id: {key: value}
|
|
70
|
+
self._value_cache: dict[str, dict[str, object]] = {}
|
|
71
|
+
|
|
72
|
+
@abc.abstractmethod
|
|
73
|
+
def make_loader(
|
|
74
|
+
self,
|
|
75
|
+
originating_file: pathlib.Path,
|
|
76
|
+
module_cache: MutableMapping[str, ModuleTy],
|
|
77
|
+
is_cmd: bool,
|
|
78
|
+
) -> "BasePluginLoader[ModuleTy]":
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
@abc.abstractmethod
|
|
82
|
+
def make_evaluator(self) -> EvalTy:
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def host_logger(self) -> RuyiLogger:
|
|
87
|
+
return self._host_logger
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def plugin_root(self) -> pathlib.Path:
|
|
91
|
+
return self._plugin_root
|
|
92
|
+
|
|
93
|
+
def load_plugin(self, plugin_id: str, is_cmd: bool) -> None:
|
|
94
|
+
plugin_dir = paths.get_plugin_dir(plugin_id, self._plugin_root)
|
|
95
|
+
|
|
96
|
+
loader = self.make_loader(
|
|
97
|
+
plugin_dir / paths.PLUGIN_ENTRYPOINT_FILENAME,
|
|
98
|
+
self._module_cache,
|
|
99
|
+
is_cmd,
|
|
100
|
+
)
|
|
101
|
+
loaded_plugin = loader.load_this_plugin()
|
|
102
|
+
self._loaded_plugins[plugin_id] = loaded_plugin
|
|
103
|
+
|
|
104
|
+
def is_plugin_loaded(self, plugin_id: str) -> bool:
|
|
105
|
+
return plugin_id in self._loaded_plugins
|
|
106
|
+
|
|
107
|
+
def get_from_plugin(
|
|
108
|
+
self,
|
|
109
|
+
plugin_id: str,
|
|
110
|
+
key: str,
|
|
111
|
+
is_cmd_plugin: bool = False,
|
|
112
|
+
) -> object | None:
|
|
113
|
+
if not self.is_plugin_loaded(plugin_id):
|
|
114
|
+
self.load_plugin(plugin_id, is_cmd_plugin)
|
|
115
|
+
|
|
116
|
+
if plugin_id not in self._value_cache:
|
|
117
|
+
self._value_cache[plugin_id] = {}
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
return self._value_cache[plugin_id][key]
|
|
121
|
+
except KeyError:
|
|
122
|
+
v = self._loaded_plugins[plugin_id].get_option(key)
|
|
123
|
+
self._value_cache[plugin_id][key] = v
|
|
124
|
+
return v
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class BasePluginLoader(Generic[ModuleTy], metaclass=abc.ABCMeta):
|
|
128
|
+
"""Base class for plugin loaders loading from Ruyi repo.
|
|
129
|
+
|
|
130
|
+
Load paths take one of the following shapes:
|
|
131
|
+
|
|
132
|
+
* relative path: loads the path relative from the originating file's location,
|
|
133
|
+
but crossing plugin boundary is not allowed
|
|
134
|
+
* absolute path: similar to above, but relative to the plugin's FS root
|
|
135
|
+
* `ruyi-plugin://${plugin-id}`: loads from the plugin `plugin-id` residing
|
|
136
|
+
in the same repo as the originating plugin, the "entrypoint" being hard-coded
|
|
137
|
+
to whatever the concrete implementation dictates
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
phctx: PluginHostContext[ModuleTy, SupportsEvalFunction],
|
|
143
|
+
originating_file: pathlib.Path,
|
|
144
|
+
module_cache: MutableMapping[str, ModuleTy],
|
|
145
|
+
is_cmd: bool,
|
|
146
|
+
) -> None:
|
|
147
|
+
self._phctx = phctx
|
|
148
|
+
self.originating_file = originating_file
|
|
149
|
+
self.module_cache = module_cache
|
|
150
|
+
self.is_cmd = is_cmd
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def host_logger(self) -> RuyiLogger:
|
|
154
|
+
return self._phctx.host_logger
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def root(self) -> pathlib.Path:
|
|
158
|
+
return self._phctx.plugin_root
|
|
159
|
+
|
|
160
|
+
def make_sub_loader(self, originating_file: pathlib.Path) -> "Self":
|
|
161
|
+
return self.__class__(
|
|
162
|
+
self._phctx,
|
|
163
|
+
originating_file,
|
|
164
|
+
self.module_cache,
|
|
165
|
+
self.is_cmd,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def load_this_plugin(self) -> ModuleTy:
|
|
169
|
+
return self._load(str(self.originating_file), True)
|
|
170
|
+
|
|
171
|
+
def load(self, path: str) -> ModuleTy:
|
|
172
|
+
return self._load(path, False)
|
|
173
|
+
|
|
174
|
+
def _load(self, path: str, is_root: bool) -> ModuleTy:
|
|
175
|
+
resolved_path: pathlib.Path
|
|
176
|
+
if is_root:
|
|
177
|
+
resolved_path = pathlib.Path(path)
|
|
178
|
+
else:
|
|
179
|
+
resolved_path = paths.resolve_ruyi_load_path(
|
|
180
|
+
path,
|
|
181
|
+
self.root,
|
|
182
|
+
False,
|
|
183
|
+
self.originating_file,
|
|
184
|
+
self.is_cmd,
|
|
185
|
+
)
|
|
186
|
+
resolved_path_str = str(resolved_path)
|
|
187
|
+
if resolved_path_str in self.module_cache:
|
|
188
|
+
return self.module_cache[resolved_path_str]
|
|
189
|
+
|
|
190
|
+
plugin_id = resolved_path.relative_to(self.root).parts[0]
|
|
191
|
+
plugin_dir = self.root / plugin_id
|
|
192
|
+
|
|
193
|
+
host_bridge = api.make_ruyi_plugin_api_for_module(
|
|
194
|
+
self._phctx,
|
|
195
|
+
resolved_path,
|
|
196
|
+
plugin_dir,
|
|
197
|
+
self.is_cmd,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
mod = self.do_load_module(
|
|
201
|
+
resolved_path,
|
|
202
|
+
resolved_path.read_text("utf-8"),
|
|
203
|
+
host_bridge,
|
|
204
|
+
)
|
|
205
|
+
self.module_cache[resolved_path_str] = mod
|
|
206
|
+
return mod
|
|
207
|
+
|
|
208
|
+
@abc.abstractmethod
|
|
209
|
+
def do_load_module(
|
|
210
|
+
self,
|
|
211
|
+
resolved_path: pathlib.Path,
|
|
212
|
+
program: str,
|
|
213
|
+
ruyi_host_bridge: Callable[[object], api.RuyiHostAPI],
|
|
214
|
+
) -> ModuleTy:
|
|
215
|
+
raise NotImplementedError
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# import the built-in supported PluginHostContext implementation(s)
|
|
219
|
+
# this must come after the baseclass declarations
|
|
220
|
+
|
|
221
|
+
# pylint: disable-next=wrong-import-position
|
|
222
|
+
from .unsandboxed import UnsandboxedPluginHostContext # noqa: E402
|
ruyi/pluginhost/paths.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
import re
|
|
3
|
+
from typing import Final
|
|
4
|
+
from urllib.parse import unquote, urlparse
|
|
5
|
+
|
|
6
|
+
PLUGIN_ENTRYPOINT_FILENAME: Final = "mod.star"
|
|
7
|
+
PLUGIN_DATA_DIR: Final = "data"
|
|
8
|
+
PLUGIN_ID_RE: Final = re.compile("^[A-Za-z_][A-Za-z0-9_-]*$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_plugin_id(name: str) -> None:
|
|
12
|
+
if PLUGIN_ID_RE.match(name) is None:
|
|
13
|
+
raise RuntimeError(f"invalid plugin ID '{name}'")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_plugin_dir(plugin_id: str, plugin_root: pathlib.Path) -> pathlib.Path:
|
|
17
|
+
validate_plugin_id(plugin_id)
|
|
18
|
+
return plugin_root / plugin_id
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_ruyi_load_path(
|
|
22
|
+
path: str,
|
|
23
|
+
plugin_root: pathlib.Path,
|
|
24
|
+
is_for_data: bool,
|
|
25
|
+
originating_file: pathlib.Path,
|
|
26
|
+
allow_host_fs_access: bool,
|
|
27
|
+
) -> pathlib.Path:
|
|
28
|
+
parsed = urlparse(path)
|
|
29
|
+
if parsed.params or parsed.query or parsed.fragment:
|
|
30
|
+
raise RuntimeError("fancy URI features are not supported for load paths")
|
|
31
|
+
|
|
32
|
+
match parsed.scheme:
|
|
33
|
+
case "":
|
|
34
|
+
if parsed.netloc:
|
|
35
|
+
raise RuntimeError("'//' is not allowed as load path prefix")
|
|
36
|
+
return resolve_plain_load_path(
|
|
37
|
+
parsed.path,
|
|
38
|
+
plugin_root,
|
|
39
|
+
is_for_data,
|
|
40
|
+
originating_file=originating_file,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
case "ruyi-plugin":
|
|
44
|
+
if is_for_data:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
"the ruyi-plugin protocol is not allowed in this context"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if parsed.path:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"non-empty path segment is not allowed for ruyi-plugin:// load paths"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not parsed.netloc:
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
"empty location is not allowed for ruyi-plugin:// load paths"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
plugin_id = unquote(parsed.netloc)
|
|
60
|
+
return get_plugin_dir(plugin_id, plugin_root) / PLUGIN_ENTRYPOINT_FILENAME
|
|
61
|
+
|
|
62
|
+
case "ruyi-plugin-data":
|
|
63
|
+
if not is_for_data:
|
|
64
|
+
raise RuntimeError(
|
|
65
|
+
"the ruyi-plugin-data protocol is not allowed in this context"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if not parsed.path:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
"empty path segment is not allowed for ruyi-plugin-data:// load paths"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if not parsed.netloc:
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"empty location is not allowed for ruyi-plugin-data:// load paths"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return resolve_plain_load_path(
|
|
79
|
+
parsed.path,
|
|
80
|
+
plugin_root,
|
|
81
|
+
True,
|
|
82
|
+
plugin_id=parsed.netloc,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
case "host":
|
|
86
|
+
if not allow_host_fs_access:
|
|
87
|
+
raise RuntimeError("the host protocol is not allowed in this context")
|
|
88
|
+
|
|
89
|
+
if not parsed.path:
|
|
90
|
+
raise RuntimeError(
|
|
91
|
+
"empty path segment is not allowed for host:// load paths"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if parsed.netloc:
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
"non-empty location is not allowed for host:// load paths"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return pathlib.Path(parsed.path)
|
|
100
|
+
|
|
101
|
+
case _:
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
f"unsupported Ruyi Starlark load path scheme {parsed.scheme}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve_plain_load_path(
|
|
108
|
+
path: str,
|
|
109
|
+
plugin_root: pathlib.Path,
|
|
110
|
+
is_for_data: bool,
|
|
111
|
+
*,
|
|
112
|
+
originating_file: pathlib.Path | None = None,
|
|
113
|
+
plugin_id: str | None = None,
|
|
114
|
+
) -> pathlib.Path:
|
|
115
|
+
if originating_file is None and plugin_id is None:
|
|
116
|
+
raise ValueError("one of originating_file or plugin_id must be specified")
|
|
117
|
+
|
|
118
|
+
if plugin_id is None:
|
|
119
|
+
assert originating_file is not None
|
|
120
|
+
rel = originating_file.relative_to(plugin_root)
|
|
121
|
+
plugin_id = rel.parts[0]
|
|
122
|
+
|
|
123
|
+
plugin_dir = plugin_root / plugin_id
|
|
124
|
+
if is_for_data:
|
|
125
|
+
plugin_dir = plugin_dir / PLUGIN_DATA_DIR
|
|
126
|
+
|
|
127
|
+
p = pathlib.PurePosixPath(path)
|
|
128
|
+
if p.is_absolute():
|
|
129
|
+
return plugin_dir / p.relative_to("/")
|
|
130
|
+
|
|
131
|
+
resolved = (plugin_dir / p).resolve()
|
|
132
|
+
if not resolved.is_relative_to(plugin_dir):
|
|
133
|
+
raise ValueError("plain load paths are not allowed to cross plugin boundary")
|
|
134
|
+
|
|
135
|
+
return resolved
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from ..cli.cmd import AdminCommand
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from ..cli.completion import ArgumentParser
|
|
8
|
+
from ..config import GlobalConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AdminRunPluginCommand(
|
|
12
|
+
AdminCommand,
|
|
13
|
+
cmd="run-plugin-cmd",
|
|
14
|
+
help="Run a plugin-defined command",
|
|
15
|
+
):
|
|
16
|
+
@classmethod
|
|
17
|
+
def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None:
|
|
18
|
+
p.add_argument(
|
|
19
|
+
"cmd_name",
|
|
20
|
+
type=str,
|
|
21
|
+
metavar="COMMAND-NAME",
|
|
22
|
+
help="Command name",
|
|
23
|
+
)
|
|
24
|
+
p.add_argument(
|
|
25
|
+
"cmd_args",
|
|
26
|
+
type=str,
|
|
27
|
+
nargs="*",
|
|
28
|
+
metavar="COMMAND-ARG",
|
|
29
|
+
help="Arguments to pass to the plugin command",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int:
|
|
34
|
+
cmd_name = args.cmd_name
|
|
35
|
+
cmd_args = args.cmd_args
|
|
36
|
+
|
|
37
|
+
return cfg.repo.run_plugin_cmd(cmd_name, cmd_args)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import builtins
|
|
3
|
+
import inspect
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import sys
|
|
7
|
+
from types import CodeType
|
|
8
|
+
from typing import Callable, Final, MutableMapping, NoReturn, TYPE_CHECKING, cast
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from typing_extensions import Buffer
|
|
12
|
+
|
|
13
|
+
from .api import RuyiHostAPI
|
|
14
|
+
from .ctx import PluginHostContext, BasePluginLoader
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UnsandboxedModuleDict(dict[str, object]):
|
|
18
|
+
def get_option(self, key: str) -> object:
|
|
19
|
+
return self.get(key, None)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnsandboxedTrivialEvaluator:
|
|
23
|
+
def eval_function(
|
|
24
|
+
self,
|
|
25
|
+
function: object,
|
|
26
|
+
*args: object,
|
|
27
|
+
**kwargs: object,
|
|
28
|
+
) -> object:
|
|
29
|
+
if callable(function):
|
|
30
|
+
return function(*args, **kwargs)
|
|
31
|
+
raise RuntimeError(f"the Python value {function!r} is not callable")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
BUILTINS_TO_EXPOSE: Final = {
|
|
35
|
+
k: getattr(builtins, k)
|
|
36
|
+
for k in [
|
|
37
|
+
"abs",
|
|
38
|
+
"any",
|
|
39
|
+
"all",
|
|
40
|
+
"bool",
|
|
41
|
+
"bytes",
|
|
42
|
+
"dict",
|
|
43
|
+
"dir",
|
|
44
|
+
"enumerate",
|
|
45
|
+
"float",
|
|
46
|
+
"getattr",
|
|
47
|
+
"hasattr",
|
|
48
|
+
"hash",
|
|
49
|
+
"int",
|
|
50
|
+
"len",
|
|
51
|
+
"list",
|
|
52
|
+
"max",
|
|
53
|
+
"min",
|
|
54
|
+
"print",
|
|
55
|
+
"range",
|
|
56
|
+
"repr",
|
|
57
|
+
"reversed",
|
|
58
|
+
"sorted",
|
|
59
|
+
"str",
|
|
60
|
+
"tuple",
|
|
61
|
+
"type",
|
|
62
|
+
"zip",
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _fail_helper(*args: object) -> NoReturn:
|
|
68
|
+
raise RuntimeError(f"fail: {''.join(str(x) for x in args)}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class UnsandboxedPluginHostContext(
|
|
72
|
+
PluginHostContext[UnsandboxedModuleDict, UnsandboxedTrivialEvaluator]
|
|
73
|
+
):
|
|
74
|
+
def make_loader(
|
|
75
|
+
self,
|
|
76
|
+
originating_file: pathlib.Path,
|
|
77
|
+
module_cache: MutableMapping[str, UnsandboxedModuleDict],
|
|
78
|
+
is_cmd: bool,
|
|
79
|
+
) -> BasePluginLoader[UnsandboxedModuleDict]:
|
|
80
|
+
return UnsandboxedRuyiPluginLoader(self, originating_file, module_cache, is_cmd)
|
|
81
|
+
|
|
82
|
+
def make_evaluator(self) -> UnsandboxedTrivialEvaluator:
|
|
83
|
+
return UnsandboxedTrivialEvaluator()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_name_private(n: str) -> bool:
|
|
87
|
+
return n.startswith("_")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _assert_name_is_public(n: str) -> None | NoReturn:
|
|
91
|
+
if _is_name_private(n):
|
|
92
|
+
raise RuntimeError(f"error: trying to load private name {n}")
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class UnsandboxedRuyiPluginLoader(BasePluginLoader[UnsandboxedModuleDict]):
|
|
97
|
+
def do_load_module(
|
|
98
|
+
self,
|
|
99
|
+
resolved_path: pathlib.Path,
|
|
100
|
+
program: str,
|
|
101
|
+
ruyi_host_bridge: Callable[[object], RuyiHostAPI],
|
|
102
|
+
) -> UnsandboxedModuleDict:
|
|
103
|
+
self.host_logger.D(f"unsandboxed module load: path {resolved_path}")
|
|
104
|
+
|
|
105
|
+
sub_loader = self.make_sub_loader(resolved_path)
|
|
106
|
+
|
|
107
|
+
def _load_stmt_helper(
|
|
108
|
+
spec: str,
|
|
109
|
+
*values_to_bind: str,
|
|
110
|
+
**renamed_values_to_bind: str,
|
|
111
|
+
) -> None:
|
|
112
|
+
mod = sub_loader.load(spec)
|
|
113
|
+
|
|
114
|
+
curr_frame = inspect.currentframe()
|
|
115
|
+
if curr_frame is None:
|
|
116
|
+
raise RuntimeError(
|
|
117
|
+
"cannot inspect the Python runtime for the current frame"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
parent_frame = curr_frame.f_back
|
|
121
|
+
if parent_frame is None:
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
"internal error: no parent frame for load() statement"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
g = parent_frame.f_locals
|
|
127
|
+
for name in values_to_bind:
|
|
128
|
+
_assert_name_is_public(name)
|
|
129
|
+
g[name] = mod[name]
|
|
130
|
+
for dst_name, src_name in renamed_values_to_bind.items():
|
|
131
|
+
_assert_name_is_public(src_name)
|
|
132
|
+
g[dst_name] = mod[src_name]
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
code = self.source_to_code(program, resolved_path)
|
|
136
|
+
mod_globals: dict[str, object] = {
|
|
137
|
+
"__builtins__": BUILTINS_TO_EXPOSE,
|
|
138
|
+
"fail": _fail_helper,
|
|
139
|
+
"load": _load_stmt_helper,
|
|
140
|
+
"ruyi_plugin_rev": ruyi_host_bridge,
|
|
141
|
+
}
|
|
142
|
+
# pylint: disable-next=exec-used
|
|
143
|
+
exec(code, mod_globals)
|
|
144
|
+
return UnsandboxedModuleDict(mod_globals)
|
|
145
|
+
|
|
146
|
+
# intentionally follows the importlib.abc.InspectLoader protocol, for
|
|
147
|
+
# easier refactoring whenever necessary.
|
|
148
|
+
@staticmethod
|
|
149
|
+
def source_to_code(
|
|
150
|
+
data: "Buffer | str | ast.Module",
|
|
151
|
+
path: "Buffer | str | os.PathLike[str]" = "<string>",
|
|
152
|
+
) -> CodeType:
|
|
153
|
+
mod_ast: ast.Module
|
|
154
|
+
if isinstance(data, ast.Module):
|
|
155
|
+
mod_ast = data
|
|
156
|
+
else: # isinstance(data, str) or isinstance(data, Buffer)
|
|
157
|
+
mod_ast = ast.parse(data, path, "exec")
|
|
158
|
+
|
|
159
|
+
# lint the module on a best-effort basis to help fight syntax feature
|
|
160
|
+
# creep
|
|
161
|
+
lint_module(mod_ast)
|
|
162
|
+
|
|
163
|
+
return compile(mod_ast, path, "exec")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def lint_module(mod: ast.Module) -> None:
|
|
167
|
+
if node := GatedLanguageFeaturesPass().visit(mod):
|
|
168
|
+
raise RuntimeError(f"line {node.lineno}: language feature is gated")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class GatedLanguageFeaturesPass(ast.NodeVisitor):
|
|
172
|
+
def visit(self, node: ast.AST) -> ast.expr | ast.stmt | None:
|
|
173
|
+
return cast(ast.expr | ast.stmt | None, super().visit(node))
|
|
174
|
+
|
|
175
|
+
def generic_visit(self, node: ast.AST) -> ast.expr | ast.stmt | None:
|
|
176
|
+
"""Traverses all types of nodes, bailing if non-minimal language
|
|
177
|
+
features are found."""
|
|
178
|
+
|
|
179
|
+
for _, value in ast.iter_fields(node):
|
|
180
|
+
if isinstance(value, list):
|
|
181
|
+
for item in value:
|
|
182
|
+
if isinstance(item, ast.AST):
|
|
183
|
+
if x := self.visit(item):
|
|
184
|
+
return x
|
|
185
|
+
elif isinstance(value, ast.AST):
|
|
186
|
+
if x := self.visit(value):
|
|
187
|
+
return x
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def visit_NamedExpr(self, node: ast.NamedExpr) -> ast.NamedExpr:
|
|
191
|
+
return node
|
|
192
|
+
|
|
193
|
+
def visit_Raise(self, node: ast.Raise) -> ast.Raise:
|
|
194
|
+
return node
|
|
195
|
+
|
|
196
|
+
def visit_Assert(self, node: ast.Assert) -> ast.Assert:
|
|
197
|
+
return node
|
|
198
|
+
|
|
199
|
+
def visit_Import(self, node: ast.Import) -> ast.Import:
|
|
200
|
+
return node
|
|
201
|
+
|
|
202
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> ast.ImportFrom:
|
|
203
|
+
return node
|
|
204
|
+
|
|
205
|
+
def visit_Try(self, node: ast.Try) -> ast.Try:
|
|
206
|
+
return node
|
|
207
|
+
|
|
208
|
+
if sys.version_info >= (3, 11):
|
|
209
|
+
|
|
210
|
+
def visit_TryStar(self, node: ast.TryStar) -> ast.TryStar:
|
|
211
|
+
return node
|
|
212
|
+
|
|
213
|
+
def visit_With(self, node: ast.With) -> ast.With:
|
|
214
|
+
return node
|
|
215
|
+
|
|
216
|
+
def visit_Match(self, node: ast.Match) -> ast.Match:
|
|
217
|
+
return node
|
|
218
|
+
|
|
219
|
+
def visit_Yield(self, node: ast.Yield) -> ast.Yield:
|
|
220
|
+
return node
|
|
221
|
+
|
|
222
|
+
def visit_YieldFrom(self, node: ast.YieldFrom) -> ast.YieldFrom:
|
|
223
|
+
return node
|
|
224
|
+
|
|
225
|
+
def visit_Global(self, node: ast.Global) -> ast.Global:
|
|
226
|
+
return node
|
|
227
|
+
|
|
228
|
+
def visit_Nonlocal(self, node: ast.Nonlocal) -> ast.Nonlocal:
|
|
229
|
+
return node
|
|
230
|
+
|
|
231
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef:
|
|
232
|
+
return node
|
|
233
|
+
|
|
234
|
+
def visit_AsyncFunctionDef(
|
|
235
|
+
self, node: ast.AsyncFunctionDef
|
|
236
|
+
) -> ast.AsyncFunctionDef:
|
|
237
|
+
return node
|
|
238
|
+
|
|
239
|
+
def visit_Await(self, node: ast.Await) -> ast.Await:
|
|
240
|
+
return node
|
|
241
|
+
|
|
242
|
+
def visit_AsyncFor(self, node: ast.AsyncFor) -> ast.AsyncFor:
|
|
243
|
+
return node
|
|
244
|
+
|
|
245
|
+
def visit_AsyncWith(self, node: ast.AsyncWith) -> ast.AsyncWith:
|
|
246
|
+
return node
|
ruyi/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import zlib
|
|
3
|
+
|
|
4
|
+
from .data import RESOURCES, TEMPLATES
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _unpack_payload(x: bytes) -> str:
|
|
8
|
+
return zlib.decompress(base64.b64decode(x)).decode("utf-8")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_resource_str(template_name: str) -> str | None:
|
|
12
|
+
if t := RESOURCES.get(template_name):
|
|
13
|
+
return _unpack_payload(t)
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_template_str(template_name: str) -> str | None:
|
|
18
|
+
if t := TEMPLATES.get(template_name):
|
|
19
|
+
return _unpack_payload(t)
|
|
20
|
+
return None
|