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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """FileFlow — 智能文件整理器."""
2
+
3
+ __version__ = "0.1.0"
fileflow/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """支持 python -m fileflow 运行."""
2
+
3
+ from .cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
fileflow/actions.py ADDED
@@ -0,0 +1,164 @@
1
+ """文件操作:移动、复制、删除等.
2
+
3
+ 支持冲突策略:
4
+ - skip: 跳过目标已存在的文件
5
+ - rename: 自动添加后缀 (1), (2)...
6
+ - overwrite: 覆盖目标文件
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ from enum import Enum
13
+ from pathlib import Path
14
+ from typing import Callable
15
+
16
+ from .templates import resolve_path
17
+
18
+
19
+ class ConflictStrategy(str, Enum):
20
+ """文件冲突处理策略."""
21
+
22
+ SKIP = "skip"
23
+ RENAME = "rename"
24
+ OVERWRITE = "overwrite"
25
+
26
+
27
+ class ActionResult:
28
+ """一次文件操作的结果."""
29
+
30
+ def __init__(
31
+ self,
32
+ source: Path,
33
+ target: Path | None = None,
34
+ success: bool = True,
35
+ action: str = "move",
36
+ skipped: bool = False,
37
+ error: str | None = None,
38
+ ) -> None:
39
+ self.source = source
40
+ self.target = target
41
+ self.success = success
42
+ self.action = action
43
+ self.skipped = skipped
44
+ self.error = error
45
+
46
+ @property
47
+ def summary(self) -> str:
48
+ if self.error:
49
+ return f"[red]✗ {self.action}: {self.source.name}[/] → {self.error}"
50
+ if self.skipped:
51
+ return f"[yellow]– 跳过: {self.source.name}[/] (目标已存在)"
52
+ return f"[green]✓ {self.action}: {self.source.name}[/] → {self.target}"
53
+
54
+
55
+ def resolve_target_path(
56
+ target_template: str,
57
+ source: Path,
58
+ base_dir: Path,
59
+ conflict: ConflictStrategy = ConflictStrategy.SKIP,
60
+ ) -> Path:
61
+ """解析目标路径,并根据冲突策略处理.
62
+
63
+ Returns:
64
+ 最终的目标路径(可能已根据冲突策略调整).
65
+ """
66
+ dest = resolve_path(target_template, source, base_dir)
67
+
68
+ if conflict == ConflictStrategy.RENAME and dest.exists():
69
+ dest = _find_available_name(dest)
70
+
71
+ return dest
72
+
73
+
74
+ def _find_available_name(dest: Path) -> Path:
75
+ """找到一个不存在的路径,如 file (1).txt, file (2).txt."""
76
+ parent = dest.parent
77
+ stem = dest.stem
78
+ suffix = dest.suffix
79
+ counter = 1
80
+ while True:
81
+ new_name = f"{stem} ({counter}){suffix}"
82
+ candidate = parent / new_name
83
+ if not candidate.exists():
84
+ return candidate
85
+ counter += 1
86
+
87
+
88
+ def move_file(
89
+ source: Path,
90
+ target_template: str,
91
+ base_dir: Path,
92
+ create_dirs: bool = True,
93
+ conflict: ConflictStrategy = ConflictStrategy.SKIP,
94
+ ) -> ActionResult:
95
+ """移动文件."""
96
+ try:
97
+ dest = resolve_target_path(target_template, source, base_dir, conflict)
98
+ if dest == source:
99
+ return ActionResult(source, dest, success=True, action="move")
100
+
101
+ if dest.exists() and conflict == ConflictStrategy.SKIP:
102
+ return ActionResult(source, dest, skipped=True, action="move")
103
+
104
+ if create_dirs:
105
+ dest.parent.mkdir(parents=True, exist_ok=True)
106
+
107
+ shutil.move(str(source), str(dest))
108
+ return ActionResult(source, dest, success=True, action="move")
109
+ except OSError as e:
110
+ return ActionResult(source, error=str(e), success=False, action="move")
111
+
112
+
113
+ def copy_file(
114
+ source: Path,
115
+ target_template: str,
116
+ base_dir: Path,
117
+ create_dirs: bool = True,
118
+ conflict: ConflictStrategy = ConflictStrategy.SKIP,
119
+ ) -> ActionResult:
120
+ """复制文件."""
121
+ try:
122
+ dest = resolve_target_path(target_template, source, base_dir, conflict)
123
+ if dest.exists() and conflict == ConflictStrategy.SKIP:
124
+ return ActionResult(source, dest, skipped=True, action="copy")
125
+
126
+ if create_dirs:
127
+ dest.parent.mkdir(parents=True, exist_ok=True)
128
+
129
+ shutil.copy2(str(source), str(dest))
130
+ return ActionResult(source, dest, success=True, action="copy")
131
+ except OSError as e:
132
+ return ActionResult(source, error=str(e), success=False, action="copy")
133
+
134
+
135
+ def delete_file(source: Path) -> ActionResult:
136
+ """删除文件."""
137
+ try:
138
+ source.unlink()
139
+ return ActionResult(source, success=True, action="delete")
140
+ except OSError as e:
141
+ return ActionResult(source, error=str(e), success=False, action="delete")
142
+
143
+
144
+ # ── 操作注册表 ─────────────────────────────────────────────
145
+
146
+ ActionFunc = Callable[..., ActionResult]
147
+
148
+ _actions: dict[str, ActionFunc] = {
149
+ "move": move_file,
150
+ "copy": copy_file,
151
+ "delete": delete_file,
152
+ }
153
+
154
+
155
+ def get_action(name: str) -> ActionFunc:
156
+ """按名称获取操作函数."""
157
+ if name not in _actions:
158
+ raise KeyError(f"未知操作: {name!r},可选: {list(_actions.keys())}")
159
+ return _actions[name]
160
+
161
+
162
+ def list_actions() -> list[str]:
163
+ """列出所有注册的操作名称."""
164
+ return list(_actions.keys())
fileflow/cli.py ADDED
@@ -0,0 +1,193 @@
1
+ """FileFlow CLI 命令入口.
2
+
3
+ 使用 Typer 构建,支持自动补全和 --help。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import typer
12
+ from rich import print as rprint
13
+
14
+ from . import __version__
15
+ from .config import (
16
+ ConfigError,
17
+ find_config,
18
+ generate_default_config,
19
+ load_config,
20
+ )
21
+ from .engine import run
22
+ from .report import (
23
+ print_banner,
24
+ print_config_preview,
25
+ print_rule_list,
26
+ console,
27
+ )
28
+
29
+ app = typer.Typer(
30
+ name="fileflow",
31
+ help="🧹 智能文件整理器 — 基于 YAML 规则引擎自动分类整理文件",
32
+ add_completion=True,
33
+ pretty_exceptions_show_locals=False,
34
+ )
35
+
36
+
37
+ def _version_callback(value: bool) -> None:
38
+ if value:
39
+ rprint(f"[bold cyan]FileFlow[/] [yellow]v{__version__}[/]")
40
+ raise typer.Exit()
41
+
42
+
43
+ @app.callback()
44
+ def main(
45
+ version: bool = typer.Option(
46
+ False, "--version", "-V",
47
+ help="显示版本号并退出",
48
+ callback=_version_callback,
49
+ is_eager=True,
50
+ ),
51
+ ) -> None:
52
+ """FileFlow 智能文件整理器."""
53
+ pass
54
+
55
+
56
+ @app.command(name="run")
57
+ def run_command(
58
+ source_dir: str = typer.Argument(
59
+ ".", help="要整理的目录路径(默认当前目录)",
60
+ ),
61
+ config: Optional[str] = typer.Option(
62
+ None, "--config", "-c",
63
+ help="配置文件路径(默认自动搜索 .fileflow.yaml)",
64
+ ),
65
+ force: bool = typer.Option(
66
+ False, "--force", "-f",
67
+ help="跳过 dry-run 确认,直接执行",
68
+ ),
69
+ dry_run: bool = typer.Option(
70
+ False, "--dry-run", "-n",
71
+ help="仅预览不执行(覆盖配置中的 dry_run_by_default)",
72
+ ),
73
+ yes: bool = typer.Option(
74
+ False, "--yes", "-y",
75
+ help="自动确认,跳过交互提示",
76
+ ),
77
+ no_recursive: bool = typer.Option(
78
+ False, "--no-recursive",
79
+ help="不递归扫描子目录",
80
+ ),
81
+ ) -> None:
82
+ """运行文件整理(默认先预览)."""
83
+ print_banner()
84
+
85
+ try:
86
+ config_path = Path(config) if config else None
87
+ is_dry_run = dry_run or None # None 表示使用配置默认值
88
+ if force:
89
+ is_dry_run = False
90
+
91
+ result = run(
92
+ source_dir=source_dir,
93
+ config_path=config_path,
94
+ dry_run=is_dry_run,
95
+ force=force,
96
+ recursive=not no_recursive,
97
+ )
98
+ except ConfigError as e:
99
+ console.print(f"[red]配置错误:[/] {e}")
100
+ raise typer.Exit(code=1)
101
+ except ValueError as e:
102
+ console.print(f"[red]错误:[/] {e}")
103
+ raise typer.Exit(code=1)
104
+
105
+ result.print()
106
+
107
+ # Dry-run 模式下询问是否要执行
108
+ if result.dry_run and result.results and not yes:
109
+ proceed = typer.confirm(
110
+ "\n[bold]是否要执行以上操作?[/]",
111
+ default=False,
112
+ )
113
+ if proceed:
114
+ result = run(
115
+ source_dir=source_dir,
116
+ config_path=config_path,
117
+ dry_run=False,
118
+ force=False,
119
+ recursive=not no_recursive,
120
+ )
121
+ result.print()
122
+
123
+
124
+ @app.command()
125
+ def init(
126
+ global_config: bool = typer.Option(
127
+ False, "--global", "-g",
128
+ help="在当前目录生成默认配置",
129
+ ),
130
+ ) -> None:
131
+ """在当前目录生成默认配置文件 .fileflow.yaml."""
132
+ target = Path.cwd() / ".fileflow.yaml"
133
+ if target.exists():
134
+ overwrite = typer.confirm(
135
+ f"[yellow]{target} 已存在,是否覆盖?[/]",
136
+ default=False,
137
+ )
138
+ if not overwrite:
139
+ console.print("[yellow]已取消[/]")
140
+ raise typer.Exit()
141
+
142
+ content = generate_default_config()
143
+ target.write_text(content, encoding="utf-8")
144
+ console.print(f"[green]✅ 已生成配置文件:[/] {target}")
145
+ console.print("\n[dim]现在你可以:\n 1. 编辑 {target.name} 自定义规则\n 2. 运行 [bold]fileflow run .[/bold] 预览效果\n 3. 加 [bold]--force[/bold] 执行整理[/dim]")
146
+
147
+
148
+ @app.command()
149
+ def validate(
150
+ config: Optional[str] = typer.Option(
151
+ None, "--config", "-c",
152
+ help="配置文件路径",
153
+ ),
154
+ ) -> None:
155
+ """验证配置文件语法和规则."""
156
+ try:
157
+ config_path = Path(config) if config else None
158
+ data = load_config(config_path)
159
+ console.print("[green]✅ 配置文件验证通过[/]")
160
+ print_config_preview(data)
161
+ except ConfigError as e:
162
+ console.print(f"[red]❌ 配置验证失败:[/] {e}")
163
+ raise typer.Exit(code=1)
164
+
165
+
166
+ @app.command()
167
+ def list_rules(
168
+ verbose: bool = typer.Option(
169
+ False, "--verbose", "-v",
170
+ help="显示详细的匹配条件",
171
+ ),
172
+ config: Optional[str] = typer.Option(
173
+ None, "--config", "-c",
174
+ help="配置文件路径",
175
+ ),
176
+ ) -> None:
177
+ """列出当前配置中的所有规则."""
178
+ try:
179
+ config_path = Path(config) if config else None
180
+ data = load_config(config_path)
181
+ rules = data.get("rules", [])
182
+ if not rules:
183
+ console.print("[yellow]⚠ 配置中没有定义任何规则[/]")
184
+ raise typer.Exit()
185
+
186
+ print_rule_list(rules, verbose=verbose)
187
+ except ConfigError as e:
188
+ console.print(f"[red]错误:[/] {e}")
189
+ raise typer.Exit(code=1)
190
+
191
+
192
+ if __name__ == "__main__":
193
+ app()
fileflow/config.py ADDED
@@ -0,0 +1,183 @@
1
+ """配置文件加载、解析和验证."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from .rules import RuleSet
11
+
12
+
13
+ DEFAULT_CONFIG = {
14
+ "version": "1.0",
15
+ "settings": {
16
+ "history_file": ".fileflow_history.json",
17
+ "dry_run_by_default": True,
18
+ "create_target_dirs": True,
19
+ "on_conflict": "skip",
20
+ },
21
+ "rules": [],
22
+ }
23
+
24
+
25
+ class ConfigError(Exception):
26
+ """配置相关错误."""
27
+
28
+
29
+ def find_config(path: Path | str | None = None) -> Path | None:
30
+ """查找配置文件。
31
+
32
+ 搜索顺序:
33
+ 1. 用户指定的路径
34
+ 2. 当前目录下的 .fileflow.yaml
35
+ 3. 当前目录下的 .fileflow.yml
36
+ """
37
+ if path:
38
+ p = Path(path)
39
+ if p.exists():
40
+ return p.resolve()
41
+ raise ConfigError(f"配置文件不存在: {path}")
42
+
43
+ cwd = Path.cwd()
44
+ for name in (".fileflow.yaml", ".fileflow.yml"):
45
+ candidate = cwd / name
46
+ if candidate.exists():
47
+ return candidate.resolve()
48
+
49
+ return None
50
+
51
+
52
+ def load_config(path: Path | str | None = None) -> dict[str, Any]:
53
+ """加载 YAML 配置文件.
54
+
55
+ Args:
56
+ path: 配置文件路径,为 None 时自动搜索.
57
+
58
+ Returns:
59
+ 完整的配置字典(合并了默认值).
60
+
61
+ Raises:
62
+ ConfigError: 文件不存在或格式错误.
63
+ """
64
+ resolved = find_config(path)
65
+ if resolved is None:
66
+ msg = (
67
+ "未找到 .fileflow.yaml 配置文件。\n"
68
+ "请先在目标目录运行: fileflow init"
69
+ )
70
+ raise ConfigError(msg)
71
+
72
+ try:
73
+ raw = resolved.read_text(encoding="utf-8")
74
+ data: dict[str, Any] = yaml.safe_load(raw) or {}
75
+ except yaml.YAMLError as e:
76
+ raise ConfigError(f"YAML 解析错误: {e}") from e
77
+ except OSError as e:
78
+ raise ConfigError(f"读取文件失败: {e}") from e
79
+
80
+ config = _merge_defaults(data)
81
+ validate_config(config)
82
+ return config
83
+
84
+
85
+ def _merge_defaults(data: dict[str, Any]) -> dict[str, Any]:
86
+ """将用户配置与默认值合并."""
87
+ merged = DEFAULT_CONFIG.copy()
88
+ merged.update(data)
89
+
90
+ # 合并 settings
91
+ 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
+ })
97
+ merged["settings"] = merged_settings
98
+
99
+ return merged
100
+
101
+
102
+ def validate_config(config: dict[str, Any]) -> None:
103
+ """验证配置合法性."""
104
+ settings = config.get("settings", {})
105
+ valid_conflicts = {"skip", "rename", "overwrite", "ask"}
106
+ on_conflict = settings.get("on_conflict", "skip")
107
+ if on_conflict not in valid_conflicts:
108
+ raise ConfigError(
109
+ f"无效的 on_conflict 值: {on_conflict!r}。"
110
+ f" 可选: {', '.join(valid_conflicts)}"
111
+ )
112
+
113
+ rules = config.get("rules", [])
114
+ if not isinstance(rules, list):
115
+ raise ConfigError("'rules' 必须是列表")
116
+
117
+ for i, rule in enumerate(rules):
118
+ if not isinstance(rule, dict):
119
+ raise ConfigError(f"rules[{i}] 必须是对象")
120
+ if "name" not in rule:
121
+ raise ConfigError(f"rules[{i}] 缺少 'name' 字段")
122
+ if "match" not in rule:
123
+ raise ConfigError(f"规则 '{rule['name']}' 缺少 'match' 字段")
124
+ if "target" not in rule and rule.get("action", "move") in ("move", "copy"):
125
+ raise ConfigError(f"规则 '{rule['name']}' 缺少 'target' 路径")
126
+
127
+
128
+ def generate_default_config() -> str:
129
+ """生成默认配置文件内容."""
130
+ return """# FileFlow 配置文件
131
+ # 运行 `fileflow run` 会自动读取此文件
132
+
133
+ version: "1.0"
134
+
135
+ settings:
136
+ history_file: ".fileflow_history.json"
137
+ dry_run_by_default: true
138
+ create_target_dirs: true
139
+ on_conflict: "skip" # skip | rename | overwrite | ask
140
+
141
+ rules:
142
+ - name: "整理图片"
143
+ description: "按扩展名整理图片文件"
144
+ match:
145
+ extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp"]
146
+ action: move
147
+ target: "Pictures/{ext_no_dot}/{filename}"
148
+
149
+ - name: "整理文档"
150
+ description: "归类 Office 和 PDF 文件"
151
+ match:
152
+ extensions: [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"]
153
+ action: move
154
+ target: "Documents/{ext_no_dot}/{filename}"
155
+
156
+ - name: "整理视频"
157
+ description: "归类视频文件"
158
+ match:
159
+ extensions: [".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv"]
160
+ action: move
161
+ target: "Videos/{ext_no_dot}/{filename}"
162
+
163
+ - name: "整理音频"
164
+ description: "归类音频文件"
165
+ match:
166
+ extensions: [".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma"]
167
+ action: move
168
+ target: "Audio/{ext_no_dot}/{filename}"
169
+
170
+ - name: "整理压缩包"
171
+ description: "归类压缩文件"
172
+ match:
173
+ extensions: [".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"]
174
+ action: move
175
+ target: "Archives/{ext_no_dot}/{filename}"
176
+
177
+ - name: "整理代码文件"
178
+ description: "归类代码和文本文件"
179
+ match:
180
+ extensions: [".py", ".js", ".ts", ".html", ".css", ".json", ".xml", ".md", ".txt"]
181
+ action: move
182
+ target: "Code/{ext_no_dot}/{filename}"
183
+ """