-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgenerate_linux_scripts.py
More file actions
319 lines (261 loc) · 11.3 KB
/
Copy pathgenerate_linux_scripts.py
File metadata and controls
319 lines (261 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
#!/usr/bin/env python3
"""把 commands 中的 TXT 命令文件转换为可执行的 Bash 脚本。"""
import argparse
import csv
import io
import os
import re
import shlex
import stat
import sys
from pathlib import Path
from typing import Dict, Iterable, List, Sequence, Tuple
BASE_DIR = Path(__file__).resolve().parent
SOURCE_DIR = BASE_DIR / "commands"
CONFIG_DIR = BASE_DIR / "config"
OUTPUT_DIR = BASE_DIR / "output"
CONFIG_FILE = CONFIG_DIR / "script_config.csv"
class GeneratorError(Exception):
"""可直接展示给操作者的错误。"""
def read_compatible_text(path: Path) -> str:
"""优先读取 UTF-8,也兼容常见的中文 Windows 文本编码。"""
data = path.read_bytes()
for encoding in ("utf-8-sig", "gb18030"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
pass
raise GeneratorError("无法识别文件编码:{}(请保存为 UTF-8)".format(path))
def discover_txt_files() -> List[Path]:
if not SOURCE_DIR.exists():
SOURCE_DIR.mkdir(parents=True, exist_ok=True)
return []
files = [
path
for path in SOURCE_DIR.rglob("*")
if path.is_file() and path.suffix.casefold() == ".txt"
]
return sorted(files, key=lambda item: item.relative_to(SOURCE_DIR).as_posix().casefold())
def relative_source_name(path: Path) -> str:
return path.relative_to(SOURCE_DIR).as_posix()
def load_config() -> Tuple[Dict[str, str], List[str]]:
if not CONFIG_FILE.exists():
return {}, []
text = read_compatible_text(CONFIG_FILE)
reader = csv.DictReader(io.StringIO(text))
required = {"txt_file", "work_dir"}
if reader.fieldnames is None or not required.issubset(set(reader.fieldnames)):
raise GeneratorError(
"配置文件表头不正确:{}\n需要包含:txt_file,work_dir".format(CONFIG_FILE)
)
values: Dict[str, str] = {}
order: List[str] = []
for line_number, row in enumerate(reader, start=2):
name = (row.get("txt_file") or "").strip().replace("\\", "/")
if not name:
continue
if name in values:
raise GeneratorError(
"配置文件第 {} 行存在重复的 txt_file:{}".format(line_number, name)
)
values[name] = (row.get("work_dir") or "").strip() or "~"
order.append(name)
return values, order
def synchronize_config(files: Sequence[Path]) -> Tuple[Dict[str, str], int]:
"""加入新 TXT 的默认配置,并保留暂时不存在文件的旧配置。"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
values, old_order = load_config()
current_names = [relative_source_name(path) for path in files]
added = 0
for name in current_names:
if name not in values:
values[name] = "~"
added += 1
stale_names = [name for name in old_order if name not in set(current_names)]
rows = current_names + stale_names
with CONFIG_FILE.open("w", encoding="utf-8-sig", newline="") as config_stream:
writer = csv.DictWriter(config_stream, fieldnames=["txt_file", "work_dir"])
writer.writeheader()
for name in rows:
writer.writerow({"txt_file": name, "work_dir": values[name]})
return values, added
def output_path_for(source_path: Path) -> Path:
relative_path = source_path.relative_to(SOURCE_DIR).with_suffix(".sh")
return OUTPUT_DIR / relative_path
def display_files(files: Sequence[Path], config: Dict[str, str]) -> None:
print("\n发现以下 TXT 命令文件:")
for index, path in enumerate(files, start=1):
name = relative_source_name(path)
output_name = output_path_for(path).relative_to(OUTPUT_DIR).as_posix()
print(" [{:>2}] {} -> {} (运行目录:{})".format(
index, name, output_name, config.get(name, "~") or "~"
))
print(" [ 0] 转换全部")
def parse_selection(raw_value: str, file_count: int) -> List[int]:
value = raw_value.strip()
if not value:
raise ValueError("没有输入任何编号。")
if value.casefold() in {"q", "quit", "exit"}:
return []
value = re.sub(r"(\d+)\s*-\s*(\d+)", r"\1-\2", value)
tokens = [token for token in re.split(r"[,,;;\s]+", value) if token]
if "0" in tokens:
return list(range(file_count))
selected: List[int] = []
for token in tokens:
range_match = re.fullmatch(r"(\d+)-(\d+)", token)
if range_match:
start, end = (int(part) for part in range_match.groups())
if start > end:
raise ValueError("范围起点不能大于终点:{}".format(token))
one_based_numbers: Iterable[int] = range(start, end + 1)
elif token.isdigit():
one_based_numbers = [int(token)]
else:
raise ValueError("无法识别的选择:{}".format(token))
for one_based in one_based_numbers:
if one_based < 1 or one_based > file_count:
raise ValueError("编号超出范围:{}".format(one_based))
zero_based = one_based - 1
if zero_based not in selected:
selected.append(zero_based)
return selected
def ask_for_selection(file_count: int) -> List[int]:
while True:
try:
raw_value = input("\n请输入编号(0=全部;可输入 1,3 或 1-3;q=退出):")
except EOFError:
raise GeneratorError("没有收到选择。可使用 --all 参数直接转换全部文件。")
try:
return parse_selection(raw_value, file_count)
except ValueError as error:
print("输入有误:{}".format(error))
def remove_full_line_comments(text: str) -> str:
"""删除 TXT 中去掉行首空白后以 # 开头的整行注释。"""
return "\n".join(
line for line in text.split("\n") if not line.lstrip().startswith("#")
)
def build_script(source_path: Path, configured_work_dir: str) -> str:
source_name = relative_source_name(source_path)
commands = read_compatible_text(source_path).replace("\r\n", "\n").replace("\r", "\n")
commands = commands.lstrip("\ufeff")
commands = remove_full_line_comments(commands)
safe_dir = shlex.quote(configured_work_dir or "~")
safe_source_comment = source_name.replace("\n", " ").replace("\r", " ")
header = """#!/usr/bin/env bash
# 此文件由 generate_linux_scripts.py 自动生成,请修改原始 TXT 后重新生成。
# 原始文件:{source_name}
__generator_script_path="${{BASH_SOURCE[0]}}"
if ! __generator_script_dir="$(cd -- "$(dirname -- "$__generator_script_path")" && pwd)"; then
printf '错误:无法确定脚本所在目录。\n' >&2
exit 1
fi
__generator_script_name="$(basename -- "$__generator_script_path")"
__generator_log_file="$__generator_script_dir/${{__generator_script_name%.sh}}.log"
if ! command -v tee >/dev/null 2>&1; then
printf '错误:系统中没有 tee 命令,无法创建运行日志。\n' >&2
exit 1
fi
if ! : >> "$__generator_log_file"; then
printf '错误:无法写入日志文件:%s\n' "$__generator_log_file" >&2
exit 1
fi
# 同时输出到终端,并追加到脚本旁的同名日志文件。
exec > >(tee -a -- "$__generator_log_file") 2>&1
printf '\n===== 开始:%s | 脚本:%s =====\n' \
"$(date '+%Y-%m-%d %H:%M:%S %z')" "$__generator_script_name"
printf '日志文件:%s\n' "$__generator_log_file"
__generator_on_exit() {{
__generator_exit_code=$?
trap - EXIT
printf '===== 结束:%s | 退出码:%s =====\n' \
"$(date '+%Y-%m-%d %H:%M:%S %z')" "$__generator_exit_code"
exit "$__generator_exit_code"
}}
trap __generator_on_exit EXIT
unset __generator_script_path __generator_script_dir __generator_script_name __generator_log_file
__generator_configured_dir={safe_dir}
case "$__generator_configured_dir" in
"~") __generator_work_dir="$HOME" ;;
"~/"*) __generator_work_dir="$HOME/${{__generator_configured_dir#\\~/}}" ;;
/*) __generator_work_dir="$__generator_configured_dir" ;;
*) __generator_work_dir="$HOME/$__generator_configured_dir" ;;
esac
if ! cd -- "$__generator_work_dir"; then
printf '错误:无法进入配置的运行目录:%s\\n' "$__generator_work_dir" >&2
exit 1
fi
unset __generator_configured_dir __generator_work_dir
# ---------- 原始 TXT 命令(按原顺序执行) ----------
""".format(source_name=safe_source_comment, safe_dir=safe_dir)
return header + commands.rstrip("\n") + "\n"
def write_script(source_path: Path, configured_work_dir: str) -> Path:
target_path = output_path_for(source_path)
target_path.parent.mkdir(parents=True, exist_ok=True)
content = build_script(source_path, configured_work_dir)
temporary_path = target_path.with_name(target_path.name + ".tmp")
with temporary_path.open("w", encoding="utf-8", newline="\n") as script_stream:
script_stream.write(content)
os.replace(str(temporary_path), str(target_path))
if os.name != "nt":
current_mode = target_path.stat().st_mode
target_path.chmod(
current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
)
return target_path
def build_argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="将 commands 中的 TXT 文件转换为 Bash 脚本。"
)
selection = parser.add_mutually_exclusive_group()
selection.add_argument("--all", action="store_true", help="不询问,转换全部 TXT")
selection.add_argument(
"--select", metavar="编号", help='不询问,转换指定编号,例如 --select "1,3-5"'
)
selection.add_argument("--list", action="store_true", help="只列出文件和配置,不生成脚本")
return parser
def run(argv: Sequence[str]) -> int:
args = build_argument_parser().parse_args(argv)
SOURCE_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
files = discover_txt_files()
config, added = synchronize_config(files)
if added:
print("配置文件已更新,新增 {} 个默认项:{}".format(added, CONFIG_FILE))
else:
print("配置文件:{}".format(CONFIG_FILE))
if not files:
print("未找到 TXT 文件,请先把命令文件放入:{}".format(SOURCE_DIR))
return 0
display_files(files, config)
if args.list:
return 0
if args.all:
indexes = list(range(len(files)))
elif args.select is not None:
try:
indexes = parse_selection(args.select, len(files))
except ValueError as error:
raise GeneratorError("--select 参数有误:{}".format(error))
else:
indexes = ask_for_selection(len(files))
if not indexes:
print("已退出,未生成脚本。")
return 0
print("\n开始生成:")
for index in indexes:
source_path = files[index]
name = relative_source_name(source_path)
configured_work_dir = config.get(name, "~") or "~"
target_path = write_script(source_path, configured_work_dir)
print(" 已生成:{}".format(target_path.relative_to(BASE_DIR)))
print("\n完成,共生成 {} 个脚本。".format(len(indexes)))
return 0
def main() -> int:
try:
return run(sys.argv[1:])
except (GeneratorError, OSError) as error:
print("错误:{}".format(error), file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())