fileflow-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fileflow/engine.py ADDED
@@ -0,0 +1,167 @@
1
+ """核心编排引擎:扫描 → 匹配 → 执行."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .actions import (
9
+ ConflictStrategy,
10
+ ActionResult,
11
+ get_action,
12
+ )
13
+ from .config import load_config
14
+ from .matchers import matches
15
+ from .report import format_results, print_summary
16
+ from .rules import Rule, RuleSet
17
+
18
+
19
+ class EngineResult:
20
+ """一次引擎运行的结果."""
21
+
22
+ def __init__(
23
+ self,
24
+ source_dir: Path,
25
+ results: list[ActionResult],
26
+ dry_run: bool,
27
+ rule_matches: dict[str, int],
28
+ ) -> None:
29
+ self.source_dir = source_dir
30
+ self.results = results
31
+ self.dry_run = dry_run
32
+ self.rule_matches = rule_matches
33
+
34
+ @property
35
+ def success_count(self) -> int:
36
+ return sum(1 for r in self.results if r.success and not r.skipped)
37
+
38
+ @property
39
+ def skipped_count(self) -> int:
40
+ return sum(1 for r in self.results if r.skipped)
41
+
42
+ @property
43
+ def error_count(self) -> int:
44
+ return sum(1 for r in self.results if not r.success)
45
+
46
+ def print(self) -> None:
47
+ """打印结果到终端."""
48
+ print_summary(self)
49
+
50
+
51
+ def run(
52
+ source_dir: str | Path,
53
+ config_path: str | Path | None = None,
54
+ dry_run: bool | None = None,
55
+ force: bool = False,
56
+ recursive: bool = True,
57
+ ) -> EngineResult:
58
+ """运行文件整理.
59
+
60
+ 1. 加载配置
61
+ 2. 扫描源目录
62
+ 3. 对每个文件匹配规则
63
+ 4. 执行操作(或预览)
64
+
65
+ Args:
66
+ source_dir: 要整理的目录路径.
67
+ config_path: 配置文件路径,None 则自动搜索.
68
+ dry_run: 是否仅预览;None 则使用配置中的 dry_run_by_default.
69
+ force: 如果 True 且 dry_run 未明确设置,则 dry_run=False.
70
+ recursive: 是否递归扫描子目录.
71
+ """
72
+ source = Path(source_dir).resolve()
73
+ if not source.is_dir():
74
+ raise ValueError(f"源路径不是目录: {source}")
75
+
76
+ config = load_config(config_path)
77
+ settings = config.get("settings", {})
78
+
79
+ # 确定 dry-run 模式
80
+ if dry_run is None:
81
+ dry_run = settings.get("dry_run_by_default", True)
82
+
83
+ if force and dry_run is None:
84
+ dry_run = False
85
+
86
+ conflict_str = ConflictStrategy(settings.get("on_conflict", "skip"))
87
+ create_dirs = settings.get("create_target_dirs", True)
88
+
89
+ rule_set = RuleSet.load_from_yaml(config.get("rules", []))
90
+
91
+ # 扫描文件
92
+ files = _scan_files(source, recursive)
93
+ results: list[ActionResult] = []
94
+ rule_matches: dict[str, int] = {}
95
+ unmatched_count = 0
96
+
97
+ for file_path in files:
98
+ matched = False
99
+ for rule in rule_set.active_rules:
100
+ if matches(file_path, rule):
101
+ matched = True
102
+ rule_matches[rule.name] = rule_matches.get(rule.name, 0) + 1
103
+
104
+ if dry_run:
105
+ # 预览模式:只记录不执行
106
+ from .templates import resolve_path
107
+
108
+ try:
109
+ dest = resolve_path(rule.target, file_path, source)
110
+ results.append(
111
+ ActionResult(
112
+ file_path,
113
+ target=dest,
114
+ success=True,
115
+ action=f"[dry-run] {rule.action}",
116
+ )
117
+ )
118
+ except Exception as e:
119
+ results.append(
120
+ ActionResult(
121
+ file_path,
122
+ error=str(e),
123
+ success=False,
124
+ action=rule.action,
125
+ )
126
+ )
127
+ else:
128
+ # 执行模式
129
+ action_func = get_action(rule.action)
130
+ result = action_func(
131
+ file_path,
132
+ rule.target,
133
+ source,
134
+ create_dirs=create_dirs,
135
+ conflict=conflict_str,
136
+ )
137
+ results.append(result)
138
+ break # 一个文件只匹配第一条规则
139
+
140
+ if not matched:
141
+ unmatched_count += 1
142
+
143
+ return EngineResult(
144
+ source_dir=source,
145
+ results=results,
146
+ dry_run=dry_run,
147
+ rule_matches=rule_matches,
148
+ )
149
+
150
+
151
+ def _scan_files(directory: Path, recursive: bool) -> list[Path]:
152
+ """扫描目录中的普通文件(忽略隐藏文件). """
153
+ files: list[Path] = []
154
+
155
+ if recursive:
156
+ # 排除隐藏目录(以 . 开头)
157
+ for entry in directory.rglob("*"):
158
+ if entry.is_file() and not any(
159
+ part.startswith(".") for part in entry.relative_to(directory).parts
160
+ ):
161
+ files.append(entry)
162
+ else:
163
+ for entry in directory.iterdir():
164
+ if entry.is_file() and not entry.name.startswith("."):
165
+ files.append(entry)
166
+
167
+ return sorted(files)
fileflow/matchers.py ADDED
@@ -0,0 +1,214 @@
1
+ """匹配器注册表和内置匹配器.
2
+
3
+ 匹配器是一个可调用对象: (Path) -> bool
4
+ 注册表模式使新增匹配器只需注册,无需修改其他代码。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .rules import Rule
15
+
16
+ # 类型: 匹配器函数签名
17
+ MatcherFunc = Callable[[Path], bool]
18
+
19
+
20
+ # ── 匹配器注册表 ──────────────────────────────────────────
21
+
22
+ _matchers: dict[str, type[Matcher]] = {}
23
+
24
+
25
+ def register(name: str) -> Callable[[type[Matcher]], type[Matcher]]:
26
+ """装饰器:在注册表中注册一个匹配器类."""
27
+
28
+ def wrapper(cls: type[Matcher]) -> type[Matcher]:
29
+ _matchers[name] = cls
30
+ return cls
31
+
32
+ return wrapper
33
+
34
+
35
+ def get_matcher(name: str) -> type[Matcher]:
36
+ """按名称获取匹配器类."""
37
+ if name not in _matchers:
38
+ raise KeyError(f"未知的匹配器: {name!r},可选: {list(_matchers.keys())}")
39
+ return _matchers[name]
40
+
41
+
42
+ def list_matchers() -> list[str]:
43
+ """列出所有注册的匹配器名称."""
44
+ return list(_matchers.keys())
45
+
46
+
47
+ # ── 基类 ────────────────────────────────────────────────
48
+
49
+
50
+ class Matcher:
51
+ """匹配器基类.
52
+
53
+ 子类需实现 __call__(path) -> bool。
54
+ 构造时接收匹配条件值(字符串或列表)。
55
+ """
56
+
57
+ def __init__(self, value: Any) -> None:
58
+ self.value = value
59
+
60
+ def __call__(self, path: Path) -> bool:
61
+ raise NotImplementedError
62
+
63
+ def __repr__(self) -> str:
64
+ return f"{type(self).__name__}({self.value!r})"
65
+
66
+
67
+ # ── 内置匹配器 ────────────────────────────────────────────
68
+
69
+
70
+ @register("extensions")
71
+ class ExtensionsMatcher(Matcher):
72
+ """按文件扩展名匹配."""
73
+
74
+ def __init__(self, value: list[str]) -> None:
75
+ # 统一处理:确保 . 开头、小写
76
+ self.extensions = {
77
+ e.lower() if e.startswith(".") else f".{e.lower()}"
78
+ for e in value
79
+ }
80
+
81
+ def __call__(self, path: Path) -> bool:
82
+ if not path.is_file():
83
+ return False
84
+ return path.suffix.lower() in self.extensions
85
+
86
+
87
+ @register("patterns")
88
+ class PatternsMatcher(Matcher):
89
+ """按文件名通配符模式匹配(如 *.txt, data_*.csv). """
90
+
91
+ def __init__(self, value: list[str]) -> None:
92
+ import fnmatch
93
+
94
+ self._fnmatch = fnmatch
95
+ self.patterns = value
96
+
97
+ def __call__(self, path: Path) -> bool:
98
+ name = path.name
99
+ return any(self._fnmatch.fnmatch(name, p) for p in self.patterns)
100
+
101
+
102
+ @register("name_contains")
103
+ class NameContainsMatcher(Matcher):
104
+ """按文件名包含子串匹配."""
105
+
106
+ def __init__(self, value: list[str]) -> None:
107
+ self.substrings = value
108
+
109
+ def __call__(self, path: Path) -> bool:
110
+ name = path.name.lower()
111
+ return any(s.lower() in name for s in self.substrings)
112
+
113
+
114
+ @register("name_regex")
115
+ class NameRegexMatcher(Matcher):
116
+ """按文件名正则表达式匹配."""
117
+
118
+ def __init__(self, value: list[str]) -> None:
119
+ self.patterns = [re.compile(p, re.IGNORECASE) for p in value]
120
+
121
+ def __call__(self, path: Path) -> bool:
122
+ name = path.name
123
+ return any(p.search(name) is not None for p in self.patterns)
124
+
125
+
126
+ @register("min_size_mb")
127
+ @register("max_size_mb")
128
+ class SizeMatcher(Matcher):
129
+ """按文件大小匹配.
130
+
131
+ 支持 min_size_mb / max_size_mb / min_size_bytes / max_size_bytes。
132
+ 值由 Rule 对象直接提供,本匹配器不直接构造。
133
+ """
134
+
135
+ def __init__(self, value: float | int) -> None:
136
+ self.limit = value
137
+
138
+ @classmethod
139
+ def from_rule(cls, rule: Rule) -> SizeMatcher | None:
140
+ """从 Rule 构建大小匹配器(合并所有大小条件)."""
141
+ min_bytes = None
142
+ max_bytes = None
143
+
144
+ if rule.min_size_mb is not None:
145
+ min_bytes = int(rule.min_size_mb * 1024 * 1024)
146
+ if rule.min_size_bytes is not None:
147
+ min_bytes = rule.min_size_bytes
148
+ if rule.max_size_mb is not None:
149
+ max_bytes = int(rule.max_size_mb * 1024 * 1024)
150
+ if rule.max_size_bytes is not None:
151
+ max_bytes = rule.max_size_bytes
152
+
153
+ if min_bytes is None and max_bytes is None:
154
+ return None
155
+
156
+ sm = cls(0)
157
+ sm.min_bytes = min_bytes
158
+ sm.max_bytes = max_bytes
159
+ return sm
160
+
161
+ def __call__(self, path: Path) -> bool:
162
+ if not path.is_file():
163
+ return False
164
+ try:
165
+ size = path.stat().st_size
166
+ except OSError:
167
+ return False
168
+
169
+ min_b = getattr(self, "min_bytes", None)
170
+ max_b = getattr(self, "max_bytes", None)
171
+
172
+ if min_b is not None and size < min_b:
173
+ return False
174
+ if max_b is not None and size > max_b:
175
+ return False
176
+ return True
177
+
178
+
179
+ # ── 规则匹配引擎 ──────────────────────────────────────────
180
+
181
+
182
+ def build_matchers(rule: Rule) -> list[MatcherFunc]:
183
+ """从一条规则构建所有匹配器函数列表.
184
+
185
+ 返回值是一个可调用函数列表,每个接受 Path 返回 bool。
186
+ """
187
+ matchers: list[MatcherFunc] = []
188
+
189
+ if rule.extensions:
190
+ matchers.append(ExtensionsMatcher(rule.extensions))
191
+ if rule.patterns:
192
+ matchers.append(PatternsMatcher(rule.patterns))
193
+ if rule.name_contains:
194
+ matchers.append(NameContainsMatcher(rule.name_contains))
195
+ if rule.name_regex:
196
+ matchers.append(NameRegexMatcher(rule.name_regex))
197
+
198
+ size_matcher = SizeMatcher.from_rule(rule)
199
+ if size_matcher is not None:
200
+ matchers.append(size_matcher)
201
+
202
+ return matchers
203
+
204
+
205
+ def matches(path: Path, rule: Rule) -> bool:
206
+ """判断一个文件是否匹配一条规则.
207
+
208
+ - 如果规则没有任何匹配条件,返回 False(不会匹配所有文件)
209
+ - 多个匹配条件是 AND 关系:文件必须满足所有条件
210
+ """
211
+ checkers = build_matchers(rule)
212
+ if not checkers:
213
+ return False
214
+ return all(check(path) for check in checkers)
fileflow/report.py ADDED
@@ -0,0 +1,164 @@
1
+ """Rich 终端格式化输出."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from rich import box
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from .actions import ActionResult
15
+
16
+ if TYPE_CHECKING:
17
+ from .engine import EngineResult
18
+
19
+ console = Console()
20
+
21
+
22
+ def print_banner() -> None:
23
+ """打印 FileFlow 启动横幅."""
24
+ banner = Text()
25
+ banner.append("🧹 FileFlow", style="bold cyan")
26
+ banner.append(" v", style="dim")
27
+ banner.append("0.1.0", style="bold yellow")
28
+ banner.append(" — 智能文件整理器", style="dim")
29
+ console.print(banner)
30
+
31
+
32
+ def print_config_preview(config: dict[str, Any]) -> None:
33
+ """打印配置预览."""
34
+ rules = config.get("rules", [])
35
+ table = Table(
36
+ title="📋 规则列表",
37
+ box=box.ROUNDED,
38
+ header_style="bold cyan",
39
+ )
40
+ table.add_column("名称", style="bold")
41
+ table.add_column("操作", style="yellow")
42
+ table.add_column("目标路径", style="green")
43
+ table.add_column("条件", style="dim")
44
+ table.add_column("状态")
45
+
46
+ for rule in rules:
47
+ match = rule.get("match", {})
48
+ conditions = []
49
+ if match.get("extensions"):
50
+ conditions.append(f"ext: {', '.join(match['extensions'])}")
51
+ if match.get("patterns"):
52
+ conditions.append(f"pattern: {', '.join(match['patterns'])}")
53
+ if match.get("name_contains"):
54
+ conditions.append(f"name contains: {', '.join(match['name_contains'])}")
55
+ if match.get("max_size_mb"):
56
+ conditions.append(f"< {match['max_size_mb']}MB")
57
+
58
+ status = "[green]✓ 启用[/]" if rule.get("enabled", True) else "[red]✗ 禁用[/]"
59
+ table.add_row(
60
+ rule.get("name", ""),
61
+ rule.get("action", "move"),
62
+ rule.get("target", ""),
63
+ "\n".join(conditions) or "[dim]无[/]",
64
+ status,
65
+ )
66
+
67
+ console.print(table)
68
+
69
+
70
+ def format_results(results: list[ActionResult]) -> None:
71
+ """格式化输出操作结果列表."""
72
+ for result in results:
73
+ style = "green" if result.success else "red"
74
+ icon = "✓" if result.success else "✗"
75
+ if result.skipped:
76
+ style = "yellow"
77
+ icon = "–"
78
+ if result.error:
79
+ console.print(f" [{style}]{icon} {result.source.name}[/] [red]{result.error}[/]")
80
+ elif result.skipped:
81
+ console.print(f" [{style}]{icon} {result.source.name}[/] (已存在)")
82
+ else:
83
+ target = result.target or ""
84
+ console.print(f" [{style}]{icon} {result.source.name}[/] → {target}")
85
+
86
+
87
+ def print_summary(result: EngineResult) -> None:
88
+ """打印引擎运行摘要."""
89
+ header = "[bold cyan]🔍 预览结果[/]" if result.dry_run else "[bold green]📦 整理完成[/]"
90
+ console.print()
91
+
92
+ # 匹配统计
93
+ table = Table(box=box.SIMPLE, header_style="bold")
94
+ table.add_column("规则", style="bold")
95
+ table.add_column("匹配数", justify="right")
96
+
97
+ for rule_name, count in sorted(result.rule_matches.items()):
98
+ table.add_row(rule_name, str(count))
99
+
100
+ if table.row_count > 0:
101
+ console.print(Panel(table, title=header))
102
+
103
+ # 汇总信息
104
+ total = len(result.results)
105
+ success = result.success_count
106
+ skipped = result.skipped_count
107
+ errors = result.error_count
108
+
109
+ summary = Text()
110
+ summary.append(f"\n 共处理 {total} 个文件", style="bold")
111
+ if success:
112
+ summary.append(f" ✅ {success} 成功", style="green")
113
+ if skipped:
114
+ summary.append(f" ⏭ {skipped} 跳过", style="yellow")
115
+ if errors:
116
+ summary.append(f" ❌ {errors} 失败", style="red")
117
+
118
+ console.print(summary)
119
+
120
+ # 打印详细信息
121
+ if result.results:
122
+ console.print("\n[bold]详细信息:[/]")
123
+ format_results(result.results)
124
+
125
+ if not result.dry_run and result.success_count > 0:
126
+ console.print("\n[dim]💡 提示: 使用 fileflow undo 可撤销本次操作[/]")
127
+
128
+ console.print()
129
+
130
+
131
+ def print_rule_list(rules: list[dict[str, Any]], verbose: bool = False) -> None:
132
+ """打印规则列表."""
133
+ table = Table(
134
+ title="📋 规则配置",
135
+ box=box.ROUNDED,
136
+ header_style="bold cyan",
137
+ )
138
+ table.add_column("#", justify="right")
139
+ table.add_column("名称", style="bold")
140
+ table.add_column("描述", style="dim")
141
+ table.add_column("操作", style="yellow")
142
+ table.add_column("目标")
143
+ table.add_column("状态")
144
+
145
+ for i, rule in enumerate(rules, 1):
146
+ status = "[green]启用[/]" if rule.get("enabled", True) else "[red]禁用[/]"
147
+ table.add_row(
148
+ str(i),
149
+ rule.get("name", ""),
150
+ rule.get("description", ""),
151
+ rule.get("action", "move"),
152
+ rule.get("target", ""),
153
+ status,
154
+ )
155
+
156
+ if verbose and "match" in rule:
157
+ match = rule["match"]
158
+ details = []
159
+ for k, v in match.items():
160
+ details.append(f" {k}: {v}")
161
+ if details:
162
+ table.add_row("", "", "", "", "", "")
163
+ # 使用 add_row 的 extra 特性要小心,我们直接用 panel
164
+ console.print(table)
fileflow/rules.py ADDED
@@ -0,0 +1,86 @@
1
+ """规则数据模型."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class Rule:
12
+ """一条整理规则."""
13
+
14
+ name: str
15
+ description: str = ""
16
+ action: str = "move"
17
+ target: str = ""
18
+ enabled: bool = True
19
+
20
+ # 匹配条件
21
+ extensions: list[str] = field(default_factory=list)
22
+ patterns: list[str] = field(default_factory=list)
23
+ name_contains: list[str] = field(default_factory=list)
24
+ name_regex: list[str] = field(default_factory=list)
25
+ min_size_mb: float | None = None
26
+ max_size_mb: float | None = None
27
+ min_size_bytes: int | None = None
28
+ max_size_bytes: int | None = None
29
+
30
+ @classmethod
31
+ def from_dict(cls, data: dict[str, Any]) -> Rule:
32
+ """从 YAML 字典构建规则."""
33
+ match: dict[str, Any] = data.get("match", {})
34
+
35
+ return cls(
36
+ name=data.get("name", ""),
37
+ description=data.get("description", ""),
38
+ action=data.get("action", "move"),
39
+ target=data.get("target", ""),
40
+ enabled=data.get("enabled", True),
41
+ extensions=match.get("extensions", []),
42
+ patterns=match.get("patterns", []),
43
+ name_contains=match.get("name_contains", []),
44
+ name_regex=match.get("name_regex", []),
45
+ min_size_mb=match.get("min_size_mb"),
46
+ max_size_mb=match.get("max_size_mb"),
47
+ min_size_bytes=match.get("min_size_bytes"),
48
+ max_size_bytes=match.get("max_size_bytes"),
49
+ )
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ """转换为可序列化字典(用于历史记录等). """
53
+ return {
54
+ "name": self.name,
55
+ "description": self.description,
56
+ "action": self.action,
57
+ "target": self.target,
58
+ "enabled": self.enabled,
59
+ }
60
+
61
+
62
+ class RuleSet:
63
+ """一组规则的集合,支持启用/禁用过滤."""
64
+
65
+ def __init__(self, rules: list[Rule] | None = None) -> None:
66
+ self.rules = rules or []
67
+
68
+ @classmethod
69
+ def load_from_yaml(cls, data: list[dict[str, Any]]) -> RuleSet:
70
+ """从 YAML 解析的列表加载规则集."""
71
+ return cls([Rule.from_dict(item) for item in data])
72
+
73
+ @property
74
+ def active_rules(self) -> list[Rule]:
75
+ """仅返回启用的规则."""
76
+ return [r for r in self.rules if r.enabled]
77
+
78
+ def add_rule(self, rule: Rule) -> None:
79
+ """添加一条规则."""
80
+ self.rules.append(rule)
81
+
82
+ def __len__(self) -> int:
83
+ return len(self.rules)
84
+
85
+ def __getitem__(self, index: int) -> Rule:
86
+ return self.rules[index]