pyquipu-cli 0.3.2__tar.gz → 0.3.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 (55) hide show
  1. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/PKG-INFO +2 -2
  2. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/pyproject.toml +1 -1
  3. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/axon.py +2 -4
  4. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/cache.py +1 -1
  5. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/export.py +11 -11
  6. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/helpers.py +6 -8
  7. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/navigation.py +2 -2
  8. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/query.py +7 -9
  9. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/remote.py +6 -6
  10. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/run.py +7 -10
  11. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/show.py +3 -3
  12. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/workspace.py +2 -2
  13. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/main.py +2 -3
  14. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/tui.py +10 -10
  15. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/ui_utils.py +2 -4
  16. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/view_model.py +11 -12
  17. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/test_navigation_commands.py +2 -2
  18. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/test_workspace_commands.py +3 -2
  19. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/unit/test_view_model.py +13 -14
  20. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/.gitignore +0 -0
  21. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/__init__.py +0 -0
  22. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/__init__.py +0 -0
  23. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/cache.stitcher.yaml +0 -0
  24. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/export.stitcher.yaml +0 -0
  25. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/helpers.stitcher.yaml +0 -0
  26. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/query.stitcher.yaml +0 -0
  27. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/show.stitcher.yaml +0 -0
  28. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/commands/ui.py +1 -1
  29. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/config.py +0 -0
  30. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/config.stitcher.yaml +0 -0
  31. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/logger_config.py +0 -0
  32. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/logger_config.stitcher.yaml +0 -0
  33. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/rendering.py +0 -0
  34. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/rendering.stitcher.yaml +0 -0
  35. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/tui.css +0 -0
  36. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/tui.stitcher.yaml +0 -0
  37. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/ui_utils.stitcher.yaml +0 -0
  38. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/src/quipu/cli/view_model.stitcher.yaml +0 -0
  39. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/stitcher.lock +0 -0
  40. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/conftest.py +0 -0
  41. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/test_cache_commands.py +0 -0
  42. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/test_cache_commands.stitcher.yaml +0 -0
  43. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/test_cli_interaction.py +1 -1
  44. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/test_cli_interaction.stitcher.yaml +0 -0
  45. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/test_export_command.py +0 -0
  46. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/test_export_command.stitcher.yaml +0 -0
  47. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/test_query_commands.py +1 -1
  48. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/test_query_commands.stitcher.yaml +0 -0
  49. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/test_unfriendly_paths.py +0 -0
  50. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/integration/test_unfriendly_paths.stitcher.yaml +0 -0
  51. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/unit/test_tui_logic.py +0 -0
  52. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/unit/test_tui_logic.stitcher.yaml +0 -0
  53. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/unit/test_tui_reachability.py +0 -0
  54. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/unit/test_tui_reachability.stitcher.yaml +0 -0
  55. {pyquipu_cli-0.3.2 → pyquipu_cli-0.3.3}/tests/unit/test_view_model.stitcher.yaml +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pyquipu-cli
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: The command-line interface for Quipu.
5
5
  Project-URL: Homepage, https://github.com/quipu-dev/Quipu
6
6
  Project-URL: Repository, https://github.com/quipu-dev/Quipu
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyquipu-cli"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  authors = [{ name="doucx", email="doucxldh@gmail.com" }]
9
9
  description = "The command-line interface for Quipu."
10
10
  requires-python = ">=3.10"
@@ -2,7 +2,7 @@ import inspect
2
2
  import logging
3
3
  import sys
4
4
  from pathlib import Path
5
- from typing import Annotated, Optional
5
+ from typing import Annotated
6
6
 
7
7
  import typer
8
8
  from needle.pointer import L
@@ -19,9 +19,7 @@ def register(app: typer.Typer):
19
19
  @app.command(name="axon", help="无状态执行 Plan 文件,绕过 Quipu 引擎。")
20
20
  def axon_command(
21
21
  ctx: typer.Context,
22
- file: Annotated[
23
- Optional[Path], typer.Argument(help="包含 Markdown 指令的文件路径。", resolve_path=True)
24
- ] = None,
22
+ file: Annotated[Path | None, typer.Argument(help="包含 Markdown 指令的文件路径。", resolve_path=True)] = None,
25
23
  work_dir: Annotated[
26
24
  Path,
27
25
  typer.Option(
@@ -31,7 +31,7 @@ def cache_sync(
31
31
  pass
32
32
  bus.success(L.cache.sync.success)
33
33
  except Exception as e:
34
- logger.error("数据同步失败", exc_info=True)
34
+ logger.exception("数据同步失败")
35
35
  bus.error(L.cache.sync.error, error=str(e))
36
36
  ctx.exit(1)
37
37
 
@@ -2,7 +2,7 @@ import logging
2
2
  import re
3
3
  import shutil
4
4
  from pathlib import Path
5
- from typing import Annotated, Dict, List, Optional, Set
5
+ from typing import Annotated
6
6
 
7
7
  import typer
8
8
  import yaml
@@ -49,9 +49,9 @@ def _format_frontmatter(node: QuipuNode) -> str:
49
49
 
50
50
  def _generate_navbar(
51
51
  current_node: QuipuNode,
52
- exported_hashes_set: Set[str],
53
- filename_map: Dict[str, str],
54
- hidden_link_types: Set[str],
52
+ exported_hashes_set: set[str],
53
+ filename_map: dict[str, str],
54
+ hidden_link_types: set[str],
55
55
  ) -> str:
56
56
  nav_links = []
57
57
 
@@ -102,9 +102,9 @@ def _generate_file_content(
102
102
  engine: Engine,
103
103
  no_frontmatter: bool,
104
104
  no_nav: bool,
105
- exported_hashes_set: Set[str],
106
- filename_map: Dict[str, str],
107
- hidden_link_types: Set[str],
105
+ exported_hashes_set: set[str],
106
+ filename_map: dict[str, str],
107
+ hidden_link_types: set[str],
108
108
  ) -> str:
109
109
  parts = []
110
110
  if not no_frontmatter:
@@ -138,14 +138,14 @@ def register(app: typer.Typer):
138
138
  output_dir: Annotated[Path, typer.Option("--output-dir", "-o", help="导出目录", resolve_path=True)] = Path(
139
139
  "./.quipu/export"
140
140
  ),
141
- limit: Annotated[Optional[int], typer.Option("--limit", "-n", help="限制最新节点数量")] = None,
142
- since: Annotated[Optional[str], typer.Option("--since", help="起始时间戳 (YYYY-MM-DD HH:MM)")] = None,
143
- until: Annotated[Optional[str], typer.Option("--until", help="截止时间戳 (YYYY-MM-DD HH:MM)")] = None,
141
+ limit: Annotated[int | None, typer.Option("--limit", "-n", help="限制最新节点数量")] = None,
142
+ since: Annotated[str | None, typer.Option("--since", help="起始时间戳 (YYYY-MM-DD HH:MM)")] = None,
143
+ until: Annotated[str | None, typer.Option("--until", help="截止时间戳 (YYYY-MM-DD HH:MM)")] = None,
144
144
  zip_output: Annotated[bool, typer.Option("--zip", help="压缩导出目录")] = False,
145
145
  no_nav: Annotated[bool, typer.Option("--no-nav", help="禁用导航栏")] = False,
146
146
  no_frontmatter: Annotated[bool, typer.Option("--no-frontmatter", help="禁用 Frontmatter")] = False,
147
147
  hide_link_type: Annotated[
148
- Optional[List[str]],
148
+ list[str] | None,
149
149
  typer.Option(
150
150
  "--hide-link-type", help="禁用特定类型的导航链接 (可多次使用: summary, branch, parent, child)"
151
151
  ),
@@ -1,12 +1,12 @@
1
1
  import logging
2
+ from collections.abc import Generator
2
3
  from contextlib import contextmanager
3
4
  from datetime import datetime
4
5
  from pathlib import Path
5
- from typing import Dict, Generator, List, Optional
6
6
 
7
7
  import typer
8
- from quipu.application.factory import create_engine
9
8
  from needle.pointer import L
9
+ from quipu.application.factory import create_engine
10
10
  from quipu.common.bus import bus
11
11
  from quipu.engine.state_machine import Engine
12
12
  from quipu.spec.models.graph import QuipuNode
@@ -28,7 +28,7 @@ def engine_context(work_dir: Path) -> Generator[Engine, None, None]:
28
28
  engine.close()
29
29
 
30
30
 
31
- def _find_current_node(engine: Engine, graph: Dict[str, QuipuNode]) -> Optional[QuipuNode]:
31
+ def _find_current_node(engine: Engine, graph: dict[str, QuipuNode]) -> QuipuNode | None:
32
32
  current_hash = engine.git_db.get_tree_hash()
33
33
  # 修复:直接从 graph 中通过 output_tree hash 查找
34
34
  for node in graph.values():
@@ -46,14 +46,12 @@ def _execute_visit(ctx: typer.Context, engine: Engine, target_hash: str, msg_id:
46
46
  engine.visit(target_hash)
47
47
  bus.success(L.navigation.success.visit, short_hash=target_hash[:7])
48
48
  except Exception as e:
49
- logger.error(f"导航操作失败 (目标哈希: {target_hash[:12]})", exc_info=True)
49
+ logger.exception(f"导航操作失败 (目标哈希: {target_hash[:12]})")
50
50
  bus.error(L.navigation.error.generic, error=str(e))
51
51
  ctx.exit(1)
52
52
 
53
53
 
54
- def filter_nodes(
55
- nodes: List[QuipuNode], limit: Optional[int], since: Optional[str], until: Optional[str]
56
- ) -> List[QuipuNode]:
54
+ def filter_nodes(nodes: list[QuipuNode], limit: int | None, since: str | None, until: str | None) -> list[QuipuNode]:
57
55
  filtered = nodes
58
56
  if since:
59
57
  try:
@@ -72,7 +70,7 @@ def filter_nodes(
72
70
  return filtered
73
71
 
74
72
 
75
- def filter_reachable_nodes(engine: Engine, nodes: List[QuipuNode]) -> List[QuipuNode]:
73
+ def filter_reachable_nodes(engine: Engine, nodes: list[QuipuNode]) -> list[QuipuNode]:
76
74
  current_node = _find_current_node(engine, engine.history_graph)
77
75
  if not current_node:
78
76
  # 如果工作区是脏的,无法确定起点,返回所有节点
@@ -218,7 +218,7 @@ def register(app: typer.Typer):
218
218
  else:
219
219
  bus.warning(L.navigation.back.atStart)
220
220
  except Exception as e:
221
- logger.error("后退操作失败", exc_info=True)
221
+ logger.exception("后退操作失败")
222
222
  bus.error(L.navigation.back.error, error=str(e))
223
223
  ctx.exit(1)
224
224
 
@@ -235,6 +235,6 @@ def register(app: typer.Typer):
235
235
  else:
236
236
  bus.warning(L.navigation.forward.atEnd)
237
237
  except Exception as e:
238
- logger.error("前进操作失败", exc_info=True)
238
+ logger.exception("前进操作失败")
239
239
  bus.error(L.navigation.forward.error, error=str(e))
240
240
  ctx.exit(1)
@@ -2,7 +2,7 @@ import dataclasses
2
2
  import json
3
3
  from datetime import datetime
4
4
  from pathlib import Path
5
- from typing import Annotated, List, Optional
5
+ from typing import Annotated
6
6
 
7
7
  import typer
8
8
  from needle.pointer import L
@@ -13,7 +13,7 @@ from ..config import DEFAULT_WORK_DIR
13
13
  from .helpers import engine_context, filter_nodes, filter_reachable_nodes
14
14
 
15
15
 
16
- def _nodes_to_json_str(nodes: List[QuipuNode]) -> str:
16
+ def _nodes_to_json_str(nodes: list[QuipuNode]) -> str:
17
17
  EXCLUDED_FIELDS = {"parent", "children", "content", "filename"}
18
18
  node_list = []
19
19
  for node in nodes:
@@ -44,9 +44,9 @@ def register(app: typer.Typer):
44
44
  "--work-dir", "-w", help="操作执行的根目录(工作区)", file_okay=False, dir_okay=True, resolve_path=True
45
45
  ),
46
46
  ] = DEFAULT_WORK_DIR,
47
- limit: Annotated[Optional[int], typer.Option("--limit", "-n", help="限制显示的节点数量。")] = None,
48
- since: Annotated[Optional[str], typer.Option("--since", help="起始时间戳 (YYYY-MM-DD HH:MM)。")] = None,
49
- until: Annotated[Optional[str], typer.Option("--until", help="截止时间戳 (YYYY-MM-DD HH:MM)。")] = None,
47
+ limit: Annotated[int | None, typer.Option("--limit", "-n", help="限制显示的节点数量。")] = None,
48
+ since: Annotated[str | None, typer.Option("--since", help="起始时间戳 (YYYY-MM-DD HH:MM)。")] = None,
49
+ until: Annotated[str | None, typer.Option("--until", help="截止时间戳 (YYYY-MM-DD HH:MM)。")] = None,
50
50
  reachable_only: Annotated[
51
51
  bool, typer.Option("--reachable-only", help="仅显示与当前工作区状态直接相关的节点。")
52
52
  ] = False,
@@ -98,11 +98,9 @@ def register(app: typer.Typer):
98
98
  def find_command(
99
99
  ctx: typer.Context,
100
100
  summary_regex: Annotated[
101
- Optional[str], typer.Option("--summary", "-s", help="用于匹配节点摘要的正则表达式 (不区分大小写)。")
102
- ] = None,
103
- node_type: Annotated[
104
- Optional[str], typer.Option("--type", "-t", help="节点类型 ('plan' 或 'capture')。")
101
+ str | None, typer.Option("--summary", "-s", help="用于匹配节点摘要的正则表达式 (不区分大小写)。")
105
102
  ] = None,
103
+ node_type: Annotated[str | None, typer.Option("--type", "-t", help="节点类型 ('plan' 或 'capture')。")] = None,
106
104
  limit: Annotated[int, typer.Option("--limit", "-n", help="返回的最大结果数量。")] = 10,
107
105
  work_dir: Annotated[Path, typer.Option("--work-dir", "-w", help="工作区根目录。")] = DEFAULT_WORK_DIR,
108
106
  json_output: Annotated[bool, typer.Option("--json", help="以 JSON 格式输出结果。")] = False,
@@ -1,11 +1,11 @@
1
1
  import subprocess
2
2
  from enum import Enum
3
3
  from pathlib import Path
4
- from typing import Annotated, Optional
4
+ from typing import Annotated
5
5
 
6
6
  import typer
7
- from quipu.application.utils import find_git_repository_root
8
7
  from needle.pointer import L
8
+ from quipu.application.utils import find_git_repository_root
9
9
  from quipu.common.bus import bus
10
10
  from quipu.common.identity import get_user_id_from_email
11
11
  from quipu.engine.config import ConfigManager
@@ -35,7 +35,7 @@ def register(app: typer.Typer):
35
35
  ),
36
36
  ] = DEFAULT_WORK_DIR,
37
37
  remote_option: Annotated[
38
- Optional[str], typer.Option("--remote", "-r", help="Git 远程仓库的名称 (覆盖配置文件)。")
38
+ str | None, typer.Option("--remote", "-r", help="Git 远程仓库的名称 (覆盖配置文件)。")
39
39
  ] = None,
40
40
  mode: Annotated[
41
41
  SyncMode,
@@ -85,7 +85,7 @@ def register(app: typer.Typer):
85
85
  match mode:
86
86
  case SyncMode.BIDIRECTIONAL:
87
87
  bus.info(L.sync.run.info.pulling)
88
- for target_id in sorted(list(target_ids_to_fetch)):
88
+ for target_id in sorted(target_ids_to_fetch):
89
89
  git_db.fetch_quipu_refs(remote, target_id)
90
90
  bus.info(L.sync.run.info.reconciling)
91
91
  git_db.reconcile_local_with_remote(remote, final_user_id)
@@ -95,7 +95,7 @@ def register(app: typer.Typer):
95
95
 
96
96
  case SyncMode.PULL_ONLY:
97
97
  bus.info(L.sync.run.info.pulling)
98
- for target_id in sorted(list(target_ids_to_fetch)):
98
+ for target_id in sorted(target_ids_to_fetch):
99
99
  git_db.fetch_quipu_refs(remote, target_id)
100
100
  bus.info(L.sync.run.info.reconciling)
101
101
  git_db.reconcile_local_with_remote(remote, final_user_id)
@@ -103,7 +103,7 @@ def register(app: typer.Typer):
103
103
 
104
104
  case SyncMode.PULL_PRUNE:
105
105
  bus.info(L.sync.run.info.pullingPrune)
106
- for target_id in sorted(list(target_ids_to_fetch)):
106
+ for target_id in sorted(target_ids_to_fetch):
107
107
  git_db.fetch_quipu_refs(remote, target_id)
108
108
  bus.info(L.sync.run.info.reconciling)
109
109
  git_db.reconcile_local_with_remote(remote, final_user_id)
@@ -2,11 +2,11 @@ import inspect
2
2
  import logging
3
3
  import sys
4
4
  from pathlib import Path
5
- from typing import Annotated, Optional
5
+ from typing import Annotated
6
6
 
7
7
  import typer
8
- from quipu.application.controller import run_quipu
9
8
  from needle.pointer import L
9
+ from quipu.application.controller import run_quipu
10
10
  from quipu.common.bus import bus
11
11
 
12
12
  from ..config import DEFAULT_ENTRY_FILE, DEFAULT_WORK_DIR
@@ -20,9 +20,7 @@ def register(app: typer.Typer):
20
20
  @app.command(name="run", help="执行 Plan 文件并记录到 Quipu 历史。")
21
21
  def run_command(
22
22
  ctx: typer.Context,
23
- file: Annotated[
24
- Optional[Path], typer.Argument(help="包含 Markdown 指令的文件路径。", resolve_path=True)
25
- ] = None,
23
+ file: Annotated[Path | None, typer.Argument(help="包含 Markdown 指令的文件路径。", resolve_path=True)] = None,
26
24
  work_dir: Annotated[
27
25
  Path,
28
26
  typer.Option(
@@ -75,11 +73,10 @@ def register(app: typer.Typer):
75
73
  bus.error(L.common.error.fileNotFound, path=file)
76
74
  bus.warning(L.run.error.ambiguousCommand, command=file.name)
77
75
  ctx.exit(1)
78
- if not content.strip():
79
- if not file:
80
- bus.warning(L.run.warning.noInput, filename=DEFAULT_ENTRY_FILE.name)
81
- bus.info(L.run.info.usageHint)
82
- ctx.exit(0)
76
+ if not content.strip() and not file:
77
+ bus.warning(L.run.warning.noInput, filename=DEFAULT_ENTRY_FILE.name)
78
+ bus.info(L.run.info.usageHint)
79
+ ctx.exit(0)
83
80
 
84
81
  logger.info(f"已加载指令源: {source_desc}")
85
82
  logger.info(f"工作区根目录: {work_dir}")
@@ -1,7 +1,7 @@
1
1
  import json
2
2
  import logging
3
3
  from pathlib import Path
4
- from typing import Annotated, Dict, List, Optional
4
+ from typing import Annotated
5
5
 
6
6
  import typer
7
7
  from needle.pointer import L
@@ -15,7 +15,7 @@ from .helpers import engine_context
15
15
  logger = logging.getLogger(__name__)
16
16
 
17
17
 
18
- def _find_target_node(graph: Dict, hash_prefix: str):
18
+ def _find_target_node(graph: dict, hash_prefix: str):
19
19
  matches = [
20
20
  node
21
21
  for node in graph.values()
@@ -48,7 +48,7 @@ def register(app: typer.Typer):
48
48
  ] = DEFAULT_WORK_DIR,
49
49
  json_output: Annotated[bool, typer.Option("--json", help="以 JSON 格式将结果输出到 stdout。")] = False,
50
50
  extract: Annotated[
51
- Optional[List[str]], typer.Option("--extract", "-e", help="仅提取并显示指定文件的内容 (可多次使用)。")
51
+ list[str] | None, typer.Option("--extract", "-e", help="仅提取并显示指定文件的内容 (可多次使用)。")
52
52
  ] = None,
53
53
  ):
54
54
  with engine_context(work_dir) as engine:
@@ -1,5 +1,5 @@
1
1
  from pathlib import Path
2
- from typing import Annotated, Optional
2
+ from typing import Annotated
3
3
 
4
4
  import typer
5
5
  from needle.pointer import L
@@ -15,7 +15,7 @@ def register(app: typer.Typer):
15
15
  @app.command(help="将当前工作区的变更创建为一个新的快照节点。")
16
16
  def save(
17
17
  ctx: typer.Context,
18
- message: Annotated[Optional[str], typer.Argument(help="本次快照的简短描述。")] = None,
18
+ message: Annotated[str | None, typer.Argument(help="本次快照的简短描述。")] = None,
19
19
  work_dir: Annotated[
20
20
  Path,
21
21
  typer.Option(
@@ -1,6 +1,6 @@
1
1
  import importlib.metadata
2
2
  import logging
3
- from typing import Annotated, Optional
3
+ from typing import Annotated
4
4
 
5
5
  import typer
6
6
  from quipu.common.bus import bus
@@ -54,14 +54,13 @@ app.add_typer(cache.cache_app)
54
54
  @app.callback()
55
55
  def main(
56
56
  version: Annotated[
57
- Optional[bool],
57
+ bool | None,
58
58
  typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="显示版本信息并退出。"),
59
59
  ] = None,
60
60
  ):
61
61
  """
62
62
  Quipu: 一个基于 Git 的、用于文件系统状态溯源与文学化操作的工具。
63
63
  """
64
- pass
65
64
 
66
65
 
67
66
  # 注册顶级命令
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  from enum import Enum, auto
3
3
  from pathlib import Path
4
- from typing import List, Optional
4
+ from typing import ClassVar
5
5
 
6
6
  from quipu.application.factory import create_engine
7
7
  from quipu.engine.state_machine import Engine
@@ -29,11 +29,11 @@ class ContentViewSate(Enum):
29
29
  SHOWING_CONTENT = auto()
30
30
 
31
31
 
32
- class QuipuUiApp(App[Optional[UiResult]]):
32
+ class QuipuUiApp(App[UiResult | None]):
33
33
  CSS_PATH = "tui.css"
34
34
  TITLE = "Quipu History Explorer"
35
35
 
36
- BINDINGS = [
36
+ BINDINGS: ClassVar[list[Binding]] = [
37
37
  Binding("q", "quit", "退出"),
38
38
  Binding("space", "checkout_node", "检出节点"),
39
39
  Binding("enter", "checkout_node", "检出节点"),
@@ -54,12 +54,12 @@ class QuipuUiApp(App[Optional[UiResult]]):
54
54
  def __init__(self, work_dir: Path, initial_raw_mode: bool = False):
55
55
  super().__init__()
56
56
  self.work_dir = work_dir
57
- self.engine: Optional[Engine] = None
58
- self.view_model: Optional[GraphViewModel] = None
57
+ self.engine: Engine | None = None
58
+ self.view_model: GraphViewModel | None = None
59
59
 
60
60
  # --- State Machine ---
61
61
  self.content_view_state = ContentViewSate.HIDDEN
62
- self.update_timer: Optional[Timer] = None
62
+ self.update_timer: Timer | None = None
63
63
  self.debounce_delay_seconds: float = 0.50
64
64
  self.markdown_enabled = not initial_raw_mode
65
65
 
@@ -177,10 +177,10 @@ class QuipuUiApp(App[Optional[UiResult]]):
177
177
  self._focus_current_node(table)
178
178
  self._update_header()
179
179
 
180
- def _populate_table(self, table: DataTable, nodes: List[QuipuNode]):
180
+ def _populate_table(self, table: DataTable, nodes: list[QuipuNode]):
181
181
  assert self.view_model is not None
182
182
  # 移除了过滤逻辑,因为 ViewModel 已经处理
183
- tracks: list[Optional[str]] = []
183
+ tracks: list[str | None] = []
184
184
 
185
185
  for node in nodes:
186
186
  is_reachable = self.view_model.is_reachable(node.output_tree)
@@ -279,8 +279,8 @@ class QuipuUiApp(App[Optional[UiResult]]):
279
279
  # LookupError 捕获 RowKeyError 等
280
280
  logger.warning(f"DEBUG: Row key {row_key} not found in DataTable.")
281
281
 
282
- except Exception as e:
283
- logger.error(f"DEBUG: Failed to focus current node: {e}", exc_info=True)
282
+ except Exception:
283
+ logger.exception("DEBUG: Failed to focus current node")
284
284
 
285
285
  def _update_loading_preview(self):
286
286
  assert self.view_model is not None
@@ -1,5 +1,3 @@
1
- from typing import List, Optional
2
-
3
1
  import click
4
2
  import typer
5
3
  from needle.pointer import L
@@ -7,7 +5,7 @@ from quipu.common.bus import bus
7
5
  from quipu.spec.exceptions import OperationCancelledError
8
6
 
9
7
 
10
- def confirmation_handler_for_executor(diff_lines: List[str], prompt: str) -> bool:
8
+ def confirmation_handler_for_executor(diff_lines: list[str], prompt: str) -> bool:
11
9
  # 原始逻辑是 `char.lower() != "n"`,这相当于默认为 True
12
10
  confirmed = prompt_for_confirmation(prompt=prompt, diff_lines=diff_lines, default=True)
13
11
  if not confirmed:
@@ -17,7 +15,7 @@ def confirmation_handler_for_executor(diff_lines: List[str], prompt: str) -> boo
17
15
  return True
18
16
 
19
17
 
20
- def prompt_for_confirmation(prompt: str, diff_lines: Optional[List[str]] = None, default: bool = False) -> bool:
18
+ def prompt_for_confirmation(prompt: str, diff_lines: list[str] | None = None, default: bool = False) -> bool:
21
19
  if diff_lines:
22
20
  bus.info(L.prompt.ui.diffHeader)
23
21
  for line in diff_lines:
@@ -1,12 +1,11 @@
1
1
  import math
2
- from typing import Dict, List, Optional, Set
3
2
 
4
3
  from quipu.spec.models.graph import QuipuNode
5
4
  from quipu.spec.protocols.storage import HistoryReader
6
5
 
7
6
 
8
7
  class GraphViewModel:
9
- def __init__(self, reader: HistoryReader, current_output_tree_hash: Optional[str], page_size: int = 50):
8
+ def __init__(self, reader: HistoryReader, current_output_tree_hash: str | None, page_size: int = 50):
10
9
  self.reader = reader
11
10
  self.current_output_tree_hash = current_output_tree_hash
12
11
  self.page_size = page_size
@@ -18,10 +17,10 @@ class GraphViewModel:
18
17
 
19
18
  # --- TUI 交互状态 ---
20
19
  self.show_unreachable: bool = True
21
- self.current_page_nodes: List[QuipuNode] = []
22
- self.current_selected_node: Optional[QuipuNode] = None
23
- self._node_by_key: Dict[str, QuipuNode] = {}
24
- self.reachable_set: Set[str] = set()
20
+ self.current_page_nodes: list[QuipuNode] = []
21
+ self.current_selected_node: QuipuNode | None = None
22
+ self._node_by_key: dict[str, QuipuNode] = {}
23
+ self.reachable_set: set[str] = set()
25
24
 
26
25
  def initialize(self):
27
26
  self.total_nodes = self.reader.get_node_count()
@@ -58,7 +57,7 @@ class GraphViewModel:
58
57
  # e.g. pos 0 -> page 1; pos 49 -> page 1; pos 50 -> page 2
59
58
  return (position // self.page_size) + 1
60
59
 
61
- def load_page(self, page_number: int) -> List[QuipuNode]:
60
+ def load_page(self, page_number: int) -> list[QuipuNode]:
62
61
  if not (1 <= page_number <= self.total_pages):
63
62
  self.current_page_nodes = []
64
63
  self._node_by_key = {}
@@ -74,26 +73,26 @@ class GraphViewModel:
74
73
  def toggle_unreachable(self):
75
74
  self.show_unreachable = not self.show_unreachable
76
75
 
77
- def get_nodes_to_render(self) -> List[QuipuNode]:
76
+ def get_nodes_to_render(self) -> list[QuipuNode]:
78
77
  if self.show_unreachable:
79
78
  return self.current_page_nodes
80
79
  return [node for node in self.current_page_nodes if self.is_reachable(node.output_tree)]
81
80
 
82
- def select_node_by_key(self, key: str) -> Optional[QuipuNode]:
81
+ def select_node_by_key(self, key: str) -> QuipuNode | None:
83
82
  node = self._node_by_key.get(key)
84
83
  self.current_selected_node = node
85
84
  return node
86
85
 
87
- def get_selected_node(self) -> Optional[QuipuNode]:
86
+ def get_selected_node(self) -> QuipuNode | None:
88
87
  return self.current_selected_node
89
88
 
90
89
  def get_public_content(self, node: QuipuNode) -> str:
91
90
  return self.reader.get_node_content(node) or ""
92
91
 
93
- def previous_page(self) -> List[QuipuNode]:
92
+ def previous_page(self) -> list[QuipuNode]:
94
93
  return self.load_page(self.current_page - 1)
95
94
 
96
- def next_page(self) -> List[QuipuNode]:
95
+ def next_page(self) -> list[QuipuNode]:
97
96
  return self.load_page(self.current_page + 1)
98
97
 
99
98
  def get_content_bundle(self, node: QuipuNode) -> str:
@@ -14,7 +14,7 @@ def populated_workspace(quipu_workspace):
14
14
 
15
15
 
16
16
  def test_cli_back_forward_flow(runner, populated_workspace, monkeypatch):
17
- workspace, hash_a, hash_b = populated_workspace
17
+ workspace, hash_a, _hash_b = populated_workspace
18
18
  mock_bus_nav = MagicMock()
19
19
  mock_bus_helper = MagicMock()
20
20
  monkeypatch.setattr("quipu.cli.commands.navigation.bus", mock_bus_nav)
@@ -41,7 +41,7 @@ def test_cli_back_forward_flow(runner, populated_workspace, monkeypatch):
41
41
 
42
42
 
43
43
  def test_cli_boundary_messages(runner, populated_workspace, monkeypatch):
44
- workspace, hash_a, hash_b = populated_workspace
44
+ workspace, hash_a, _hash_b = populated_workspace
45
45
  mock_bus = MagicMock()
46
46
  monkeypatch.setattr("quipu.cli.commands.navigation.bus", mock_bus)
47
47
 
@@ -1,7 +1,7 @@
1
- from needle.pointer import L
2
1
  from unittest import mock
3
2
  from unittest.mock import MagicMock
4
3
 
4
+ from needle.pointer import L
5
5
  from quipu.cli.main import app
6
6
 
7
7
 
@@ -49,12 +49,13 @@ def test_discard_interactive_abort(runner, quipu_workspace, monkeypatch):
49
49
  work_dir, _, engine = quipu_workspace
50
50
  mock_bus = MagicMock()
51
51
  monkeypatch.setattr("quipu.cli.commands.workspace.bus", mock_bus)
52
+ monkeypatch.setattr("click.getchar", lambda echo=False: "n")
52
53
 
53
54
  (work_dir / "file.txt").write_text("v1")
54
55
  engine.capture_drift(engine.git_db.get_tree_hash())
55
56
  (work_dir / "file.txt").write_text("v2")
56
57
 
57
- result = runner.invoke(app, ["discard", "-w", str(work_dir)], input="n")
58
+ result = runner.invoke(app, ["discard", "-w", str(work_dir)])
58
59
 
59
60
  assert result.exit_code == 1
60
61
  mock_bus.warning.assert_called_once_with(L.common.prompt.cancel)
@@ -1,6 +1,5 @@
1
1
  from datetime import datetime
2
2
  from pathlib import Path
3
- from typing import Dict, List, Optional, Set
4
3
 
5
4
  import pytest
6
5
  from quipu.cli.view_model import GraphViewModel
@@ -11,10 +10,10 @@ from quipu.spec.protocols.storage import HistoryReader
11
10
  class MockHistoryReader(HistoryReader):
12
11
  def __init__(
13
12
  self,
14
- nodes: List[QuipuNode],
15
- ancestors: Optional[Set[str]] = None,
16
- descendants: Optional[Set[str]] = None,
17
- private_data: Optional[dict[str, str]] = None,
13
+ nodes: list[QuipuNode],
14
+ ancestors: set[str] | None = None,
15
+ descendants: set[str] | None = None,
16
+ private_data: dict[str, str] | None = None,
18
17
  ):
19
18
  self._nodes = sorted(nodes, key=lambda n: n.timestamp, reverse=True)
20
19
  self._ancestors = ancestors or set()
@@ -24,13 +23,13 @@ class MockHistoryReader(HistoryReader):
24
23
  def get_node_count(self) -> int:
25
24
  return len(self._nodes)
26
25
 
27
- def load_nodes_paginated(self, limit: int, offset: int) -> List[QuipuNode]:
26
+ def load_nodes_paginated(self, limit: int, offset: int) -> list[QuipuNode]:
28
27
  return self._nodes[offset : offset + limit]
29
28
 
30
- def get_ancestor_output_trees(self, start_output_tree_hash: str) -> Set[str]:
29
+ def get_ancestor_output_trees(self, start_output_tree_hash: str) -> set[str]:
31
30
  return self._ancestors
32
31
 
33
- def get_descendant_output_trees(self, start_output_tree_hash: str) -> Set[str]:
32
+ def get_descendant_output_trees(self, start_output_tree_hash: str) -> set[str]:
34
33
  return self._descendants
35
34
 
36
35
  def get_node_position(self, output_tree_hash: str) -> int:
@@ -39,26 +38,26 @@ class MockHistoryReader(HistoryReader):
39
38
  return i
40
39
  return -1
41
40
 
42
- def get_private_data(self, node_commit_hash: str) -> Optional[str]:
41
+ def get_private_data(self, node_commit_hash: str) -> str | None:
43
42
  return self._private_data.get(node_commit_hash)
44
43
 
45
44
  def get_node_content(self, node: QuipuNode) -> str:
46
45
  # For simplicity, mock content is stored in the node's summary
47
46
  return node.summary
48
47
 
49
- def get_node_blobs(self, commit_hash: str) -> Dict[str, bytes]:
48
+ def get_node_blobs(self, commit_hash: str) -> dict[str, bytes]:
50
49
  return {}
51
50
 
52
51
  # --- Unused abstract methods ---
53
- def load_all_nodes(self) -> List[QuipuNode]:
52
+ def load_all_nodes(self) -> list[QuipuNode]:
54
53
  return self._nodes
55
54
 
56
55
  def find_nodes(
57
56
  self,
58
- summary_regex: Optional[str] = None,
59
- node_type: Optional[str] = None,
57
+ summary_regex: str | None = None,
58
+ node_type: str | None = None,
60
59
  limit: int = 10,
61
- ) -> List[QuipuNode]:
60
+ ) -> list[QuipuNode]:
62
61
  return []
63
62
 
64
63
 
File without changes
@@ -3,8 +3,8 @@ from pathlib import Path
3
3
  from typing import Annotated
4
4
 
5
5
  import typer
6
- from quipu.application.factory import create_engine
7
6
  from needle.pointer import L
7
+ from quipu.application.factory import create_engine
8
8
  from quipu.common.bus import bus
9
9
 
10
10
  from ..config import DEFAULT_WORK_DIR, LOG_LEVEL
File without changes
@@ -1,6 +1,6 @@
1
- from needle.pointer import L
2
1
  from unittest.mock import MagicMock
3
2
 
3
+ from needle.pointer import L
4
4
  from quipu.cli.main import app
5
5
  from typer.testing import CliRunner
6
6
 
@@ -1,7 +1,7 @@
1
- from needle.pointer import L
2
1
  import json
3
2
  from unittest.mock import MagicMock
4
3
 
4
+ from needle.pointer import L
5
5
  from quipu.cli.main import app
6
6
  from quipu.test_utils.helpers import create_linear_history_from_specs, create_query_branching_history
7
7