tb-order-sync 0.3.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/.env.example +50 -0
- package/CHANGELOG.md +29 -0
- package/README.md +392 -0
- package/bin/tb.js +143 -0
- package/build.py +91 -0
- package/cli/__init__.py +0 -0
- package/cli/commands.py +258 -0
- package/cli/dashboard.py +327 -0
- package/cli/setup.py +698 -0
- package/config/__init__.py +4 -0
- package/config/mappings.py +63 -0
- package/config/settings.py +95 -0
- package/connectors/__init__.py +0 -0
- package/connectors/base.py +98 -0
- package/connectors/feishu_sheets.py +96 -0
- package/connectors/tencent_docs.py +253 -0
- package/main.py +6 -0
- package/models/__init__.py +12 -0
- package/models/records.py +38 -0
- package/models/state_models.py +35 -0
- package/models/task_models.py +47 -0
- package/package.json +40 -0
- package/requirements.txt +8 -0
- package/services/__init__.py +0 -0
- package/services/c_to_a_sync_service.py +49 -0
- package/services/daemon_service.py +319 -0
- package/services/gross_profit_service.py +202 -0
- package/services/refund_match_service.py +196 -0
- package/services/scheduler_service.py +76 -0
- package/services/state_service.py +50 -0
- package/sync_service.spec +93 -0
- package/utils/__init__.py +0 -0
- package/utils/diff.py +27 -0
- package/utils/logger.py +47 -0
- package/utils/parser.py +50 -0
- package/utils/retry.py +26 -0
- package//345/220/257/345/212/250.bat +125 -0
- package//345/220/257/345/212/250.command +125 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Incremental sync state models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RowFingerprint(BaseModel):
|
|
12
|
+
"""Fingerprint for a single row, used for change detection."""
|
|
13
|
+
|
|
14
|
+
row_index: int
|
|
15
|
+
order_no: str = ""
|
|
16
|
+
fingerprint: str = "" # MD5 hex of key fields
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SyncState(BaseModel):
|
|
20
|
+
"""Persisted state for incremental sync."""
|
|
21
|
+
|
|
22
|
+
last_run_at: Optional[datetime] = None
|
|
23
|
+
|
|
24
|
+
# A 表: order_no -> fingerprint
|
|
25
|
+
a_table_fingerprints: dict[str, str] = Field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
# B 表: 退款单号集合 hash
|
|
28
|
+
b_table_refund_hash: str = ""
|
|
29
|
+
|
|
30
|
+
# B 表: 退款单号快照
|
|
31
|
+
b_table_refund_set: list[str] = Field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
# C 表(预留)
|
|
34
|
+
c_table_fingerprints: dict[str, str] = Field(default_factory=dict)
|
|
35
|
+
c_table_last_run_at: Optional[datetime] = None
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Task execution models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from config.settings import SyncMode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskName(str, Enum):
|
|
15
|
+
GROSS_PROFIT = "gross_profit"
|
|
16
|
+
REFUND_MATCH = "refund_match"
|
|
17
|
+
C_TO_A_SYNC = "c_to_a_sync"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SyncTaskConfig(BaseModel):
|
|
21
|
+
"""Configuration for a single sync task execution."""
|
|
22
|
+
|
|
23
|
+
task_name: TaskName
|
|
24
|
+
mode: SyncMode = SyncMode.INCREMENTAL
|
|
25
|
+
dry_run: bool = False
|
|
26
|
+
batch_size: int = 100
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TaskResult(BaseModel):
|
|
30
|
+
"""Result summary after a task execution."""
|
|
31
|
+
|
|
32
|
+
task_name: TaskName
|
|
33
|
+
success: bool = True
|
|
34
|
+
mode: SyncMode = SyncMode.FULL
|
|
35
|
+
rows_read: int = 0
|
|
36
|
+
rows_changed: int = 0
|
|
37
|
+
rows_error: int = 0
|
|
38
|
+
dry_run: bool = False
|
|
39
|
+
started_at: datetime = Field(default_factory=datetime.now)
|
|
40
|
+
finished_at: Optional[datetime] = None
|
|
41
|
+
error_message: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
def finish(self, success: bool = True, error_message: Optional[str] = None) -> None:
|
|
44
|
+
self.finished_at = datetime.now()
|
|
45
|
+
self.success = success
|
|
46
|
+
if error_message:
|
|
47
|
+
self.error_message = error_message
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tb-order-sync",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Multi-sheet order sync and refund workflow service with a tb CLI launcher",
|
|
5
|
+
"bin": {
|
|
6
|
+
"tb": "bin/tb.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"cli/**/*.py",
|
|
11
|
+
"config/**/*.py",
|
|
12
|
+
"connectors/**/*.py",
|
|
13
|
+
"models/**/*.py",
|
|
14
|
+
"services/**/*.py",
|
|
15
|
+
"utils/**/*.py",
|
|
16
|
+
"main.py",
|
|
17
|
+
"requirements.txt",
|
|
18
|
+
"README.md",
|
|
19
|
+
"CHANGELOG.md",
|
|
20
|
+
".env.example",
|
|
21
|
+
"build.py",
|
|
22
|
+
"sync_service.spec",
|
|
23
|
+
"启动.bat",
|
|
24
|
+
"启动.command"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"tb": "node bin/tb.js",
|
|
28
|
+
"check": "node bin/tb.js check",
|
|
29
|
+
"pack:local": "npm pack"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"python",
|
|
33
|
+
"scheduler",
|
|
34
|
+
"daemon",
|
|
35
|
+
"rich-cli",
|
|
36
|
+
"tencent-docs",
|
|
37
|
+
"feishu"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT"
|
|
40
|
+
}
|
package/requirements.txt
ADDED
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""C table → A table sync service (skeleton / placeholder).
|
|
2
|
+
|
|
3
|
+
This service will sync raw data from Feishu C table into Tencent Docs A table.
|
|
4
|
+
Currently a structural placeholder — implementation pending Feishu connector completion.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from config.settings import Settings, SyncMode, get_settings
|
|
12
|
+
from connectors.base import BaseSheetConnector
|
|
13
|
+
from models.task_models import TaskName, TaskResult
|
|
14
|
+
from services.state_service import StateService
|
|
15
|
+
from utils.logger import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CToASyncService:
|
|
21
|
+
"""Sync records from C table (Feishu) to A table (Tencent Docs).
|
|
22
|
+
|
|
23
|
+
TODO: Implement when Feishu connector is ready.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
source_connector: BaseSheetConnector,
|
|
29
|
+
target_connector: BaseSheetConnector,
|
|
30
|
+
state_service: StateService,
|
|
31
|
+
settings: Optional[Settings] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._source = source_connector
|
|
34
|
+
self._target = target_connector
|
|
35
|
+
self._state_svc = state_service
|
|
36
|
+
self._settings = settings or get_settings()
|
|
37
|
+
|
|
38
|
+
def run(
|
|
39
|
+
self,
|
|
40
|
+
mode: Optional[SyncMode] = None,
|
|
41
|
+
dry_run: Optional[bool] = None,
|
|
42
|
+
) -> TaskResult:
|
|
43
|
+
mode = mode or self._settings.c_sync_mode
|
|
44
|
+
dry_run = dry_run if dry_run is not None else self._settings.dry_run
|
|
45
|
+
result = TaskResult(task_name=TaskName.C_TO_A_SYNC, mode=mode, dry_run=dry_run)
|
|
46
|
+
|
|
47
|
+
logger.warning("C → A sync service is not yet implemented (Feishu connector pending)")
|
|
48
|
+
result.finish(success=False, error_message="Not implemented")
|
|
49
|
+
return result
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Cross-platform daemon management for the scheduler process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from collections import deque
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from config.settings import PROJECT_ROOT, Settings
|
|
18
|
+
from utils.logger import get_logger
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
_PID_FILENAME = "scheduler.pid"
|
|
23
|
+
_META_FILENAME = "scheduler.meta.json"
|
|
24
|
+
_LOG_FILENAME = "scheduler.console.log"
|
|
25
|
+
_WINDOWS_PROCESS_TERMINATE = 0x0001
|
|
26
|
+
_WINDOWS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
27
|
+
_WINDOWS_STILL_ACTIVE = 259
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class DaemonStatus:
|
|
32
|
+
"""Current daemon status snapshot."""
|
|
33
|
+
|
|
34
|
+
running: bool
|
|
35
|
+
pid: int | None
|
|
36
|
+
pid_file: Path
|
|
37
|
+
log_file: Path
|
|
38
|
+
message: str
|
|
39
|
+
started_at: str | None = None
|
|
40
|
+
command: list[str] | None = None
|
|
41
|
+
stale: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DaemonService:
|
|
45
|
+
"""Manage a single detached scheduler process."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, settings: Settings) -> None:
|
|
48
|
+
self._settings = settings
|
|
49
|
+
self._state_dir = Path(settings.state_dir)
|
|
50
|
+
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
self._pid_path = self._state_dir / _PID_FILENAME
|
|
52
|
+
self._meta_path = self._state_dir / _META_FILENAME
|
|
53
|
+
self._log_path = self._state_dir / _LOG_FILENAME
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def log_file(self) -> Path:
|
|
57
|
+
return self._log_path
|
|
58
|
+
|
|
59
|
+
def status(self, *, cleanup_stale: bool = True) -> DaemonStatus:
|
|
60
|
+
"""Return current daemon status and clean stale pid files by default."""
|
|
61
|
+
pid = self._read_pid()
|
|
62
|
+
meta = self._read_meta()
|
|
63
|
+
|
|
64
|
+
if pid is None:
|
|
65
|
+
return DaemonStatus(
|
|
66
|
+
running=False,
|
|
67
|
+
pid=None,
|
|
68
|
+
pid_file=self._pid_path,
|
|
69
|
+
log_file=self._log_path,
|
|
70
|
+
message="守护进程未运行",
|
|
71
|
+
started_at=meta.get("started_at"),
|
|
72
|
+
command=meta.get("command"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if self._is_process_alive(pid):
|
|
76
|
+
return DaemonStatus(
|
|
77
|
+
running=True,
|
|
78
|
+
pid=pid,
|
|
79
|
+
pid_file=self._pid_path,
|
|
80
|
+
log_file=self._log_path,
|
|
81
|
+
message=f"守护进程运行中 (PID {pid})",
|
|
82
|
+
started_at=meta.get("started_at"),
|
|
83
|
+
command=meta.get("command"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if cleanup_stale:
|
|
87
|
+
self._clear_runtime_files()
|
|
88
|
+
|
|
89
|
+
return DaemonStatus(
|
|
90
|
+
running=False,
|
|
91
|
+
pid=pid,
|
|
92
|
+
pid_file=self._pid_path,
|
|
93
|
+
log_file=self._log_path,
|
|
94
|
+
message=f"检测到失效 PID 文件,已清理 (PID {pid})",
|
|
95
|
+
started_at=meta.get("started_at"),
|
|
96
|
+
command=meta.get("command"),
|
|
97
|
+
stale=True,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def start(self, *, force: bool = False) -> DaemonStatus:
|
|
101
|
+
"""Launch the scheduler in a detached background process."""
|
|
102
|
+
current = self.status()
|
|
103
|
+
if current.running:
|
|
104
|
+
if not force:
|
|
105
|
+
return DaemonStatus(
|
|
106
|
+
running=True,
|
|
107
|
+
pid=current.pid,
|
|
108
|
+
pid_file=self._pid_path,
|
|
109
|
+
log_file=self._log_path,
|
|
110
|
+
message=f"守护进程已在运行 (PID {current.pid})",
|
|
111
|
+
started_at=current.started_at,
|
|
112
|
+
command=current.command,
|
|
113
|
+
)
|
|
114
|
+
self.stop(force=True)
|
|
115
|
+
|
|
116
|
+
cmd = self._build_spawn_command()
|
|
117
|
+
logger.info("Starting daemon: %s", cmd)
|
|
118
|
+
self._log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
log_handle = self._log_path.open("ab")
|
|
121
|
+
try:
|
|
122
|
+
spawn_kwargs: dict[str, Any] = {
|
|
123
|
+
"cwd": str(PROJECT_ROOT),
|
|
124
|
+
"stdin": subprocess.DEVNULL,
|
|
125
|
+
"stdout": log_handle,
|
|
126
|
+
"stderr": log_handle,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if os.name == "nt":
|
|
130
|
+
spawn_kwargs["creationflags"] = (
|
|
131
|
+
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
spawn_kwargs["start_new_session"] = True
|
|
135
|
+
|
|
136
|
+
proc = subprocess.Popen(cmd, **spawn_kwargs)
|
|
137
|
+
finally:
|
|
138
|
+
log_handle.close()
|
|
139
|
+
|
|
140
|
+
time.sleep(1.0)
|
|
141
|
+
if not self._is_process_alive(proc.pid):
|
|
142
|
+
return DaemonStatus(
|
|
143
|
+
running=False,
|
|
144
|
+
pid=proc.pid,
|
|
145
|
+
pid_file=self._pid_path,
|
|
146
|
+
log_file=self._log_path,
|
|
147
|
+
message="守护进程启动失败,请检查日志文件",
|
|
148
|
+
command=cmd,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
self._write_runtime_files(proc.pid, cmd)
|
|
152
|
+
return DaemonStatus(
|
|
153
|
+
running=True,
|
|
154
|
+
pid=proc.pid,
|
|
155
|
+
pid_file=self._pid_path,
|
|
156
|
+
log_file=self._log_path,
|
|
157
|
+
message=f"守护进程已启动 (PID {proc.pid})",
|
|
158
|
+
started_at=self._read_meta().get("started_at"),
|
|
159
|
+
command=cmd,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def stop(self, *, force: bool = False, timeout: float = 10.0) -> DaemonStatus:
|
|
163
|
+
"""Stop the detached scheduler process."""
|
|
164
|
+
current = self.status(cleanup_stale=False)
|
|
165
|
+
if not current.running or current.pid is None:
|
|
166
|
+
self._clear_runtime_files()
|
|
167
|
+
return DaemonStatus(
|
|
168
|
+
running=False,
|
|
169
|
+
pid=None,
|
|
170
|
+
pid_file=self._pid_path,
|
|
171
|
+
log_file=self._log_path,
|
|
172
|
+
message="守护进程未运行",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
pid = current.pid
|
|
176
|
+
logger.info("Stopping daemon pid=%s", pid)
|
|
177
|
+
self._terminate_process(pid, force=False)
|
|
178
|
+
|
|
179
|
+
deadline = time.time() + timeout
|
|
180
|
+
while time.time() < deadline:
|
|
181
|
+
if not self._is_process_alive(pid):
|
|
182
|
+
self._clear_runtime_files()
|
|
183
|
+
return DaemonStatus(
|
|
184
|
+
running=False,
|
|
185
|
+
pid=None,
|
|
186
|
+
pid_file=self._pid_path,
|
|
187
|
+
log_file=self._log_path,
|
|
188
|
+
message=f"守护进程已停止 (PID {pid})",
|
|
189
|
+
)
|
|
190
|
+
time.sleep(0.25)
|
|
191
|
+
|
|
192
|
+
if force or os.name == "nt":
|
|
193
|
+
self._terminate_process(pid, force=True)
|
|
194
|
+
time.sleep(0.5)
|
|
195
|
+
|
|
196
|
+
if self._is_process_alive(pid):
|
|
197
|
+
return DaemonStatus(
|
|
198
|
+
running=True,
|
|
199
|
+
pid=pid,
|
|
200
|
+
pid_file=self._pid_path,
|
|
201
|
+
log_file=self._log_path,
|
|
202
|
+
message=f"守护进程停止超时 (PID {pid}),请检查日志",
|
|
203
|
+
started_at=current.started_at,
|
|
204
|
+
command=current.command,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
self._clear_runtime_files()
|
|
208
|
+
return DaemonStatus(
|
|
209
|
+
running=False,
|
|
210
|
+
pid=None,
|
|
211
|
+
pid_file=self._pid_path,
|
|
212
|
+
log_file=self._log_path,
|
|
213
|
+
message=f"守护进程已强制停止 (PID {pid})",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def restart(self, *, force: bool = True) -> DaemonStatus:
|
|
217
|
+
"""Restart the daemon process."""
|
|
218
|
+
self.stop(force=force)
|
|
219
|
+
return self.start(force=False)
|
|
220
|
+
|
|
221
|
+
def read_log_tail(self, lines: int = 40) -> str:
|
|
222
|
+
"""Return the last N lines of daemon console output."""
|
|
223
|
+
if not self._log_path.exists():
|
|
224
|
+
return ""
|
|
225
|
+
with self._log_path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
226
|
+
return "".join(deque(handle, maxlen=max(1, lines)))
|
|
227
|
+
|
|
228
|
+
def _build_spawn_command(self) -> list[str]:
|
|
229
|
+
if getattr(sys, "frozen", False):
|
|
230
|
+
return [str(Path(sys.executable).resolve()), "schedule"]
|
|
231
|
+
return [sys.executable, str(PROJECT_ROOT / "main.py"), "schedule"]
|
|
232
|
+
|
|
233
|
+
def _read_pid(self) -> int | None:
|
|
234
|
+
if not self._pid_path.exists():
|
|
235
|
+
return None
|
|
236
|
+
try:
|
|
237
|
+
return int(self._pid_path.read_text(encoding="utf-8").strip())
|
|
238
|
+
except Exception:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
def _read_meta(self) -> dict[str, Any]:
|
|
242
|
+
if not self._meta_path.exists():
|
|
243
|
+
return {}
|
|
244
|
+
try:
|
|
245
|
+
return json.loads(self._meta_path.read_text(encoding="utf-8"))
|
|
246
|
+
except Exception:
|
|
247
|
+
return {}
|
|
248
|
+
|
|
249
|
+
def _write_runtime_files(self, pid: int, cmd: list[str]) -> None:
|
|
250
|
+
self._pid_path.write_text(str(pid), encoding="utf-8")
|
|
251
|
+
payload = {
|
|
252
|
+
"pid": pid,
|
|
253
|
+
"started_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
254
|
+
"command": cmd,
|
|
255
|
+
"log_file": str(self._log_path),
|
|
256
|
+
}
|
|
257
|
+
self._meta_path.write_text(
|
|
258
|
+
json.dumps(payload, ensure_ascii=False, indent=2),
|
|
259
|
+
encoding="utf-8",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def _clear_runtime_files(self) -> None:
|
|
263
|
+
for path in (self._pid_path, self._meta_path):
|
|
264
|
+
try:
|
|
265
|
+
path.unlink(missing_ok=True)
|
|
266
|
+
except Exception:
|
|
267
|
+
logger.debug("Failed to remove runtime file: %s", path, exc_info=True)
|
|
268
|
+
|
|
269
|
+
def _is_process_alive(self, pid: int) -> bool:
|
|
270
|
+
if pid <= 0:
|
|
271
|
+
return False
|
|
272
|
+
if os.name == "nt":
|
|
273
|
+
return self._is_process_alive_windows(pid)
|
|
274
|
+
try:
|
|
275
|
+
os.kill(pid, 0)
|
|
276
|
+
except OSError:
|
|
277
|
+
return False
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
def _terminate_process(self, pid: int, *, force: bool) -> None:
|
|
281
|
+
if pid <= 0:
|
|
282
|
+
return
|
|
283
|
+
if os.name == "nt":
|
|
284
|
+
self._terminate_process_windows(pid)
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
sig = signal.SIGKILL if force else signal.SIGTERM
|
|
288
|
+
try:
|
|
289
|
+
os.kill(pid, sig)
|
|
290
|
+
except ProcessLookupError:
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _is_process_alive_windows(pid: int) -> bool:
|
|
295
|
+
kernel32 = ctypes.windll.kernel32
|
|
296
|
+
handle = kernel32.OpenProcess(_WINDOWS_QUERY_LIMITED_INFORMATION, 0, pid)
|
|
297
|
+
if not handle:
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
exit_code = ctypes.c_ulong()
|
|
302
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
303
|
+
return False
|
|
304
|
+
return exit_code.value == _WINDOWS_STILL_ACTIVE
|
|
305
|
+
finally:
|
|
306
|
+
kernel32.CloseHandle(handle)
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def _terminate_process_windows(pid: int) -> None:
|
|
310
|
+
kernel32 = ctypes.windll.kernel32
|
|
311
|
+
access = _WINDOWS_PROCESS_TERMINATE | _WINDOWS_QUERY_LIMITED_INFORMATION
|
|
312
|
+
handle = kernel32.OpenProcess(access, 0, pid)
|
|
313
|
+
if not handle:
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
kernel32.TerminateProcess(handle, 1)
|
|
318
|
+
finally:
|
|
319
|
+
kernel32.CloseHandle(handle)
|