mikancli 0.1.8__tar.gz → 0.2.0__tar.gz

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 (37) hide show
  1. {mikancli-0.1.8 → mikancli-0.2.0}/PKG-INFO +24 -3
  2. {mikancli-0.1.8 → mikancli-0.2.0}/README.md +22 -2
  3. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/__init__.py +1 -1
  4. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/entrypoint.py +6 -6
  5. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/input_parsing.py +3 -2
  6. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/qbittorrent_flow.py +32 -25
  7. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/save_path_flow.py +4 -3
  8. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/search_flow.py +15 -16
  9. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/display.py +70 -32
  10. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/i18n.py +10 -0
  11. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli.egg-info/PKG-INFO +24 -3
  12. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli.egg-info/SOURCES.txt +2 -0
  13. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli.egg-info/requires.txt +1 -0
  14. {mikancli-0.1.8 → mikancli-0.2.0}/pyproject.toml +2 -1
  15. mikancli-0.2.0/tests/test_cli_json.py +30 -0
  16. mikancli-0.2.0/tests/test_display.py +67 -0
  17. mikancli-0.2.0/tests/test_feed_preview.py +76 -0
  18. mikancli-0.1.8/tests/test_feed_preview.py +0 -38
  19. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/__main__.py +0 -0
  20. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/__init__.py +0 -0
  21. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/cli/prompts.py +0 -0
  22. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/config.py +0 -0
  23. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/core/__init__.py +0 -0
  24. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/core/models.py +0 -0
  25. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/core/normalize.py +0 -0
  26. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/core/rules.py +0 -0
  27. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/integrations/__init__.py +0 -0
  28. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/integrations/mikan.py +0 -0
  29. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/integrations/mikan_parsers.py +0 -0
  30. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/integrations/mikan_urls.py +0 -0
  31. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/integrations/qbittorrent.py +0 -0
  32. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli/integrations/qbittorrent_client.py +0 -0
  33. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli.egg-info/dependency_links.txt +0 -0
  34. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli.egg-info/entry_points.txt +0 -0
  35. {mikancli-0.1.8 → mikancli-0.2.0}/mikancli.egg-info/top_level.txt +0 -0
  36. {mikancli-0.1.8 → mikancli-0.2.0}/setup.cfg +0 -0
  37. {mikancli-0.1.8 → mikancli-0.2.0}/tests/test_qbittorrent_episode_filter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mikancli
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: CLI foundation for building qBittorrent RSS rules from anime search input.
5
5
  Author: metails
6
6
  Project-URL: Repository, https://github.com/JianHua-Deng/MikanCli
@@ -20,6 +20,7 @@ Classifier: Topic :: Utilities
20
20
  Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  Requires-Dist: InquirerPy>=0.3
23
+ Requires-Dist: rich>=14.0
23
24
 
24
25
  # MikanCli
25
26
 
@@ -34,6 +35,7 @@ It supports both a guided interactive flow and a JSON preview mode for scripting
34
35
  - Search Mikan by anime title or keyword
35
36
  - Choose from matching Bangumi results and subgroup-specific RSS feeds
36
37
  - Preview recent RSS feed items with pagination before confirming a feed
38
+ - Read Rich-formatted rule summaries, feed previews, and status messages, with loading indicators for network requests
37
39
  - Build qBittorrent RSS rules with include and exclude filters
38
40
  - Filter anime-style episode numbers at or above a minimum episode
39
41
  - Choose and save a default download folder
@@ -88,7 +90,26 @@ python -m pip install -e .
88
90
  python -m mikancli
89
91
  ```
90
92
 
91
- Dependencies are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
93
+ Dependencies, including Rich for output and InquirerPy for prompts, are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
94
+
95
+ After pulling dependency changes into an existing editable `pip` installation, rerun `python -m pip install -e .` in that environment. Update `pipx` installations through `pipx` so dependencies stay in the app's environment.
96
+
97
+ ## Terminal Output
98
+
99
+ Rich formats rule summaries, feed previews, and feedback. InquirerPy handles menus and text input. Release titles containing brackets are displayed literally, and long titles, URLs, and paths wrap to fit the terminal.
100
+
101
+ Interactive terminals show loading indicators during network requests. Indicators stop before prompts and after failures. Redirected output uses static status messages, and feed previews retain their plain-text layout.
102
+
103
+ To disable Rich colors in PowerShell:
104
+
105
+ ```powershell
106
+ $env:NO_COLOR = "1"
107
+ mikancli
108
+ ```
109
+
110
+ Remove that setting with `Remove-Item Env:NO_COLOR`. Bold or dim text and loading animations may remain; InquirerPy manages menu styling separately.
111
+
112
+ The `--json` draft mode prints one JSON document without Rich formatting or progress messages.
92
113
 
93
114
  ## Guided Flow
94
115
 
@@ -169,7 +190,7 @@ mikancli/
169
190
  core/ dataclasses, normalization, and rule-building logic
170
191
  integrations/ Mikan and qBittorrent adapters
171
192
  config.py user config and folder selection helpers
172
- display.py text summaries and feed previews
193
+ display.py Rich summaries, feed previews, feedback, and loading indicators
173
194
  ```
174
195
 
175
196
  The console command is declared in `pyproject.toml`:
@@ -11,6 +11,7 @@ It supports both a guided interactive flow and a JSON preview mode for scripting
11
11
  - Search Mikan by anime title or keyword
12
12
  - Choose from matching Bangumi results and subgroup-specific RSS feeds
13
13
  - Preview recent RSS feed items with pagination before confirming a feed
14
+ - Read Rich-formatted rule summaries, feed previews, and status messages, with loading indicators for network requests
14
15
  - Build qBittorrent RSS rules with include and exclude filters
15
16
  - Filter anime-style episode numbers at or above a minimum episode
16
17
  - Choose and save a default download folder
@@ -65,7 +66,26 @@ python -m pip install -e .
65
66
  python -m mikancli
66
67
  ```
67
68
 
68
- Dependencies are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
69
+ Dependencies, including Rich for output and InquirerPy for prompts, are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
70
+
71
+ After pulling dependency changes into an existing editable `pip` installation, rerun `python -m pip install -e .` in that environment. Update `pipx` installations through `pipx` so dependencies stay in the app's environment.
72
+
73
+ ## Terminal Output
74
+
75
+ Rich formats rule summaries, feed previews, and feedback. InquirerPy handles menus and text input. Release titles containing brackets are displayed literally, and long titles, URLs, and paths wrap to fit the terminal.
76
+
77
+ Interactive terminals show loading indicators during network requests. Indicators stop before prompts and after failures. Redirected output uses static status messages, and feed previews retain their plain-text layout.
78
+
79
+ To disable Rich colors in PowerShell:
80
+
81
+ ```powershell
82
+ $env:NO_COLOR = "1"
83
+ mikancli
84
+ ```
85
+
86
+ Remove that setting with `Remove-Item Env:NO_COLOR`. Bold or dim text and loading animations may remain; InquirerPy manages menu styling separately.
87
+
88
+ The `--json` draft mode prints one JSON document without Rich formatting or progress messages.
69
89
 
70
90
  ## Guided Flow
71
91
 
@@ -146,7 +166,7 @@ mikancli/
146
166
  core/ dataclasses, normalization, and rule-building logic
147
167
  integrations/ Mikan and qBittorrent adapters
148
168
  config.py user config and folder selection helpers
149
- display.py text summaries and feed previews
169
+ display.py Rich summaries, feed previews, feedback, and loading indicators
150
170
  ```
151
171
 
152
172
  The console command is declared in `pyproject.toml`:
@@ -4,4 +4,4 @@ __all__ = [
4
4
  "__version__",
5
5
  ]
6
6
 
7
- __version__ = "0.1.8"
7
+ __version__ = "0.2.0"
@@ -31,7 +31,7 @@ from mikancli.config import get_config_path, load_config, save_config
31
31
  from mikancli.core.models import AppConfig, RuleDraft, SearchRequest
32
32
  from mikancli.core.normalize import collapse_spaces
33
33
  from mikancli.core.rules import build_rule_draft
34
- from mikancli.display import print_text_summary
34
+ from mikancli.display import print_message, print_text_summary
35
35
  from mikancli.i18n import (
36
36
  LANGUAGE_LABELS,
37
37
  SUPPORTED_LANGUAGES,
@@ -165,7 +165,7 @@ def run_language_configuration_flow(config: AppConfig, config_path: Path) -> App
165
165
  set_language(selected_language)
166
166
  updated_config = replace(config, language=selected_language)
167
167
  save_config(config_path, updated_config)
168
- print(t("language.saved", language=LANGUAGE_LABELS[selected_language]))
168
+ print_message(t("language.saved", language=LANGUAGE_LABELS[selected_language]), style="success")
169
169
  return updated_config
170
170
 
171
171
 
@@ -247,7 +247,7 @@ def main(argv: list[str] | None = None) -> int:
247
247
  try:
248
248
  return setup_qbittorrent(config, config_path)
249
249
  except ExitRequested:
250
- print(t("common.exited"))
250
+ print_message(t("common.exited"))
251
251
  return 0
252
252
 
253
253
  if args.json:
@@ -307,7 +307,7 @@ def main(argv: list[str] | None = None) -> int:
307
307
  config_path=config_path,
308
308
  )
309
309
  except ExitRequested:
310
- print(t("common.exited"))
310
+ print_message(t("common.exited"))
311
311
  return 0
312
312
 
313
313
  summary_exit_code = print_text_summary(draft)
@@ -316,7 +316,7 @@ def main(argv: list[str] | None = None) -> int:
316
316
  try:
317
317
  submission_exit_code = prompt_to_submit_rule_to_qbittorrent(config, draft)
318
318
  if submission_exit_code == QBITTORRENT_NOT_CONFIGURED:
319
- print(t("qb.submit.not_configured"))
319
+ print_message(t("qb.submit.not_configured"), style="warning")
320
320
  setup_exit_code = prompt_for_qbittorrent_setup_if_needed(config, config_path)
321
321
  if setup_exit_code not in {
322
322
  QBITTORRENT_SETUP_SUCCESS,
@@ -331,7 +331,7 @@ def main(argv: list[str] | None = None) -> int:
331
331
  continue
332
332
 
333
333
  except ExitRequested:
334
- print(t("common.exited"))
334
+ print_message(t("common.exited"))
335
335
  return 0
336
336
 
337
337
  if has_startup_menu and submission_exit_code == QBITTORRENT_SUBMISSION_SKIPPED:
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from mikancli.cli.prompts import prompt_text
4
4
  from mikancli.core.normalize import collapse_spaces
5
+ from mikancli.display import print_message
5
6
  from mikancli.i18n import t
6
7
 
7
8
 
@@ -10,7 +11,7 @@ def prompt_required_text(prompt: str) -> str:
10
11
  entered = collapse_spaces(prompt_text(prompt, allow_exit=True))
11
12
  if entered:
12
13
  return entered
13
- print(t("common.value_required"))
14
+ print_message(t("common.value_required"), style="warning")
14
15
 
15
16
 
16
17
  def parse_word_list(value: str) -> tuple[str, ...]:
@@ -62,4 +63,4 @@ def prompt_optional_positive_int(prompt: str, *, field_name: str) -> int | None:
62
63
  try:
63
64
  return parse_optional_positive_int(entered, field_name=field_name)
64
65
  except ValueError as exc:
65
- print(exc)
66
+ print_message(str(exc), style="warning")
@@ -6,6 +6,7 @@ from mikancli.cli.prompts import confirm_choice, prompt_password, prompt_text
6
6
  from mikancli.config import save_config
7
7
  from mikancli.core.models import AppConfig, QBittorrentSettings, RuleDraft
8
8
  from mikancli.core.normalize import collapse_spaces, sanitize_folder_name
9
+ from mikancli.display import loading, print_message
9
10
  from mikancli.i18n import t
10
11
  from mikancli.integrations.qbittorrent import (
11
12
  MIN_RSS_ARTICLES_FOR_EPISODE_FILTER,
@@ -49,7 +50,7 @@ def setup_qbittorrent(config: AppConfig, config_path: Path) -> int:
49
50
  or "http://localhost:8080"
50
51
  )
51
52
 
52
- print(t("qb.setup.auth_hint"))
53
+ print_message(t("qb.setup.auth_hint"))
53
54
  username = (
54
55
  collapse_spaces(
55
56
  prompt_text(
@@ -72,10 +73,10 @@ def setup_qbittorrent(config: AppConfig, config_path: Path) -> int:
72
73
  )
73
74
 
74
75
  try:
75
- print(t("qb.setup.verifying"))
76
- version = check_connection(settings)
76
+ with loading(t("qb.setup.verifying")):
77
+ version = check_connection(settings)
77
78
  except QBittorrentError as exc:
78
- print(str(exc))
79
+ print_message(str(exc), style="error")
79
80
  return QBITTORRENT_ERROR
80
81
 
81
82
  save_config(
@@ -90,7 +91,7 @@ def setup_qbittorrent(config: AppConfig, config_path: Path) -> int:
90
91
  qbittorrent_add_paused=config.qbittorrent_add_paused,
91
92
  ),
92
93
  )
93
- print(t("qb.setup.verified", version=version))
94
+ print_message(t("qb.setup.verified", version=version), style="success")
94
95
  return QBITTORRENT_SETUP_SUCCESS
95
96
 
96
97
 
@@ -160,7 +161,8 @@ def prompt_to_raise_rss_article_limit_if_needed(
160
161
  if draft.min_episode is None:
161
162
  return
162
163
 
163
- current_limit = get_rss_max_articles_per_feed(settings)
164
+ with loading(t("qb.submit.checking")):
165
+ current_limit = get_rss_max_articles_per_feed(settings)
164
166
  if (
165
167
  current_limit is None
166
168
  or current_limit >= MIN_RSS_ARTICLES_FOR_EPISODE_FILTER
@@ -177,23 +179,26 @@ def prompt_to_raise_rss_article_limit_if_needed(
177
179
  allow_exit=True,
178
180
  )
179
181
  if not should_update:
180
- print(
182
+ print_message(
181
183
  t(
182
184
  "qb.submit.rss_limit_skipped",
183
185
  current=current_limit,
184
- )
186
+ ),
187
+ style="warning",
185
188
  )
186
189
  return
187
190
 
188
- set_rss_max_articles_per_feed(
189
- settings,
190
- MIN_RSS_ARTICLES_FOR_EPISODE_FILTER,
191
- )
192
- print(
191
+ with loading(t("qb.submit.updating_limit")):
192
+ set_rss_max_articles_per_feed(
193
+ settings,
194
+ MIN_RSS_ARTICLES_FOR_EPISODE_FILTER,
195
+ )
196
+ print_message(
193
197
  t(
194
198
  "qb.submit.rss_limit_updated",
195
199
  minimum=MIN_RSS_ARTICLES_FOR_EPISODE_FILTER,
196
- )
200
+ ),
201
+ style="success",
197
202
  )
198
203
 
199
204
 
@@ -218,7 +223,9 @@ def prompt_to_submit_rule_to_qbittorrent(config: AppConfig, draft: RuleDraft,) -
218
223
  )
219
224
 
220
225
  try:
221
- if qbittorrent_rule_exists(settings, draft.rule_name):
226
+ with loading(t("qb.submit.checking")):
227
+ rule_exists = qbittorrent_rule_exists(settings, draft.rule_name)
228
+ if rule_exists:
222
229
  should_replace = confirm_choice(
223
230
  t("qb.submit.replace_existing", rule_name=draft.rule_name),
224
231
  default=False,
@@ -229,17 +236,17 @@ def prompt_to_submit_rule_to_qbittorrent(config: AppConfig, draft: RuleDraft,) -
229
236
 
230
237
  prompt_to_raise_rss_article_limit_if_needed(settings, draft)
231
238
  feed_path = prompt_for_rss_feed_name(draft)
232
- print(t("qb.submit.submitting"))
233
- submit_rule_draft(
234
- settings,
235
- draft,
236
- add_paused=config.qbittorrent_add_paused,
237
- assigned_category=config.qbittorrent_category,
238
- feed_path=feed_path,
239
- )
239
+ with loading(t("qb.submit.submitting")):
240
+ submit_rule_draft(
241
+ settings,
242
+ draft,
243
+ add_paused=config.qbittorrent_add_paused,
244
+ assigned_category=config.qbittorrent_category,
245
+ feed_path=feed_path,
246
+ )
240
247
  except QBittorrentError as exc:
241
- print(t("qb.submit.failed", error=str(exc)))
248
+ print_message(t("qb.submit.failed", error=str(exc)), style="error")
242
249
  return QBITTORRENT_ERROR
243
250
 
244
- print(t("qb.submit.success"))
251
+ print_message(t("qb.submit.success"), style="success")
245
252
  return QBITTORRENT_SETUP_SUCCESS
@@ -10,6 +10,7 @@ from mikancli.config import (
10
10
  )
11
11
  from mikancli.core.models import AppConfig
12
12
  from mikancli.core.normalize import collapse_spaces, sanitize_folder_name
13
+ from mikancli.display import print_message
13
14
  from mikancli.i18n import t
14
15
 
15
16
 
@@ -61,7 +62,7 @@ def prompt_for_content_folder_name(default_name: str, base_path: str | None) ->
61
62
  ):
62
63
  return folder_name
63
64
 
64
- print(t("save.choose_different"))
65
+ print_message(t("save.choose_different"), style="warning")
65
66
 
66
67
 
67
68
  def build_content_save_path(base_path: str | None, folder_name: str) -> str | None:
@@ -104,12 +105,12 @@ def prompt_for_save_path(config: AppConfig, config_path: Path) -> str:
104
105
  initial_dir=config.default_save_path or downloads_path
105
106
  )
106
107
  if not selected_path:
107
- print(t("save.no_folder_selected"))
108
+ print_message(t("save.no_folder_selected"), style="warning")
108
109
  continue
109
110
  else:
110
111
  selected_path = prompt_for_manual_save_path()
111
112
  if not selected_path:
112
- print(t("save.no_path_entered"))
113
+ print_message(t("save.no_path_entered"), style="warning")
113
114
  continue
114
115
 
115
116
  if should_save_as_default(selected_path, config):
@@ -11,7 +11,9 @@ from mikancli.core.models import (
11
11
  from mikancli.core.normalize import collapse_spaces
12
12
  from mikancli.display import (
13
13
  FEED_PREVIEW_PAGE_SIZE,
14
- build_feed_preview_page_text,
14
+ loading,
15
+ print_feed_preview_page,
16
+ print_message,
15
17
  )
16
18
  from mikancli.i18n import t
17
19
  from mikancli.integrations.mikan import (
@@ -96,13 +98,7 @@ def confirm_subgroup_selection(
96
98
  )
97
99
 
98
100
  while True:
99
- print(
100
- build_feed_preview_page_text(
101
- subgroup,
102
- feed_items,
103
- page=page,
104
- )
105
- )
101
+ print_feed_preview_page(subgroup, feed_items, page=page)
106
102
 
107
103
  options = [(CONFIRM_SUBGROUP, t("common.yes"))]
108
104
  if page < total_pages:
@@ -184,14 +180,15 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
184
180
 
185
181
  while True:
186
182
  try:
187
- candidates = search_mikan_bangumi(keyword)
183
+ with loading(t("search.searching")):
184
+ candidates = search_mikan_bangumi(keyword)
188
185
  except MikanLookupError as exc:
189
- print(str(exc))
186
+ print_message(str(exc), style="error")
190
187
  keyword = prompt_required_text(search_prompt(retry=True))
191
188
  continue
192
189
 
193
190
  if not candidates:
194
- print(t("search.no_results", keyword=keyword))
191
+ print_message(t("search.no_results", keyword=keyword), style="warning")
195
192
  keyword = prompt_required_text(search_prompt(retry=True))
196
193
  continue
197
194
 
@@ -204,13 +201,14 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
204
201
 
205
202
  while True:
206
203
  try:
207
- subgroups = fetch_mikan_subgroups(bangumi.bangumi_id)
204
+ with loading(t("search.loading_subgroups")):
205
+ subgroups = fetch_mikan_subgroups(bangumi.bangumi_id)
208
206
  except MikanLookupError as exc:
209
- print(str(exc))
207
+ print_message(str(exc), style="error")
210
208
  break
211
209
 
212
210
  if not subgroups:
213
- print(t("search.no_subgroup"))
211
+ print_message(t("search.no_subgroup"), style="warning")
214
212
  break
215
213
 
216
214
  selected_subgroup = select_subgroup_or_navigate(
@@ -227,9 +225,10 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
227
225
  subgroup = selected_subgroup
228
226
 
229
227
  try:
230
- feed_items = fetch_mikan_feed_items(subgroup.feed_url)
228
+ with loading(t("search.loading_feed")):
229
+ feed_items = fetch_mikan_feed_items(subgroup.feed_url)
231
230
  except MikanLookupError as exc:
232
- print(str(exc))
231
+ print_message(str(exc), style="error")
233
232
  continue
234
233
 
235
234
  decision = confirm_subgroup_selection(subgroup, feed_items)
@@ -1,48 +1,65 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from contextlib import AbstractContextManager, nullcontext
3
4
  from datetime import datetime
4
5
 
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+ from rich.theme import Theme
10
+
5
11
  from mikancli.core.models import MikanFeedItem, MikanSubgroup, RuleDraft
6
12
  from mikancli.i18n import t
7
13
 
8
14
  FEED_PREVIEW_PAGE_SIZE = 12
15
+ console = Console(
16
+ markup=False, highlight=False, emoji=False,
17
+ theme=Theme({
18
+ "heading": "bold cyan", "label": "cyan", "note": "dim",
19
+ "success": "green", "warning": "yellow", "error": "bold red",
20
+ }),
21
+ )
22
+
23
+
24
+ def print_message(message: str, *, style: str = "note") -> None:
25
+ """Print literal feedback with wrapping in terminals and plain redirected text."""
26
+ console.print(Text(message), style=style, soft_wrap=not console.is_terminal)
27
+
28
+
29
+ def loading(message: str) -> AbstractContextManager[object]:
30
+ """Animate terminal requests, or print one static message when redirected."""
31
+ if console.is_terminal and console.is_interactive:
32
+ return console.status(Text(message), spinner="line")
33
+ print_message(message)
34
+ return nullcontext()
9
35
 
10
36
 
11
37
  def print_text_summary(draft: RuleDraft) -> int:
12
38
  """Print a human-readable summary of a rule draft. Returns 0 after writing the summary and any next-step notes to stdout."""
13
- print()
14
- print(t("display.rule_header"))
15
- print(t("display.keyword", value=draft.keyword))
16
- print(t("display.normalized_keyword", value=draft.normalized_keyword))
17
- print(t("display.rule_name", value=draft.rule_name))
18
- print(t("display.mikan_title", value=draft.mikan_title or t("common.not_found")))
19
- print(t("display.mikan_subgroup", value=draft.mikan_subgroup or t("common.not_found")))
20
- print(t("display.mikan_page", value=draft.mikan_page_url or t("common.not_found")))
21
- print(t("display.feed_url", value=draft.feed_url or t("common.not_found")))
22
- print(
23
- t(
24
- "display.must_contain",
25
- value=", ".join(draft.must_contain) if draft.must_contain else t("common.none"),
26
- )
27
- )
28
- print(
29
- t(
30
- "display.must_not_contain",
31
- value=", ".join(draft.must_not_contain) if draft.must_not_contain else t("common.none"),
32
- )
33
- )
34
- print(
35
- t(
36
- "display.min_episode",
37
- value=draft.min_episode if draft.min_episode is not None else t("common.none"),
38
- )
39
- )
40
- print(t("display.save_path", value=draft.save_path or t("common.not_set")))
41
- print(t("display.rule_footer"))
42
- print()
43
-
39
+ fields = {
40
+ "keyword": draft.keyword,
41
+ "normalized_keyword": draft.normalized_keyword,
42
+ "rule_name": draft.rule_name,
43
+ "mikan_title": draft.mikan_title or t("common.not_found"),
44
+ "mikan_subgroup": draft.mikan_subgroup or t("common.not_found"),
45
+ "mikan_page": draft.mikan_page_url or t("common.not_found"),
46
+ "feed_url": draft.feed_url or t("common.not_found"),
47
+ "must_contain": ", ".join(draft.must_contain) or t("common.none"),
48
+ "must_not_contain": ", ".join(draft.must_not_contain) or t("common.none"),
49
+ "min_episode": draft.min_episode if draft.min_episode is not None else t("common.none"),
50
+ "save_path": draft.save_path or t("common.not_set"),
51
+ }
52
+ table = Table.grid(padding=(0, 2))
53
+ table.add_column(style="label")
54
+ table.add_column(overflow="fold")
55
+ for key, value in fields.items():
56
+ table.add_row(Text(t(f"display.{key}", value="").rstrip()), Text(str(value)))
57
+ console.print()
58
+ console.rule(Text(t("display.rule_header").strip("- ")), style="heading")
59
+ console.print(table)
60
+ console.print()
44
61
  for note in draft.notes:
45
- print(t("display.next_step", value=note))
62
+ console.print(Text(t("display.next_step", value=note)), style="note")
46
63
 
47
64
  return 0
48
65
 
@@ -88,6 +105,27 @@ def build_feed_preview_text(
88
105
  )
89
106
 
90
107
 
108
+ def print_feed_preview_page(
109
+ subgroup: MikanSubgroup,
110
+ feed_items: tuple[MikanFeedItem, ...],
111
+ *,
112
+ page: int,
113
+ ) -> None:
114
+ """Render a feed page with literal titles and wrapping terminal output."""
115
+ content = build_feed_preview_page_text(subgroup, feed_items, page=page)
116
+ if not console.is_terminal:
117
+ console.print(Text(content), soft_wrap=True)
118
+ return
119
+
120
+ preview = Text()
121
+ for index, line in enumerate(content.splitlines(keepends=True)):
122
+ style = "heading" if index == 0 else ""
123
+ if index in (1, 2) or line.startswith(" "):
124
+ style = "note"
125
+ preview.append(line, style=style)
126
+ console.print(preview, overflow="fold")
127
+
128
+
91
129
  def build_feed_preview_page_text(
92
130
  subgroup: MikanSubgroup,
93
131
  feed_items: tuple[MikanFeedItem, ...],
@@ -50,6 +50,9 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
50
50
  "filters.min_episode_field": "minimum episode",
51
51
  "filters.positive_int_required": "{field} must be a positive whole number.",
52
52
  "draft.review_note": "Review the draft before submitting it to qBittorrent.",
53
+ "search.searching": "Searching Mikan...",
54
+ "search.loading_subgroups": "Loading subgroup feeds...",
55
+ "search.loading_feed": "Fetching RSS feed preview...",
53
56
  "search.prompt": "Enter anime title or search keyword (or type 'exit' to quit): ",
54
57
  "search.prompt_retry": "Enter another anime title or search keyword (or type 'exit' to quit): ",
55
58
  "search.choose_candidate": "Choose the Mikan entry for '{keyword}'",
@@ -88,6 +91,8 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
88
91
  "qb.submit.rss_limit_offer": "qBittorrent is currently keeping only {current} RSS articles per feed. Minimum-episode filters may need at least {minimum}. Increase it now?",
89
92
  "qb.submit.rss_limit_skipped": "Continuing with qBittorrent's current RSS article limit of {current}.",
90
93
  "qb.submit.rss_limit_updated": "qBittorrent RSS article limit updated to {minimum}.",
94
+ "qb.submit.checking": "Checking qBittorrent RSS configuration...",
95
+ "qb.submit.updating_limit": "Updating qBittorrent RSS article limit...",
91
96
  "qb.submit.submitting": "Submitting RSS feed and download rule to qBittorrent...",
92
97
  "qb.submit.failed": "Failed to submit to qBittorrent: {error}",
93
98
  "qb.submit.success": "qBittorrent feed and download rule submitted and verified successfully.",
@@ -159,6 +164,9 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
159
164
  "filters.min_episode_field": "最低集数",
160
165
  "filters.positive_int_required": "{field}必须是正整数。",
161
166
  "draft.review_note": "提交到 qBittorrent 前请检查规则草稿。",
167
+ "search.searching": "正在搜索 Mikan...",
168
+ "search.loading_subgroups": "正在加载字幕组订阅...",
169
+ "search.loading_feed": "正在获取 RSS 订阅预览...",
162
170
  "search.prompt": "输入动画标题或搜索关键词(输入 'exit' 退出):",
163
171
  "search.prompt_retry": "输入另一个动画标题或搜索关键词(输入 'exit' 退出):",
164
172
  "search.choose_candidate": "为 '{keyword}' 选择 Mikan 条目",
@@ -197,6 +205,8 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
197
205
  "qb.submit.rss_limit_offer": "qBittorrent 当前每个 RSS 订阅只保留 {current} 个条目。最低集数过滤可能至少需要 {minimum} 个。现在增加吗?",
198
206
  "qb.submit.rss_limit_skipped": "将继续使用 qBittorrent 当前的 RSS 条目上限:{current}。",
199
207
  "qb.submit.rss_limit_updated": "qBittorrent RSS 条目上限已更新为 {minimum}。",
208
+ "qb.submit.checking": "正在检查 qBittorrent RSS 配置...",
209
+ "qb.submit.updating_limit": "正在更新 qBittorrent RSS 条目上限...",
200
210
  "qb.submit.submitting": "正在向 qBittorrent 提交 RSS 订阅和下载规则...",
201
211
  "qb.submit.failed": "提交到 qBittorrent 失败:{error}",
202
212
  "qb.submit.success": "qBittorrent 订阅和下载规则已提交并验证成功。",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mikancli
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: CLI foundation for building qBittorrent RSS rules from anime search input.
5
5
  Author: metails
6
6
  Project-URL: Repository, https://github.com/JianHua-Deng/MikanCli
@@ -20,6 +20,7 @@ Classifier: Topic :: Utilities
20
20
  Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  Requires-Dist: InquirerPy>=0.3
23
+ Requires-Dist: rich>=14.0
23
24
 
24
25
  # MikanCli
25
26
 
@@ -34,6 +35,7 @@ It supports both a guided interactive flow and a JSON preview mode for scripting
34
35
  - Search Mikan by anime title or keyword
35
36
  - Choose from matching Bangumi results and subgroup-specific RSS feeds
36
37
  - Preview recent RSS feed items with pagination before confirming a feed
38
+ - Read Rich-formatted rule summaries, feed previews, and status messages, with loading indicators for network requests
37
39
  - Build qBittorrent RSS rules with include and exclude filters
38
40
  - Filter anime-style episode numbers at or above a minimum episode
39
41
  - Choose and save a default download folder
@@ -88,7 +90,26 @@ python -m pip install -e .
88
90
  python -m mikancli
89
91
  ```
90
92
 
91
- Dependencies are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
93
+ Dependencies, including Rich for output and InquirerPy for prompts, are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
94
+
95
+ After pulling dependency changes into an existing editable `pip` installation, rerun `python -m pip install -e .` in that environment. Update `pipx` installations through `pipx` so dependencies stay in the app's environment.
96
+
97
+ ## Terminal Output
98
+
99
+ Rich formats rule summaries, feed previews, and feedback. InquirerPy handles menus and text input. Release titles containing brackets are displayed literally, and long titles, URLs, and paths wrap to fit the terminal.
100
+
101
+ Interactive terminals show loading indicators during network requests. Indicators stop before prompts and after failures. Redirected output uses static status messages, and feed previews retain their plain-text layout.
102
+
103
+ To disable Rich colors in PowerShell:
104
+
105
+ ```powershell
106
+ $env:NO_COLOR = "1"
107
+ mikancli
108
+ ```
109
+
110
+ Remove that setting with `Remove-Item Env:NO_COLOR`. Bold or dim text and loading animations may remain; InquirerPy manages menu styling separately.
111
+
112
+ The `--json` draft mode prints one JSON document without Rich formatting or progress messages.
92
113
 
93
114
  ## Guided Flow
94
115
 
@@ -169,7 +190,7 @@ mikancli/
169
190
  core/ dataclasses, normalization, and rule-building logic
170
191
  integrations/ Mikan and qBittorrent adapters
171
192
  config.py user config and folder selection helpers
172
- display.py text summaries and feed previews
193
+ display.py Rich summaries, feed previews, feedback, and loading indicators
173
194
  ```
174
195
 
175
196
  The console command is declared in `pyproject.toml`:
@@ -28,5 +28,7 @@ mikancli/integrations/mikan_parsers.py
28
28
  mikancli/integrations/mikan_urls.py
29
29
  mikancli/integrations/qbittorrent.py
30
30
  mikancli/integrations/qbittorrent_client.py
31
+ tests/test_cli_json.py
32
+ tests/test_display.py
31
33
  tests/test_feed_preview.py
32
34
  tests/test_qbittorrent_episode_filter.py
@@ -1 +1,2 @@
1
1
  InquirerPy>=0.3
2
+ rich>=14.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mikancli"
7
- version = "0.1.8"
7
+ version = "0.2.0"
8
8
  description = "CLI foundation for building qBittorrent RSS rules from anime search input."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -27,6 +27,7 @@ classifiers = [
27
27
  ]
28
28
  dependencies = [
29
29
  "InquirerPy>=0.3",
30
+ "rich>=14.0",
30
31
  ]
31
32
 
32
33
  [tool.setuptools.packages.find]
@@ -0,0 +1,30 @@
1
+ import json
2
+ from contextlib import redirect_stdout
3
+ from io import StringIO
4
+ from unittest import TestCase
5
+ from unittest.mock import patch
6
+
7
+ from mikancli.cli import entrypoint, search_flow
8
+ from mikancli.core.models import AppConfig, MikanBangumi, MikanSubgroup
9
+ from mikancli.i18n import get_language, set_language
10
+
11
+
12
+ class JsonOutputTests(TestCase):
13
+ def test_json_draft_bypasses_rich_feedback_and_loading(self) -> None:
14
+ self.addCleanup(set_language, get_language())
15
+ title = "[bold]Anime[/bold]"
16
+ bangumi = MikanBangumi(1, title, "https://example.test/anime", "https://example.test/rss")
17
+ subgroup = MikanSubgroup(534, "Group", "https://example.test/rss")
18
+ output = StringIO()
19
+ with (
20
+ patch.object(entrypoint, "load_config", return_value=AppConfig()),
21
+ patch.object(search_flow, "search_mikan_bangumi", return_value=(bangumi,)),
22
+ patch.object(search_flow, "fetch_mikan_subgroups", return_value=(subgroup,)),
23
+ patch.object(search_flow, "loading", side_effect=AssertionError("JSON invoked loading UI")),
24
+ patch.object(entrypoint, "print_message", side_effect=AssertionError("JSON invoked feedback UI")),
25
+ redirect_stdout(output),
26
+ ):
27
+ self.assertEqual(entrypoint.main([title, "--json", "--save-path", "C:/Anime"]), 0)
28
+ result = json.loads(output.getvalue())
29
+ self.assertEqual(result["keyword"], title)
30
+ self.assertEqual(result["feed_url"], subgroup.feed_url)
@@ -0,0 +1,67 @@
1
+ from contextlib import redirect_stdout
2
+ from io import StringIO
3
+ from unittest import TestCase
4
+ from unittest.mock import ANY, MagicMock, patch
5
+
6
+ from rich.console import Console
7
+ from rich.theme import Theme
8
+
9
+ from mikancli.core.models import RuleDraft
10
+ from mikancli.display import loading, print_message, print_text_summary
11
+ from mikancli.i18n import get_language, set_language
12
+
13
+
14
+ class DisplayTests(TestCase):
15
+ def test_loading_gates_animation_and_cleans_up_when_requests_fail(self) -> None:
16
+ literal = "[bold]正在加载[/bold] :smile:"
17
+ for terminal, interactive in ((False, False), (False, True), (True, False), (True, True)):
18
+ with self.subTest(terminal=terminal, interactive=interactive):
19
+ target = MagicMock(is_terminal=terminal, is_interactive=interactive)
20
+ failure = ValueError("request failed")
21
+ with patch("mikancli.display.console", target), patch("mikancli.display.print_message") as message:
22
+ with self.assertRaises(ValueError), loading(literal):
23
+ raise failure
24
+ if terminal and interactive:
25
+ target.status.assert_called_once()
26
+ self.assertEqual(target.status.call_args.args[0].plain, literal)
27
+ target.status.return_value.__exit__.assert_called_once_with(ValueError, failure, ANY)
28
+ message.assert_not_called()
29
+ else:
30
+ target.status.assert_not_called()
31
+ message.assert_called_once_with(literal)
32
+
33
+ def test_messages_preserve_literal_text_in_terminals_and_redirects(self) -> None:
34
+ literal = r"[bold]错误[/bold] C:\Anime\:smile: https://example.test/[title]"
35
+ for style in ("note", "success", "warning", "error"):
36
+ for interactive in (False, True):
37
+ with self.subTest(style=style, interactive=interactive):
38
+ output = StringIO()
39
+ terminal = Console(file=output, width=40, force_terminal=interactive,
40
+ color_system=None, theme=Theme({style: "red"}))
41
+ with patch("mikancli.display.console", terminal):
42
+ print_message(literal, style=style)
43
+ rendered = output.getvalue()
44
+ self.assertEqual("".join(rendered.split()), "".join(literal.split()))
45
+ self.assertNotIn("\x1b", rendered)
46
+ if not interactive:
47
+ self.assertEqual(rendered, literal + "\n")
48
+
49
+ def test_summary_preserves_literal_values_and_language_when_redirected(self) -> None:
50
+ self.addCleanup(set_language, get_language())
51
+ literal = r"[bold]动画[/bold] C:\Anime\:smile:"
52
+ url = "https://example.test/rss?title=[OnePiece]&group=534"
53
+ draft = RuleDraft(literal, literal, literal, (), (), feed_url=url, save_path=literal, notes=(literal,))
54
+ for language, label in (("en", "Keyword:"), ("zh-CN", "关键词:")):
55
+ with self.subTest(language=language):
56
+ set_language(language)
57
+ output = StringIO()
58
+ with redirect_stdout(output), patch(
59
+ "mikancli.display.console",
60
+ Console(width=120, force_terminal=False, theme=Theme({"heading": "cyan", "label": "cyan", "note": "dim"})),
61
+ ):
62
+ self.assertEqual(print_text_summary(draft), 0)
63
+ rendered = output.getvalue()
64
+ self.assertEqual(rendered.count(literal), 5)
65
+ self.assertIn(url, rendered)
66
+ self.assertIn(label, rendered)
67
+ self.assertNotIn("\x1b", rendered)
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from io import StringIO
4
+ from unittest import TestCase
5
+ from unittest.mock import patch
6
+
7
+ from rich.console import Console
8
+ from rich.theme import Theme
9
+
10
+ from mikancli.core.models import MikanFeedItem, MikanSubgroup
11
+ from mikancli.display import build_feed_preview_page_text, print_feed_preview_page
12
+ from mikancli.i18n import get_language, set_language, t
13
+
14
+
15
+ class FeedPreviewTests(TestCase):
16
+ def test_rich_preview_preserves_pages_and_literal_content(self) -> None:
17
+ self.addCleanup(set_language, get_language())
18
+ subgroup = MikanSubgroup(534, "[bold]天月动漫[/bold]" * 5,
19
+ "https://example.test/rss?title=[OnePiece]&group=534" * 2)
20
+ items = tuple(MikanFeedItem(title=f"[bold]动画[/bold][{i}]:smile:", content_length=1536)
21
+ for i in range(1, 14))
22
+ theme = Theme({"heading": "cyan", "note": "dim"})
23
+ for language in ("en", "zh-CN"):
24
+ set_language(language)
25
+ for width in (40, 120):
26
+ for page in (1, 2):
27
+ with self.subTest(language=language, width=width, page=page):
28
+ output = StringIO()
29
+ terminal = Console(file=output, width=width, force_terminal=True,
30
+ color_system=None, theme=theme)
31
+ with patch("mikancli.display.console", terminal):
32
+ print_feed_preview_page(subgroup, items, page=page)
33
+ rendered = "".join(output.getvalue().split())
34
+ expected = build_feed_preview_page_text(subgroup, items, page=page)
35
+ self.assertEqual(rendered, "".join(expected.split()))
36
+ self.assertEqual("[13]" in rendered, page == 2)
37
+ self.assertEqual("[1]" in rendered, page == 1)
38
+ self.assertNotIn("\x1b", rendered)
39
+
40
+ def test_redirected_empty_preview_keeps_plain_text(self) -> None:
41
+ subgroup = MikanSubgroup(534, "[bold]Group[/bold]", "https://example.test/rss")
42
+ output = StringIO()
43
+ with patch("mikancli.display.console", Console(file=output, width=20, force_terminal=False)):
44
+ print_feed_preview_page(subgroup, (), page=1)
45
+ self.assertEqual(output.getvalue(), build_feed_preview_page_text(subgroup, (), page=1) + "\n")
46
+ self.assertIn(t("display.feed_empty"), output.getvalue())
47
+
48
+ def test_feed_preview_paginates_items(self) -> None:
49
+ subgroup = MikanSubgroup(
50
+ subgroup_id=534,
51
+ title="天月动漫&发布组",
52
+ feed_url="https://example.test/rss",
53
+ )
54
+ items = tuple(
55
+ MikanFeedItem(title=f"[Skymoon-Raws][One Piece][{episode}][1080p]")
56
+ for episode in range(1126, 1141)
57
+ )
58
+
59
+ first_page = build_feed_preview_page_text(
60
+ subgroup,
61
+ items,
62
+ page=1,
63
+ page_size=12,
64
+ )
65
+ second_page = build_feed_preview_page_text(
66
+ subgroup,
67
+ items,
68
+ page=2,
69
+ page_size=12,
70
+ )
71
+
72
+ self.assertIn("Items: 15 | Page 1/2", first_page)
73
+ self.assertIn("[1126]", first_page)
74
+ self.assertNotIn("[1138]", first_page)
75
+ self.assertIn("Items: 15 | Page 2/2", second_page)
76
+ self.assertIn("[1138]", second_page)
@@ -1,38 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from unittest import TestCase
4
-
5
- from mikancli.core.models import MikanFeedItem, MikanSubgroup
6
- from mikancli.display import build_feed_preview_page_text
7
-
8
-
9
- class FeedPreviewTests(TestCase):
10
- def test_feed_preview_paginates_items(self) -> None:
11
- subgroup = MikanSubgroup(
12
- subgroup_id=534,
13
- title="天月动漫&发布组",
14
- feed_url="https://example.test/rss",
15
- )
16
- items = tuple(
17
- MikanFeedItem(title=f"[Skymoon-Raws][One Piece][{episode}][1080p]")
18
- for episode in range(1126, 1141)
19
- )
20
-
21
- first_page = build_feed_preview_page_text(
22
- subgroup,
23
- items,
24
- page=1,
25
- page_size=12,
26
- )
27
- second_page = build_feed_preview_page_text(
28
- subgroup,
29
- items,
30
- page=2,
31
- page_size=12,
32
- )
33
-
34
- self.assertIn("Items: 15 | Page 1/2", first_page)
35
- self.assertIn("[1126]", first_page)
36
- self.assertNotIn("[1138]", first_page)
37
- self.assertIn("Items: 15 | Page 2/2", second_page)
38
- self.assertIn("[1138]", second_page)
File without changes
File without changes
File without changes