openclaw-diag-cli 0.1.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/LICENSE +21 -0
- package/README.md +260 -0
- package/bin/ocdiag +14 -0
- package/bin/openclaw-diag.js +275 -0
- package/diag/01_sys_health.py +443 -0
- package/diag/02_environment.py +292 -0
- package/diag/03_configuration.py +131 -0
- package/diag/04_gateway.py +651 -0
- package/diag/05_recent_errors.py +246 -0
- package/diag/06_cron_jobs.py +694 -0
- package/diag/07_performance.py +687 -0
- package/diag/08_sessions.py +518 -0
- package/diag/09_plugin_diag.py +535 -0
- package/diag/10_shell_history.py +121 -0
- package/diag/__init__.py +0 -0
- package/lib/bundle.py +204 -0
- package/ocdiag/__init__.py +3 -0
- package/ocdiag/cli.py +39 -0
- package/ocdiag/dispatcher.py +137 -0
- package/ocdiag/jsonlog.py +65 -0
- package/ocdiag/output.py +131 -0
- package/ocdiag/paths.py +48 -0
- package/ocdiag/recent_logs.py +53 -0
- package/ocdiag/sensitive.py +41 -0
- package/ocdiag/timeutil.py +77 -0
- package/ocdiag/tokens.py +46 -0
- package/package.json +42 -0
- package/tools/__init__.py +0 -0
- package/tools/oc_session_extract.py +254 -0
- package/tools/oc_session_trace.py +715 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Mask sensitive config values (keys, secrets, tokens)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
SENSITIVE_PATTERN = re.compile(
|
|
8
|
+
r"(key|secret|token|password|credential|auth|private|signing)",
|
|
9
|
+
re.IGNORECASE,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
SENSITIVE_KEY_NAMES = {
|
|
13
|
+
"apikey", "appkey", "appsecret", "secret",
|
|
14
|
+
"token", "password", "encryptkey", "verificationtoken",
|
|
15
|
+
"webhook", "accesstoken", "refreshtoken", "signingsecret",
|
|
16
|
+
"clientsecret",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def mask(val) -> str:
|
|
21
|
+
"""Mask a sensitive value, preserving first/last 4 chars for long strings."""
|
|
22
|
+
s = str(val)
|
|
23
|
+
if len(s) <= 4:
|
|
24
|
+
return "****"
|
|
25
|
+
if len(s) <= 10:
|
|
26
|
+
return s[:2] + "****"
|
|
27
|
+
return s[:4] + "****" + s[-4:]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_sensitive_key(key_path: str) -> bool:
|
|
31
|
+
"""Check if a dotted config key path is sensitive."""
|
|
32
|
+
last = key_path.rsplit(".", 1)[-1].lower().replace("-", "").replace("_", "")
|
|
33
|
+
return last in SENSITIVE_KEY_NAMES or SENSITIVE_PATTERN.search(last) is not None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def safe_val(key: str, val, max_len: int = 300) -> str:
|
|
37
|
+
"""Return display-safe value: mask if sensitive, truncate if long."""
|
|
38
|
+
if SENSITIVE_PATTERN.search(key):
|
|
39
|
+
return mask(val) if val else '""'
|
|
40
|
+
s = str(val)
|
|
41
|
+
return s[:max_len] + "..." if len(s) > max_len else s
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Timestamp parsing and duration formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_obj_ts(ts_str: Optional[str]) -> Optional[datetime]:
|
|
10
|
+
"""obj.timestamp is ISO 8601."""
|
|
11
|
+
if not ts_str:
|
|
12
|
+
return None
|
|
13
|
+
try:
|
|
14
|
+
return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
15
|
+
except Exception:
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_msg_ts(ms) -> Optional[datetime]:
|
|
20
|
+
"""msg.timestamp is epoch milliseconds."""
|
|
21
|
+
if ms is None:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
return datetime.fromtimestamp(int(ms) / 1000, tz=timezone.utc)
|
|
25
|
+
except Exception:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fmt_duration(sec) -> str:
|
|
30
|
+
if sec is None:
|
|
31
|
+
return "?"
|
|
32
|
+
s = float(sec)
|
|
33
|
+
if s < 60:
|
|
34
|
+
return f"{s:.0f}s"
|
|
35
|
+
if s < 3600:
|
|
36
|
+
return f"{s/60:.1f}m"
|
|
37
|
+
return f"{s/3600:.1f}h"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fmt_duration_ms(ms) -> str:
|
|
41
|
+
if ms is None:
|
|
42
|
+
return "?"
|
|
43
|
+
s = float(ms) / 1000.0
|
|
44
|
+
if s < 60:
|
|
45
|
+
return f"{s:.1f}s"
|
|
46
|
+
if s < 3600:
|
|
47
|
+
return f"{s/60:.1f}min"
|
|
48
|
+
return f"{s/3600:.1f}h"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fmt_age(ms_delta) -> str:
|
|
52
|
+
s = abs(float(ms_delta)) / 1000
|
|
53
|
+
if s < 60:
|
|
54
|
+
return f"{s:.0f}秒"
|
|
55
|
+
if s < 3600:
|
|
56
|
+
return f"{s/60:.0f}分钟"
|
|
57
|
+
if s < 86400:
|
|
58
|
+
return f"{s/3600:.1f}小时"
|
|
59
|
+
return f"{s/86400:.1f}天"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fmt_ts(ms) -> str:
|
|
63
|
+
if not ms:
|
|
64
|
+
return "?"
|
|
65
|
+
try:
|
|
66
|
+
return datetime.fromtimestamp(int(ms) / 1000).strftime("%Y-%m-%d %H:%M:%S")
|
|
67
|
+
except Exception:
|
|
68
|
+
return str(ms)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fmt_hms(ts: Optional[str]) -> str:
|
|
72
|
+
if not ts:
|
|
73
|
+
return "?"
|
|
74
|
+
try:
|
|
75
|
+
return ts.split("T", 1)[1][:8]
|
|
76
|
+
except Exception:
|
|
77
|
+
return ts[:19]
|
package/ocdiag/tokens.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Token / size formatters and percentile helper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fmt_tokens(n) -> str:
|
|
9
|
+
if n is None:
|
|
10
|
+
return "?"
|
|
11
|
+
n = int(n)
|
|
12
|
+
if n >= 1_000_000:
|
|
13
|
+
return f"{n/1_000_000:.1f}M"
|
|
14
|
+
if n >= 1_000:
|
|
15
|
+
return f"{n/1_000:.1f}K"
|
|
16
|
+
return str(n)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fmt_k(n) -> str:
|
|
20
|
+
return fmt_tokens(n)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def percentile(sorted_list: List[float], p: float) -> Optional[float]:
|
|
24
|
+
if not sorted_list:
|
|
25
|
+
return None
|
|
26
|
+
k = max(0, min(len(sorted_list) - 1, int(len(sorted_list) * p)))
|
|
27
|
+
return sorted_list[k]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def pct(sorted_vals: List[float], p: float) -> float:
|
|
31
|
+
if not sorted_vals:
|
|
32
|
+
return 0.0
|
|
33
|
+
n = len(sorted_vals)
|
|
34
|
+
idx = min(n - 1, int(n * p))
|
|
35
|
+
return sorted_vals[idx]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def human_size(b) -> str:
|
|
39
|
+
b = int(b)
|
|
40
|
+
if b < 1024:
|
|
41
|
+
return f"{b}B"
|
|
42
|
+
if b < 1048576:
|
|
43
|
+
return f"{b/1024:.1f}KB"
|
|
44
|
+
if b < 1073741824:
|
|
45
|
+
return f"{b/1048576:.1f}MB"
|
|
46
|
+
return f"{b/1073741824:.1f}GB"
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-diag-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenClaw / ArkClaw read-only diagnostic CLI. Zero-dependency Python scripts wrapped in Node for npx-friendly install.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"openclaw",
|
|
7
|
+
"arkclaw",
|
|
8
|
+
"diagnostic",
|
|
9
|
+
"cli",
|
|
10
|
+
"ops",
|
|
11
|
+
"troubleshooting",
|
|
12
|
+
"ai-agent"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/wujiaming88/openclaw-diag-cli#readme",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/wujiaming88/openclaw-diag-cli.git"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/wujiaming88/openclaw-diag-cli/issues"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "wujiaming88",
|
|
24
|
+
"bin": {
|
|
25
|
+
"openclaw-diag": "./bin/openclaw-diag.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"bin/",
|
|
29
|
+
"lib/",
|
|
30
|
+
"ocdiag/",
|
|
31
|
+
"diag/",
|
|
32
|
+
"tools/",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "node bin/openclaw-diag.js list && echo OK"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract OpenClaw session JSONL files into human-readable format."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import glob
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterator, List, Optional, TextIO, Tuple
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
15
|
+
|
|
16
|
+
from ocdiag import paths
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_DIR = paths.SESSIONS_BASE
|
|
20
|
+
SEPARATOR = "═" * 63
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def human_size(n: int) -> str:
|
|
24
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
25
|
+
if n < 1024:
|
|
26
|
+
return f"{n:.1f} {unit}" if unit != "B" else f"{n} {unit}"
|
|
27
|
+
n /= 1024
|
|
28
|
+
return f"{n:.1f} PB"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def classify_state(filename: str) -> str:
|
|
32
|
+
if filename.endswith(".jsonl"):
|
|
33
|
+
return "active"
|
|
34
|
+
if ".jsonl.deleted." in filename:
|
|
35
|
+
return "deleted"
|
|
36
|
+
if ".jsonl.reset." in filename:
|
|
37
|
+
return "reset"
|
|
38
|
+
if ".jsonl.bak-" in filename:
|
|
39
|
+
return "backup"
|
|
40
|
+
return "unknown"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_session_files(session_id, base_dir=DEFAULT_BASE_DIR, agent=None):
|
|
44
|
+
if agent:
|
|
45
|
+
agent_dirs = [os.path.join(base_dir, agent)]
|
|
46
|
+
else:
|
|
47
|
+
agent_dirs = sorted(glob.glob(os.path.join(base_dir, "*")))
|
|
48
|
+
found = []
|
|
49
|
+
for agent_dir in agent_dirs:
|
|
50
|
+
sessions_dir = os.path.join(agent_dir, "sessions")
|
|
51
|
+
if not os.path.isdir(sessions_dir):
|
|
52
|
+
continue
|
|
53
|
+
pattern = os.path.join(sessions_dir, f"{session_id}.jsonl*")
|
|
54
|
+
for path in sorted(glob.glob(pattern)):
|
|
55
|
+
name = os.path.basename(path)
|
|
56
|
+
if ".trajectory" in name:
|
|
57
|
+
continue
|
|
58
|
+
state = classify_state(name)
|
|
59
|
+
found.append((path, state))
|
|
60
|
+
return found
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def stream_records(path):
|
|
64
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
65
|
+
for i, line in enumerate(f, start=1):
|
|
66
|
+
stripped = line.rstrip("\n")
|
|
67
|
+
if not stripped.strip():
|
|
68
|
+
continue
|
|
69
|
+
try:
|
|
70
|
+
obj = json.loads(stripped)
|
|
71
|
+
yield i, obj, stripped, None
|
|
72
|
+
except json.JSONDecodeError as e:
|
|
73
|
+
yield i, None, stripped, str(e)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def write_header(out, path, state):
|
|
77
|
+
try:
|
|
78
|
+
size = os.path.getsize(path)
|
|
79
|
+
except OSError:
|
|
80
|
+
size = 0
|
|
81
|
+
out.write(SEPARATOR + "\n")
|
|
82
|
+
out.write(f"File: {path}\n")
|
|
83
|
+
out.write(f"Size: {human_size(size)}\n")
|
|
84
|
+
out.write(f"State: {state}\n")
|
|
85
|
+
out.write(SEPARATOR + "\n\n")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def extract_file(path, state, out, pretty=True, type_filter=None):
|
|
89
|
+
write_header(out, path, state)
|
|
90
|
+
written = 0
|
|
91
|
+
for line_no, obj, raw, err in stream_records(path):
|
|
92
|
+
if err is not None:
|
|
93
|
+
out.write(f"--- Record {line_no} [PARSE ERROR: {err}] ---\n")
|
|
94
|
+
out.write(raw + "\n\n")
|
|
95
|
+
written += 1
|
|
96
|
+
continue
|
|
97
|
+
rtype = obj.get("type", "?") if isinstance(obj, dict) else "?"
|
|
98
|
+
if type_filter is not None and rtype not in type_filter:
|
|
99
|
+
continue
|
|
100
|
+
out.write(f"--- Record {line_no} [type: {rtype}] ---\n")
|
|
101
|
+
if pretty:
|
|
102
|
+
out.write(json.dumps(obj, indent=2, ensure_ascii=False))
|
|
103
|
+
else:
|
|
104
|
+
out.write(raw)
|
|
105
|
+
out.write("\n\n")
|
|
106
|
+
written += 1
|
|
107
|
+
return written
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def summarize_file(path, state, out):
|
|
111
|
+
write_header(out, path, state)
|
|
112
|
+
counts: dict = {}
|
|
113
|
+
total = 0
|
|
114
|
+
earliest: Optional[str] = None
|
|
115
|
+
latest: Optional[str] = None
|
|
116
|
+
parse_errors = 0
|
|
117
|
+
for _, obj, _, err in stream_records(path):
|
|
118
|
+
total += 1
|
|
119
|
+
if err is not None:
|
|
120
|
+
parse_errors += 1
|
|
121
|
+
continue
|
|
122
|
+
if not isinstance(obj, dict):
|
|
123
|
+
counts["<non-object>"] = counts.get("<non-object>", 0) + 1
|
|
124
|
+
continue
|
|
125
|
+
rtype = obj.get("type", "<no-type>")
|
|
126
|
+
counts[rtype] = counts.get(rtype, 0) + 1
|
|
127
|
+
ts = obj.get("timestamp")
|
|
128
|
+
if isinstance(ts, str):
|
|
129
|
+
if earliest is None or ts < earliest:
|
|
130
|
+
earliest = ts
|
|
131
|
+
if latest is None or ts > latest:
|
|
132
|
+
latest = ts
|
|
133
|
+
out.write(f"Total records: {total}\n")
|
|
134
|
+
if parse_errors:
|
|
135
|
+
out.write(f"Parse errors: {parse_errors}\n")
|
|
136
|
+
out.write("By type:\n")
|
|
137
|
+
for k in sorted(counts, key=lambda k: -counts[k]):
|
|
138
|
+
out.write(f" {k}: {counts[k]}\n")
|
|
139
|
+
if earliest or latest:
|
|
140
|
+
out.write(f"Time range: {earliest or '?'} → {latest or '?'}\n")
|
|
141
|
+
out.write("\n")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def list_files(files, out):
|
|
145
|
+
out.write(f"Found {len(files)} file(s):\n\n")
|
|
146
|
+
for i, (path, state) in enumerate(files, start=1):
|
|
147
|
+
try:
|
|
148
|
+
size_s = human_size(os.path.getsize(path))
|
|
149
|
+
except OSError:
|
|
150
|
+
size_s = "?"
|
|
151
|
+
out.write(f" [{i}] {state:8s} {size_s:>10s} {path}\n")
|
|
152
|
+
out.write("\n")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def select_files(files, extract_all, _out):
|
|
156
|
+
if len(files) <= 1 or extract_all:
|
|
157
|
+
return files
|
|
158
|
+
list_files(files, sys.stderr)
|
|
159
|
+
sys.stderr.write("Multiple files found. Enter index (1-based), 'a' for all, or 'q' to quit: ")
|
|
160
|
+
sys.stderr.flush()
|
|
161
|
+
try:
|
|
162
|
+
choice = sys.stdin.readline().strip().lower()
|
|
163
|
+
except (KeyboardInterrupt, EOFError):
|
|
164
|
+
return []
|
|
165
|
+
if choice in ("q", ""):
|
|
166
|
+
return []
|
|
167
|
+
if choice == "a":
|
|
168
|
+
return files
|
|
169
|
+
try:
|
|
170
|
+
idx = int(choice)
|
|
171
|
+
if 1 <= idx <= len(files):
|
|
172
|
+
return [files[idx - 1]]
|
|
173
|
+
except ValueError:
|
|
174
|
+
pass
|
|
175
|
+
sys.stderr.write(f"Invalid choice: {choice}\n")
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main() -> int:
|
|
180
|
+
p = argparse.ArgumentParser(
|
|
181
|
+
description="Extract OpenClaw session JSONL files into human-readable format.",
|
|
182
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
183
|
+
)
|
|
184
|
+
p.add_argument("session_id", help="Session UUID to extract")
|
|
185
|
+
p.add_argument("-o", "--output", help="Write output to FILE instead of stdout")
|
|
186
|
+
p.add_argument("-a", "--all", action="store_true",
|
|
187
|
+
help="Extract all versions found (active + deleted + reset + backup)")
|
|
188
|
+
p.add_argument("--list", action="store_true", help="List found files; do not extract")
|
|
189
|
+
p.add_argument("--agent", help="Limit search to specific agent directory")
|
|
190
|
+
p.add_argument("--base-dir", default=DEFAULT_BASE_DIR, help="Override base directory")
|
|
191
|
+
p.add_argument("--no-pretty", action="store_true", help="Output raw JSON lines")
|
|
192
|
+
p.add_argument("--types", help="Filter by record type (comma-separated, e.g. 'message,toolCall')")
|
|
193
|
+
p.add_argument("--summary", action="store_true",
|
|
194
|
+
help="Show record-count summary instead of full extraction")
|
|
195
|
+
args = p.parse_args()
|
|
196
|
+
|
|
197
|
+
files = find_session_files(args.session_id, args.base_dir, args.agent)
|
|
198
|
+
if not files:
|
|
199
|
+
sys.stderr.write(
|
|
200
|
+
f"Error: no files found for session ID '{args.session_id}' under {args.base_dir}"
|
|
201
|
+
+ (f" (agent={args.agent})" if args.agent else "")
|
|
202
|
+
+ "\n"
|
|
203
|
+
)
|
|
204
|
+
return 1
|
|
205
|
+
|
|
206
|
+
if args.list:
|
|
207
|
+
list_files(files, sys.stdout)
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
selected = select_files(files, args.all, sys.stdout)
|
|
211
|
+
if not selected:
|
|
212
|
+
sys.stderr.write("No files selected.\n")
|
|
213
|
+
return 1
|
|
214
|
+
|
|
215
|
+
type_filter: Optional[set] = None
|
|
216
|
+
if args.types:
|
|
217
|
+
type_filter = {t.strip() for t in args.types.split(",") if t.strip()}
|
|
218
|
+
|
|
219
|
+
out_path = args.output
|
|
220
|
+
out_fp: TextIO
|
|
221
|
+
close_out = False
|
|
222
|
+
if out_path:
|
|
223
|
+
out_fp = open(out_path, "w", encoding="utf-8")
|
|
224
|
+
close_out = True
|
|
225
|
+
else:
|
|
226
|
+
out_fp = sys.stdout
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
for path, state in selected:
|
|
230
|
+
if args.summary:
|
|
231
|
+
summarize_file(path, state, out_fp)
|
|
232
|
+
else:
|
|
233
|
+
extract_file(path, state, out_fp, pretty=not args.no_pretty,
|
|
234
|
+
type_filter=type_filter)
|
|
235
|
+
except BrokenPipeError:
|
|
236
|
+
try:
|
|
237
|
+
sys.stdout.flush()
|
|
238
|
+
except BrokenPipeError:
|
|
239
|
+
pass
|
|
240
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
241
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
242
|
+
return 0
|
|
243
|
+
except KeyboardInterrupt:
|
|
244
|
+
sys.stderr.write("\nInterrupted.\n")
|
|
245
|
+
return 130
|
|
246
|
+
finally:
|
|
247
|
+
if close_out:
|
|
248
|
+
out_fp.close()
|
|
249
|
+
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
sys.exit(main())
|