ruyi 0.39.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. ruyi/__init__.py +21 -0
  2. ruyi/__main__.py +98 -0
  3. ruyi/cli/__init__.py +5 -0
  4. ruyi/cli/builtin_commands.py +14 -0
  5. ruyi/cli/cmd.py +224 -0
  6. ruyi/cli/completer.py +50 -0
  7. ruyi/cli/completion.py +26 -0
  8. ruyi/cli/config_cli.py +153 -0
  9. ruyi/cli/main.py +111 -0
  10. ruyi/cli/self_cli.py +295 -0
  11. ruyi/cli/user_input.py +127 -0
  12. ruyi/cli/version_cli.py +45 -0
  13. ruyi/config/__init__.py +401 -0
  14. ruyi/config/editor.py +92 -0
  15. ruyi/config/errors.py +76 -0
  16. ruyi/config/news.py +39 -0
  17. ruyi/config/schema.py +197 -0
  18. ruyi/device/__init__.py +0 -0
  19. ruyi/device/provision.py +591 -0
  20. ruyi/device/provision_cli.py +40 -0
  21. ruyi/log/__init__.py +272 -0
  22. ruyi/mux/.gitignore +1 -0
  23. ruyi/mux/__init__.py +0 -0
  24. ruyi/mux/runtime.py +213 -0
  25. ruyi/mux/venv/__init__.py +12 -0
  26. ruyi/mux/venv/emulator_cfg.py +41 -0
  27. ruyi/mux/venv/maker.py +782 -0
  28. ruyi/mux/venv/venv_cli.py +92 -0
  29. ruyi/mux/venv_cfg.py +214 -0
  30. ruyi/pluginhost/__init__.py +0 -0
  31. ruyi/pluginhost/api.py +206 -0
  32. ruyi/pluginhost/ctx.py +222 -0
  33. ruyi/pluginhost/paths.py +135 -0
  34. ruyi/pluginhost/plugin_cli.py +37 -0
  35. ruyi/pluginhost/unsandboxed.py +246 -0
  36. ruyi/py.typed +0 -0
  37. ruyi/resource_bundle/__init__.py +20 -0
  38. ruyi/resource_bundle/__main__.py +55 -0
  39. ruyi/resource_bundle/data.py +26 -0
  40. ruyi/ruyipkg/__init__.py +0 -0
  41. ruyi/ruyipkg/admin_checksum.py +88 -0
  42. ruyi/ruyipkg/admin_cli.py +83 -0
  43. ruyi/ruyipkg/atom.py +184 -0
  44. ruyi/ruyipkg/augmented_pkg.py +212 -0
  45. ruyi/ruyipkg/canonical_dump.py +320 -0
  46. ruyi/ruyipkg/checksum.py +39 -0
  47. ruyi/ruyipkg/cli_completion.py +42 -0
  48. ruyi/ruyipkg/distfile.py +208 -0
  49. ruyi/ruyipkg/entity.py +387 -0
  50. ruyi/ruyipkg/entity_cli.py +123 -0
  51. ruyi/ruyipkg/entity_provider.py +273 -0
  52. ruyi/ruyipkg/fetch.py +271 -0
  53. ruyi/ruyipkg/host.py +55 -0
  54. ruyi/ruyipkg/install.py +554 -0
  55. ruyi/ruyipkg/install_cli.py +150 -0
  56. ruyi/ruyipkg/list.py +126 -0
  57. ruyi/ruyipkg/list_cli.py +79 -0
  58. ruyi/ruyipkg/list_filter.py +173 -0
  59. ruyi/ruyipkg/msg.py +99 -0
  60. ruyi/ruyipkg/news.py +123 -0
  61. ruyi/ruyipkg/news_cli.py +78 -0
  62. ruyi/ruyipkg/news_store.py +183 -0
  63. ruyi/ruyipkg/pkg_manifest.py +657 -0
  64. ruyi/ruyipkg/profile.py +208 -0
  65. ruyi/ruyipkg/profile_cli.py +33 -0
  66. ruyi/ruyipkg/protocols.py +55 -0
  67. ruyi/ruyipkg/repo.py +763 -0
  68. ruyi/ruyipkg/state.py +345 -0
  69. ruyi/ruyipkg/unpack.py +369 -0
  70. ruyi/ruyipkg/unpack_method.py +91 -0
  71. ruyi/ruyipkg/update_cli.py +54 -0
  72. ruyi/telemetry/__init__.py +0 -0
  73. ruyi/telemetry/aggregate.py +72 -0
  74. ruyi/telemetry/event.py +41 -0
  75. ruyi/telemetry/node_info.py +192 -0
  76. ruyi/telemetry/provider.py +411 -0
  77. ruyi/telemetry/scope.py +43 -0
  78. ruyi/telemetry/store.py +238 -0
  79. ruyi/telemetry/telemetry_cli.py +127 -0
  80. ruyi/utils/__init__.py +0 -0
  81. ruyi/utils/ar.py +74 -0
  82. ruyi/utils/ci.py +63 -0
  83. ruyi/utils/frontmatter.py +38 -0
  84. ruyi/utils/git.py +169 -0
  85. ruyi/utils/global_mode.py +204 -0
  86. ruyi/utils/l10n.py +83 -0
  87. ruyi/utils/markdown.py +73 -0
  88. ruyi/utils/nuitka.py +33 -0
  89. ruyi/utils/porcelain.py +51 -0
  90. ruyi/utils/prereqs.py +77 -0
  91. ruyi/utils/ssl_patch.py +170 -0
  92. ruyi/utils/templating.py +34 -0
  93. ruyi/utils/toml.py +115 -0
  94. ruyi/utils/url.py +7 -0
  95. ruyi/utils/xdg_basedir.py +80 -0
  96. ruyi/version.py +67 -0
  97. ruyi-0.39.0.dist-info/LICENSE-Apache.txt +201 -0
  98. ruyi-0.39.0.dist-info/METADATA +403 -0
  99. ruyi-0.39.0.dist-info/RECORD +101 -0
  100. ruyi-0.39.0.dist-info/WHEEL +4 -0
  101. ruyi-0.39.0.dist-info/entry_points.txt +3 -0
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"]}[/]')
@@ -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("")
@@ -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
+ )