filepulse 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.
- filepulse/__init__.py +3 -0
- filepulse/__main__.py +5 -0
- filepulse/archive.py +215 -0
- filepulse/base.py +71 -0
- filepulse/cli.py +329 -0
- filepulse/config.py +55 -0
- filepulse/hooks.py +79 -0
- filepulse/monitor.py +193 -0
- filepulse/paths.py +57 -0
- filepulse/state.py +51 -0
- filepulse/utils.py +81 -0
- filepulse-0.1.0.dist-info/METADATA +241 -0
- filepulse-0.1.0.dist-info/RECORD +16 -0
- filepulse-0.1.0.dist-info/WHEEL +5 -0
- filepulse-0.1.0.dist-info/entry_points.txt +2 -0
- filepulse-0.1.0.dist-info/top_level.txt +1 -0
filepulse/__init__.py
ADDED
filepulse/__main__.py
ADDED
filepulse/archive.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Archive monitor: detects new zip files, extracts and merges to targets."""
|
|
2
|
+
|
|
3
|
+
import glob as glob_mod
|
|
4
|
+
import logging
|
|
5
|
+
import shutil
|
|
6
|
+
import zipfile
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Tuple
|
|
10
|
+
|
|
11
|
+
from .base import BaseMonitor
|
|
12
|
+
from .hooks import run_hook
|
|
13
|
+
from .utils import merge_folder
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ArchiveMonitor(BaseMonitor):
|
|
19
|
+
"""Scan for zip archives matching glob patterns, extract, and merge."""
|
|
20
|
+
|
|
21
|
+
state_filename = "archive_monitor_state.json"
|
|
22
|
+
|
|
23
|
+
# ---- discovery ------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def find_new_zips(self, task: Dict) -> List[Path]:
|
|
26
|
+
"""Return unprocessed zip paths for *task* sorted by name."""
|
|
27
|
+
base_dir = Path(self.config['archive_monitor'].get('base_dir', '.'))
|
|
28
|
+
pattern = task.get('source_pattern', '')
|
|
29
|
+
full_pattern = str(base_dir / pattern)
|
|
30
|
+
|
|
31
|
+
matches = glob_mod.glob(full_pattern, recursive=True)
|
|
32
|
+
task_state = self.state.get(task['name'], {})
|
|
33
|
+
new: list[Path] = []
|
|
34
|
+
|
|
35
|
+
for zip_path_str in sorted(matches):
|
|
36
|
+
zp = Path(zip_path_str)
|
|
37
|
+
if not zp.exists():
|
|
38
|
+
continue
|
|
39
|
+
key = str(zp)
|
|
40
|
+
mtime = zp.stat().st_mtime
|
|
41
|
+
if key in task_state and task_state[key].get('mtime') == mtime:
|
|
42
|
+
continue
|
|
43
|
+
new.append(zp)
|
|
44
|
+
return new
|
|
45
|
+
|
|
46
|
+
# ---- process one archive --------------------------------------------
|
|
47
|
+
|
|
48
|
+
def process_archive(self, task: Dict, zip_path: Path) -> bool:
|
|
49
|
+
"""Extract *zip_path* and merge into configured target directories.
|
|
50
|
+
|
|
51
|
+
Supports ``flatten_targets`` (copy all files flat) and the legacy
|
|
52
|
+
``target_dir`` / ``flatten`` config keys.
|
|
53
|
+
"""
|
|
54
|
+
name: str = task['name']
|
|
55
|
+
archive_dir = Path(task['archive_dir'])
|
|
56
|
+
extract_temp = Path(self.config['archive_monitor']['extract_temp'])
|
|
57
|
+
rename_to: str = task.get('rename_to', '')
|
|
58
|
+
|
|
59
|
+
# Resolve target directories
|
|
60
|
+
target_dirs: List[str] = task.get('target_dirs')
|
|
61
|
+
if target_dirs is None:
|
|
62
|
+
td = task.get('target_dir', '')
|
|
63
|
+
target_dirs = [td] if td else []
|
|
64
|
+
if not target_dirs:
|
|
65
|
+
logger.error("任务 %s 未配置 target_dir 或 target_dirs", name)
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
# Flatten set
|
|
69
|
+
flatten_set: set[str] = set()
|
|
70
|
+
flat_targets = task.get('flatten_targets', [])
|
|
71
|
+
if isinstance(flat_targets, list):
|
|
72
|
+
for p in flat_targets:
|
|
73
|
+
flatten_set.add(str(Path(p).resolve()))
|
|
74
|
+
if task.get('flatten', False):
|
|
75
|
+
flatten_set = {str(Path(td).resolve()) for td in target_dirs}
|
|
76
|
+
|
|
77
|
+
zip_mtime = zip_path.stat().st_mtime
|
|
78
|
+
|
|
79
|
+
# ---- before hook ----
|
|
80
|
+
hook_before = task.get('on_before_process', '')
|
|
81
|
+
if hook_before:
|
|
82
|
+
env = {
|
|
83
|
+
'MONITOR_TYPE': 'archive',
|
|
84
|
+
'MONITOR_EVENT': 'before_process',
|
|
85
|
+
'MONITOR_SOURCE': str(zip_path),
|
|
86
|
+
'MONITOR_TARGET': target_dirs[0],
|
|
87
|
+
'MONITOR_TASK': name,
|
|
88
|
+
}
|
|
89
|
+
if not run_hook(
|
|
90
|
+
hook_before, env,
|
|
91
|
+
retries=self.config['archive_monitor'].get('hook_retries', 3),
|
|
92
|
+
delay=self.config['archive_monitor'].get('hook_retry_delay', 1),
|
|
93
|
+
):
|
|
94
|
+
logger.warning("前置钩子失败,跳过处理: %s", zip_path)
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
# ---- extract ----
|
|
98
|
+
extract_temp.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
extract_root = extract_temp / f"{name}_{zip_path.stem}"
|
|
100
|
+
if extract_root.exists():
|
|
101
|
+
shutil.rmtree(extract_root)
|
|
102
|
+
extract_root.mkdir(exist_ok=True)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
with zipfile.ZipFile(zip_path, 'r') as zf:
|
|
106
|
+
zf.extractall(extract_root)
|
|
107
|
+
except zipfile.BadZipFile:
|
|
108
|
+
logger.exception("无效的压缩包 %s", zip_path)
|
|
109
|
+
shutil.rmtree(extract_root, ignore_errors=True)
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
top_items = list(extract_root.iterdir())
|
|
113
|
+
if not top_items:
|
|
114
|
+
logger.warning("压缩包为空: %s", zip_path)
|
|
115
|
+
shutil.rmtree(extract_root, ignore_errors=True)
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
# ---- merge to each target ----
|
|
119
|
+
for td in target_dirs:
|
|
120
|
+
target = Path(td).resolve()
|
|
121
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
|
|
123
|
+
if str(target) in flatten_set:
|
|
124
|
+
logger.info("目标目录 %s 启用扁平化", target)
|
|
125
|
+
files_to_copy = [p for p in extract_root.rglob('*') if p.is_file()]
|
|
126
|
+
for src_file in files_to_copy:
|
|
127
|
+
dest = target / src_file.name
|
|
128
|
+
if dest.exists():
|
|
129
|
+
stem, suffix = dest.stem, dest.suffix
|
|
130
|
+
counter = 1
|
|
131
|
+
while dest.exists():
|
|
132
|
+
dest = target / f"{stem}_{counter}{suffix}"
|
|
133
|
+
counter += 1
|
|
134
|
+
shutil.copy2(str(src_file), str(dest))
|
|
135
|
+
logger.info("扁平化复制 %d 个文件到: %s", len(files_to_copy), target)
|
|
136
|
+
else:
|
|
137
|
+
for item in top_items:
|
|
138
|
+
item_name = rename_to if rename_to else item.name
|
|
139
|
+
dest = target / item_name
|
|
140
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
if item.is_dir():
|
|
142
|
+
shutil.copytree(str(item), str(dest), dirs_exist_ok=True)
|
|
143
|
+
else:
|
|
144
|
+
shutil.copy2(str(item), str(dest))
|
|
145
|
+
logger.info("已复制到: %s", target)
|
|
146
|
+
|
|
147
|
+
shutil.rmtree(extract_root, ignore_errors=True)
|
|
148
|
+
|
|
149
|
+
# ---- archive the zip ----
|
|
150
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
archive_dest = archive_dir / zip_path.name
|
|
152
|
+
if archive_dest.exists():
|
|
153
|
+
ts = datetime.fromtimestamp(zip_mtime).strftime('%Y%m%d_%H%M%S')
|
|
154
|
+
archive_dest = archive_dir / f"{zip_path.stem}.{ts}{zip_path.suffix}"
|
|
155
|
+
shutil.move(str(zip_path), str(archive_dest))
|
|
156
|
+
logger.info("已归档zip: %s -> %s", zip_path, archive_dest)
|
|
157
|
+
|
|
158
|
+
# ---- persist state ----
|
|
159
|
+
self.state.setdefault(name, {})[str(zip_path)] = {
|
|
160
|
+
'mtime': zip_mtime,
|
|
161
|
+
'processed_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
162
|
+
'archive_path': str(archive_dest),
|
|
163
|
+
}
|
|
164
|
+
self.save_state()
|
|
165
|
+
|
|
166
|
+
# ---- after hook ----
|
|
167
|
+
hook_after = task.get('on_after_process', '')
|
|
168
|
+
if hook_after:
|
|
169
|
+
env = {
|
|
170
|
+
'MONITOR_TYPE': 'archive',
|
|
171
|
+
'MONITOR_EVENT': 'after_process',
|
|
172
|
+
'MONITOR_SOURCE': str(zip_path),
|
|
173
|
+
'MONITOR_TARGET': target_dirs[0],
|
|
174
|
+
'MONITOR_TASK': name,
|
|
175
|
+
'MONITOR_EXTRACTED': target_dirs[0],
|
|
176
|
+
}
|
|
177
|
+
run_hook(
|
|
178
|
+
hook_after, env,
|
|
179
|
+
retries=self.config['archive_monitor'].get('hook_retries', 3),
|
|
180
|
+
delay=self.config['archive_monitor'].get('hook_retry_delay', 1),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
# ---- run once / continuous ------------------------------------------
|
|
186
|
+
|
|
187
|
+
def run_once(self) -> Tuple[int, int]:
|
|
188
|
+
tasks = self.config.get('archives', [])
|
|
189
|
+
if not tasks:
|
|
190
|
+
logger.warning("配置文件中未定义归档监控任务 ([[archives]])")
|
|
191
|
+
return 0, 0
|
|
192
|
+
|
|
193
|
+
processed = 0
|
|
194
|
+
errors = 0
|
|
195
|
+
for task in tasks:
|
|
196
|
+
logger.info("检查归档任务: %s", task['name'])
|
|
197
|
+
new_zips = self.find_new_zips(task)
|
|
198
|
+
if new_zips:
|
|
199
|
+
logger.info("发现 %d 个新压缩包", len(new_zips))
|
|
200
|
+
for zp in new_zips:
|
|
201
|
+
try:
|
|
202
|
+
logger.info("处理压缩包: %s", zp)
|
|
203
|
+
if self.process_archive(task, zp):
|
|
204
|
+
processed += 1
|
|
205
|
+
else:
|
|
206
|
+
errors += 1
|
|
207
|
+
except Exception:
|
|
208
|
+
logger.exception("处理压缩包失败 %s", zp)
|
|
209
|
+
errors += 1
|
|
210
|
+
return processed, errors
|
|
211
|
+
|
|
212
|
+
def run_continuous(self) -> None:
|
|
213
|
+
interval = self.config['archive_monitor'].get('interval', 10.0)
|
|
214
|
+
logger.info("监控 %d 个归档任务", len(self.config.get('archives', [])))
|
|
215
|
+
super().run_continuous(interval)
|
filepulse/base.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Shared base class for FileMonitor and ArchiveMonitor."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import signal
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Tuple
|
|
8
|
+
|
|
9
|
+
from . import paths
|
|
10
|
+
from .config import load_config
|
|
11
|
+
from .state import StateManager
|
|
12
|
+
from .utils import setup_logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseMonitor(ABC):
|
|
18
|
+
"""Common scaffolding: config loading, state persistence, signal
|
|
19
|
+
handling, and the continuous polling loop."""
|
|
20
|
+
|
|
21
|
+
# Override in subclass to pick the right state file name.
|
|
22
|
+
state_filename: str = "monitor_state.json"
|
|
23
|
+
|
|
24
|
+
def __init__(self, config_path: str):
|
|
25
|
+
self.config_path = Path(config_path)
|
|
26
|
+
self.config = load_config(config_path)
|
|
27
|
+
self.logger = setup_logging()
|
|
28
|
+
self.running = True
|
|
29
|
+
|
|
30
|
+
state_path = paths.get_data_dir() / self.state_filename
|
|
31
|
+
self.state_mgr = StateManager(state_path)
|
|
32
|
+
self.state: Dict = self.state_mgr.load()
|
|
33
|
+
|
|
34
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
35
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
36
|
+
|
|
37
|
+
# ---- abstract -------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def run_once(self) -> Tuple[int, int]:
|
|
41
|
+
"""Perform one polling cycle. Returns (success_count, error_count)."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
# ---- continuous loop ------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def run_continuous(self, interval: float) -> None:
|
|
47
|
+
"""Block and poll at *interval* seconds until stopped."""
|
|
48
|
+
self.logger.info("启动监控服务,检查间隔: %.1f 秒", interval)
|
|
49
|
+
while self.running:
|
|
50
|
+
try:
|
|
51
|
+
self.run_once()
|
|
52
|
+
except KeyboardInterrupt:
|
|
53
|
+
self.logger.info("收到中断信号,停止监控")
|
|
54
|
+
break
|
|
55
|
+
except Exception:
|
|
56
|
+
self.logger.exception("监控循环错误")
|
|
57
|
+
if self.running:
|
|
58
|
+
import time
|
|
59
|
+
time.sleep(interval)
|
|
60
|
+
self.logger.info("监控服务已停止")
|
|
61
|
+
|
|
62
|
+
# ---- signal ---------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def _signal_handler(self, signum, frame):
|
|
65
|
+
self.logger.info("收到信号 %d,准备停止...", signum)
|
|
66
|
+
self.running = False
|
|
67
|
+
|
|
68
|
+
# ---- state persistence ----------------------------------------------
|
|
69
|
+
|
|
70
|
+
def save_state(self) -> None:
|
|
71
|
+
self.state_mgr.save(self.state)
|
filepulse/cli.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""filepulse CLI — file sync and archive monitor."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from . import paths
|
|
15
|
+
from .archive import ArchiveMonitor
|
|
16
|
+
from .monitor import FileMonitor
|
|
17
|
+
from .utils import get_file_info, setup_logging
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ======================================================================
|
|
21
|
+
# PID helpers
|
|
22
|
+
# ======================================================================
|
|
23
|
+
|
|
24
|
+
def _pid_path(name: str) -> Path:
|
|
25
|
+
"""Resolve a PID file under the data directory."""
|
|
26
|
+
return paths.get_data_dir() / f"{name}.pid"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_pid(name: str) -> Optional[int]:
|
|
30
|
+
try:
|
|
31
|
+
return int(_pid_path(name).read_text().strip())
|
|
32
|
+
except Exception:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _pid_running(pid: int) -> bool:
|
|
37
|
+
try:
|
|
38
|
+
os.kill(pid, 0)
|
|
39
|
+
return True
|
|
40
|
+
except OSError:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write_pid(name: str) -> None:
|
|
45
|
+
_pid_path(name).write_text(str(os.getpid()))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cleanup_pid(name: str) -> None:
|
|
49
|
+
_pid_path(name).unlink(missing_ok=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_CONTEXT_SETTINGS = {'help_option_names': ['-h', '--help']}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ======================================================================
|
|
56
|
+
# CLI entry point
|
|
57
|
+
# ======================================================================
|
|
58
|
+
|
|
59
|
+
@click.group(context_settings=_CONTEXT_SETTINGS)
|
|
60
|
+
@click.version_option(__version__, '-V', '--version')
|
|
61
|
+
def main():
|
|
62
|
+
"""filepulse — 文件监控与归档同步工具
|
|
63
|
+
|
|
64
|
+
轮询检测源文件变化并自动同步到目标位置,支持 zip 归档自动解压处理。
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ======================================================================
|
|
69
|
+
# sync — file monitoring
|
|
70
|
+
# ======================================================================
|
|
71
|
+
|
|
72
|
+
@main.group()
|
|
73
|
+
def sync():
|
|
74
|
+
"""文件同步 — 监控源文件变化并复制到目标."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@sync.command('run')
|
|
78
|
+
@click.argument('config', type=click.Path(exists=True))
|
|
79
|
+
@click.option('--daemon', '-d', is_flag=True, help='后台运行')
|
|
80
|
+
def sync_run(config: str, daemon: bool):
|
|
81
|
+
"""持续监控文件变化(前台阻塞运行)."""
|
|
82
|
+
logger = setup_logging(daemon)
|
|
83
|
+
monitor = FileMonitor(config)
|
|
84
|
+
|
|
85
|
+
if daemon:
|
|
86
|
+
_write_pid('monitor')
|
|
87
|
+
logger.info("后台进程 PID: %d", os.getpid())
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
monitor.run_continuous()
|
|
91
|
+
finally:
|
|
92
|
+
if daemon:
|
|
93
|
+
_cleanup_pid('monitor')
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@sync.command('once')
|
|
97
|
+
@click.argument('config', type=click.Path(exists=True))
|
|
98
|
+
def sync_once(config: str):
|
|
99
|
+
"""一次性同步所有已变更的文件."""
|
|
100
|
+
setup_logging()
|
|
101
|
+
monitor = FileMonitor(config)
|
|
102
|
+
synced, errors = monitor.run_once()
|
|
103
|
+
click.echo(f"同步完成: {synced} 个文件已更新,{errors} 个错误")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@sync.command('check')
|
|
107
|
+
@click.argument('file', type=click.Path())
|
|
108
|
+
@click.argument('config', type=click.Path(exists=True))
|
|
109
|
+
def sync_check(file: str, config: str):
|
|
110
|
+
"""检查单个文件是否有变更."""
|
|
111
|
+
setup_logging()
|
|
112
|
+
monitor = FileMonitor(config)
|
|
113
|
+
source = Path(file)
|
|
114
|
+
file_id = hashlib.md5(str(source).encode()).hexdigest()[:8]
|
|
115
|
+
changed = monitor.has_file_changed(source, file_id)
|
|
116
|
+
|
|
117
|
+
click.echo(f"文件: {source}")
|
|
118
|
+
click.echo(f"状态: {'已更改' if changed else '未更改'}")
|
|
119
|
+
if source.exists():
|
|
120
|
+
info = get_file_info(source)
|
|
121
|
+
if info:
|
|
122
|
+
click.echo(f"大小: {info['size']} 字节")
|
|
123
|
+
click.echo(f"修改时间: {datetime.fromtimestamp(info['mtime'])}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@sync.command('stats')
|
|
127
|
+
@click.argument('config', type=click.Path(exists=True))
|
|
128
|
+
def sync_stats(config: str):
|
|
129
|
+
"""列出已配置的文件映射."""
|
|
130
|
+
monitor = FileMonitor(config)
|
|
131
|
+
files = monitor.config.get('files', {})
|
|
132
|
+
click.echo(f"监控文件数量: {len(files)}")
|
|
133
|
+
click.echo()
|
|
134
|
+
for file_id, mapping in files.items():
|
|
135
|
+
if isinstance(mapping, str):
|
|
136
|
+
click.echo(f" {mapping}")
|
|
137
|
+
elif isinstance(mapping, dict):
|
|
138
|
+
click.echo(f" {mapping.get('source', '')} -> {mapping.get('target', '')}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ======================================================================
|
|
142
|
+
# archive — zip monitoring
|
|
143
|
+
# ======================================================================
|
|
144
|
+
|
|
145
|
+
@main.group()
|
|
146
|
+
def archive():
|
|
147
|
+
"""归档处理 — 监控 zip 压缩包并自动解压合并."""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@archive.command('run')
|
|
151
|
+
@click.argument('config', type=click.Path(exists=True))
|
|
152
|
+
@click.option('--daemon', '-d', is_flag=True, help='后台运行')
|
|
153
|
+
def archive_run(config: str, daemon: bool):
|
|
154
|
+
"""持续监控新 zip 文件(前台阻塞运行)."""
|
|
155
|
+
logger = setup_logging(daemon)
|
|
156
|
+
monitor = ArchiveMonitor(config)
|
|
157
|
+
|
|
158
|
+
if daemon:
|
|
159
|
+
_write_pid('archive_monitor')
|
|
160
|
+
logger.info("后台进程 PID: %d", os.getpid())
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
monitor.run_continuous()
|
|
164
|
+
finally:
|
|
165
|
+
if daemon:
|
|
166
|
+
_cleanup_pid('archive_monitor')
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@archive.command('once')
|
|
170
|
+
@click.argument('config', type=click.Path(exists=True))
|
|
171
|
+
def archive_once(config: str):
|
|
172
|
+
"""一次性处理所有待处理的 zip."""
|
|
173
|
+
setup_logging()
|
|
174
|
+
monitor = ArchiveMonitor(config)
|
|
175
|
+
processed, errors = monitor.run_once()
|
|
176
|
+
click.echo(f"归档处理完成: {processed} 个已处理,{errors} 个错误")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@archive.command('check')
|
|
180
|
+
@click.argument('config', type=click.Path(exists=True))
|
|
181
|
+
def archive_check(config: str):
|
|
182
|
+
"""列出待处理 zip 文件(干跑,不实际处理)."""
|
|
183
|
+
monitor = ArchiveMonitor(config)
|
|
184
|
+
tasks = monitor.config.get('archives', [])
|
|
185
|
+
if not tasks:
|
|
186
|
+
click.echo("未配置归档监控任务")
|
|
187
|
+
return
|
|
188
|
+
for task in tasks:
|
|
189
|
+
click.echo(f"\n任务: {task['name']}")
|
|
190
|
+
click.echo(f" 模式: {task.get('source_pattern', '')}")
|
|
191
|
+
click.echo(f" 目标: {task.get('target_dir', task.get('target_dirs', ''))}")
|
|
192
|
+
new_zips = monitor.find_new_zips(task)
|
|
193
|
+
if new_zips:
|
|
194
|
+
click.echo(f" 待处理 ({len(new_zips)}):")
|
|
195
|
+
for zp in new_zips:
|
|
196
|
+
click.echo(f" - {zp}")
|
|
197
|
+
else:
|
|
198
|
+
click.echo(" 待处理: 无")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ======================================================================
|
|
202
|
+
# daemon — process management
|
|
203
|
+
# ======================================================================
|
|
204
|
+
|
|
205
|
+
@main.group()
|
|
206
|
+
def daemon():
|
|
207
|
+
"""守护进程管理 — 查看/停止后台监控."""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@daemon.command('status')
|
|
211
|
+
def daemon_status():
|
|
212
|
+
"""查看监控守护进程状态."""
|
|
213
|
+
# File sync
|
|
214
|
+
pid = _read_pid('monitor')
|
|
215
|
+
if pid and _pid_running(pid):
|
|
216
|
+
click.echo(f"✓ 文件同步监控运行中 (PID: {pid})")
|
|
217
|
+
else:
|
|
218
|
+
click.echo("✗ 文件同步监控未运行")
|
|
219
|
+
_cleanup_pid('monitor')
|
|
220
|
+
|
|
221
|
+
# Archive
|
|
222
|
+
pid = _read_pid('archive_monitor')
|
|
223
|
+
if pid and _pid_running(pid):
|
|
224
|
+
click.echo(f"✓ 归档监控运行中 (PID: {pid})")
|
|
225
|
+
else:
|
|
226
|
+
click.echo("✗ 归档监控未运行")
|
|
227
|
+
_cleanup_pid('archive_monitor')
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@daemon.command('stop')
|
|
231
|
+
@click.option('--sync/--no-sync', 'stop_sync', default=True, help='停止文件同步守护进程')
|
|
232
|
+
@click.option('--archive/--no-archive', 'stop_archive', default=True, help='停止归档守护进程')
|
|
233
|
+
def daemon_stop(stop_sync: bool, stop_archive: bool):
|
|
234
|
+
"""停止后台监控进程."""
|
|
235
|
+
stopped = 0
|
|
236
|
+
for label, pid_name in [('文件同步', 'monitor'), ('归档', 'archive_monitor')]:
|
|
237
|
+
if label == '文件同步' and not stop_sync:
|
|
238
|
+
continue
|
|
239
|
+
if label == '归档' and not stop_archive:
|
|
240
|
+
continue
|
|
241
|
+
pid = _read_pid(pid_name)
|
|
242
|
+
if pid and _pid_running(pid):
|
|
243
|
+
os.kill(pid, 15) # SIGTERM
|
|
244
|
+
_cleanup_pid(pid_name)
|
|
245
|
+
click.echo(f"✓ {label}监控已停止 (PID: {pid})")
|
|
246
|
+
stopped += 1
|
|
247
|
+
else:
|
|
248
|
+
_cleanup_pid(pid_name)
|
|
249
|
+
if stopped == 0:
|
|
250
|
+
click.echo("无运行中的监控进程")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@daemon.command('logs')
|
|
254
|
+
@click.argument('num', type=int, default=50)
|
|
255
|
+
def daemon_logs(num: int):
|
|
256
|
+
"""查看最近 N 行日志."""
|
|
257
|
+
logfile = paths.get_cache_dir() / 'monitor.log'
|
|
258
|
+
if not logfile.exists():
|
|
259
|
+
click.echo("暂无日志文件")
|
|
260
|
+
return
|
|
261
|
+
lines = logfile.read_text().splitlines()
|
|
262
|
+
click.echo('\n'.join(lines[-num:]))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@daemon.command('reload')
|
|
266
|
+
def daemon_reload():
|
|
267
|
+
"""重载配置(停止后重新后台启动)."""
|
|
268
|
+
click.echo("停止监控...")
|
|
269
|
+
ctx = click.get_current_context()
|
|
270
|
+
# Invoke stop first
|
|
271
|
+
ctx.invoke(daemon_stop, stop_sync=True, stop_archive=True)
|
|
272
|
+
time.sleep(1)
|
|
273
|
+
click.echo("请手动运行 'filepulse sync run --daemon <config>' 或 "
|
|
274
|
+
"'filepulse archive run --daemon <config>' 重新启动")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ======================================================================
|
|
278
|
+
# utility commands
|
|
279
|
+
# ======================================================================
|
|
280
|
+
|
|
281
|
+
@main.command('clean')
|
|
282
|
+
@click.option('--logs-days', type=int, default=7, help='删除多少天前的日志')
|
|
283
|
+
@click.option('--backup-days', type=int, default=30, help='删除多少天前的备份')
|
|
284
|
+
def clean_cmd(logs_days: int, backup_days: int):
|
|
285
|
+
"""清理旧日志和备份文件."""
|
|
286
|
+
import subprocess
|
|
287
|
+
|
|
288
|
+
log_dir = paths.get_cache_dir()
|
|
289
|
+
if log_dir.exists():
|
|
290
|
+
result = subprocess.run(
|
|
291
|
+
['find', str(log_dir), '-name', '*.log', '-mtime', f'+{logs_days}', '-delete'],
|
|
292
|
+
capture_output=True, text=True,
|
|
293
|
+
)
|
|
294
|
+
click.echo(f"✓ 已清理 {logs_days} 天前的日志")
|
|
295
|
+
|
|
296
|
+
# Backup files are stored next to targets, find them
|
|
297
|
+
result = subprocess.run(
|
|
298
|
+
['find', str(Path.cwd()), '-name', '*.bak.*', '-mtime', f'+{backup_days}', '-delete'],
|
|
299
|
+
capture_output=True, text=True,
|
|
300
|
+
)
|
|
301
|
+
click.echo(f"✓ 已清理 {backup_days} 天前的备份")
|
|
302
|
+
click.echo("✓ 清理完成")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@main.command('treasury-run')
|
|
306
|
+
@click.option('--send-only', is_flag=True, help='仅发送邮件,跳过 ETL 和渲染')
|
|
307
|
+
def treasury_run(send_only: bool):
|
|
308
|
+
"""运行司库日报流程(调用 hooks/treasury_daily.sh)."""
|
|
309
|
+
script = Path(__file__).resolve().parent.parent.parent / 'hooks' / 'treasury_daily.sh'
|
|
310
|
+
if not script.exists():
|
|
311
|
+
click.echo(f"✗ 脚本不存在: {script}")
|
|
312
|
+
raise SystemExit(1)
|
|
313
|
+
|
|
314
|
+
import subprocess
|
|
315
|
+
args = ['bash', str(script)]
|
|
316
|
+
if send_only:
|
|
317
|
+
args.append('--send-only')
|
|
318
|
+
click.echo("司库日报流程 (前台运行)")
|
|
319
|
+
subprocess.run(args, check=False)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ======================================================================
|
|
323
|
+
# entry point
|
|
324
|
+
# ======================================================================
|
|
325
|
+
# entry point
|
|
326
|
+
# ======================================================================
|
|
327
|
+
|
|
328
|
+
if __name__ == '__main__':
|
|
329
|
+
main()
|
filepulse/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Configuration loading and schema defaults."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
|
+
import tomli
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Default values merged into user config
|
|
10
|
+
MONITOR_DEFAULTS: Dict = {
|
|
11
|
+
'interval': 2.0,
|
|
12
|
+
'check_size': True,
|
|
13
|
+
'check_mtime': True,
|
|
14
|
+
'check_hash': False,
|
|
15
|
+
'create_backup': True,
|
|
16
|
+
'backup_dir': 'backup',
|
|
17
|
+
'hook_retries': 3,
|
|
18
|
+
'hook_retry_delay': 1,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
ARCHIVE_MONITOR_DEFAULTS: Dict = {
|
|
22
|
+
'interval': 10.0,
|
|
23
|
+
'extract_temp': '/tmp/sync_extract',
|
|
24
|
+
'base_dir': '.',
|
|
25
|
+
'hook_retries': 3,
|
|
26
|
+
'hook_retry_delay': 1,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _merge_defaults(config: Dict, section: str, defaults: Dict) -> Dict:
|
|
31
|
+
"""Merge default values into a config section without overwriting user values."""
|
|
32
|
+
if section not in config:
|
|
33
|
+
config[section] = {}
|
|
34
|
+
for key, value in defaults.items():
|
|
35
|
+
if key not in config[section]:
|
|
36
|
+
config[section][key] = value
|
|
37
|
+
return config
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_config(config_path: str) -> Dict:
|
|
41
|
+
"""Load a TOML configuration file and merge in defaults.
|
|
42
|
+
|
|
43
|
+
Raises SystemExit if the file is unreadable or invalid TOML.
|
|
44
|
+
"""
|
|
45
|
+
path = Path(config_path)
|
|
46
|
+
try:
|
|
47
|
+
with open(path, 'rb') as f:
|
|
48
|
+
config = tomli.load(f)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
raise SystemExit(f"错误: 无法加载配置文件 {config_path}: {e}")
|
|
51
|
+
|
|
52
|
+
_merge_defaults(config, 'monitor', MONITOR_DEFAULTS)
|
|
53
|
+
_merge_defaults(config, 'archive_monitor', ARCHIVE_MONITOR_DEFAULTS)
|
|
54
|
+
|
|
55
|
+
return config
|