fileflow-cli 0.2.0__tar.gz → 0.2.1__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 (30) hide show
  1. {fileflow_cli-0.2.0/src/fileflow_cli.egg-info → fileflow_cli-0.2.1}/PKG-INFO +1 -1
  2. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/pyproject.toml +6 -1
  3. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/__init__.py +1 -1
  4. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/actions.py +1 -1
  5. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/cli.py +66 -40
  6. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/config.py +5 -14
  7. fileflow_cli-0.2.1/src/fileflow/engine.py +283 -0
  8. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/history.py +8 -8
  9. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/matchers.py +5 -8
  10. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/report.py +1 -3
  11. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/rules.py +1 -2
  12. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/templates.py +10 -4
  13. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1/src/fileflow_cli.egg-info}/PKG-INFO +1 -1
  14. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow_cli.egg-info/SOURCES.txt +1 -0
  15. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/tests/test_actions.py +6 -2
  16. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/tests/test_engine.py +12 -9
  17. fileflow_cli-0.2.1/tests/test_history.py +132 -0
  18. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/tests/test_matchers.py +1 -2
  19. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/tests/test_templates.py +1 -2
  20. fileflow_cli-0.2.0/src/fileflow/engine.py +0 -251
  21. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/LICENSE +0 -0
  22. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/README.md +0 -0
  23. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/setup.cfg +0 -0
  24. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/__main__.py +0 -0
  25. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow_cli.egg-info/dependency_links.txt +0 -0
  26. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow_cli.egg-info/entry_points.txt +0 -0
  27. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow_cli.egg-info/requires.txt +0 -0
  28. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow_cli.egg-info/top_level.txt +0 -0
  29. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/tests/test_config.py +0 -0
  30. {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/tests/test_rules.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fileflow-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: 🧹 智能文件整理器 — YAML 规则引擎驱动的 CLI 文件分类/移动/清理工具
5
5
  Author-email: Yuluo <lingeryuluo@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fileflow-cli"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "🧹 智能文件整理器 — YAML 规则引擎驱动的 CLI 文件分类/移动/清理工具"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,6 +71,11 @@ python_version = "3.10"
71
71
  exclude = ["tests/"]
72
72
  ignore_missing_imports = true
73
73
 
74
+ # Typer decorators don't have type stubs
75
+ [[tool.mypy.overrides]]
76
+ module = "fileflow.cli"
77
+ disable_error_code = ["untyped-decorator"]
78
+
74
79
  [tool.pytest.ini_options]
75
80
  testpaths = ["tests"]
76
81
  python_files = ["test_*.py"]
@@ -1,3 +1,3 @@
1
1
  """FileFlow — 智能文件整理器."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
@@ -9,9 +9,9 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  import shutil
12
+ from collections.abc import Callable
12
13
  from enum import Enum
13
14
  from pathlib import Path
14
- from typing import Callable
15
15
 
16
16
  from .templates import resolve_path
17
17
 
@@ -6,7 +6,6 @@
6
6
  from __future__ import annotations
7
7
 
8
8
  from pathlib import Path
9
- from typing import Optional
10
9
 
11
10
  import typer
12
11
  from rich import box
@@ -23,7 +22,7 @@ from .config import (
23
22
  load_config,
24
23
  )
25
24
  from .engine import run
26
- from .history import HistoryManager
25
+ from .history import HistoryEntry, HistoryManager
27
26
  from .report import (
28
27
  print_banner,
29
28
  print_config_preview,
@@ -51,7 +50,9 @@ def _version_callback(value: bool) -> None:
51
50
  @app.callback()
52
51
  def main(
53
52
  version: bool = typer.Option(
54
- False, "--version", "-V",
53
+ False,
54
+ "--version",
55
+ "-V",
55
56
  help="显示版本号并退出",
56
57
  callback=_version_callback,
57
58
  is_eager=True,
@@ -64,30 +65,41 @@ def main(
64
65
  @app.command(name="run")
65
66
  def run_command(
66
67
  source_dir: str = typer.Argument(
67
- ".", help="要整理的目录路径(默认当前目录)",
68
+ ".",
69
+ help="要整理的目录路径(默认当前目录)",
68
70
  ),
69
- config: Optional[str] = typer.Option(
70
- None, "--config", "-c",
71
+ config: str | None = typer.Option(
72
+ None,
73
+ "--config",
74
+ "-c",
71
75
  help="配置文件路径(默认自动搜索 .fileflow.yaml)",
72
76
  ),
73
77
  force: bool = typer.Option(
74
- False, "--force", "-f",
78
+ False,
79
+ "--force",
80
+ "-f",
75
81
  help="跳过预览,直接执行整理",
76
82
  ),
77
83
  dry_run: bool = typer.Option(
78
- False, "--dry-run", "-n",
84
+ False,
85
+ "--dry-run",
86
+ "-n",
79
87
  help="仅预览,不执行任何操作",
80
88
  ),
81
89
  yes: bool = typer.Option(
82
- False, "--yes", "-y",
90
+ False,
91
+ "--yes",
92
+ "-y",
83
93
  help="自动确认所有提示",
84
94
  ),
85
95
  no_recursive: bool = typer.Option(
86
- False, "--no-recursive",
96
+ False,
97
+ "--no-recursive",
87
98
  help="不递归扫描子目录",
88
99
  ),
89
100
  no_progress: bool = typer.Option(
90
- False, "--no-progress",
101
+ False,
102
+ "--no-progress",
91
103
  help="不显示进度条",
92
104
  ),
93
105
  ) -> None:
@@ -116,9 +128,14 @@ def run_command(
116
128
  console.print("[red]❌ 请先运行 [bold]fileflow init[/] 创建配置文件[/]")
117
129
  raise typer.Exit(code=1)
118
130
 
119
- is_dry_run: bool | None = dry_run or None
131
+ # ── 确定 dry-run 模式 ────────────────────────────
132
+ # 优先级: --force > --dry-run > 配置文件 > 默认值
120
133
  if force:
121
134
  is_dry_run = False
135
+ elif dry_run:
136
+ is_dry_run = True
137
+ else:
138
+ is_dry_run = None # 由 engine.run() 读取配置文件
122
139
 
123
140
  # ── 执行引擎 ──────────────────────────────────────
124
141
  result = run(
@@ -157,8 +174,6 @@ def run_command(
157
174
  result.print()
158
175
  # 保存操作历史
159
176
  if result.results:
160
- from .history import HistoryEntry, HistoryManager
161
-
162
177
  entry = HistoryEntry.from_results(Path(source_dir).resolve(), result.results)
163
178
  hm = HistoryManager()
164
179
  hm.append(entry)
@@ -167,8 +182,6 @@ def run_command(
167
182
  console.print("[dim]已取消,未执行任何操作[/]")
168
183
  elif result.results and not result.dry_run:
169
184
  # 非 dry-run 模式,直接保存历史
170
- from .history import HistoryEntry, HistoryManager
171
-
172
185
  entry = HistoryEntry.from_results(Path(source_dir).resolve(), result.results)
173
186
  hm = HistoryManager()
174
187
  hm.append(entry)
@@ -177,15 +190,14 @@ def run_command(
177
190
  @app.command()
178
191
  def init(
179
192
  global_config: bool = typer.Option(
180
- False, "--global", "-g",
193
+ False,
194
+ "--global",
195
+ "-g",
181
196
  help="生成到用户目录 (~/.fileflow.yaml)",
182
197
  ),
183
198
  ) -> None:
184
199
  """🚀 生成默认配置文件 .fileflow.yaml"""
185
- if global_config:
186
- target = Path.home() / ".fileflow.yaml"
187
- else:
188
- target = Path.cwd() / ".fileflow.yaml"
200
+ target = Path.home() / ".fileflow.yaml" if global_config else Path.cwd() / ".fileflow.yaml"
189
201
 
190
202
  if target.exists():
191
203
  overwrite = typer.confirm(
@@ -201,19 +213,23 @@ def init(
201
213
 
202
214
  console.print(f"[green]✅ 已生成配置文件:[/] {target}")
203
215
  console.print()
204
- console.print(Panel.fit(
205
- "[bold]📋 下一步:[/]\n"
206
- f" 1. 编辑 [cyan]{target.name}[/] 自定义规则\n"
207
- f" 2. 运行 [bold cyan]fileflow run .[/] 预览效果\n"
208
- f" 3. [bold yellow]--force[/] 执行整理",
209
- border_style="green",
210
- ))
216
+ console.print(
217
+ Panel.fit(
218
+ "[bold]📋 下一步:[/]\n"
219
+ f" 1. 编辑 [cyan]{target.name}[/] 自定义规则\n"
220
+ f" 2. 运行 [bold cyan]fileflow run .[/] 预览效果\n"
221
+ f" 3. 加 [bold yellow]--force[/] 执行整理",
222
+ border_style="green",
223
+ )
224
+ )
211
225
 
212
226
 
213
227
  @app.command()
214
228
  def validate(
215
- config: Optional[str] = typer.Option(
216
- None, "--config", "-c",
229
+ config: str | None = typer.Option(
230
+ None,
231
+ "--config",
232
+ "-c",
217
233
  help="配置文件路径",
218
234
  ),
219
235
  ) -> None:
@@ -232,11 +248,15 @@ def validate(
232
248
  @app.command(name="list-rules")
233
249
  def list_rules_command(
234
250
  verbose: bool = typer.Option(
235
- False, "--verbose", "-v",
251
+ False,
252
+ "--verbose",
253
+ "-v",
236
254
  help="显示详细的匹配条件",
237
255
  ),
238
- config: Optional[str] = typer.Option(
239
- None, "--config", "-c",
256
+ config: str | None = typer.Option(
257
+ None,
258
+ "--config",
259
+ "-c",
240
260
  help="配置文件路径",
241
261
  ),
242
262
  ) -> None:
@@ -248,7 +268,7 @@ def list_rules_command(
248
268
  if not rules:
249
269
  console.print("[yellow]⚠ 配置中没有定义任何规则[/]")
250
270
  console.print("[dim]用 [bold]fileflow init[/] 可以生成默认规则[/]")
251
- raise typer.Exit()
271
+ raise typer.Exit(code=1)
252
272
 
253
273
  print_rule_list(rules, verbose=verbose)
254
274
  except ConfigError as e:
@@ -259,12 +279,17 @@ def list_rules_command(
259
279
  @app.command()
260
280
  def undo(
261
281
  steps: int = typer.Option(
262
- 1, "--steps", "-n",
282
+ 1,
283
+ "--steps",
284
+ "-n",
263
285
  help="撤销最近 N 步操作(默认 1)",
264
- min=1, max=10,
286
+ min=1,
287
+ max=10,
265
288
  ),
266
289
  all: bool = typer.Option(
267
- False, "--all", "-a",
290
+ False,
291
+ "--all",
292
+ "-a",
268
293
  help="撤销所有操作",
269
294
  ),
270
295
  ) -> None:
@@ -298,13 +323,14 @@ def undo(
298
323
 
299
324
  @app.command()
300
325
  def stats(
301
- config: Optional[str] = typer.Option(
302
- None, "--config", "-c",
326
+ config: str | None = typer.Option(
327
+ None,
328
+ "--config",
329
+ "-c",
303
330
  help="配置文件路径",
304
331
  ),
305
332
  ) -> None:
306
333
  """📊 查看整理统计信息."""
307
- from datetime import datetime
308
334
 
309
335
  # 历史统计
310
336
  hm = HistoryManager()
@@ -7,9 +7,6 @@ from typing import Any
7
7
 
8
8
  import yaml
9
9
 
10
- from .rules import RuleSet
11
-
12
-
13
10
  DEFAULT_CONFIG = {
14
11
  "version": "1.0",
15
12
  "settings": {
@@ -63,10 +60,7 @@ def load_config(path: Path | str | None = None) -> dict[str, Any]:
63
60
  """
64
61
  resolved = find_config(path)
65
62
  if resolved is None:
66
- msg = (
67
- "未找到 .fileflow.yaml 配置文件。\n"
68
- "请先在目标目录运行: fileflow init"
69
- )
63
+ msg = "未找到 .fileflow.yaml 配置文件。\n请先在目标目录运行: fileflow init"
70
64
  raise ConfigError(msg)
71
65
 
72
66
  try:
@@ -89,11 +83,9 @@ def _merge_defaults(data: dict[str, Any]) -> dict[str, Any]:
89
83
 
90
84
  # 合并 settings
91
85
  if "settings" in data and isinstance(data["settings"], dict):
92
- merged_settings = merged["settings"].copy()
93
- merged_settings.update({
94
- k: v for k, v in data["settings"].items()
95
- if v is not None
96
- })
86
+ default_settings: dict[str, Any] = merged["settings"] # type: ignore[assignment]
87
+ merged_settings = default_settings.copy()
88
+ merged_settings.update({k: v for k, v in data["settings"].items() if v is not None})
97
89
  merged["settings"] = merged_settings
98
90
 
99
91
  return merged
@@ -106,8 +98,7 @@ def validate_config(config: dict[str, Any]) -> None:
106
98
  on_conflict = settings.get("on_conflict", "skip")
107
99
  if on_conflict not in valid_conflicts:
108
100
  raise ConfigError(
109
- f"无效的 on_conflict 值: {on_conflict!r}。"
110
- f" 可选: {', '.join(valid_conflicts)}"
101
+ f"无效的 on_conflict 值: {on_conflict!r}。 可选: {', '.join(valid_conflicts)}"
111
102
  )
112
103
 
113
104
  rules = config.get("rules", [])
@@ -0,0 +1,283 @@
1
+ """核心编排引擎:扫描 → 匹配 → 执行."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from rich.progress import (
8
+ BarColumn,
9
+ Progress,
10
+ SpinnerColumn,
11
+ TextColumn,
12
+ TimeElapsedColumn,
13
+ )
14
+
15
+ from .actions import ActionResult, ConflictStrategy, get_action
16
+ from .config import load_config
17
+ from .matchers import matches
18
+ from .report import print_summary
19
+ from .rules import RuleSet
20
+ from .templates import resolve_path
21
+
22
+
23
+ class EngineResult:
24
+ """一次引擎运行的结果."""
25
+
26
+ def __init__(
27
+ self,
28
+ source_dir: Path,
29
+ results: list[ActionResult],
30
+ dry_run: bool,
31
+ rule_matches: dict[str, int],
32
+ total_scanned: int = 0,
33
+ ) -> None:
34
+ self.source_dir = source_dir
35
+ self.results = results
36
+ self.dry_run = dry_run
37
+ self.rule_matches = rule_matches
38
+ self.total_scanned = total_scanned
39
+
40
+ @property
41
+ def success_count(self) -> int:
42
+ return sum(1 for r in self.results if r.success and not r.skipped)
43
+
44
+ @property
45
+ def skipped_count(self) -> int:
46
+ return sum(1 for r in self.results if r.skipped)
47
+
48
+ @property
49
+ def error_count(self) -> int:
50
+ return sum(1 for r in self.results if not r.success)
51
+
52
+ def print(self) -> None:
53
+ """打印结果到终端."""
54
+ print_summary(self)
55
+
56
+
57
+ def run(
58
+ source_dir: str | Path,
59
+ config_path: str | Path | None = None,
60
+ dry_run: bool | None = None,
61
+ force: bool = False,
62
+ recursive: bool = True,
63
+ show_progress: bool = True,
64
+ ) -> EngineResult:
65
+ """运行文件整理.
66
+
67
+ 1. 加载配置
68
+ 2. 扫描源目录
69
+ 3. 对每个文件匹配规则
70
+ 4. 执行操作(或预览)
71
+
72
+ Args:
73
+ source_dir: 要整理的目录路径.
74
+ config_path: 配置文件路径,None 则自动搜索.
75
+ dry_run: 是否仅预览;None 则使用配置中的 dry_run_by_default.
76
+ force: 忽略 dry_run 设置,强制执行.
77
+ recursive: 是否递归扫描子目录.
78
+ show_progress: 是否显示进度条.
79
+ """
80
+ source = Path(source_dir).resolve()
81
+ if not source.is_dir():
82
+ raise ValueError(f"源路径不是目录: {source}")
83
+
84
+ config = load_config(config_path)
85
+ settings = config.get("settings", {})
86
+
87
+ # 确定 dry-run 模式
88
+ # 优先级: --force > --dry-run > 配置文件 > 默认值
89
+ if force:
90
+ dry_run = False
91
+ elif dry_run is None:
92
+ dry_run = settings.get("dry_run_by_default", True)
93
+
94
+ conflict_str = ConflictStrategy(settings.get("on_conflict", "skip"))
95
+ create_dirs = settings.get("create_target_dirs", True)
96
+
97
+ rule_set = RuleSet.load_from_yaml(config.get("rules", []))
98
+
99
+ # ── 扫描文件 ───────────────────────────────────────────
100
+ if show_progress:
101
+ spinner = Progress(
102
+ SpinnerColumn(),
103
+ TextColumn("[bold cyan]🔍 扫描文件中...[/]"),
104
+ transient=True,
105
+ )
106
+ with spinner:
107
+ spinner.add_task("", total=None)
108
+ files = _scan_files(source, recursive)
109
+ else:
110
+ files = _scan_files(source, recursive)
111
+
112
+ # ── 匹配 + 执行 ─────────────────────────────────────────
113
+ results: list[ActionResult] = []
114
+ rule_matches: dict[str, int] = {}
115
+
116
+ if show_progress and files:
117
+ _run_with_progress(
118
+ files,
119
+ rule_set,
120
+ dry_run,
121
+ source,
122
+ conflict_str,
123
+ create_dirs,
124
+ results,
125
+ rule_matches,
126
+ )
127
+ else:
128
+ _run_simple(
129
+ files,
130
+ rule_set,
131
+ dry_run,
132
+ source,
133
+ conflict_str,
134
+ create_dirs,
135
+ results,
136
+ rule_matches,
137
+ )
138
+
139
+ return EngineResult(
140
+ source_dir=source,
141
+ results=results,
142
+ dry_run=dry_run,
143
+ rule_matches=rule_matches,
144
+ total_scanned=len(files),
145
+ )
146
+
147
+
148
+ def _match_and_execute(
149
+ file_path: Path,
150
+ rule_set: RuleSet,
151
+ dry_run: bool,
152
+ source: Path,
153
+ conflict_str: ConflictStrategy,
154
+ create_dirs: bool,
155
+ results: list[ActionResult],
156
+ rule_matches: dict[str, int],
157
+ ) -> bool:
158
+ """对一个文件尝试所有规则,匹配则执行/记录操作.
159
+
160
+ Returns:
161
+ True 表示匹配到了规则,False 表示没有匹配.
162
+ """
163
+ for rule in rule_set.active_rules:
164
+ if matches(file_path, rule):
165
+ rule_matches[rule.name] = rule_matches.get(rule.name, 0) + 1
166
+ if dry_run:
167
+ try:
168
+ dest = resolve_path(rule.target, file_path, source)
169
+ results.append(
170
+ ActionResult(
171
+ file_path,
172
+ target=dest,
173
+ success=True,
174
+ action=f"[dry-run] {rule.action}",
175
+ )
176
+ )
177
+ except Exception as e:
178
+ results.append(
179
+ ActionResult(
180
+ file_path,
181
+ error=str(e),
182
+ success=False,
183
+ action=rule.action,
184
+ )
185
+ )
186
+ else:
187
+ action_func = get_action(rule.action)
188
+ result = action_func(
189
+ file_path,
190
+ rule.target,
191
+ source,
192
+ create_dirs=create_dirs,
193
+ conflict=conflict_str,
194
+ )
195
+ results.append(result)
196
+ return True
197
+ return False
198
+
199
+
200
+ def _run_with_progress(
201
+ files: list[Path],
202
+ rule_set: RuleSet,
203
+ dry_run: bool,
204
+ source: Path,
205
+ conflict_str: ConflictStrategy,
206
+ create_dirs: bool,
207
+ results: list[ActionResult],
208
+ rule_matches: dict[str, int],
209
+ ) -> None:
210
+ """带进度条的文件匹配执行."""
211
+ progress = Progress(
212
+ TextColumn("[bold cyan]📦[/]"),
213
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
214
+ BarColumn(),
215
+ TextColumn("[bold]{task.completed}/{task.total}"),
216
+ TextColumn("[dim]{task.fields[status]}"),
217
+ TimeElapsedColumn(),
218
+ transient=True,
219
+ )
220
+ with progress:
221
+ task = progress.add_task(
222
+ "预览中..." if dry_run else "整理中...",
223
+ total=len(files),
224
+ status="",
225
+ )
226
+ for file_path in files:
227
+ _match_and_execute(
228
+ file_path,
229
+ rule_set,
230
+ dry_run,
231
+ source,
232
+ conflict_str,
233
+ create_dirs,
234
+ results,
235
+ rule_matches,
236
+ )
237
+ progress.update(
238
+ task,
239
+ advance=1,
240
+ status=f"[dim]{file_path.name}[/]",
241
+ )
242
+
243
+
244
+ def _run_simple(
245
+ files: list[Path],
246
+ rule_set: RuleSet,
247
+ dry_run: bool,
248
+ source: Path,
249
+ conflict_str: ConflictStrategy,
250
+ create_dirs: bool,
251
+ results: list[ActionResult],
252
+ rule_matches: dict[str, int],
253
+ ) -> None:
254
+ """无进度条的简单文件匹配执行."""
255
+ for file_path in files:
256
+ _match_and_execute(
257
+ file_path,
258
+ rule_set,
259
+ dry_run,
260
+ source,
261
+ conflict_str,
262
+ create_dirs,
263
+ results,
264
+ rule_matches,
265
+ )
266
+
267
+
268
+ def _scan_files(directory: Path, recursive: bool) -> list[Path]:
269
+ """扫描目录中的普通文件(忽略隐藏文件)."""
270
+ files: list[Path] = []
271
+
272
+ if recursive:
273
+ for entry in directory.rglob("*"):
274
+ if entry.is_file() and not any(
275
+ part.startswith(".") for part in entry.relative_to(directory).parts
276
+ ):
277
+ files.append(entry)
278
+ else:
279
+ for entry in directory.iterdir():
280
+ if entry.is_file() and not entry.name.startswith("."):
281
+ files.append(entry)
282
+
283
+ return sorted(files)
@@ -11,8 +11,7 @@ from datetime import datetime
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
- from .actions import ActionResult, move_file, ConflictStrategy
15
-
14
+ from .actions import ActionResult, ConflictStrategy, move_file
16
15
 
17
16
  HISTORY_FILE = ".fileflow_history.json"
18
17
 
@@ -42,11 +41,13 @@ class HistoryEntry:
42
41
  operations = []
43
42
  for r in results:
44
43
  if r.success and not r.skipped and r.target and r.target != r.source:
45
- operations.append({
46
- "source": str(r.source),
47
- "target": str(r.target),
48
- "action": r.action.replace("[dry-run] ", ""),
49
- })
44
+ operations.append(
45
+ {
46
+ "source": str(r.source),
47
+ "target": str(r.target),
48
+ "action": r.action.replace("[dry-run] ", ""),
49
+ }
50
+ )
50
51
 
51
52
  return cls(
52
53
  timestamp=datetime.now().isoformat(),
@@ -138,7 +139,6 @@ class HistoryManager:
138
139
 
139
140
  # 反向执行:把文件从 target 移回 source
140
141
  for op in reversed(entry.operations):
141
- source = Path(op["source"])
142
142
  target = Path(op["target"])
143
143
  if target.exists():
144
144
  try:
@@ -73,10 +73,7 @@ class ExtensionsMatcher(Matcher):
73
73
 
74
74
  def __init__(self, value: list[str]) -> None:
75
75
  # 统一处理:确保 . 开头、小写
76
- self.extensions = {
77
- e.lower() if e.startswith(".") else f".{e.lower()}"
78
- for e in value
79
- }
76
+ self.extensions = {e.lower() if e.startswith(".") else f".{e.lower()}" for e in value}
80
77
 
81
78
  def __call__(self, path: Path) -> bool:
82
79
  if not path.is_file():
@@ -86,7 +83,7 @@ class ExtensionsMatcher(Matcher):
86
83
 
87
84
  @register("patterns")
88
85
  class PatternsMatcher(Matcher):
89
- """按文件名通配符模式匹配(如 *.txt, data_*.csv). """
86
+ """按文件名通配符模式匹配(如 *.txt, data_*.csv)."""
90
87
 
91
88
  def __init__(self, value: list[str]) -> None:
92
89
  import fnmatch
@@ -134,6 +131,8 @@ class SizeMatcher(Matcher):
134
131
 
135
132
  def __init__(self, value: float | int) -> None:
136
133
  self.limit = value
134
+ self.min_bytes: int | None = None
135
+ self.max_bytes: int | None = None
137
136
 
138
137
  @classmethod
139
138
  def from_rule(cls, rule: Rule) -> SizeMatcher | None:
@@ -171,9 +170,7 @@ class SizeMatcher(Matcher):
171
170
 
172
171
  if min_b is not None and size < min_b:
173
172
  return False
174
- if max_b is not None and size > max_b:
175
- return False
176
- return True
173
+ return not (max_b is not None and size > max_b)
177
174
 
178
175
 
179
176
  # ── 规则匹配引擎 ──────────────────────────────────────────
@@ -2,14 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from pathlib import Path
6
5
  from typing import TYPE_CHECKING, Any
7
6
 
8
7
  from rich import box
9
8
  from rich.console import Console
10
9
  from rich.panel import Panel
11
10
  from rich.table import Table
12
- from rich.text import Text
13
11
 
14
12
  from .actions import ActionResult
15
13
 
@@ -22,7 +20,7 @@ console = Console()
22
20
  def print_banner() -> None:
23
21
  """打印 FileFlow 启动横幅."""
24
22
  banner = Panel.fit(
25
- "[bold cyan]🧹 FileFlow[/] [yellow]v0.2.0[/] [dim]— 智能文件整理器[/]",
23
+ "[bold cyan]🧹 FileFlow[/] [yellow]v0.2.1[/] [dim]— 智能文件整理器[/]",
26
24
  border_style="cyan",
27
25
  )
28
26
  console.print(banner)
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass, field
6
- from pathlib import Path
7
6
  from typing import Any
8
7
 
9
8
 
@@ -49,7 +48,7 @@ class Rule:
49
48
  )
50
49
 
51
50
  def to_dict(self) -> dict[str, Any]:
52
- """转换为可序列化字典(用于历史记录等). """
51
+ """转换为可序列化字典(用于历史记录等)."""
53
52
  return {
54
53
  "name": self.name,
55
54
  "description": self.description,
@@ -16,6 +16,7 @@
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import re
19
20
  import string
20
21
  from dataclasses import dataclass
21
22
  from datetime import datetime
@@ -67,8 +68,15 @@ class TemplateFormatter(string.Formatter):
67
68
 
68
69
  _formatter = TemplateFormatter()
69
70
  _ALLOWED_VARS = {
70
- "filename", "name", "ext", "ext_no_dot",
71
- "year", "month", "day", "hour", "minute",
71
+ "filename",
72
+ "name",
73
+ "ext",
74
+ "ext_no_dot",
75
+ "year",
76
+ "month",
77
+ "day",
78
+ "hour",
79
+ "minute",
72
80
  }
73
81
 
74
82
 
@@ -100,8 +108,6 @@ def render(template: str, path: Path) -> str:
100
108
 
101
109
  def _find_unresolved_braces(text: str) -> list[str]:
102
110
  """查找未解析的 {var} 模式."""
103
- import re
104
-
105
111
  return re.findall(r"\{(\w+)\}", text)
106
112
 
107
113
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fileflow-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: 🧹 智能文件整理器 — YAML 规则引擎驱动的 CLI 文件分类/移动/清理工具
5
5
  Author-email: Yuluo <lingeryuluo@gmail.com>
6
6
  License-Expression: MIT
@@ -21,6 +21,7 @@ src/fileflow_cli.egg-info/top_level.txt
21
21
  tests/test_actions.py
22
22
  tests/test_config.py
23
23
  tests/test_engine.py
24
+ tests/test_history.py
24
25
  tests/test_matchers.py
25
26
  tests/test_rules.py
26
27
  tests/test_templates.py
@@ -48,7 +48,9 @@ class TestMoveFile:
48
48
  dest.write_text("existing")
49
49
 
50
50
  result = move_file(
51
- src, "moved/{filename}", temp_dir,
51
+ src,
52
+ "moved/{filename}",
53
+ temp_dir,
52
54
  conflict=ConflictStrategy.RENAME,
53
55
  )
54
56
  assert result.success is True
@@ -64,7 +66,9 @@ class TestMoveFile:
64
66
  dest.write_text("old content")
65
67
 
66
68
  result = move_file(
67
- src, "moved/{filename}", temp_dir,
69
+ src,
70
+ "moved/{filename}",
71
+ temp_dir,
68
72
  conflict=ConflictStrategy.OVERWRITE,
69
73
  )
70
74
  assert result.success is True
@@ -7,9 +7,7 @@ from pathlib import Path
7
7
  import pytest
8
8
  import yaml
9
9
 
10
- from fileflow.config import load_config
11
- from fileflow.engine import EngineResult, _scan_files, run
12
- from fileflow.rules import RuleSet
10
+ from fileflow.engine import _scan_files, run
13
11
 
14
12
 
15
13
  class TestScanFiles:
@@ -129,12 +127,17 @@ class TestEngineResult:
129
127
  (temp_dir / "test.jpg").write_text("jpeg")
130
128
  # 创建临时配置文件
131
129
  cfg = temp_dir / ".fileflow.yaml"
132
- cfg.write_text(yaml.dump({
133
- "version": "1.0",
134
- "rules": [
135
- {"name": "IMG", "match": {"extensions": [".jpg"]}, "target": "Images/"},
136
- ],
137
- }), encoding="utf-8")
130
+ cfg.write_text(
131
+ yaml.dump(
132
+ {
133
+ "version": "1.0",
134
+ "rules": [
135
+ {"name": "IMG", "match": {"extensions": [".jpg"]}, "target": "Images/"},
136
+ ],
137
+ }
138
+ ),
139
+ encoding="utf-8",
140
+ )
138
141
 
139
142
  result = run(source_dir=temp_dir, config_path=cfg, dry_run=True)
140
143
  result.print()
@@ -0,0 +1,132 @@
1
+ """测试历史记录和撤销功能."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from fileflow.actions import ActionResult
8
+ from fileflow.history import HistoryEntry, HistoryManager
9
+
10
+
11
+ class TestHistoryEntry:
12
+ def test_from_results_empty(self) -> None:
13
+ entry = HistoryEntry.from_results(Path("/tmp"), [])
14
+ assert entry.operations == []
15
+ assert entry.action == "run"
16
+
17
+ def test_from_results_filters_skipped(self) -> None:
18
+ results = [
19
+ ActionResult(Path("/a.txt"), target=Path("/b.txt"), success=True),
20
+ ActionResult(Path("/c.txt"), skipped=True),
21
+ ActionResult(Path("/d.txt"), success=False, error="err"),
22
+ ]
23
+ entry = HistoryEntry.from_results(Path("/tmp"), results)
24
+ assert len(entry.operations) == 1 # 只记录成功的
25
+ assert entry.operations[0]["source"] == str(Path("/a.txt"))
26
+
27
+ def test_to_dict(self) -> None:
28
+ entry = HistoryEntry(
29
+ timestamp="2026-01-01T00:00:00",
30
+ action="run",
31
+ source_dir="/tmp",
32
+ operations=[{"source": "/a.txt", "target": "/b.txt", "action": "move"}],
33
+ )
34
+ d = entry.to_dict()
35
+ assert d["timestamp"] == "2026-01-01T00:00:00"
36
+ assert len(d["operations"]) == 1
37
+
38
+
39
+ class TestHistoryManager:
40
+ def test_append_and_load(self, temp_dir: Path) -> None:
41
+ hm = HistoryManager(temp_dir / ".history.json")
42
+ entry = HistoryEntry(
43
+ timestamp="2026-01-01T00:00:00",
44
+ action="run",
45
+ source_dir=str(temp_dir),
46
+ operations=[
47
+ {
48
+ "source": str(temp_dir / "a.txt"),
49
+ "target": str(temp_dir / "Docs" / "a.txt"),
50
+ "action": "move",
51
+ }
52
+ ],
53
+ )
54
+ hm.append(entry)
55
+
56
+ entries = hm.get_all()
57
+ assert len(entries) == 1
58
+ assert entries[0].timestamp == "2026-01-01T00:00:00"
59
+
60
+ def test_get_latest_empty(self) -> None:
61
+ hm = HistoryManager()
62
+ assert hm.get_latest() is None
63
+
64
+ def test_get_latest(self, temp_dir: Path) -> None:
65
+ hm = HistoryManager(temp_dir / ".history.json")
66
+ e1 = HistoryEntry("2026-01-01T00:00:00", "run", str(temp_dir), [])
67
+ e2 = HistoryEntry("2026-01-02T00:00:00", "run", str(temp_dir), [])
68
+ hm.append(e1)
69
+ hm.append(e2)
70
+
71
+ latest = hm.get_latest()
72
+ assert latest is not None
73
+ assert latest.timestamp == "2026-01-02T00:00:00"
74
+
75
+ def test_clear(self, temp_dir: Path) -> None:
76
+ path = temp_dir / ".history.json"
77
+ hm = HistoryManager(path)
78
+ hm.append(HistoryEntry("2026-01-01T00:00:00", "run", str(temp_dir), []))
79
+ assert path.exists()
80
+ hm.clear()
81
+ assert not path.exists()
82
+
83
+ def test_multiple_append(self, temp_dir: Path) -> None:
84
+ hm = HistoryManager(temp_dir / ".history.json")
85
+ for i in range(3):
86
+ hm.append(
87
+ HistoryEntry(
88
+ f"2026-01-0{i + 1}T00:00:00",
89
+ "run",
90
+ str(temp_dir),
91
+ [],
92
+ )
93
+ )
94
+ assert len(hm.get_all()) == 3
95
+
96
+ def test_invalid_json(self, temp_dir: Path) -> None:
97
+ path = temp_dir / ".history.json"
98
+ path.write_text("invalid json", encoding="utf-8")
99
+ hm = HistoryManager(path)
100
+ assert hm.get_all() == [] # 不会崩溃
101
+
102
+ def test_undo_latest_with_real_files(self, temp_dir: Path) -> None:
103
+ """集成测试:创建文件 → 记录历史 → 撤销."""
104
+ # 创建源文件
105
+ src = temp_dir / "test.txt"
106
+ src.write_text("hello")
107
+ target = temp_dir / "Moved" / "test.txt"
108
+ target.parent.mkdir(parents=True)
109
+ target.write_text("hello")
110
+
111
+ hm = HistoryManager(temp_dir / ".history.json")
112
+ entry = HistoryEntry(
113
+ timestamp="2026-01-01T00:00:00",
114
+ action="run",
115
+ source_dir=str(temp_dir),
116
+ operations=[
117
+ {
118
+ "source": str(src),
119
+ "target": str(target),
120
+ "action": "move",
121
+ }
122
+ ],
123
+ )
124
+ hm.append(entry)
125
+
126
+ # 执行撤销
127
+ success, failed = hm.undo_latest()
128
+ assert success == 1
129
+ assert failed == 0
130
+ # 文件应该被移回
131
+ assert src.exists()
132
+ assert not target.exists()
@@ -11,9 +11,8 @@ from fileflow.matchers import (
11
11
  PatternsMatcher,
12
12
  SizeMatcher,
13
13
  build_matchers,
14
- matches,
15
- register,
16
14
  list_matchers,
15
+ matches,
17
16
  )
18
17
  from fileflow.rules import Rule
19
18
 
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from unittest.mock import patch
7
6
 
8
7
  import pytest
9
8
 
@@ -65,7 +64,7 @@ class TestRender:
65
64
  render("{unknown}", f)
66
65
 
67
66
  def test_directory_path(self, temp_dir: Path) -> None:
68
- """对目录路径也能正常渲染(虽然一般不用于目录). """
67
+ """对目录路径也能正常渲染(虽然一般不用于目录)."""
69
68
  result = render("Backup/{name}", temp_dir)
70
69
  name = temp_dir.name
71
70
  assert result == f"Backup/{name}"
@@ -1,251 +0,0 @@
1
- """核心编排引擎:扫描 → 匹配 → 执行."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
- from typing import Any
7
-
8
- from rich.progress import (
9
- BarColumn,
10
- Progress,
11
- SpinnerColumn,
12
- TextColumn,
13
- TimeElapsedColumn,
14
- )
15
-
16
- from .actions import (
17
- ConflictStrategy,
18
- ActionResult,
19
- get_action,
20
- )
21
- from .config import load_config
22
- from .matchers import matches
23
- from .report import print_summary
24
- from .rules import RuleSet
25
-
26
-
27
- class EngineResult:
28
- """一次引擎运行的结果."""
29
-
30
- def __init__(
31
- self,
32
- source_dir: Path,
33
- results: list[ActionResult],
34
- dry_run: bool,
35
- rule_matches: dict[str, int],
36
- total_scanned: int = 0,
37
- ) -> None:
38
- self.source_dir = source_dir
39
- self.results = results
40
- self.dry_run = dry_run
41
- self.rule_matches = rule_matches
42
- self.total_scanned = total_scanned
43
-
44
- @property
45
- def success_count(self) -> int:
46
- return sum(1 for r in self.results if r.success and not r.skipped)
47
-
48
- @property
49
- def skipped_count(self) -> int:
50
- return sum(1 for r in self.results if r.skipped)
51
-
52
- @property
53
- def error_count(self) -> int:
54
- return sum(1 for r in self.results if not r.success)
55
-
56
- def print(self) -> None:
57
- """打印结果到终端."""
58
- print_summary(self)
59
-
60
-
61
- def run(
62
- source_dir: str | Path,
63
- config_path: str | Path | None = None,
64
- dry_run: bool | None = None,
65
- force: bool = False,
66
- recursive: bool = True,
67
- show_progress: bool = True,
68
- ) -> EngineResult:
69
- """运行文件整理.
70
-
71
- 1. 加载配置
72
- 2. 扫描源目录
73
- 3. 对每个文件匹配规则
74
- 4. 执行操作(或预览)
75
-
76
- Args:
77
- source_dir: 要整理的目录路径.
78
- config_path: 配置文件路径,None 则自动搜索.
79
- dry_run: 是否仅预览;None 则使用配置中的 dry_run_by_default.
80
- force: 如果 True 且 dry_run 未明确设置,则 dry_run=False.
81
- recursive: 是否递归扫描子目录.
82
- show_progress: 是否显示进度条.
83
- """
84
- source = Path(source_dir).resolve()
85
- if not source.is_dir():
86
- raise ValueError(f"源路径不是目录: {source}")
87
-
88
- config = load_config(config_path)
89
- settings = config.get("settings", {})
90
-
91
- # 确定 dry-run 模式
92
- if dry_run is None:
93
- dry_run = settings.get("dry_run_by_default", True)
94
-
95
- if force and dry_run is None:
96
- dry_run = False
97
-
98
- conflict_str = ConflictStrategy(settings.get("on_conflict", "skip"))
99
- create_dirs = settings.get("create_target_dirs", True)
100
-
101
- rule_set = RuleSet.load_from_yaml(config.get("rules", []))
102
-
103
- # ── 扫描文件 ───────────────────────────────────────────
104
- if show_progress:
105
- spinner = Progress(
106
- SpinnerColumn(),
107
- TextColumn("[bold cyan]🔍 扫描文件中...[/]"),
108
- TextColumn("[progress.description]{task.description}"),
109
- transient=True,
110
- )
111
- with spinner:
112
- scan_task = spinner.add_task("", total=None)
113
- files = _scan_files(source, recursive)
114
- spinner.update(scan_task, visible=False)
115
- else:
116
- files = _scan_files(source, recursive)
117
-
118
- # ── 匹配 + 执行 ─────────────────────────────────────────
119
- results: list[ActionResult] = []
120
- rule_matches: dict[str, int] = {}
121
-
122
- if show_progress and files:
123
- progress = Progress(
124
- TextColumn("[bold cyan]📦[/]"),
125
- TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
126
- BarColumn(),
127
- TextColumn("[bold]{task.completed}/{task.total}"),
128
- TextColumn("[dim]{task.fields[status]}"),
129
- TimeElapsedColumn(),
130
- transient=True,
131
- )
132
- with progress:
133
- task = progress.add_task(
134
- "预览中..." if dry_run else "整理中...",
135
- total=len(files),
136
- status="",
137
- )
138
-
139
- for file_path in files:
140
- matched = False
141
- for rule in rule_set.active_rules:
142
- if matches(file_path, rule):
143
- matched = True
144
- rule_matches[rule.name] = rule_matches.get(rule.name, 0) + 1
145
-
146
- if dry_run:
147
- from .templates import resolve_path
148
-
149
- try:
150
- dest = resolve_path(rule.target, file_path, source)
151
- results.append(
152
- ActionResult(
153
- file_path,
154
- target=dest,
155
- success=True,
156
- action=f"[dry-run] {rule.action}",
157
- )
158
- )
159
- except Exception as e:
160
- results.append(
161
- ActionResult(
162
- file_path,
163
- error=str(e),
164
- success=False,
165
- action=rule.action,
166
- )
167
- )
168
- else:
169
- action_func = get_action(rule.action)
170
- result = action_func(
171
- file_path,
172
- rule.target,
173
- source,
174
- create_dirs=create_dirs,
175
- conflict=conflict_str,
176
- )
177
- results.append(result)
178
- break
179
-
180
- progress.update(
181
- task,
182
- advance=1,
183
- status=f"[dim]{file_path.name}[/]",
184
- )
185
- else:
186
- # 无进度条模式
187
- for file_path in files:
188
- matched = False
189
- for rule in rule_set.active_rules:
190
- if matches(file_path, rule):
191
- matched = True
192
- rule_matches[rule.name] = rule_matches.get(rule.name, 0) + 1
193
- if dry_run:
194
- from .templates import resolve_path
195
-
196
- try:
197
- dest = resolve_path(rule.target, file_path, source)
198
- results.append(
199
- ActionResult(
200
- file_path,
201
- target=dest,
202
- success=True,
203
- action=f"[dry-run] {rule.action}",
204
- )
205
- )
206
- except Exception as e:
207
- results.append(
208
- ActionResult(
209
- file_path,
210
- error=str(e),
211
- success=False,
212
- action=rule.action,
213
- )
214
- )
215
- else:
216
- action_func = get_action(rule.action)
217
- result = action_func(
218
- file_path,
219
- rule.target,
220
- source,
221
- create_dirs=create_dirs,
222
- conflict=conflict_str,
223
- )
224
- results.append(result)
225
- break
226
-
227
- return EngineResult(
228
- source_dir=source,
229
- results=results,
230
- dry_run=dry_run,
231
- rule_matches=rule_matches,
232
- total_scanned=len(files),
233
- )
234
-
235
-
236
- def _scan_files(directory: Path, recursive: bool) -> list[Path]:
237
- """扫描目录中的普通文件(忽略隐藏文件). """
238
- files: list[Path] = []
239
-
240
- if recursive:
241
- for entry in directory.rglob("*"):
242
- if entry.is_file() and not any(
243
- part.startswith(".") for part in entry.relative_to(directory).parts
244
- ):
245
- files.append(entry)
246
- else:
247
- for entry in directory.iterdir():
248
- if entry.is_file() and not entry.name.startswith("."):
249
- files.append(entry)
250
-
251
- return sorted(files)
File without changes
File without changes
File without changes