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/ruyipkg/list.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from itertools import chain
|
|
2
|
+
|
|
3
|
+
from ..config import GlobalConfig
|
|
4
|
+
from ..log import RuyiLogger
|
|
5
|
+
from ..utils.porcelain import PorcelainOutput
|
|
6
|
+
from .augmented_pkg import AugmentedPkg
|
|
7
|
+
from .list_filter import ListFilter
|
|
8
|
+
from .pkg_manifest import BoundPackageManifest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def do_list(
|
|
12
|
+
cfg: GlobalConfig,
|
|
13
|
+
filters: ListFilter,
|
|
14
|
+
verbose: bool,
|
|
15
|
+
) -> int:
|
|
16
|
+
logger = cfg.logger
|
|
17
|
+
|
|
18
|
+
if not filters:
|
|
19
|
+
if cfg.is_porcelain:
|
|
20
|
+
# we don't want to print message for humans in case of porcelain
|
|
21
|
+
# mode, but we don't want to retain the old behavior of listing
|
|
22
|
+
# all packages either
|
|
23
|
+
return 1
|
|
24
|
+
|
|
25
|
+
logger.F("no filter specified for list operation")
|
|
26
|
+
logger.I(
|
|
27
|
+
"for the old behavior of listing all packages, try [yellow]ruyi list --name-contains ''[/]"
|
|
28
|
+
)
|
|
29
|
+
return 1
|
|
30
|
+
|
|
31
|
+
augmented_pkgs = list(AugmentedPkg.yield_from_repo(cfg, cfg.repo, filters))
|
|
32
|
+
|
|
33
|
+
if cfg.is_porcelain:
|
|
34
|
+
return _do_list_porcelain(augmented_pkgs)
|
|
35
|
+
|
|
36
|
+
if not verbose:
|
|
37
|
+
return _do_list_non_verbose(logger, augmented_pkgs)
|
|
38
|
+
|
|
39
|
+
for i, ver in enumerate(chain(*(ap.versions for ap in augmented_pkgs))):
|
|
40
|
+
if i > 0:
|
|
41
|
+
logger.stdout("\n")
|
|
42
|
+
|
|
43
|
+
_print_pkg_detail(logger, ver.pm, cfg.lang_code)
|
|
44
|
+
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _do_list_non_verbose(
|
|
49
|
+
logger: RuyiLogger,
|
|
50
|
+
augmented_pkgs: list[AugmentedPkg],
|
|
51
|
+
) -> int:
|
|
52
|
+
logger.stdout("List of available packages:\n")
|
|
53
|
+
|
|
54
|
+
for ap in augmented_pkgs:
|
|
55
|
+
logger.stdout(f"* [bold green]{ap.category}/{ap.name}[/]")
|
|
56
|
+
for ver in ap.versions:
|
|
57
|
+
if ver.remarks:
|
|
58
|
+
comments_str = (
|
|
59
|
+
f" ({', '.join(r.as_rich_markup() for r in ver.remarks)})"
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
comments_str = ""
|
|
63
|
+
slug_str = f" slug: [yellow]{ver.pm.slug}[/]" if ver.pm.slug else ""
|
|
64
|
+
logger.stdout(f" - [blue]{ver.pm.semver}[/]{comments_str}{slug_str}")
|
|
65
|
+
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _do_list_porcelain(augmented_pkgs: list[AugmentedPkg]) -> int:
|
|
70
|
+
with PorcelainOutput() as po:
|
|
71
|
+
for ap in augmented_pkgs:
|
|
72
|
+
po.emit(ap.to_porcelain())
|
|
73
|
+
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _print_pkg_detail(
|
|
78
|
+
logger: RuyiLogger,
|
|
79
|
+
pm: BoundPackageManifest,
|
|
80
|
+
lang_code: str,
|
|
81
|
+
) -> None:
|
|
82
|
+
logger.stdout(f"[bold]## [green]{pm.category}/{pm.name}[/] [blue]{pm.ver}[/][/]\n")
|
|
83
|
+
|
|
84
|
+
if pm.slug is not None:
|
|
85
|
+
logger.stdout(f"* Slug: [yellow]{pm.slug}[/]")
|
|
86
|
+
else:
|
|
87
|
+
logger.stdout("* Slug: (none)")
|
|
88
|
+
logger.stdout(f"* Package kind: {sorted(pm.kind)}")
|
|
89
|
+
logger.stdout(f"* Vendor: {pm.vendor_name}")
|
|
90
|
+
if upstream_ver := pm.upstream_version:
|
|
91
|
+
logger.stdout(f"* Upstream version number: {upstream_ver}")
|
|
92
|
+
else:
|
|
93
|
+
logger.stdout("* Upstream version number: (undeclared)")
|
|
94
|
+
logger.stdout("")
|
|
95
|
+
|
|
96
|
+
sv = pm.service_level
|
|
97
|
+
if sv.has_known_issues:
|
|
98
|
+
logger.stdout("\nPackage has known issue(s):\n")
|
|
99
|
+
for x in sv.render_known_issues(pm.repo.messages, lang_code):
|
|
100
|
+
logger.stdout(x, end="\n\n")
|
|
101
|
+
|
|
102
|
+
df = pm.distfiles
|
|
103
|
+
logger.stdout(f"Package declares {len(df)} distfile(s):\n")
|
|
104
|
+
for dd in df.values():
|
|
105
|
+
logger.stdout(f"* [green]{dd.name}[/]")
|
|
106
|
+
logger.stdout(f" - Size: [yellow]{dd.size}[/] bytes")
|
|
107
|
+
for kind, csum in dd.checksums.items():
|
|
108
|
+
logger.stdout(f" - {kind.upper()}: [yellow]{csum}[/]")
|
|
109
|
+
|
|
110
|
+
if bm := pm.binary_metadata:
|
|
111
|
+
logger.stdout("\n### Binary artifacts\n")
|
|
112
|
+
for host, data in bm.data.items():
|
|
113
|
+
logger.stdout(f"* Host [green]{host}[/]:")
|
|
114
|
+
logger.stdout(f" - Distfiles: {data['distfiles']}")
|
|
115
|
+
if cmds := data.get("commands"):
|
|
116
|
+
logger.stdout(" - Available command(s):")
|
|
117
|
+
for k in sorted(cmds.keys()):
|
|
118
|
+
logger.stdout(f" - [green]{k}[/]")
|
|
119
|
+
|
|
120
|
+
if tm := pm.toolchain_metadata:
|
|
121
|
+
logger.stdout("\n### Toolchain metadata\n")
|
|
122
|
+
logger.stdout(f"* Target: [bold green]{tm.target}[/]")
|
|
123
|
+
logger.stdout(f"* Quirks: {tm.quirks}")
|
|
124
|
+
logger.stdout("* Components:")
|
|
125
|
+
for tc in tm.components:
|
|
126
|
+
logger.stdout(f' - {tc["name"]} [bold green]{tc["version"]}[/]')
|
ruyi/ruyipkg/list_cli.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from ..cli.cmd import RootCommand
|
|
5
|
+
from .list_filter import ListFilter, ListFilterAction
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ..cli.completion import ArgumentParser
|
|
9
|
+
from ..config import GlobalConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ListCommand(
|
|
13
|
+
RootCommand,
|
|
14
|
+
cmd="list",
|
|
15
|
+
has_subcommands=True,
|
|
16
|
+
is_subcommand_required=False,
|
|
17
|
+
has_main=True,
|
|
18
|
+
help="List available packages in configured repository",
|
|
19
|
+
):
|
|
20
|
+
@classmethod
|
|
21
|
+
def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None:
|
|
22
|
+
p.add_argument(
|
|
23
|
+
"--verbose",
|
|
24
|
+
"-v",
|
|
25
|
+
action="store_true",
|
|
26
|
+
help="Also show details for every package",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# filter expressions
|
|
30
|
+
p.add_argument(
|
|
31
|
+
"--is-installed",
|
|
32
|
+
action=ListFilterAction,
|
|
33
|
+
nargs=1,
|
|
34
|
+
dest="filters",
|
|
35
|
+
help="Match packages that are installed (y/true/1) or not installed (n/false/0)",
|
|
36
|
+
)
|
|
37
|
+
p.add_argument(
|
|
38
|
+
"--category-contains",
|
|
39
|
+
action=ListFilterAction,
|
|
40
|
+
nargs=1,
|
|
41
|
+
dest="filters",
|
|
42
|
+
help="Match packages from categories whose names contain the given string",
|
|
43
|
+
)
|
|
44
|
+
p.add_argument(
|
|
45
|
+
"--category-is",
|
|
46
|
+
action=ListFilterAction,
|
|
47
|
+
nargs=1,
|
|
48
|
+
dest="filters",
|
|
49
|
+
help="Match packages from the given category",
|
|
50
|
+
)
|
|
51
|
+
p.add_argument(
|
|
52
|
+
"--name-contains",
|
|
53
|
+
action=ListFilterAction,
|
|
54
|
+
nargs=1,
|
|
55
|
+
dest="filters",
|
|
56
|
+
help="Match packages whose names contain the given string",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if gc.is_experimental:
|
|
60
|
+
p.add_argument(
|
|
61
|
+
"--related-to-entity",
|
|
62
|
+
action=ListFilterAction,
|
|
63
|
+
nargs=1,
|
|
64
|
+
dest="filters",
|
|
65
|
+
help="Match packages related to the given entity",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int:
|
|
70
|
+
from .list import do_list
|
|
71
|
+
|
|
72
|
+
verbose: bool = args.verbose
|
|
73
|
+
filters: ListFilter = args.filters
|
|
74
|
+
|
|
75
|
+
return do_list(
|
|
76
|
+
cfg,
|
|
77
|
+
filters=filters,
|
|
78
|
+
verbose=verbose,
|
|
79
|
+
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import enum
|
|
3
|
+
from typing import Any, Callable, Iterable, NamedTuple, Sequence, TypeVar, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..cli.completion import ArgcompleteAction
|
|
6
|
+
from ..utils.global_mode import TRUTHY_ENV_VAR_VALUES
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ..config import GlobalConfig
|
|
10
|
+
from .repo import MetadataRepo
|
|
11
|
+
|
|
12
|
+
_T = TypeVar("_T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ListFilterOpKind(enum.Enum):
|
|
16
|
+
UNKNOWN = 0
|
|
17
|
+
CATEGORY_CONTAINS = 1
|
|
18
|
+
CATEGORY_IS = 2
|
|
19
|
+
NAME_CONTAINS = 3
|
|
20
|
+
RELATED_TO_ENTITY = 4
|
|
21
|
+
IS_INSTALLED = 5
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ListFilterOp(NamedTuple):
|
|
25
|
+
op: ListFilterOpKind
|
|
26
|
+
arg: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ListFilterExecCtx(NamedTuple):
|
|
30
|
+
cfg: "GlobalConfig"
|
|
31
|
+
mr: "MetadataRepo"
|
|
32
|
+
category: str
|
|
33
|
+
pkg_name: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _execute_filter_op(op: ListFilterOp, ctx: ListFilterExecCtx) -> bool:
|
|
37
|
+
match op.op:
|
|
38
|
+
case ListFilterOpKind.CATEGORY_CONTAINS:
|
|
39
|
+
return op.arg in ctx.category
|
|
40
|
+
case ListFilterOpKind.CATEGORY_IS:
|
|
41
|
+
return op.arg == ctx.category
|
|
42
|
+
case ListFilterOpKind.NAME_CONTAINS:
|
|
43
|
+
return op.arg in ctx.pkg_name
|
|
44
|
+
case ListFilterOpKind.RELATED_TO_ENTITY:
|
|
45
|
+
es = ctx.mr.entity_store
|
|
46
|
+
return es.is_entity_related_to(
|
|
47
|
+
f"pkg:{ctx.category}/{ctx.pkg_name}",
|
|
48
|
+
op.arg,
|
|
49
|
+
transitive=True,
|
|
50
|
+
unidirectional=False,
|
|
51
|
+
)
|
|
52
|
+
case ListFilterOpKind.IS_INSTALLED:
|
|
53
|
+
asks_for_installed = op.arg.lower() in TRUTHY_ENV_VAR_VALUES
|
|
54
|
+
|
|
55
|
+
# We need to check all versions of this package to see if any are installed
|
|
56
|
+
# For now, we'll use a simple heuristic - check if ANY version is installed
|
|
57
|
+
installed_packages = ctx.cfg.ruyipkg_global_state.list_installed_packages()
|
|
58
|
+
is_installed = any(
|
|
59
|
+
pkg.category == ctx.category and pkg.name == ctx.pkg_name
|
|
60
|
+
for pkg in installed_packages
|
|
61
|
+
)
|
|
62
|
+
return not (is_installed ^ asks_for_installed)
|
|
63
|
+
case _:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ListFilter:
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self.ops: list[ListFilterOp] = []
|
|
70
|
+
|
|
71
|
+
def __bool__(self) -> bool:
|
|
72
|
+
return len(self.ops) > 0
|
|
73
|
+
|
|
74
|
+
def __repr__(self) -> str:
|
|
75
|
+
return f"<ListFilter ops={self.ops!r}>"
|
|
76
|
+
|
|
77
|
+
def append(self, op: ListFilterOp) -> None:
|
|
78
|
+
self.ops.append(op)
|
|
79
|
+
|
|
80
|
+
def check_pkg_name(
|
|
81
|
+
self,
|
|
82
|
+
cfg: "GlobalConfig",
|
|
83
|
+
mr: "MetadataRepo",
|
|
84
|
+
category: str,
|
|
85
|
+
pkg_name: str,
|
|
86
|
+
) -> bool:
|
|
87
|
+
ctx = ListFilterExecCtx(cfg, mr, category, pkg_name)
|
|
88
|
+
return all(_execute_filter_op(op, ctx) for op in self.ops)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ListFilterAction(ArgcompleteAction):
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
option_strings: Sequence[str],
|
|
95
|
+
dest: str,
|
|
96
|
+
nargs: int | str | None = None,
|
|
97
|
+
const: _T | None = None,
|
|
98
|
+
default: _T | str | None = None,
|
|
99
|
+
type: Callable[[str], _T] | argparse.FileType | None = None,
|
|
100
|
+
choices: Iterable[_T] | None = None,
|
|
101
|
+
required: bool = False,
|
|
102
|
+
help: str | None = None,
|
|
103
|
+
metavar: str | tuple[str, ...] | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
# for now let's just support unary filter ops
|
|
106
|
+
if nargs != 1:
|
|
107
|
+
raise ValueError("nargs != 1 not supported")
|
|
108
|
+
if const is not None:
|
|
109
|
+
raise ValueError("const not supported")
|
|
110
|
+
if default is not None:
|
|
111
|
+
raise ValueError("default not supported")
|
|
112
|
+
if type is not None:
|
|
113
|
+
raise ValueError("type not supported")
|
|
114
|
+
if choices is not None:
|
|
115
|
+
raise ValueError("choices not supported")
|
|
116
|
+
if required:
|
|
117
|
+
raise ValueError("required not supported")
|
|
118
|
+
if metavar is None:
|
|
119
|
+
metavar = "STR"
|
|
120
|
+
|
|
121
|
+
super().__init__(
|
|
122
|
+
option_strings,
|
|
123
|
+
dest,
|
|
124
|
+
nargs,
|
|
125
|
+
const,
|
|
126
|
+
default,
|
|
127
|
+
type,
|
|
128
|
+
choices,
|
|
129
|
+
required,
|
|
130
|
+
help,
|
|
131
|
+
metavar,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self.filter_op_kind: ListFilterOpKind
|
|
135
|
+
match option_strings[0].lstrip("-"):
|
|
136
|
+
case "category-contains":
|
|
137
|
+
self.filter_op_kind = ListFilterOpKind.CATEGORY_CONTAINS
|
|
138
|
+
case "category-is":
|
|
139
|
+
self.filter_op_kind = ListFilterOpKind.CATEGORY_IS
|
|
140
|
+
case "name-contains":
|
|
141
|
+
self.filter_op_kind = ListFilterOpKind.NAME_CONTAINS
|
|
142
|
+
case "related-to-entity":
|
|
143
|
+
self.filter_op_kind = ListFilterOpKind.RELATED_TO_ENTITY
|
|
144
|
+
case "is-installed":
|
|
145
|
+
self.filter_op_kind = ListFilterOpKind.IS_INSTALLED
|
|
146
|
+
case _:
|
|
147
|
+
# should never happen
|
|
148
|
+
self.filter_op_kind = ListFilterOpKind.UNKNOWN
|
|
149
|
+
|
|
150
|
+
def __call__(
|
|
151
|
+
self,
|
|
152
|
+
parser: argparse.ArgumentParser,
|
|
153
|
+
namespace: argparse.Namespace,
|
|
154
|
+
values: str | Sequence[Any] | None,
|
|
155
|
+
option_string: str | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
dest: ListFilter | None = getattr(namespace, self.dest, None)
|
|
158
|
+
if not dest:
|
|
159
|
+
dest = ListFilter()
|
|
160
|
+
setattr(namespace, self.dest, dest)
|
|
161
|
+
|
|
162
|
+
val: str
|
|
163
|
+
if isinstance(values, str):
|
|
164
|
+
val = values
|
|
165
|
+
elif isinstance(values, list):
|
|
166
|
+
val = values[0]
|
|
167
|
+
else:
|
|
168
|
+
# should never happen
|
|
169
|
+
# XXX: no easy way to wire to the global logger instance here
|
|
170
|
+
# log.D(f"unexpected values type: {type(values)}")
|
|
171
|
+
val = ""
|
|
172
|
+
|
|
173
|
+
dest.append(ListFilterOp(self.filter_op_kind, val))
|
ruyi/ruyipkg/msg.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from typing import Callable, TypedDict, TypeGuard, cast
|
|
2
|
+
|
|
3
|
+
from jinja2 import BaseLoader, Environment, TemplateNotFound
|
|
4
|
+
|
|
5
|
+
from ..utils.l10n import match_lang_code
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
RepoMessagesV1Type = TypedDict(
|
|
9
|
+
"RepoMessagesV1Type",
|
|
10
|
+
{
|
|
11
|
+
"ruyi-repo-messages": str,
|
|
12
|
+
# lang_code: message_content
|
|
13
|
+
},
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_repo_messages_v1(x: object) -> TypeGuard[RepoMessagesV1Type]:
|
|
18
|
+
if not isinstance(x, dict):
|
|
19
|
+
return False
|
|
20
|
+
x = cast(dict[str, object], x)
|
|
21
|
+
if x.get("ruyi-repo-messages", "") != "v1":
|
|
22
|
+
return False
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def group_messages_by_lang_code(decl: RepoMessagesV1Type) -> dict[str, dict[str, str]]:
|
|
27
|
+
obj = cast(dict[str, dict[str, str]], decl)
|
|
28
|
+
|
|
29
|
+
result: dict[str, dict[str, str]] = {}
|
|
30
|
+
for msgid, msg_decl in obj.items():
|
|
31
|
+
# skip the file type marker
|
|
32
|
+
if msgid == "ruyi-repo-messages":
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
for lang_code, msg in msg_decl.items():
|
|
36
|
+
if lang_code not in result:
|
|
37
|
+
result[lang_code] = {}
|
|
38
|
+
result[lang_code][msgid] = msg
|
|
39
|
+
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RepoMessageStore:
|
|
44
|
+
def __init__(self, decl: RepoMessagesV1Type) -> None:
|
|
45
|
+
self._msgs_by_lang_code = group_messages_by_lang_code(decl)
|
|
46
|
+
self._cached_envs_by_lang_code: dict[str, Environment] = {}
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_object(cls, obj: object) -> "RepoMessageStore":
|
|
50
|
+
if not validate_repo_messages_v1(obj):
|
|
51
|
+
# TODO: more detail in the error message
|
|
52
|
+
raise RuntimeError("malformed v1 repo messages definition")
|
|
53
|
+
return cls(obj)
|
|
54
|
+
|
|
55
|
+
def get_message_template(self, msgid: str, lang_code: str) -> str | None:
|
|
56
|
+
resolved_lang_code = match_lang_code(lang_code, self._msgs_by_lang_code.keys())
|
|
57
|
+
return self._msgs_by_lang_code[resolved_lang_code].get(msgid)
|
|
58
|
+
|
|
59
|
+
def get_jinja(self, lang_code: str) -> Environment:
|
|
60
|
+
if lang_code in self._cached_envs_by_lang_code:
|
|
61
|
+
return self._cached_envs_by_lang_code[lang_code]
|
|
62
|
+
|
|
63
|
+
env = Environment(
|
|
64
|
+
loader=RepoMessageLoader(self, lang_code),
|
|
65
|
+
autoescape=False, # we're not producing HTML
|
|
66
|
+
auto_reload=False, # we're serving static assets
|
|
67
|
+
)
|
|
68
|
+
self._cached_envs_by_lang_code[lang_code] = env
|
|
69
|
+
return env
|
|
70
|
+
|
|
71
|
+
def render_message(
|
|
72
|
+
self,
|
|
73
|
+
msgid: str,
|
|
74
|
+
lang_code: str,
|
|
75
|
+
params: dict[str, str],
|
|
76
|
+
add_trailing_newline: bool = False,
|
|
77
|
+
) -> str:
|
|
78
|
+
env = self.get_jinja(lang_code)
|
|
79
|
+
tmpl = env.get_template(msgid)
|
|
80
|
+
result = tmpl.render(params)
|
|
81
|
+
if add_trailing_newline and not result.endswith("\n"):
|
|
82
|
+
return result + "\n"
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RepoMessageLoader(BaseLoader):
|
|
87
|
+
def __init__(self, store: RepoMessageStore, lang_code: str) -> None:
|
|
88
|
+
self.store = store
|
|
89
|
+
self.lang_code = lang_code
|
|
90
|
+
|
|
91
|
+
def get_source(
|
|
92
|
+
self,
|
|
93
|
+
environment: Environment,
|
|
94
|
+
template: str,
|
|
95
|
+
) -> tuple[str, (str | None), (Callable[[], bool] | None)]:
|
|
96
|
+
result = self.store.get_message_template(template, self.lang_code)
|
|
97
|
+
if result is None:
|
|
98
|
+
raise TemplateNotFound(template)
|
|
99
|
+
return result, None, None
|
ruyi/ruyipkg/news.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from rich import box
|
|
2
|
+
from rich.table import Table
|
|
3
|
+
|
|
4
|
+
from ..config import GlobalConfig
|
|
5
|
+
from ..log import RuyiLogger
|
|
6
|
+
from ..utils.markdown import RuyiStyledMarkdown
|
|
7
|
+
from ..utils.porcelain import PorcelainOutput
|
|
8
|
+
from .news_store import NewsItem, NewsItemContent, NewsItemStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def print_news_item_titles(
|
|
12
|
+
logger: RuyiLogger,
|
|
13
|
+
newsitems: list[NewsItem],
|
|
14
|
+
lang: str,
|
|
15
|
+
) -> None:
|
|
16
|
+
tbl = Table(box=box.SIMPLE, show_edge=False)
|
|
17
|
+
tbl.add_column("No.")
|
|
18
|
+
tbl.add_column("ID")
|
|
19
|
+
tbl.add_column("Title")
|
|
20
|
+
|
|
21
|
+
for ni in newsitems:
|
|
22
|
+
unread = not ni.is_read
|
|
23
|
+
ord = f"[bold green]{ni.ordinal}[/]" if unread else f"{ni.ordinal}"
|
|
24
|
+
id = f"[bold green]{ni.id}[/]" if unread else ni.id
|
|
25
|
+
|
|
26
|
+
tbl.add_row(
|
|
27
|
+
ord,
|
|
28
|
+
id,
|
|
29
|
+
ni.get_content_for_lang(lang).display_title,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger.stdout(tbl)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def do_news_list(
|
|
36
|
+
cfg: GlobalConfig,
|
|
37
|
+
only_unread: bool,
|
|
38
|
+
) -> int:
|
|
39
|
+
logger = cfg.logger
|
|
40
|
+
store = cfg.repo.news_store()
|
|
41
|
+
newsitems = store.list(only_unread)
|
|
42
|
+
|
|
43
|
+
if cfg.is_porcelain:
|
|
44
|
+
with PorcelainOutput() as po:
|
|
45
|
+
for ni in newsitems:
|
|
46
|
+
po.emit(ni.to_porcelain())
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
logger.stdout("[bold green]News items:[/]\n")
|
|
50
|
+
if not newsitems:
|
|
51
|
+
logger.stdout(" (no unread item)" if only_unread else " (no item)")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
print_news_item_titles(logger, newsitems, cfg.lang_code)
|
|
55
|
+
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def do_news_read(
|
|
60
|
+
cfg: GlobalConfig,
|
|
61
|
+
quiet: bool,
|
|
62
|
+
items_strs: list[str],
|
|
63
|
+
) -> int:
|
|
64
|
+
logger = cfg.logger
|
|
65
|
+
store = cfg.repo.news_store()
|
|
66
|
+
|
|
67
|
+
# filter out requested news items
|
|
68
|
+
items = filter_news_items_by_specs(logger, store, items_strs)
|
|
69
|
+
if items is None:
|
|
70
|
+
return 1
|
|
71
|
+
|
|
72
|
+
if cfg.is_porcelain:
|
|
73
|
+
with PorcelainOutput() as po:
|
|
74
|
+
for ni in items:
|
|
75
|
+
po.emit(ni.to_porcelain())
|
|
76
|
+
elif not quiet:
|
|
77
|
+
# render the items
|
|
78
|
+
if items:
|
|
79
|
+
for ni in items:
|
|
80
|
+
print_news(logger, ni.get_content_for_lang(cfg.lang_code))
|
|
81
|
+
else:
|
|
82
|
+
logger.stdout("No news to display.")
|
|
83
|
+
|
|
84
|
+
# record read statuses
|
|
85
|
+
store.mark_as_read(*(ni.id for ni in items))
|
|
86
|
+
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def filter_news_items_by_specs(
|
|
91
|
+
logger: RuyiLogger,
|
|
92
|
+
store: NewsItemStore,
|
|
93
|
+
specs: list[str],
|
|
94
|
+
) -> list[NewsItem] | None:
|
|
95
|
+
if not specs:
|
|
96
|
+
# all unread items
|
|
97
|
+
return store.list(True)
|
|
98
|
+
|
|
99
|
+
all_ni = store.list(False)
|
|
100
|
+
items: list[NewsItem] = []
|
|
101
|
+
ni_by_ord = {ni.ordinal: ni for ni in all_ni}
|
|
102
|
+
ni_by_id = {ni.id: ni for ni in all_ni}
|
|
103
|
+
for i in specs:
|
|
104
|
+
try:
|
|
105
|
+
ni_ord = int(i)
|
|
106
|
+
if ni_ord not in ni_by_ord:
|
|
107
|
+
logger.F(f"there is no news item with ordinal {ni_ord}")
|
|
108
|
+
return None
|
|
109
|
+
items.append(ni_by_ord[ni_ord])
|
|
110
|
+
except ValueError:
|
|
111
|
+
# treat i as id
|
|
112
|
+
if i not in ni_by_id:
|
|
113
|
+
logger.F(f"there is no news item with ID '{i}'")
|
|
114
|
+
return None
|
|
115
|
+
items.append(ni_by_id[i])
|
|
116
|
+
|
|
117
|
+
return items
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def print_news(logger: RuyiLogger, nic: NewsItemContent) -> None:
|
|
121
|
+
md = RuyiStyledMarkdown(nic.content)
|
|
122
|
+
logger.stdout(md)
|
|
123
|
+
logger.stdout("")
|
ruyi/ruyipkg/news_cli.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from ..cli.cmd import RootCommand
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from ..cli.completion import ArgumentParser
|
|
8
|
+
from ..config import GlobalConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NewsCommand(
|
|
12
|
+
RootCommand,
|
|
13
|
+
cmd="news",
|
|
14
|
+
has_subcommands=True,
|
|
15
|
+
help="List and read news items from configured repository",
|
|
16
|
+
):
|
|
17
|
+
@classmethod
|
|
18
|
+
def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NewsListCommand(
|
|
23
|
+
NewsCommand,
|
|
24
|
+
cmd="list",
|
|
25
|
+
help="List news items",
|
|
26
|
+
):
|
|
27
|
+
@classmethod
|
|
28
|
+
def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None:
|
|
29
|
+
p.add_argument(
|
|
30
|
+
"--new",
|
|
31
|
+
action="store_true",
|
|
32
|
+
help="List unread news items only",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int:
|
|
37
|
+
from .news import do_news_list
|
|
38
|
+
|
|
39
|
+
only_unread: bool = args.new
|
|
40
|
+
return do_news_list(
|
|
41
|
+
cfg,
|
|
42
|
+
only_unread,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NewsReadCommand(
|
|
47
|
+
NewsCommand,
|
|
48
|
+
cmd="read",
|
|
49
|
+
help="Read news items",
|
|
50
|
+
description="Outputs news item(s) to the console and mark as already read. Defaults to reading all unread items if no item is specified.",
|
|
51
|
+
):
|
|
52
|
+
@classmethod
|
|
53
|
+
def configure_args(cls, gc: "GlobalConfig", p: "ArgumentParser") -> None:
|
|
54
|
+
p.add_argument(
|
|
55
|
+
"--quiet",
|
|
56
|
+
"-q",
|
|
57
|
+
action="store_true",
|
|
58
|
+
help="Do not output anything and only mark as read",
|
|
59
|
+
)
|
|
60
|
+
p.add_argument(
|
|
61
|
+
"item",
|
|
62
|
+
type=str,
|
|
63
|
+
nargs="*",
|
|
64
|
+
help="Ordinal or ID of the news item(s) to read",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def main(cls, cfg: "GlobalConfig", args: argparse.Namespace) -> int:
|
|
69
|
+
from .news import do_news_read
|
|
70
|
+
|
|
71
|
+
quiet: bool = args.quiet
|
|
72
|
+
items_strs: list[str] = args.item
|
|
73
|
+
|
|
74
|
+
return do_news_read(
|
|
75
|
+
cfg,
|
|
76
|
+
quiet,
|
|
77
|
+
items_strs,
|
|
78
|
+
)
|