@hupan56/wlkj 2.0.0
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.
- package/bin/cli.js +213 -0
- package/package.json +11 -0
- package/templates/cli.js +198 -0
- package/templates/qoder/commands/wl-code.md +43 -0
- package/templates/qoder/commands/wl-commit.md +30 -0
- package/templates/qoder/commands/wl-init.md +80 -0
- package/templates/qoder/commands/wl-insight.md +51 -0
- package/templates/qoder/commands/wl-prd.md +199 -0
- package/templates/qoder/commands/wl-report.md +166 -0
- package/templates/qoder/commands/wl-search.md +52 -0
- package/templates/qoder/commands/wl-spec.md +18 -0
- package/templates/qoder/commands/wl-status.md +51 -0
- package/templates/qoder/commands/wl-task.md +71 -0
- package/templates/qoder/commands/wl-test.md +42 -0
- package/templates/qoder/config.toml +5 -0
- package/templates/qoder/config.yaml +141 -0
- package/templates/qoder/hooks/inject-workflow-state.py +117 -0
- package/templates/qoder/hooks/session-start.py +204 -0
- package/templates/qoder/rules/wl-pipeline.md +105 -0
- package/templates/qoder/scripts/add_session.py +245 -0
- package/templates/qoder/scripts/benchmark.py +209 -0
- package/templates/qoder/scripts/build_style_index.py +268 -0
- package/templates/qoder/scripts/code_index.py +41 -0
- package/templates/qoder/scripts/collect_prds.py +31 -0
- package/templates/qoder/scripts/common/__init__.py +0 -0
- package/templates/qoder/scripts/common/active_task.py +230 -0
- package/templates/qoder/scripts/common/atomicio.py +172 -0
- package/templates/qoder/scripts/common/developer.py +161 -0
- package/templates/qoder/scripts/common/eval_api.py +144 -0
- package/templates/qoder/scripts/common/feishu.py +278 -0
- package/templates/qoder/scripts/common/filelock.py +211 -0
- package/templates/qoder/scripts/common/identity.py +285 -0
- package/templates/qoder/scripts/common/mentions.py +134 -0
- package/templates/qoder/scripts/common/paths.py +311 -0
- package/templates/qoder/scripts/common/reqid.py +218 -0
- package/templates/qoder/scripts/common/search_engine.py +205 -0
- package/templates/qoder/scripts/common/task_utils.py +342 -0
- package/templates/qoder/scripts/common/terms.py +234 -0
- package/templates/qoder/scripts/common/utf8.py +38 -0
- package/templates/qoder/scripts/context_pack.py +196 -0
- package/templates/qoder/scripts/eval_prd.py +225 -0
- package/templates/qoder/scripts/export.py +487 -0
- package/templates/qoder/scripts/git_sync.py +1087 -0
- package/templates/qoder/scripts/handoff.py +22 -0
- package/templates/qoder/scripts/init_developer.py +76 -0
- package/templates/qoder/scripts/init_doctor.py +527 -0
- package/templates/qoder/scripts/install_qoderwork.py +339 -0
- package/templates/qoder/scripts/learn.py +67 -0
- package/templates/qoder/scripts/notify.py +5 -0
- package/templates/qoder/scripts/parse_prds.py +33 -0
- package/templates/qoder/scripts/report.py +281 -0
- package/templates/qoder/scripts/role.py +39 -0
- package/templates/qoder/scripts/run_weekly_update.bat +17 -0
- package/templates/qoder/scripts/run_weekly_update.sh +20 -0
- package/templates/qoder/scripts/search_index.py +352 -0
- package/templates/qoder/scripts/setup.py +453 -0
- package/templates/qoder/scripts/setup_weekly_cron.bat +22 -0
- package/templates/qoder/scripts/setup_weekly_cron.sh +19 -0
- package/templates/qoder/scripts/status.py +389 -0
- package/templates/qoder/scripts/syncgate.py +330 -0
- package/templates/qoder/scripts/task.py +954 -0
- package/templates/qoder/scripts/team.py +29 -0
- package/templates/qoder/scripts/team_sync.py +419 -0
- package/templates/qoder/scripts/workspace_init.py +102 -0
- package/templates/qoder/settings.json +53 -0
- package/templates/qoder/skills/design-review/SKILL.md +25 -0
- package/templates/qoder/skills/prd-generator/SKILL.md +180 -0
- package/templates/qoder/skills/prd-review/SKILL.md +36 -0
- package/templates/qoder/skills/prototype-generator/SKILL.md +141 -0
- package/templates/qoder/skills/spec-coder/SKILL.md +69 -0
- package/templates/qoder/skills/spec-generator/SKILL.md +67 -0
- package/templates/qoder/skills/test-generator/SKILL.md +72 -0
- package/templates/qoder/skills/wl-commit/SKILL.md +76 -0
- package/templates/qoder/skills/wl-init/SKILL.md +67 -0
- package/templates/qoder/skills/wl-insight/SKILL.md +81 -0
- package/templates/qoder/skills/wl-report/SKILL.md +87 -0
- package/templates/qoder/skills/wl-search/SKILL.md +75 -0
- package/templates/qoder/skills/wl-status/SKILL.md +61 -0
- package/templates/qoder/skills/wl-task/SKILL.md +58 -0
- package/templates/qoder/templates/prd-full-template.md +103 -0
- package/templates/qoder/templates/prd-quick-template.md +69 -0
- package/templates/qoder/templates/prototype-app.html +344 -0
- package/templates/qoder/templates/prototype-web.html +310 -0
- package/templates/root/AGENTS.md +182 -0
- package/templates/root/README-pipeline.md +56 -0
- package/templates/root/ROLES.md +85 -0
- package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
feishu.py - 飞书机器人通知 (阶段 D)
|
|
4
|
+
|
|
5
|
+
通过群机器人 webhook 推送消息卡片, 失败不阻塞主流程。
|
|
6
|
+
|
|
7
|
+
配置 (.qoder/config.yaml):
|
|
8
|
+
feishu:
|
|
9
|
+
enabled: true
|
|
10
|
+
webhook_url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
|
|
11
|
+
secret: "签名密钥(可选)"
|
|
12
|
+
events: [task_finished, prd_published, eval_rejected, deadline_warning]
|
|
13
|
+
|
|
14
|
+
环境变量覆盖 (优先于 config):
|
|
15
|
+
FEISHU_WEBHOOK_URL, FEISHU_SECRET
|
|
16
|
+
|
|
17
|
+
核心 API:
|
|
18
|
+
send_card(title, content, event_type, theme) -> bool
|
|
19
|
+
notify_task_finished(task_name, assignee, hours)
|
|
20
|
+
notify_prd_published(req_id, title, eval_pct)
|
|
21
|
+
notify_eval_rejected(req_id, title, issues)
|
|
22
|
+
notify_deadline(task_name, days_left, assignee)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import hmac
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
from typing import Optional, List
|
|
33
|
+
|
|
34
|
+
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
35
|
+
if _THIS_DIR not in sys.path:
|
|
36
|
+
sys.path.insert(0, _THIS_DIR)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"send_card",
|
|
40
|
+
"send_text",
|
|
41
|
+
"notify_task_finished",
|
|
42
|
+
"notify_prd_published",
|
|
43
|
+
"notify_eval_rejected",
|
|
44
|
+
"notify_deadline",
|
|
45
|
+
"is_enabled",
|
|
46
|
+
"get_config",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
# 主题色映射 (飞书 interactive card template)
|
|
50
|
+
THEME_COLORS = {
|
|
51
|
+
'blue': 'blue',
|
|
52
|
+
'green': 'turquoise',
|
|
53
|
+
'orange': 'wathet',
|
|
54
|
+
'red': 'red',
|
|
55
|
+
'default': 'grey',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_config():
|
|
60
|
+
"""加载飞书配置 (config.yaml + 环境变量)。"""
|
|
61
|
+
# 读 config.yaml 的 feishu 段
|
|
62
|
+
config = {}
|
|
63
|
+
try:
|
|
64
|
+
import yaml
|
|
65
|
+
from .paths import get_repo_root
|
|
66
|
+
cfg_path = os.path.join(str(get_repo_root()), '.qoder', 'config.yaml')
|
|
67
|
+
with open(cfg_path, encoding='utf-8') as f:
|
|
68
|
+
full = yaml.safe_load(f) or {}
|
|
69
|
+
config = full.get('feishu') or {}
|
|
70
|
+
except Exception:
|
|
71
|
+
# PyYAML 不可用, 用简单解析
|
|
72
|
+
try:
|
|
73
|
+
from .paths import get_repo_root
|
|
74
|
+
cfg_path = os.path.join(str(get_repo_root()), '.qoder', 'config.yaml')
|
|
75
|
+
in_feishu = False
|
|
76
|
+
with open(cfg_path, encoding='utf-8') as f:
|
|
77
|
+
for line in f:
|
|
78
|
+
if line.startswith('feishu:'):
|
|
79
|
+
in_feishu = True
|
|
80
|
+
continue
|
|
81
|
+
if in_feishu:
|
|
82
|
+
if line and not line[0].isspace() and line.strip():
|
|
83
|
+
break # 离开 feishu 段
|
|
84
|
+
stripped = line.strip()
|
|
85
|
+
if stripped.startswith('enabled:'):
|
|
86
|
+
config['enabled'] = 'true' in stripped.lower()
|
|
87
|
+
elif stripped.startswith('webhook_url:'):
|
|
88
|
+
val = stripped.split(':', 1)[1].strip().strip('"').strip("'")
|
|
89
|
+
config['webhook_url'] = val
|
|
90
|
+
elif stripped.startswith('secret:'):
|
|
91
|
+
val = stripped.split(':', 1)[1].strip().strip('"').strip("'")
|
|
92
|
+
config['secret'] = val
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
# 环境变量覆盖
|
|
97
|
+
env_url = os.environ.get('FEISHU_WEBHOOK_URL')
|
|
98
|
+
if env_url:
|
|
99
|
+
config['webhook_url'] = env_url
|
|
100
|
+
env_secret = os.environ.get('FEISHU_SECRET')
|
|
101
|
+
if env_secret:
|
|
102
|
+
config['secret'] = env_secret
|
|
103
|
+
|
|
104
|
+
return config
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_config():
|
|
108
|
+
"""公开的配置获取 (供调试/检查用)。"""
|
|
109
|
+
return _load_config()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_enabled(event_type: Optional[str] = None) -> bool:
|
|
113
|
+
"""飞书推送是否启用 (含事件过滤)。
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
event_type: 若指定, 还检查该事件是否在 events 白名单里。
|
|
117
|
+
"""
|
|
118
|
+
cfg = _load_config()
|
|
119
|
+
if not cfg.get('enabled'):
|
|
120
|
+
return False
|
|
121
|
+
if not cfg.get('webhook_url'):
|
|
122
|
+
return False
|
|
123
|
+
if event_type:
|
|
124
|
+
events = cfg.get('events')
|
|
125
|
+
# events 为空 = 全推 (向后兼容)
|
|
126
|
+
if events and event_type not in events:
|
|
127
|
+
return False
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _gen_sign(secret: str, timestamp: int) -> str:
|
|
132
|
+
"""飞书签名校验: HMAC-SHA256(timestamp + "\n" + secret, "")."""
|
|
133
|
+
string_to_sign = f'{timestamp}\n{secret}'
|
|
134
|
+
hmac_code = hmac.new(string_to_sign.encode('utf-8'), digestmod=hashlib.sha256).digest()
|
|
135
|
+
import base64
|
|
136
|
+
return base64.b64encode(hmac_code).decode('utf-8')
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _post_webhook(payload: dict) -> bool:
|
|
140
|
+
"""POST 到飞书 webhook。用标准库 urllib (无第三方依赖)。
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
True 若 HTTP 200 且飞书返回 code=0 (成功)。
|
|
144
|
+
"""
|
|
145
|
+
cfg = _load_config()
|
|
146
|
+
url = cfg.get('webhook_url')
|
|
147
|
+
if not url:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# 签名校验
|
|
151
|
+
secret = cfg.get('secret')
|
|
152
|
+
if secret:
|
|
153
|
+
timestamp = int(time.time())
|
|
154
|
+
payload['timestamp'] = str(timestamp)
|
|
155
|
+
payload['sign'] = _gen_sign(secret, timestamp)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
import urllib.request
|
|
159
|
+
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
|
160
|
+
req = urllib.request.Request(
|
|
161
|
+
url, data=data,
|
|
162
|
+
headers={'Content-Type': 'application/json'},
|
|
163
|
+
method='POST',
|
|
164
|
+
)
|
|
165
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
166
|
+
body = resp.read().decode('utf-8')
|
|
167
|
+
result = json.loads(body)
|
|
168
|
+
# 飞书成功返回 {"code": 0, "msg": "success"}
|
|
169
|
+
return result.get('code') == 0 or result.get('StatusCode') == 0
|
|
170
|
+
except Exception as e:
|
|
171
|
+
# 失败不阻塞主流程, 仅记日志
|
|
172
|
+
sys.stderr.write(f'[feishu] 推送失败 (不阻塞): {e}\n')
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def send_text(text: str) -> bool:
|
|
177
|
+
"""发纯文本消息。"""
|
|
178
|
+
if not is_enabled():
|
|
179
|
+
return False
|
|
180
|
+
return _post_webhook({'msg_type': 'text', 'content': {'text': text}})
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def send_card(
|
|
184
|
+
title: str,
|
|
185
|
+
content_lines: List[str],
|
|
186
|
+
event_type: Optional[str] = None,
|
|
187
|
+
theme: Optional[str] = None,
|
|
188
|
+
) -> bool:
|
|
189
|
+
"""发交互式卡片消息。
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
title: 卡片标题。
|
|
193
|
+
content_lines: 正文行列表 (每行一个 markdown 段落)。
|
|
194
|
+
event_type: 事件类型 (用于过滤 + 选主题色)。
|
|
195
|
+
theme: 手动指定主题色 (覆盖 event_type 映射)。
|
|
196
|
+
"""
|
|
197
|
+
if not is_enabled(event_type):
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
cfg = _load_config()
|
|
201
|
+
# 主题色: 优先手动指定, 否则从 config.theme[event_type] 取
|
|
202
|
+
if not theme:
|
|
203
|
+
themes = cfg.get('theme') or {}
|
|
204
|
+
theme = themes.get(event_type, 'default')
|
|
205
|
+
color = THEME_COLORS.get(theme, 'grey')
|
|
206
|
+
|
|
207
|
+
# 构建交互式卡片 (飞书 interactive card v2)
|
|
208
|
+
elements = []
|
|
209
|
+
for line in content_lines:
|
|
210
|
+
elements.append({
|
|
211
|
+
'tag': 'div',
|
|
212
|
+
'text': {'tag': 'lark_md', 'content': line},
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
# 模板色 + 标题
|
|
216
|
+
template = color if color != 'grey' else 'blue'
|
|
217
|
+
payload = {
|
|
218
|
+
'msg_type': 'interactive',
|
|
219
|
+
'card': {
|
|
220
|
+
'config': {'wide_screen_mode': True},
|
|
221
|
+
'header': {
|
|
222
|
+
'title': {'tag': 'plain_text', 'content': title},
|
|
223
|
+
'template': template,
|
|
224
|
+
},
|
|
225
|
+
'elements': elements,
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
return _post_webhook(payload)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ============================================================
|
|
232
|
+
# 事件专用通知 (D2 触发点调用)
|
|
233
|
+
# ============================================================
|
|
234
|
+
|
|
235
|
+
def notify_task_finished(task_name: str, assignee: str, hours: Optional[float] = None) -> bool:
|
|
236
|
+
"""任务完成通知。"""
|
|
237
|
+
lines = [f'**任务完成** ✅', f'任务: {task_name}', f'负责人: {assignee}']
|
|
238
|
+
if hours is not None:
|
|
239
|
+
lines.append(f'用时: {hours:.1f} 小时')
|
|
240
|
+
lines.append(f'时间: {datetime.now().strftime("%Y-%m-%d %H:%M")}')
|
|
241
|
+
return send_card(f'任务完成: {task_name}', lines, event_type='task_finished')
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def notify_prd_published(req_id: str, title: str, eval_pct: Optional[float] = None) -> bool:
|
|
245
|
+
"""PRD 发布通知。"""
|
|
246
|
+
lines = [f'**PRD 已发布** 📄', f'编号: {req_id}', f'标题: {title}']
|
|
247
|
+
if eval_pct is not None:
|
|
248
|
+
lines.append(f'EVA 评分: {eval_pct:.0f}%')
|
|
249
|
+
lines.append(f'时间: {datetime.now().strftime("%Y-%m-%d %H:%M")}')
|
|
250
|
+
return send_card(f'PRD 发布: {req_id}', lines, event_type='prd_published')
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def notify_eval_rejected(req_id: str, title: str, issues: List[str]) -> bool:
|
|
254
|
+
"""EVA 门禁拒绝通知。"""
|
|
255
|
+
lines = [f'**PRD 未过质量门禁** ⛔', f'编号: {req_id}', f'标题: {title}', '']
|
|
256
|
+
lines.append('**问题:**')
|
|
257
|
+
for issue in issues[:5]:
|
|
258
|
+
lines.append(f'- {issue}')
|
|
259
|
+
if len(issues) > 5:
|
|
260
|
+
lines.append(f'- ... 还有 {len(issues) - 5} 个')
|
|
261
|
+
lines.append('')
|
|
262
|
+
lines.append('修复后重新提交, 或 admin 用 --skip-eval 跳过')
|
|
263
|
+
return send_card(f'⚠️ 质量门禁拦截: {req_id}', lines, event_type='eval_rejected')
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def notify_deadline(task_name: str, days_left: int, assignee: str, title: str = '') -> bool:
|
|
267
|
+
"""截止日期临近通知。"""
|
|
268
|
+
if days_left < 0:
|
|
269
|
+
lines = [f'**任务已逾期** 🔴', f'任务: {task_name}', f'负责人: {assignee}',
|
|
270
|
+
f'逾期 {-days_left} 天']
|
|
271
|
+
return send_card(f'逾期: {task_name}', lines, event_type='deadline_warning')
|
|
272
|
+
else:
|
|
273
|
+
urgency = '明天到期' if days_left <= 1 else f'{days_left} 天后到期'
|
|
274
|
+
lines = [f'**截止日期提醒** ⏰', f'任务: {task_name}', f'负责人: {assignee}',
|
|
275
|
+
f'状态: {urgency}']
|
|
276
|
+
if title:
|
|
277
|
+
lines.append(f'标题: {title}')
|
|
278
|
+
return send_card(f'截止提醒: {task_name}', lines, event_type='deadline_warning')
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
filelock.py - 跨平台原子文件锁
|
|
4
|
+
|
|
5
|
+
用 os.open(O_CREAT|O_EXCL) 实现真正的互斥(原子系统调用,无 TOCTOU 竞态)。
|
|
6
|
+
Windows / macOS / Linux 均无需管理员权限。
|
|
7
|
+
|
|
8
|
+
用法:
|
|
9
|
+
from common.filelock import FileLock
|
|
10
|
+
with FileLock('.qoder/.runtime/team-sync.lock', timeout=30):
|
|
11
|
+
# 临界区:同一时刻只有一个进程能进入
|
|
12
|
+
do_critical_work()
|
|
13
|
+
|
|
14
|
+
设计:
|
|
15
|
+
- acquire: O_CREAT|O_EXCL 原子创建。失败则锁被持有,等待重试。
|
|
16
|
+
- stale 检测: 读锁文件里的 PID,若进程已不存在则视为 stale 可抢。
|
|
17
|
+
- timeout: 总等待秒数;超时抛 LockTimeoutError。
|
|
18
|
+
- release: 持有者才能释放(记录 holding_pid 校验)。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import errno
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional, Union
|
|
28
|
+
|
|
29
|
+
__all__ = ["FileLock", "LockTimeoutError", "LockBusyError"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LockTimeoutError(Exception):
|
|
33
|
+
"""获取锁超过 timeout 秒。"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LockBusyError(Exception):
|
|
37
|
+
"""锁被他人持有且 timeout=0(非阻塞模式立即失败)。"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _pid_alive(pid: int) -> bool:
|
|
41
|
+
"""判断 PID 对应的进程是否还活着。跨平台尽力而为。"""
|
|
42
|
+
if pid <= 0:
|
|
43
|
+
return False
|
|
44
|
+
try:
|
|
45
|
+
if sys.platform == "win32":
|
|
46
|
+
# Windows: 用 OpenProcess 探测。0 表示句柄无效 → 进程已退出。
|
|
47
|
+
import ctypes
|
|
48
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
49
|
+
STILL_ACTIVE = 259
|
|
50
|
+
kernel32 = ctypes.windll.kernel32
|
|
51
|
+
h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
52
|
+
if not h:
|
|
53
|
+
return False
|
|
54
|
+
try:
|
|
55
|
+
exit_code = ctypes.c_ulong()
|
|
56
|
+
if not kernel32.GetExitCodeProcess(h, ctypes.byref(exit_code)):
|
|
57
|
+
return False
|
|
58
|
+
return exit_code.value == STILL_ACTIVE
|
|
59
|
+
finally:
|
|
60
|
+
kernel32.CloseHandle(h)
|
|
61
|
+
else:
|
|
62
|
+
# POSIX: kill(pid, 0) 不发信号,仅探测。EAGAIN 有时也表示存在。
|
|
63
|
+
try:
|
|
64
|
+
os.kill(pid, 0)
|
|
65
|
+
return True
|
|
66
|
+
except (OSError, ProcessLookupError):
|
|
67
|
+
return False
|
|
68
|
+
except PermissionError:
|
|
69
|
+
# 进程存在但属于他人
|
|
70
|
+
return True
|
|
71
|
+
except Exception:
|
|
72
|
+
# 任何探测失败都保守认为还活着(不抢他人锁)
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FileLock:
|
|
77
|
+
"""原子文件锁(基于 O_CREAT|O_EXCL)。
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
path: 锁文件路径。
|
|
81
|
+
timeout: 获取锁的总等待秒数。0 = 非阻塞,立即失败。负数 = 永久等待。
|
|
82
|
+
stale_seconds: 锁文件持有超过此秒数,且持有进程已死,视为 stale 可抢。
|
|
83
|
+
poll_interval: 重试间隔秒数。
|
|
84
|
+
|
|
85
|
+
用法:
|
|
86
|
+
with FileLock('x.lock', timeout=30):
|
|
87
|
+
...
|
|
88
|
+
# 或手动:
|
|
89
|
+
lock = FileLock('x.lock')
|
|
90
|
+
lock.acquire()
|
|
91
|
+
try:
|
|
92
|
+
...
|
|
93
|
+
finally:
|
|
94
|
+
lock.release()
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
path: Union[str, Path],
|
|
100
|
+
timeout: float = 30.0,
|
|
101
|
+
stale_seconds: float = 7200.0,
|
|
102
|
+
poll_interval: float = 0.2,
|
|
103
|
+
) -> None:
|
|
104
|
+
self.path = str(path)
|
|
105
|
+
self.timeout = timeout
|
|
106
|
+
self.stale_seconds = stale_seconds
|
|
107
|
+
self.poll_interval = poll_interval
|
|
108
|
+
self._holding = False # 本实例是否持有锁
|
|
109
|
+
|
|
110
|
+
def acquire(self) -> "FileLock":
|
|
111
|
+
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
|
112
|
+
start = time.monotonic()
|
|
113
|
+
while True:
|
|
114
|
+
# 原子创建尝试
|
|
115
|
+
try:
|
|
116
|
+
fd = os.open(
|
|
117
|
+
self.path,
|
|
118
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
|
119
|
+
0o644,
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
host = (os.environ.get("COMPUTERNAME")
|
|
123
|
+
or (os.uname().nodename if hasattr(os, "uname") else "?"))
|
|
124
|
+
content = "acquired={}\npid={}\nhost={}\n".format(
|
|
125
|
+
datetime.now().isoformat(timespec="seconds"),
|
|
126
|
+
os.getpid(),
|
|
127
|
+
host,
|
|
128
|
+
)
|
|
129
|
+
os.write(fd, content.encode("utf-8"))
|
|
130
|
+
finally:
|
|
131
|
+
os.close(fd)
|
|
132
|
+
self._holding = True
|
|
133
|
+
return self
|
|
134
|
+
except OSError as e:
|
|
135
|
+
if e.errno not in (errno.EEXIST, errno.EACCES):
|
|
136
|
+
raise
|
|
137
|
+
# 锁已存在 —— 只在"看起来 stale"时才做昂贵的 PID 探测
|
|
138
|
+
# 正常等待: 仅 stat 看 age, 不读文件不探测进程
|
|
139
|
+
try:
|
|
140
|
+
age = time.time() - os.stat(self.path).st_mtime
|
|
141
|
+
except OSError:
|
|
142
|
+
age = 0
|
|
143
|
+
# age 超过 stale_seconds 才值得探测; 否则纯等待
|
|
144
|
+
looks_stale = age > self.stale_seconds
|
|
145
|
+
if looks_stale and self._is_stale():
|
|
146
|
+
self._force_release_stale()
|
|
147
|
+
continue # 重试创建
|
|
148
|
+
# 还活着的锁 —— 决定等待还是失败
|
|
149
|
+
if self.timeout == 0:
|
|
150
|
+
raise LockBusyError("锁被持有: {}".format(self.path))
|
|
151
|
+
if 0 <= self.timeout < (time.monotonic() - start):
|
|
152
|
+
raise LockTimeoutError(
|
|
153
|
+
"获取锁超时 ({}s): {}".format(self.timeout, self.path)
|
|
154
|
+
)
|
|
155
|
+
time.sleep(self.poll_interval)
|
|
156
|
+
|
|
157
|
+
def release(self) -> None:
|
|
158
|
+
if not self._holding:
|
|
159
|
+
return
|
|
160
|
+
try:
|
|
161
|
+
os.remove(self.path)
|
|
162
|
+
except OSError:
|
|
163
|
+
pass
|
|
164
|
+
finally:
|
|
165
|
+
self._holding = False
|
|
166
|
+
|
|
167
|
+
def _is_stale(self) -> bool:
|
|
168
|
+
"""锁文件是否可被视为 stale(持有进程已死 或 超过 stale_seconds 且进程不存活)。"""
|
|
169
|
+
try:
|
|
170
|
+
st = os.stat(self.path)
|
|
171
|
+
except OSError:
|
|
172
|
+
return False # 文件没了,外层会重试创建
|
|
173
|
+
age = time.time() - st.st_mtime
|
|
174
|
+
pid = self._read_pid()
|
|
175
|
+
if pid is None:
|
|
176
|
+
# 读不出 PID;保守起见,超过 stale_seconds 才视为 stale
|
|
177
|
+
return age > self.stale_seconds
|
|
178
|
+
alive = _pid_alive(pid)
|
|
179
|
+
if not alive:
|
|
180
|
+
return True
|
|
181
|
+
# 进程活但锁放太久(可能僵死)—— 超阈值才抢
|
|
182
|
+
return age > self.stale_seconds
|
|
183
|
+
|
|
184
|
+
def _read_pid(self) -> Optional[int]:
|
|
185
|
+
"""从锁文件解析 pid=N 标记(容忍它出现在行中任意位置)。"""
|
|
186
|
+
try:
|
|
187
|
+
with open(self.path, "r", encoding="utf-8", errors="replace") as f:
|
|
188
|
+
for line in f:
|
|
189
|
+
# 同时支持 "pid=123" 在行首或行中("2026-... pid=123")
|
|
190
|
+
for tok in line.replace("\t", " ").split():
|
|
191
|
+
if tok.startswith("pid="):
|
|
192
|
+
try:
|
|
193
|
+
return int(tok.split("=", 1)[1].strip())
|
|
194
|
+
except ValueError:
|
|
195
|
+
continue
|
|
196
|
+
except OSError:
|
|
197
|
+
return None
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
def _force_release_stale(self) -> None:
|
|
201
|
+
try:
|
|
202
|
+
os.remove(self.path)
|
|
203
|
+
except OSError:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
# 上下文管理器协议
|
|
207
|
+
def __enter__(self) -> "FileLock":
|
|
208
|
+
return self.acquire()
|
|
209
|
+
|
|
210
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
211
|
+
self.release()
|