aliyun-controller 0.2.2__tar.gz → 0.2.3__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 (26) hide show
  1. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/PKG-INFO +1 -1
  2. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/__init__.py +1 -1
  3. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/app.py +66 -3
  4. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/screens/billing.py +8 -0
  5. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/screens/dns.py +32 -12
  6. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/widgets.py +202 -2
  7. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/PKG-INFO +1 -1
  8. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/pyproject.toml +1 -1
  9. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/README.md +0 -0
  10. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/app.tcss +0 -0
  11. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/billing.py +0 -0
  12. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/client.py +0 -0
  13. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/config.py +0 -0
  14. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/dns.py +0 -0
  15. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/main.py +0 -0
  16. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/screens/__init__.py +0 -0
  17. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/screens/home.py +0 -0
  18. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/screens/settings.py +0 -0
  19. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/screens/setup.py +0 -0
  20. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/ui.py +0 -0
  21. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/SOURCES.txt +0 -0
  22. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/dependency_links.txt +0 -0
  23. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/entry_points.txt +0 -0
  24. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/requires.txt +0 -0
  25. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/top_level.txt +0 -0
  26. {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aliyun_controller
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: A Textual TUI for managing Alibaba Cloud services including billing queries and DNS management
5
5
  Author-email: Moha-Master <hongkongreporter@outlook.com>
6
6
  License-Expression: MIT
@@ -1,4 +1,4 @@
1
1
  """
2
2
  Aliyun Controller
3
3
  """
4
- __version__ = "0.2.2"
4
+ __version__ = "0.2.3"
@@ -1,18 +1,65 @@
1
1
  """Textual 应用入口:全局配置、服务客户端、状态栏与首启配置向导。"""
2
2
  from pathlib import Path
3
3
 
4
+ from rich.spinner import SPINNERS
5
+ from rich.text import Text
4
6
  from textual import work
5
7
  from textual.app import App, ComposeResult
6
8
  from textual.binding import Binding
9
+ from textual.widget import Widget
7
10
  from textual.widgets import Static
8
11
 
9
12
  from .billing import AliCloudBssQuerier
10
13
  from .config import config_exists, load_config
11
14
  from .dns import AliCloudDnsQuerier
15
+ from .widgets import ProgressCover
16
+
17
+ _DOTS = SPINNERS["dots"]
12
18
 
13
19
 
14
20
  class StatusBar(Static):
15
- """底部状态栏:展示最近一次操作结果 / 进行中提示。"""
21
+ """底部状态栏:展示最近一次操作结果 / 进行中提示;spinner=True 时显示点阵转圈。"""
22
+
23
+ def __init__(self, *args, **kwargs) -> None:
24
+ super().__init__(*args, **kwargs)
25
+ self._text = ""
26
+ self._busy = False
27
+ self._frame = 0
28
+ self._timer = None
29
+
30
+ def render(self) -> Text:
31
+ if self._busy:
32
+ glyph = _DOTS["frames"][self._frame % len(_DOTS["frames"])]
33
+ return Text.assemble((glyph, "bold"), " ", self._text)
34
+ return Text(self._text)
35
+
36
+ def set_status(self, text: str) -> None:
37
+ self._busy = False
38
+ self._text = text
39
+ self._stop_timer()
40
+ self.refresh()
41
+
42
+ def set_spinner(self, text: str) -> None:
43
+ self._busy = True
44
+ self._text = text
45
+ self._ensure_timer()
46
+ self.refresh()
47
+
48
+ def _ensure_timer(self) -> None:
49
+ if self._timer is None and self.is_mounted:
50
+ self._timer = self.set_interval(_DOTS["interval"] / 1000, self._tick)
51
+
52
+ def _stop_timer(self) -> None:
53
+ if self._timer is not None:
54
+ self._timer.stop()
55
+ self._timer = None
56
+
57
+ def _tick(self) -> None:
58
+ self._frame += 1
59
+ self.refresh()
60
+
61
+ def on_mount(self) -> None:
62
+ self._ensure_timer()
16
63
 
17
64
 
18
65
  class AliyunControllerApp(App):
@@ -58,13 +105,29 @@ class AliyunControllerApp(App):
58
105
  def compose(self) -> ComposeResult:
59
106
  yield StatusBar("", id="status")
60
107
 
108
+ def get_loading_widget(self) -> Widget:
109
+ """全局默认:任意控件 .loading=True 时用 indeterminate 进度条覆盖其区域。"""
110
+ return ProgressCover()
111
+
61
112
  def status(self, message: str, kind: str = "") -> None:
62
113
  try:
63
114
  bar = self.query_one("#status", StatusBar)
64
- except Exception:
115
+ except Exception: # noqa: BLE001
65
116
  return
66
117
  bar.update_classes({"ok": kind == "ok", "warn": kind == "warn", "err": kind == "err"})
67
- bar.update(message)
118
+ bar.set_status(message)
119
+
120
+ def spinner(self, on: bool, message: str = "加载中…") -> None:
121
+ """状态栏点阵 spinner(无遮罩、不阻断输入):适合轻量、即时的后台动作。"""
122
+ try:
123
+ bar = self.query_one("#status", StatusBar)
124
+ except Exception: # noqa: BLE001
125
+ return
126
+ bar.update_classes({})
127
+ if on:
128
+ bar.set_spinner(message)
129
+ else:
130
+ bar.set_status("")
68
131
 
69
132
  def notify_ok(self, message: str) -> None:
70
133
  self.status(message, "ok")
@@ -45,6 +45,9 @@ class _BillingScreen(PageScreen):
45
45
  @work(exclusive=True)
46
46
  async def _reload(self) -> None:
47
47
  self.app.status(f"正在查询 {self.billing_cycle} 的账单明细…")
48
+ tables = list(self.query(DataTable))
49
+ for t in tables:
50
+ t.loading = True
48
51
  querier = self.app.get_billing()
49
52
  try:
50
53
  payload = await asyncio.to_thread(self.fetch, self.billing_cycle, querier)
@@ -56,6 +59,11 @@ class _BillingScreen(PageScreen):
56
59
  self.app.notify_err(f"查询失败: {type(e).__name__}: {e}")
57
60
  self._show_error()
58
61
  return
62
+ finally:
63
+ for t in tables:
64
+ t.loading = False
65
+ if tables:
66
+ tables[0].focus()
59
67
  self.set_subtitle(self.billing_cycle)
60
68
  self._show_result(payload)
61
69
 
@@ -25,6 +25,7 @@ from ..widgets import (
25
25
  ConfirmModal,
26
26
  FormField,
27
27
  FormModal,
28
+ busy,
28
29
  filter_fuzzy,
29
30
  fit_table_columns,
30
31
  load_rows,
@@ -77,6 +78,8 @@ class DomainListScreen(PageScreen):
77
78
  @work(exclusive=True)
78
79
  async def _load(self) -> None:
79
80
  self.app.status("正在获取域名列表…")
81
+ table = self.query_one(DataTable)
82
+ table.loading = True
80
83
  try:
81
84
  domains = await asyncio.to_thread(self.app.get_dns().get_domains)
82
85
  except AliyunApiError as e:
@@ -89,6 +92,9 @@ class DomainListScreen(PageScreen):
89
92
  self._domains = []
90
93
  self._rebuild()
91
94
  return
95
+ finally:
96
+ table.loading = False
97
+ table.focus()
92
98
  self._domains = domains
93
99
  self._rebuild()
94
100
 
@@ -252,6 +258,8 @@ class RecordListScreen(PageScreen):
252
258
  @work(exclusive=True)
253
259
  async def _load(self) -> None:
254
260
  self.app.status(f"正在获取 {self.domain_name} 的解析记录…")
261
+ table = self._table()
262
+ table.loading = True
255
263
  try:
256
264
  records = await asyncio.to_thread(self.app.get_dns().get_domain_records, self.domain_name)
257
265
  except AliyunApiError as e:
@@ -264,6 +272,9 @@ class RecordListScreen(PageScreen):
264
272
  self._records = []
265
273
  self._rebuild()
266
274
  return
275
+ finally:
276
+ table.loading = False
277
+ table.focus()
267
278
  self._records = records
268
279
  self._rebuild()
269
280
 
@@ -350,14 +361,14 @@ class RecordListScreen(PageScreen):
350
361
  elif isinstance(result, dict):
351
362
  self._save_record(record, is_new, result)
352
363
 
353
- def _save_record(self, record: dict | None, is_new: bool, data: dict) -> None:
364
+ @work(exclusive=True)
365
+ async def _save_record(self, record: dict | None, is_new: bool, data: dict) -> None:
354
366
  dns = self.app.get_dns()
355
367
  enabled = bool(data.get("status", True))
356
- try:
368
+
369
+ def _apply() -> None:
357
370
  if is_new:
358
- rid = dns.add_domain_record(
359
- self.domain_name, data["rr"], data["type"], data["value"], data["ttl"]
360
- )
371
+ rid = dns.add_domain_record(self.domain_name, data["rr"], data["type"], data["value"], data["ttl"])
361
372
  if rid and not enabled:
362
373
  dns.set_domain_record_status(rid, "Disable")
363
374
  else:
@@ -368,6 +379,10 @@ class RecordListScreen(PageScreen):
368
379
  dns.set_domain_record_status(
369
380
  record.get("RecordId"), "Enable" if enabled else "Disable"
370
381
  )
382
+
383
+ try:
384
+ async with busy(self.app, "正在保存解析记录…", mode="bar"):
385
+ await asyncio.to_thread(_apply)
371
386
  except AliyunApiError as e:
372
387
  self.app.notify_err(str(e))
373
388
  return
@@ -380,13 +395,7 @@ class RecordListScreen(PageScreen):
380
395
  def confirm(ok: bool) -> None:
381
396
  if not ok:
382
397
  return
383
- try:
384
- self.app.get_dns().delete_domain_record(record.get("RecordId"))
385
- except AliyunApiError as e:
386
- self.app.notify_err(str(e))
387
- return
388
- self.app.notify_ok(f"已删除解析记录 {name}")
389
- self._load()
398
+ self._do_delete(record, name)
390
399
 
391
400
  self.app.push_screen(
392
401
  ConfirmModal(
@@ -398,6 +407,17 @@ class RecordListScreen(PageScreen):
398
407
  confirm,
399
408
  )
400
409
 
410
+ @work(exclusive=True)
411
+ async def _do_delete(self, record: dict, name: str) -> None:
412
+ try:
413
+ async with busy(self.app, f"正在删除 {name}…", mode="bar"):
414
+ await asyncio.to_thread(self.app.get_dns().delete_domain_record, record.get("RecordId"))
415
+ except AliyunApiError as e:
416
+ self.app.notify_err(str(e))
417
+ return
418
+ self.app.notify_ok(f"已删除解析记录 {name}")
419
+ self._load()
420
+
401
421
  # ------------------------------------------------------------ 事件
402
422
 
403
423
  @on(Input.Changed, "#rc-search")
@@ -3,18 +3,37 @@
3
3
  所有模态均为 ModalScreen 泛型返回值,配合 push_screen_wait / 回调消费;
4
4
  数据展示统一使用 DataTable,不构造字符串伪表格。
5
5
  """
6
+ from __future__ import annotations
7
+
8
+ import contextlib
6
9
  from collections.abc import Callable
7
10
  from dataclasses import dataclass, field
8
11
  from typing import Any
9
12
 
10
13
  from rich.cells import cell_len
14
+ from rich.style import Style
11
15
  from rich.text import Text
12
16
  from textual import events
13
17
  from textual.binding import Binding
18
+ from textual.clock import Clock
14
19
  from textual.containers import Horizontal, ScrollableContainer, Vertical, VerticalScroll
15
20
  from textual.coordinate import Coordinate
21
+ from textual.css.query import NoMatches
16
22
  from textual.screen import ModalScreen
17
- from textual.widgets import Button, DataTable, Input, Label, OptionList, Select, Static, Switch, TextArea
23
+ from textual.widget import Widget
24
+ from textual.widgets import (
25
+ Button,
26
+ DataTable,
27
+ Input,
28
+ Label,
29
+ LoadingIndicator,
30
+ OptionList,
31
+ ProgressBar,
32
+ Select,
33
+ Static,
34
+ Switch,
35
+ TextArea,
36
+ )
18
37
 
19
38
  try: # textual 各版本 Option 导出位置不同
20
39
  from textual.widgets.option_list import Option
@@ -331,7 +350,7 @@ class FormModal(ModalScreen["dict | None"]):
331
350
  text = HINT_FORM
332
351
  self.query_one("#frm-hint", HintBar).update(text)
333
352
 
334
- def _collect(self) -> "dict | None":
353
+ def _collect(self) -> dict | None:
335
354
  """收集表单值;校验失败时写入错误信息并返回 None。可在子类扩展。"""
336
355
  err_box = self.query_one("#frm-error", Static)
337
356
  err_box.update(Text(""))
@@ -688,10 +707,191 @@ def fit_table_columns(table: DataTable, weights: list[float], rows: int | None =
688
707
  return visible
689
708
 
690
709
 
710
+ # ---------------------------------------------------------------- 加载指示器
711
+
712
+ class _IndeterminateBar:
713
+ """自渲染不定进度条:按**绘制时**的区域宽度取景并居中。
714
+
715
+ 不依赖 ``Widget.size``:覆盖层是交给合成器临时绘制的,首帧 ``_size`` 可能
716
+ 尚未由 ``_size_updated`` 写入,若在 ``render()`` 里读 ``self.size`` 会拿到 0
717
+ 而画出一格宽的「隐形条」。改用 Rich 渲染协议,``options.max_width`` 即为本次
718
+ 真正要绘制的区域宽度,保证任何一帧都居中可见。
719
+ """
720
+
721
+ def __init__(
722
+ self, clock: Clock, animation_level: str, highlight: Style, track: Style, cap: int
723
+ ) -> None:
724
+ self._clock = clock
725
+ self._animation_level = animation_level
726
+ self._highlight = highlight
727
+ self._track = track
728
+ self._cap = cap
729
+
730
+ def __rich_console__(self, console, options): # noqa: ANN001, ANN201
731
+ total = max(1, options.max_width)
732
+ width = min(total, self._cap)
733
+ bar = max(2, int(width * 0.25))
734
+ span = width + bar
735
+ if self._animation_level == "none":
736
+ start = 0
737
+ else:
738
+ speed = 30 # cells/s,与 Textual ProgressBar 的 indeterminate 一致
739
+ pos = int((speed * self._clock.time) % (2 * span))
740
+ if pos > span:
741
+ pos = 2 * span - pos
742
+ start = pos - bar
743
+ text = Text()
744
+ for i in range(width):
745
+ text.append("━", style=self._highlight if start <= i < start + bar else self._track)
746
+ yield text
747
+
748
+
749
+ class ProgressCover(Widget):
750
+ """控件 loading=True 时覆盖其区域的不定进度条(方框 + 居中往复高亮条)。
751
+
752
+ 必须是**单个自渲染 widget**:Textual 的 ``Widget._cover()`` 只把它交给合成器
753
+ 替换原控件区域,并不挂进 DOM 参与布局;若用容器 + 子件,子件 region 恒为 0
754
+ 而完全不可见(``ProgressBar`` 自身 compose 出 ``Bar`` 子件,因此同样不可用)。
755
+ """
756
+
757
+ COMPONENT_CLASSES = {"pc--bar", "pc--highlight"}
758
+
759
+ DEFAULT_CSS = """
760
+ ProgressCover {
761
+ width: 100%;
762
+ height: 100%;
763
+ background: $surface;
764
+ border: round $secondary;
765
+ margin: 1 1 0 1;
766
+ content-align: center middle;
767
+ &> .pc--bar { color: $panel-lighten-1; background: $surface; }
768
+ &> .pc--highlight { color: $primary; background: $surface; }
769
+ }
770
+ """
771
+
772
+ BAR_WIDTH = 40
773
+ """进度条固定宽(与全屏 BusyOverlay `.busy-box` 的内容宽一致),由 content-align 居中。"""
774
+
775
+ def __init__(self) -> None:
776
+ super().__init__()
777
+ self._clock = Clock()
778
+
779
+ def on_mount(self) -> None:
780
+ self._clock.reset()
781
+ self.auto_refresh = 1 / 15
782
+
783
+ def render(self) -> _IndeterminateBar:
784
+ highlight = Style.from_color(self.get_component_rich_style("pc--highlight").color)
785
+ track = Style.from_color(self.get_component_rich_style("pc--bar").color)
786
+ return _IndeterminateBar(
787
+ self._clock, self.app.animation_level, highlight, track, self.BAR_WIDTH
788
+ )
789
+
790
+
791
+ class BusyOverlay(ModalScreen[None]):
792
+ """全屏忙碌遮罩:阻断一切输入直到被 pop。
793
+
794
+ 使用固定宽度(46)与分行结构防止文案变化引起容器抖动或 Emoji 换行。
795
+ """
796
+
797
+ DEFAULT_CSS = """
798
+ BusyOverlay {
799
+ align: center middle;
800
+ background: $boost;
801
+ }
802
+ BusyOverlay .busy-box {
803
+ width: 46;
804
+ height: auto;
805
+ min-height: 5;
806
+ padding: 1 2;
807
+ border: round $primary;
808
+ background: $surface;
809
+ align: center middle;
810
+ }
811
+ BusyOverlay #busy-title {
812
+ width: 100%;
813
+ content-align: center middle;
814
+ text-style: bold;
815
+ text-wrap: nowrap;
816
+ text-overflow: ellipsis;
817
+ overflow: hidden;
818
+ margin-bottom: 1;
819
+ }
820
+ BusyOverlay LoadingIndicator { width: 100%; height: 1; }
821
+ BusyOverlay ProgressBar { width: 100%; }
822
+ BusyOverlay #busy-counter {
823
+ width: 100%;
824
+ content-align: center middle;
825
+ color: $text-muted;
826
+ margin-top: 1;
827
+ }
828
+ """
829
+
830
+ def __init__(self, message: str = "正在处理…", *, mode: str = "dots", total: float | None = None) -> None:
831
+ super().__init__()
832
+ self._message = message
833
+ self._mode = mode
834
+ self._total = total
835
+ self._done = 0.0
836
+
837
+ def compose(self):
838
+ with Vertical(classes="busy-box"):
839
+ yield Static(self._message, id="busy-title")
840
+ if self._mode == "bar":
841
+ yield ProgressBar(total=None, show_bar=True, show_percentage=False, show_eta=False)
842
+ elif self._mode == "percent":
843
+ yield ProgressBar(total=self._total, show_percentage=True, show_eta=False)
844
+ yield Static(f"0/{int(self._total or 0)} (0%)", id="busy-counter")
845
+ else:
846
+ yield LoadingIndicator()
847
+
848
+ def set_message(self, message: str) -> None:
849
+ self._message = message
850
+ if self.is_mounted:
851
+ with contextlib.suppress(NoMatches):
852
+ self.query_one("#busy-title", Static).update(message)
853
+
854
+ def advance(self, amount: float = 1) -> None:
855
+ """percent 模式:推进进度条并刷新计数文案。"""
856
+ self._done += amount
857
+ if self.is_mounted:
858
+ with contextlib.suppress(NoMatches):
859
+ bar = self.query_one(ProgressBar)
860
+ bar.progress += amount
861
+ if self._mode == "percent":
862
+ pct = int(round((self._done / (self._total or 1)) * 100))
863
+ self.query_one("#busy-counter", Static).update(
864
+ f"{int(self._done)}/{int(self._total or 0)} ({pct}%)"
865
+ )
866
+
867
+
868
+ @contextlib.asynccontextmanager
869
+ async def busy(app, message: str = "正在处理…", *, mode: str = "dots", total: float | None = None):
870
+ """异步 worker 内包裹耗时操作,全程显示全屏忙碌遮罩(异常/取消亦收起)。
871
+
872
+ 用法:async with busy(self.app, "正在保存…", mode="bar"): await asyncio.to_thread(...)
873
+ 需要进度回调时接住返回的遮罩对象:async with busy(app, "拉取中", mode="percent", total=n) as ov: ...
874
+ """
875
+ overlay = BusyOverlay(message, mode=mode, total=total)
876
+ app.push_screen(overlay)
877
+ try:
878
+ yield overlay
879
+ finally:
880
+ # 确保遮罩被收起:可能仍是栈顶,也可能已被其它层覆盖
881
+ for _ in range(len(app.screen_stack)):
882
+ if isinstance(app.screen, BusyOverlay) or app.screen is overlay:
883
+ app.pop_screen()
884
+ else:
885
+ break
886
+ if overlay.is_mounted:
887
+ overlay.remove()
888
+
889
+
691
890
  __all__ = [
692
891
  "ConfirmModal", "InputModal", "FormField", "FormModal", "OutputModal",
693
892
  "ClickTable", "make_table", "load_rows", "filter_fuzzy", "fuzzy_score",
694
893
  "shorten", "rcell", "fit_table_columns",
894
+ "ProgressCover", "BusyOverlay", "busy",
695
895
  "tint", "colored_text",
696
896
  "STYLE_OK", "STYLE_ERR", "STYLE_WARN", "STYLE_INFO", "STYLE_DIM",
697
897
  "HINT_PICK", "HINT_MENU", "HINT_FORM", "EDIT_HINT", "HINT_FORM_EDIT", "HINT_FORM_TA",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aliyun_controller
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: A Textual TUI for managing Alibaba Cloud services including billing queries and DNS management
5
5
  Author-email: Moha-Master <hongkongreporter@outlook.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aliyun_controller"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "A Textual TUI for managing Alibaba Cloud services including billing queries and DNS management"
9
9
  readme = "README.md"
10
10
  authors = [