lingora-cli 0.1.0rc1__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.
lingora_cli/cli.py ADDED
@@ -0,0 +1,1383 @@
1
+ """提供 CLI 执行入口、公开 HTTP 传输与 Android 翻译资源的安全同步校验。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import io
7
+ import json
8
+ import math
9
+ import os
10
+ import re
11
+ import shutil
12
+ import sys
13
+ import tempfile
14
+ import uuid
15
+ import zipfile
16
+ from collections.abc import Callable, Mapping
17
+ from contextlib import redirect_stderr, redirect_stdout
18
+ from dataclasses import dataclass
19
+ from importlib.metadata import PackageNotFoundError, version
20
+ from importlib.resources import files
21
+ from pathlib import Path, PurePosixPath
22
+ from typing import Any, Optional
23
+ from urllib.error import HTTPError, URLError
24
+ from urllib.parse import urlsplit
25
+ from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
26
+ from xml.etree import ElementTree as ET
27
+
28
+ DEFAULT_CONFIG = ".lingorarc"
29
+ DEFAULT_TIMEOUT_SECONDS = 30.0
30
+ MAX_RESPONSE_BYTES = 50 * 1024 * 1024
31
+ MAX_ZIP_ENTRIES = 1_000
32
+ MAX_EXPANDED_ZIP_BYTES = 100 * 1024 * 1024
33
+ ANDROID_PLURAL_QUANTITIES = {"zero", "one", "two", "few", "many", "other"}
34
+ JAVA_PLACEHOLDER_RE = re.compile(
35
+ r"%(?:(\d+)\$)?([-#+ 0,(<]*)(\d*)(?:\.(\d+))?([tT]?[a-zA-Z%])"
36
+ )
37
+
38
+
39
+ # 表示可向使用者报告的同步错误。
40
+ class ScriptError(RuntimeError):
41
+ """Expected configuration, validation, or remote API failure."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ # 记录单个源资源及其语言和命名空间。
46
+ class PushFile:
47
+ path: Path
48
+ language: str
49
+ namespace: str
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ # 保存不依赖本地资源的项目连接信息。
54
+ class ProjectConfig:
55
+ api_url: str
56
+ project_id: str
57
+ branch: Optional[str]
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ # 保存校验后的同步配置,不包含密钥。
62
+ class Config:
63
+ config_path: Path
64
+ api_url: str
65
+ project_id: str
66
+ branch: Optional[str]
67
+ format: str
68
+ base_language: str
69
+ target_languages: tuple[str, ...]
70
+ namespace: str
71
+ push_files: tuple[PushFile, ...]
72
+ pull_path: Path
73
+ push_force_mode: str
74
+ push_override_mode: str
75
+ push_error_on_unresolved_conflict: Optional[bool]
76
+ push_override_key_descriptions: bool
77
+ push_convert_placeholders_to_icu: bool
78
+ push_tag_new_keys: tuple[str, ...]
79
+ push_remove_other_keys: bool
80
+ pull_namespaces: tuple[str, ...]
81
+ pull_states: tuple[str, ...]
82
+ pull_file_structure_template: str
83
+ pull_empty_dir: bool
84
+ content_deliveries: dict[str, str]
85
+ pull_tags: tuple[str, ...] = ()
86
+ pull_exclude_tags: tuple[str, ...] = ()
87
+ pull_support_arrays: bool = False
88
+ pull_include_empty: bool = False
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ # 保存 HTTP 状态、响应头与原始内容。
93
+ class HttpResponse:
94
+ status: int
95
+ headers: Mapping[str, str]
96
+ body: bytes
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ # 保存 Android 资源类型和值。
101
+ class AndroidResource:
102
+ kind: str
103
+ values: Any
104
+
105
+
106
+ Requester = Callable[..., HttpResponse]
107
+
108
+
109
+ # 读取非空字符串配置。
110
+ def _required_string(data: dict[str, Any], key: str) -> str:
111
+ value = data.get(key)
112
+ if not isinstance(value, str) or not value.strip():
113
+ raise ScriptError(f"Configuration field {key!r} must be a non-empty string")
114
+ return value.strip()
115
+
116
+
117
+ # 读取对象类型配置。
118
+ def _required_object(data: dict[str, Any], key: str) -> dict[str, Any]:
119
+ value = data.get(key)
120
+ if not isinstance(value, dict):
121
+ raise ScriptError(f"Configuration field {key!r} must be an object")
122
+ return value
123
+
124
+
125
+ # 读取并校验唯一字符串列表。
126
+ def _string_list(data: dict[str, Any], key: str, *, required: bool) -> tuple[str, ...]:
127
+ value = data.get(key)
128
+ if value is None and not required:
129
+ return ()
130
+ if not isinstance(value, list) or (required and not value):
131
+ qualifier = "non-empty " if required else ""
132
+ raise ScriptError(
133
+ f"Configuration field {key!r} must be a {qualifier}array of strings"
134
+ )
135
+ if any(not isinstance(item, str) or not item.strip() for item in value):
136
+ raise ScriptError(f"Configuration field {key!r} must contain strings")
137
+ normalized = tuple(item.strip() for item in value)
138
+ if len(set(normalized)) != len(normalized):
139
+ raise ScriptError(f"Configuration field {key!r} must contain unique values")
140
+ return normalized
141
+
142
+
143
+ # 读取严格布尔配置。
144
+ def _boolean(data: dict[str, Any], key: str, default: bool) -> bool:
145
+ value = data.get(key, default)
146
+ if not isinstance(value, bool):
147
+ raise ScriptError(f"Configuration field {key!r} must be a boolean")
148
+ return value
149
+
150
+
151
+ # 校验公开 HTTP 地址并拒绝内嵌凭据。
152
+ def _validate_url(value: str, field: str) -> str:
153
+ normalized = value.rstrip("/")
154
+ try:
155
+ parsed = urlsplit(normalized)
156
+ port = parsed.port
157
+ except ValueError as exc:
158
+ raise ScriptError(
159
+ f"Configuration field {field!r} has an invalid host or port"
160
+ ) from exc
161
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
162
+ raise ScriptError(f"Configuration field {field!r} must be an HTTP(S) URL")
163
+ if not parsed.hostname or port == 0:
164
+ raise ScriptError(f"Configuration field {field!r} has an invalid host or port")
165
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
166
+ raise ScriptError(
167
+ f"Configuration field {field!r} must not contain credentials, query, or fragment"
168
+ )
169
+ return normalized
170
+
171
+
172
+ # 加载已合并参数的配置,按操作校验输入并保护输出边界。
173
+ def load_config(
174
+ path: Path, *, data: dict[str, Any] | None = None, operation: str = "all"
175
+ ) -> Config:
176
+ if data is None:
177
+ try:
178
+ config_path = path.expanduser().resolve(strict=True)
179
+ data = json.loads(config_path.read_text(encoding="utf-8"))
180
+ except FileNotFoundError as exc:
181
+ raise ScriptError(f"Configuration file not found: {path}") from exc
182
+ except json.JSONDecodeError as exc:
183
+ raise ScriptError(f"Invalid JSON configuration: {exc}") from exc
184
+ else:
185
+ config_path = path.expanduser().resolve()
186
+ if not isinstance(data, dict):
187
+ raise ScriptError("Configuration root must be a JSON object")
188
+
189
+ api_url = ""
190
+ project_id = ""
191
+ if operation != "extract" or "apiUrl" in data:
192
+ api_url = _validate_url(_required_string(data, "apiUrl"), "apiUrl")
193
+ if operation != "extract" or "projectId" in data:
194
+ project_id = _required_string(data, "projectId")
195
+ try:
196
+ uuid.UUID(project_id)
197
+ except ValueError as exc:
198
+ raise ScriptError("Configuration field 'projectId' must be a UUID") from exc
199
+
200
+ branch_value = data.get("branch")
201
+ if branch_value is not None and (
202
+ not isinstance(branch_value, str) or not branch_value.strip()
203
+ ):
204
+ raise ScriptError(
205
+ "Configuration field 'branch' must be a non-empty string when set"
206
+ )
207
+ branch = branch_value.strip() if isinstance(branch_value, str) else None
208
+ if "apiKey" in data:
209
+ raise ScriptError(
210
+ "Configuration field 'apiKey' is not allowed; use LINGORA_API_KEY or --api-key"
211
+ )
212
+
213
+ format_name = _required_string(data, "format")
214
+ if format_name != "ANDROID_XML":
215
+ raise ScriptError("Configuration field 'format' must be 'ANDROID_XML'")
216
+
217
+ push = _required_object(data, "push")
218
+ files = push.get("files")
219
+ if not isinstance(files, list) or not files:
220
+ raise ScriptError(
221
+ "Configuration field 'push.files' must contain at least one Android base file"
222
+ )
223
+ push_file_values: list[tuple[str, str, str]] = []
224
+ for index, item in enumerate(files):
225
+ if not isinstance(item, dict):
226
+ raise ScriptError(
227
+ f"Configuration field 'push.files[{index}]' must be an object"
228
+ )
229
+ path_value = _required_string(item, "path")
230
+ language = _required_string(item, "language")
231
+ namespace_value = item.get("namespace", data.get("defaultNamespace", ""))
232
+ if not isinstance(namespace_value, str):
233
+ raise ScriptError(
234
+ f"Configuration field 'push.files[{index}].namespace' must be a string"
235
+ )
236
+ push_file_values.append((path_value, language, namespace_value.strip()))
237
+
238
+ base_language = push_file_values[0][1]
239
+ namespace = push_file_values[0][2]
240
+ if any(language != base_language for _, language, _ in push_file_values):
241
+ raise ScriptError(
242
+ "All Android base files in 'push.files' must use the same language"
243
+ )
244
+ if any(file_namespace != namespace for _, _, file_namespace in push_file_values):
245
+ raise ScriptError(
246
+ "All Android base files in 'push.files' must use the same namespace"
247
+ )
248
+
249
+ force_mode = str(push.get("forceMode", "NO_FORCE")).upper()
250
+ if force_mode not in {"KEEP", "OVERRIDE", "NO_FORCE"}:
251
+ raise ScriptError("Configuration field 'push.forceMode' is invalid")
252
+ override_mode = str(push.get("overrideMode", "RECOMMENDED")).upper()
253
+ if override_mode not in {"RECOMMENDED", "ALL"}:
254
+ raise ScriptError("Configuration field 'push.overrideMode' is invalid")
255
+ error_choice = push.get("errorOnUnresolvedConflict", "auto")
256
+ error_mapping = {"yes": True, "no": False, "auto": None}
257
+ if not isinstance(error_choice, str) or error_choice not in error_mapping:
258
+ raise ScriptError(
259
+ "Configuration field 'push.errorOnUnresolvedConflict' must be yes, no, or auto"
260
+ )
261
+ tag_new_keys = _string_list(push, "tagNewKeys", required=False)
262
+
263
+ pull = data.get("pull", {})
264
+ if not isinstance(pull, dict):
265
+ raise ScriptError("Configuration field 'pull' must be an object")
266
+ target_languages = _string_list(
267
+ pull, "languages", required=operation in {"all", "pull", "validate", "delivery"}
268
+ )
269
+ if base_language in target_languages:
270
+ raise ScriptError(
271
+ "Configuration field 'pull.languages' must exclude the base language"
272
+ )
273
+ pull_namespaces = _string_list(pull, "namespaces", required=False) or (namespace,)
274
+ if pull_namespaces != (namespace,):
275
+ raise ScriptError(
276
+ "Configuration field 'pull.namespaces' must match push.files[0].namespace"
277
+ )
278
+ pull_states = _string_list(pull, "states", required=False) or (
279
+ "TRANSLATED",
280
+ "REVIEWED",
281
+ )
282
+ if any(
283
+ state not in {"UNTRANSLATED", "TRANSLATED", "REVIEWED"} for state in pull_states
284
+ ):
285
+ raise ScriptError("Configuration field 'pull.states' contains an invalid state")
286
+ file_structure_template = pull.get(
287
+ "fileStructureTemplate", "values-{androidLanguageTag}/strings.xml"
288
+ )
289
+ if not isinstance(file_structure_template, str) or not file_structure_template:
290
+ raise ScriptError("Configuration field 'pull.fileStructureTemplate' is invalid")
291
+ pull_empty_dir = _boolean(pull, "emptyDir", False)
292
+
293
+ raw_deliveries = data.get("contentDeliveries", {})
294
+ if not isinstance(raw_deliveries, dict):
295
+ raise ScriptError("Configuration field 'contentDeliveries' must be an object")
296
+ content_deliveries: dict[str, str] = {}
297
+ for name, value in raw_deliveries.items():
298
+ if not isinstance(name, str) or not name.strip() or not isinstance(value, str):
299
+ raise ScriptError("Content Delivery names and URLs must be strings")
300
+ content_deliveries[name] = _validate_url(value, f"contentDeliveries.{name}")
301
+
302
+ base_dir = config_path.parent
303
+ push_files = tuple(
304
+ PushFile(
305
+ path=(base_dir / path_value).resolve(),
306
+ language=language,
307
+ namespace=file_namespace,
308
+ )
309
+ for path_value, language, file_namespace in push_file_values
310
+ )
311
+ if len({item.path for item in push_files}) != len(push_files):
312
+ raise ScriptError("Configuration field 'push.files' contains duplicate paths")
313
+ if len({item.path.name for item in push_files}) != len(push_files):
314
+ raise ScriptError(
315
+ "Android base files in 'push.files' must have unique filenames"
316
+ )
317
+ pull_path = (
318
+ base_dir
319
+ / _required_string({"path": pull.get("path", "generated/lingora/res")}, "path")
320
+ ).resolve()
321
+ if pull_path in config_path.parents or any(
322
+ pull_path in item.path.parents for item in push_files
323
+ ):
324
+ raise ScriptError(
325
+ "Pull output must not contain configuration or base resource files"
326
+ )
327
+ output_paths = [
328
+ render_export_path(file_structure_template, language, namespace)
329
+ for language in target_languages
330
+ ]
331
+ if len(set(output_paths)) != len(output_paths):
332
+ raise ScriptError("Target languages resolve to duplicate pull output paths")
333
+ if any(
334
+ pull_path.joinpath(*output_path.parts) == push_file.path
335
+ for output_path in output_paths
336
+ for push_file in push_files
337
+ ):
338
+ raise ScriptError(
339
+ "Pull output must not overwrite the base Android resource file"
340
+ )
341
+
342
+ return Config(
343
+ config_path=config_path,
344
+ api_url=api_url,
345
+ project_id=project_id,
346
+ branch=branch,
347
+ format=format_name,
348
+ base_language=base_language,
349
+ target_languages=target_languages,
350
+ namespace=namespace,
351
+ push_files=push_files,
352
+ pull_path=pull_path,
353
+ push_force_mode=force_mode,
354
+ push_override_mode=override_mode,
355
+ push_error_on_unresolved_conflict=error_mapping[error_choice],
356
+ push_override_key_descriptions=_boolean(push, "overrideKeyDescriptions", True),
357
+ push_convert_placeholders_to_icu=_boolean(
358
+ push, "convertPlaceholdersToIcu", True
359
+ ),
360
+ push_tag_new_keys=tag_new_keys,
361
+ push_remove_other_keys=_boolean(push, "removeOtherKeys", False),
362
+ pull_namespaces=pull_namespaces,
363
+ pull_states=pull_states,
364
+ pull_file_structure_template=file_structure_template,
365
+ pull_empty_dir=pull_empty_dir,
366
+ content_deliveries=content_deliveries,
367
+ pull_tags=_string_list(pull, "tags", required=False),
368
+ pull_exclude_tags=_string_list(pull, "excludeTags", required=False),
369
+ pull_support_arrays=_boolean(pull, "supportArrays", False),
370
+ pull_include_empty=_boolean(pull, "includeEmptyTranslations", False),
371
+ )
372
+
373
+
374
+ # 从调用者的当前目录选择默认配置。
375
+ def default_config_path() -> Path:
376
+ return Path(DEFAULT_CONFIG)
377
+
378
+
379
+ # 按参数、环境、配置顺序选择分支。
380
+ def select_branch(
381
+ command_line: Optional[str],
382
+ configured: Optional[str],
383
+ environ: Mapping[str, str] = os.environ,
384
+ ) -> str:
385
+ candidate = command_line or environ.get("LINGORA_BRANCH") or configured
386
+ if not candidate or not candidate.strip():
387
+ raise ScriptError(
388
+ "Branch is required: pass --branch, set LINGORA_BRANCH, or configure 'branch'"
389
+ )
390
+ return candidate.strip()
391
+
392
+
393
+ # 读取密钥但不持久化。
394
+ def require_api_key(
395
+ command_line: Optional[str] = None,
396
+ environ: Mapping[str, str] = os.environ,
397
+ ) -> str:
398
+ value = command_line or environ.get("LINGORA_API_KEY") or ""
399
+ if not value.strip():
400
+ raise ScriptError("API key is required: pass --api-key or set LINGORA_API_KEY")
401
+ return value.strip()
402
+
403
+
404
+ # 限制远端响应大小。
405
+ def _read_limited(stream: Any, limit: int = MAX_RESPONSE_BYTES) -> bytes:
406
+ body = stream.read(limit + 1)
407
+ if len(body) > limit:
408
+ raise ScriptError(f"Remote response exceeds {limit} bytes")
409
+ return body
410
+
411
+
412
+ # 提取远端错误和冲突键摘要。
413
+ def _error_message(status: int, body: bytes) -> str:
414
+ try:
415
+ payload = json.loads(body.decode("utf-8"))
416
+ except (UnicodeDecodeError, json.JSONDecodeError):
417
+ return f"HTTP {status}"
418
+ if not isinstance(payload, dict):
419
+ return f"HTTP {status}"
420
+ message = payload.get("message")
421
+ error = payload.get("error")
422
+ code = error.get("code") if isinstance(error, dict) else None
423
+ suffix = f" ({code})" if code is not None else ""
424
+ details = error.get("details") if isinstance(error, dict) else None
425
+ conflict_suffix = _format_conflict_keys(details)
426
+ return f"HTTP {status}: {message or 'request failed'}{suffix}{conflict_suffix}"
427
+
428
+
429
+ # 生成长度受限的冲突提示。
430
+ def _format_conflict_keys(value: Any) -> str:
431
+ if not isinstance(value, list):
432
+ return ""
433
+ keys = list(
434
+ dict.fromkeys(
435
+ str(item["key"])
436
+ for item in value
437
+ if isinstance(item, dict)
438
+ and isinstance(item.get("key"), str)
439
+ and item["key"]
440
+ )
441
+ )
442
+ if not keys:
443
+ return ""
444
+ visible = ", ".join(keys[:10])
445
+ remaining = max(0, len(keys) - 10)
446
+ more = f" (+{remaining} more)" if remaining else ""
447
+ return f"; conflicting keys: {visible}{more}"
448
+
449
+
450
+ # 限制认证 HTTP 请求的重定向来源。
451
+ class ApiRedirectHandler(HTTPRedirectHandler):
452
+ # 认证请求只接受同源重定向,避免密钥被转发给第三方地址。
453
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
454
+ original = urlsplit(req.full_url)
455
+ target = urlsplit(newurl)
456
+ if (original.scheme, original.hostname, original.port) != (
457
+ target.scheme,
458
+ target.hostname,
459
+ target.port,
460
+ ):
461
+ raise ScriptError(
462
+ "Authenticated API redirect to a different origin is not allowed"
463
+ )
464
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
465
+
466
+
467
+ # 调用公开 API,并在认证请求重定向时保护密钥。
468
+ def http_request(
469
+ url: str,
470
+ *,
471
+ method: str = "GET",
472
+ api_key: Optional[str] = None,
473
+ data: Optional[bytes] = None,
474
+ content_type: Optional[str] = None,
475
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
476
+ ) -> HttpResponse:
477
+ headers = {"Accept": "application/json"}
478
+ if api_key:
479
+ headers["X-API-Key"] = api_key
480
+ if content_type:
481
+ headers["Content-Type"] = content_type
482
+ request = Request(url, data=data, headers=headers, method=method)
483
+ try:
484
+ opener = build_opener(ApiRedirectHandler()).open if api_key else urlopen
485
+ with opener(request, timeout=timeout) as response:
486
+ return HttpResponse(
487
+ status=response.status,
488
+ headers=dict(response.headers.items()),
489
+ body=_read_limited(response),
490
+ )
491
+ except HTTPError as exc:
492
+ body = _read_limited(exc)
493
+ raise ScriptError(_error_message(exc.code, body)) from exc
494
+ except URLError as exc:
495
+ raise ScriptError(f"Request failed: {exc.reason}") from exc
496
+
497
+
498
+ # 校验 API 响应并提取业务数据。
499
+ def api_data(response: HttpResponse) -> Any:
500
+ try:
501
+ payload = json.loads(response.body.decode("utf-8"))
502
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
503
+ raise ScriptError("Lingora returned an invalid JSON response") from exc
504
+ if (
505
+ not 200 <= response.status < 300
506
+ or not isinstance(payload, dict)
507
+ or payload.get("code") != response.status
508
+ ):
509
+ raise ScriptError("Lingora returned an invalid API response envelope")
510
+ return payload.get("data")
511
+
512
+
513
+ # 按精确名称获取唯一分支 UUID。
514
+ def resolve_branch(
515
+ config: Config | ProjectConfig,
516
+ api_key: str,
517
+ branch_name: str,
518
+ *,
519
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
520
+ requester: Requester = http_request,
521
+ ) -> str:
522
+ response = requester(
523
+ f"{config.api_url}/api/v1/projects/{config.project_id}/branches",
524
+ api_key=api_key,
525
+ timeout=timeout,
526
+ )
527
+ branches = api_data(response)
528
+ if not isinstance(branches, list):
529
+ raise ScriptError("Branch list response must contain an array")
530
+ matches = [
531
+ item
532
+ for item in branches
533
+ if isinstance(item, dict) and item.get("name") == branch_name
534
+ ]
535
+ if len(matches) > 1:
536
+ raise ScriptError(
537
+ f"Branch name {branch_name!r} is not unique in Project {config.project_id}"
538
+ )
539
+ if not matches:
540
+ available = sorted(
541
+ str(item["name"])
542
+ for item in branches
543
+ if isinstance(item, dict) and isinstance(item.get("name"), str)
544
+ )
545
+ choices = ", ".join(available) if available else "(none)"
546
+ raise ScriptError(
547
+ f"Branch {branch_name!r} not found. Available branches: {choices}"
548
+ )
549
+ branch_id = matches[0].get("id")
550
+ try:
551
+ return str(uuid.UUID(str(branch_id)))
552
+ except ValueError as exc:
553
+ raise ScriptError(f"Branch {branch_name!r} has an invalid UUID") from exc
554
+
555
+
556
+ # 构造多文件导入请求并清理文件名。
557
+ def build_multipart(
558
+ fields: dict[str, str], files: tuple[tuple[str, bytes], ...]
559
+ ) -> tuple[bytes, str]:
560
+ boundary = f"----lingora-{uuid.uuid4().hex}"
561
+ chunks: list[bytes] = []
562
+ for name, value in fields.items():
563
+ chunks.extend(
564
+ [
565
+ f"--{boundary}\r\n".encode(),
566
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
567
+ value.encode("utf-8"),
568
+ b"\r\n",
569
+ ]
570
+ )
571
+ for filename, content in files:
572
+ safe_filename = filename.replace('"', "").replace("\r", "").replace("\n", "")
573
+ chunks.extend(
574
+ [
575
+ f"--{boundary}\r\n".encode(),
576
+ (
577
+ 'Content-Disposition: form-data; name="files"; '
578
+ f'filename="{safe_filename}"\r\n'
579
+ ).encode(),
580
+ b"Content-Type: application/xml\r\n\r\n",
581
+ content,
582
+ b"\r\n",
583
+ ]
584
+ )
585
+ chunks.append(f"--{boundary}--\r\n".encode())
586
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
587
+
588
+
589
+ # 校验源资源后一次性上传所有文件。
590
+ def push_resources(
591
+ config: Config,
592
+ api_key: str,
593
+ branch_name: str,
594
+ force_mode: str,
595
+ remove_other_keys: bool,
596
+ *,
597
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
598
+ requester: Requester = http_request,
599
+ ) -> dict[str, Any]:
600
+ resources = parse_push_resources(config)
601
+ if not resources:
602
+ raise ScriptError(
603
+ "Configured Android base files contain no supported resources: "
604
+ + ", ".join(str(item.path) for item in config.push_files)
605
+ )
606
+ branch_id = resolve_branch(
607
+ config, api_key, branch_name, timeout=timeout, requester=requester
608
+ )
609
+ params = {
610
+ "forceMode": force_mode,
611
+ "overrideMode": config.push_override_mode,
612
+ "errorOnUnresolvedConflict": config.push_error_on_unresolved_conflict,
613
+ "languageMappings": None,
614
+ "fileMappings": [
615
+ {
616
+ "fileName": item.path.name,
617
+ "namespace": item.namespace,
618
+ "format": config.format,
619
+ "languageTag": item.language,
620
+ "languageTagsToImport": [],
621
+ }
622
+ for item in config.push_files
623
+ ],
624
+ "overrideKeyDescriptions": config.push_override_key_descriptions,
625
+ "convertPlaceholdersToIcu": config.push_convert_placeholders_to_icu,
626
+ "createNewKeys": True,
627
+ "tagNewKeys": list(config.push_tag_new_keys),
628
+ "removeOtherKeys": remove_other_keys,
629
+ }
630
+ body, content_type = build_multipart(
631
+ {"params": json.dumps(params, separators=(",", ":"))},
632
+ tuple((item.path.name, item.path.read_bytes()) for item in config.push_files),
633
+ )
634
+ response = requester(
635
+ f"{config.api_url}/api/v1/projects/{config.project_id}/branches/"
636
+ f"{branch_id}/imports/single-step",
637
+ method="POST",
638
+ api_key=api_key,
639
+ data=body,
640
+ content_type=content_type,
641
+ timeout=timeout,
642
+ )
643
+ result = api_data(response)
644
+ if not isinstance(result, dict):
645
+ raise ScriptError("Import response data must be an object")
646
+ conflicts = result.get("unresolvedConflicts")
647
+ conflict_summary = _format_conflict_keys(conflicts)
648
+ if conflict_summary:
649
+ print(
650
+ "Push warning: Lingora kept protected or disabled server values"
651
+ f"{conflict_summary}",
652
+ file=sys.stderr,
653
+ )
654
+ print(
655
+ f"Push completed: branch={branch_name} branchId={branch_id} "
656
+ f"affected={result.get('affected', 0)} deleted={result.get('deleted', 0)}"
657
+ )
658
+ return result
659
+
660
+
661
+ # 转换语言标记为 Android 限定符。
662
+ def android_language_tag(language: str) -> str:
663
+ parts = language.replace("_", "-").split("-")
664
+ base = parts[0].lower()
665
+ region = next(
666
+ (
667
+ part
668
+ for part in parts[1:]
669
+ if (len(part) == 2 and part.isalpha())
670
+ or (len(part) == 3 and part.isdigit())
671
+ ),
672
+ None,
673
+ )
674
+ return base if region is None else f"{base}-r{region.upper()}"
675
+
676
+
677
+ # 渲染并校验导出相对路径。
678
+ def render_export_path(
679
+ template: str,
680
+ language: str,
681
+ namespace: str,
682
+ ) -> PurePosixPath:
683
+ try:
684
+ rendered = template.format(
685
+ namespace=namespace,
686
+ languageTag=language,
687
+ androidLanguageTag=android_language_tag(language),
688
+ snakeLanguageTag=language.replace("-", "_"),
689
+ extension="xml",
690
+ )
691
+ except (KeyError, ValueError) as exc:
692
+ raise ScriptError(
693
+ "Configuration field 'pull.fileStructureTemplate' is invalid"
694
+ ) from exc
695
+ return _safe_zip_path(rendered)
696
+
697
+
698
+ # 拒绝归档中的绝对路径和目录穿越。
699
+ def _safe_zip_path(name: str) -> PurePosixPath:
700
+ if not name or "\\" in name or name.startswith(("/", "\\")):
701
+ raise ScriptError(f"Unsafe ZIP entry path: {name!r}")
702
+ path = PurePosixPath(name)
703
+ if any(part in {"", ".", ".."} for part in path.parts):
704
+ raise ScriptError(f"Unsafe ZIP entry path: {name!r}")
705
+ if path.parts and path.parts[0].endswith(":"):
706
+ raise ScriptError(f"Unsafe ZIP entry path: {name!r}")
707
+ return path
708
+
709
+
710
+ # 限量解压导出结果并拒绝符号链接。
711
+ def extract_export(
712
+ response: HttpResponse,
713
+ destination: Path,
714
+ languages: tuple[str, ...],
715
+ file_structure_template: str,
716
+ namespace: str,
717
+ ) -> None:
718
+ response_headers = {
719
+ key.casefold(): value for key, value in response.headers.items()
720
+ }
721
+ content_type = response_headers.get("content-type", "").split(";", 1)[0].lower()
722
+ archive = io.BytesIO(response.body)
723
+ if content_type == "application/zip" or zipfile.is_zipfile(archive):
724
+ archive.seek(0)
725
+ with zipfile.ZipFile(archive) as bundle:
726
+ entries = bundle.infolist()
727
+ if len(entries) > MAX_ZIP_ENTRIES:
728
+ raise ScriptError("Export ZIP contains too many entries")
729
+ if sum(item.file_size for item in entries) > MAX_EXPANDED_ZIP_BYTES:
730
+ raise ScriptError("Expanded export ZIP is too large")
731
+ for item in entries:
732
+ path = _safe_zip_path(item.filename)
733
+ mode = (item.external_attr >> 16) & 0o170000
734
+ if mode == 0o120000:
735
+ raise ScriptError(
736
+ f"ZIP symbolic links are not allowed: {item.filename}"
737
+ )
738
+ target = destination.joinpath(*path.parts)
739
+ if item.is_dir():
740
+ target.mkdir(parents=True, exist_ok=True)
741
+ continue
742
+ target.parent.mkdir(parents=True, exist_ok=True)
743
+ with bundle.open(item) as source, target.open("wb") as output:
744
+ shutil.copyfileobj(source, output)
745
+ return
746
+
747
+ if len(languages) != 1:
748
+ raise ScriptError("Lingora returned one XML file for a multi-language export")
749
+ try:
750
+ ET.fromstring(response.body)
751
+ except ET.ParseError as exc:
752
+ raise ScriptError(
753
+ "Lingora export is neither a ZIP nor valid Android XML"
754
+ ) from exc
755
+ relative = render_export_path(file_structure_template, languages[0], namespace)
756
+ target = destination.joinpath(*relative.parts)
757
+ target.parent.mkdir(parents=True, exist_ok=True)
758
+ target.write_bytes(response.body)
759
+
760
+
761
+ # 替换生成目录,失败时恢复旧目录。
762
+ def _replace_directory(staging: Path, destination: Path) -> None:
763
+ destination.parent.mkdir(parents=True, exist_ok=True)
764
+ backup = destination.parent / f".{destination.name}.backup-{uuid.uuid4().hex}"
765
+ moved_old = False
766
+ installed = False
767
+ try:
768
+ if destination.exists():
769
+ os.replace(destination, backup)
770
+ moved_old = True
771
+ os.replace(staging, destination)
772
+ installed = True
773
+ except OSError:
774
+ if moved_old and not destination.exists() and backup.exists():
775
+ os.replace(backup, destination)
776
+ raise
777
+ finally:
778
+ # 恢复本身失败时必须保留备份,不能把唯一的原文件清理掉。
779
+ if installed and backup.exists():
780
+ shutil.rmtree(backup)
781
+
782
+
783
+ # 校验通过后安装暂存翻译。
784
+ def _install_export(staging: Path, destination: Path, empty_dir: bool) -> None:
785
+ if empty_dir:
786
+ _replace_directory(staging, destination)
787
+ return
788
+ destination.mkdir(parents=True, exist_ok=True)
789
+ for source in sorted(path for path in staging.rglob("*") if path.is_file()):
790
+ relative = source.relative_to(staging)
791
+ target = destination / relative
792
+ target.parent.mkdir(parents=True, exist_ok=True)
793
+ os.replace(source, target)
794
+
795
+
796
+ # 下载并暂存翻译,校验通过后更新输出。
797
+ def pull_resources(
798
+ config: Config,
799
+ api_key: str,
800
+ branch_name: str,
801
+ *,
802
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
803
+ requester: Requester = http_request,
804
+ ) -> dict[str, Any]:
805
+ branch_id = resolve_branch(
806
+ config, api_key, branch_name, timeout=timeout, requester=requester
807
+ )
808
+ payload = {
809
+ "languages": list(config.target_languages),
810
+ "namespaces": list(config.pull_namespaces),
811
+ "states": list(config.pull_states),
812
+ "includeEmptyTranslations": config.pull_include_empty,
813
+ "format": config.format,
814
+ "fileStructureTemplate": config.pull_file_structure_template,
815
+ "zip": True,
816
+ "filterTagIn": list(config.pull_tags),
817
+ "filterTagNotIn": list(config.pull_exclude_tags),
818
+ "supportArrays": config.pull_support_arrays,
819
+ }
820
+ response = requester(
821
+ f"{config.api_url}/api/v1/projects/{config.project_id}/branches/{branch_id}/exports",
822
+ method="POST",
823
+ api_key=api_key,
824
+ data=json.dumps(payload, separators=(",", ":")).encode("utf-8"),
825
+ content_type="application/json",
826
+ timeout=timeout,
827
+ )
828
+
829
+ destination = config.pull_path
830
+ if destination.exists() and not destination.is_dir():
831
+ raise ScriptError(f"Pull path is not a directory: {destination}")
832
+ destination.parent.mkdir(parents=True, exist_ok=True)
833
+ staging = Path(
834
+ tempfile.mkdtemp(prefix=f".{destination.name}.staging-", dir=destination.parent)
835
+ )
836
+ try:
837
+ extract_export(
838
+ response,
839
+ staging,
840
+ config.target_languages,
841
+ config.pull_file_structure_template,
842
+ config.namespace,
843
+ )
844
+ exported_languages = validate_resources(
845
+ config,
846
+ pull_path=staging,
847
+ fallback_incomplete_arrays=True,
848
+ )
849
+ _install_export(staging, destination, config.pull_empty_dir)
850
+ finally:
851
+ if staging.exists():
852
+ shutil.rmtree(staging)
853
+ print(
854
+ f"Pull completed: branch={branch_name} branchId={branch_id} "
855
+ f"requested={','.join(config.target_languages)} "
856
+ f"exported={','.join(exported_languages) or 'none'} output={destination}"
857
+ )
858
+ if not exported_languages:
859
+ print(
860
+ "Pull notice: no non-empty translations matched states="
861
+ f"{','.join(config.pull_states)}; adding a language alone does not create "
862
+ "an Android locale file."
863
+ )
864
+ return {
865
+ "branch": branch_name,
866
+ "branchId": branch_id,
867
+ "requested": list(config.target_languages),
868
+ "exported": list(exported_languages),
869
+ "path": str(destination),
870
+ }
871
+
872
+
873
+ # 去除 XML 命名空间。
874
+ def _local_name(tag: str) -> str:
875
+ return tag.rsplit("}", 1)[-1]
876
+
877
+
878
+ # 读取资源元素的完整文本。
879
+ def _element_text(element: ET.Element) -> str:
880
+ return "".join(element.itertext())
881
+
882
+
883
+ # 解析资源并拒绝重复键。
884
+ def parse_android_resources(path: Path) -> dict[str, AndroidResource]:
885
+ try:
886
+ root = ET.parse(path).getroot()
887
+ except FileNotFoundError as exc:
888
+ raise ScriptError(f"Android resource file not found: {path}") from exc
889
+ except ET.ParseError as exc:
890
+ raise ScriptError(f"Invalid Android XML {path}: {exc}") from exc
891
+ if _local_name(root.tag) != "resources":
892
+ raise ScriptError(f"Android XML root must be <resources>: {path}")
893
+
894
+ resources: dict[str, AndroidResource] = {}
895
+ for element in root:
896
+ kind = _local_name(element.tag)
897
+ if kind not in {"string", "plurals", "string-array"}:
898
+ continue
899
+ name = element.get("name", "").strip()
900
+ if not name:
901
+ raise ScriptError(f"Android resource without a name in {path}")
902
+ if name in resources:
903
+ raise ScriptError(f"Duplicate Android resource {name!r} in {path}")
904
+ if kind == "string":
905
+ values: Any = _element_text(element)
906
+ elif kind == "string-array":
907
+ values = tuple(
908
+ _element_text(item)
909
+ for item in element
910
+ if _local_name(item.tag) == "item"
911
+ )
912
+ else:
913
+ forms: dict[str, str] = {}
914
+ for item in element:
915
+ if _local_name(item.tag) != "item":
916
+ continue
917
+ quantity = item.get("quantity", "")
918
+ if quantity not in ANDROID_PLURAL_QUANTITIES:
919
+ raise ScriptError(
920
+ f"Unsupported plural quantity {quantity!r} for {name!r} in {path}"
921
+ )
922
+ if quantity in forms:
923
+ raise ScriptError(
924
+ f"Duplicate plural quantity {quantity!r} for {name!r}"
925
+ )
926
+ forms[quantity] = _element_text(item)
927
+ if "other" not in forms:
928
+ raise ScriptError(
929
+ f"Plural resource {name!r} must include quantity 'other'"
930
+ )
931
+ values = forms
932
+ resources[name] = AndroidResource(kind=kind, values=values)
933
+ return resources
934
+
935
+
936
+ # 合并源文件并拒绝跨文件重复键。
937
+ def parse_push_resources(config: Config) -> dict[str, AndroidResource]:
938
+ resources: dict[str, AndroidResource] = {}
939
+ resource_paths: dict[str, Path] = {}
940
+ for item in config.push_files:
941
+ parsed = parse_android_resources(item.path)
942
+ duplicate_names = sorted(set(resources) & set(parsed))
943
+ if duplicate_names:
944
+ name = duplicate_names[0]
945
+ raise ScriptError(
946
+ f"Duplicate Android resource {name!r} across "
947
+ f"{resource_paths[name]} and {item.path}"
948
+ )
949
+ resources.update(parsed)
950
+ resource_paths.update({name: item.path for name in parsed})
951
+ return resources
952
+
953
+
954
+ # 规范化 printf 占位符签名。
955
+ def placeholder_signature(value: str) -> tuple[tuple[int, str], ...]:
956
+ implicit_position = 1
957
+ previous_position: Optional[int] = None
958
+ signature: list[tuple[int, str]] = []
959
+ for match in JAVA_PLACEHOLDER_RE.finditer(value):
960
+ specifier = match.group(5).lower()
961
+ if specifier in {"%", "n"}:
962
+ continue
963
+ flags = match.group(2)
964
+ if match.group(1):
965
+ position = int(match.group(1))
966
+ elif "<" in flags:
967
+ if previous_position is None:
968
+ raise ScriptError(f"Invalid relative printf placeholder in {value!r}")
969
+ position = previous_position
970
+ else:
971
+ position = implicit_position
972
+ implicit_position += 1
973
+ previous_position = position
974
+ if specifier.startswith("t"):
975
+ category = "datetime"
976
+ elif specifier[-1:] in {"d", "o", "x"}:
977
+ category = "integer"
978
+ elif specifier[-1:] in {"a", "e", "f", "g"}:
979
+ category = "float"
980
+ elif specifier[-1:] == "c":
981
+ category = "character"
982
+ elif specifier[-1:] == "b":
983
+ category = "boolean"
984
+ else:
985
+ category = "string"
986
+ signature.append((position, category))
987
+ return tuple(sorted(signature))
988
+
989
+
990
+ # 校验类型、数组长度与占位符。
991
+ def _validate_resource_value(
992
+ key: str, base: AndroidResource, target: AndroidResource, target_path: Path
993
+ ) -> None:
994
+ if base.kind != target.kind:
995
+ raise ScriptError(
996
+ f"Resource type mismatch for {key!r} in {target_path}: "
997
+ f"expected {base.kind}, got {target.kind}"
998
+ )
999
+ if base.kind == "string":
1000
+ if placeholder_signature(base.values) != placeholder_signature(target.values):
1001
+ raise ScriptError(f"Placeholder mismatch for {key!r} in {target_path}")
1002
+ return
1003
+ if base.kind == "string-array":
1004
+ if len(base.values) != len(target.values):
1005
+ raise ScriptError(f"Array length mismatch for {key!r} in {target_path}")
1006
+ for index, (base_value, target_value) in enumerate(
1007
+ zip(base.values, target.values)
1008
+ ):
1009
+ if placeholder_signature(base_value) != placeholder_signature(target_value):
1010
+ raise ScriptError(
1011
+ f"Placeholder mismatch for {key!r}[{index}] in {target_path}"
1012
+ )
1013
+ return
1014
+
1015
+ base_signatures = {placeholder_signature(value) for value in base.values.values()}
1016
+ if len(base_signatures) != 1:
1017
+ raise ScriptError(
1018
+ f"Base plural {key!r} has inconsistent placeholder signatures"
1019
+ )
1020
+ expected = next(iter(base_signatures))
1021
+ for quantity, value in target.values.items():
1022
+ if placeholder_signature(value) != expected:
1023
+ raise ScriptError(
1024
+ f"Placeholder mismatch for {key!r}[{quantity}] in {target_path}"
1025
+ )
1026
+
1027
+
1028
+ # 移除不完整数组以回退基础资源。
1029
+ def _remove_incomplete_arrays(
1030
+ path: Path, base: dict[str, AndroidResource]
1031
+ ) -> tuple[tuple[str, int, int], ...]:
1032
+ parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True))
1033
+ try:
1034
+ tree = ET.parse(path, parser=parser)
1035
+ except ET.ParseError as exc:
1036
+ raise ScriptError(f"Invalid Android XML {path}: {exc}") from exc
1037
+ root = tree.getroot()
1038
+ removed: list[tuple[str, int, int]] = []
1039
+ for element in list(root):
1040
+ if (
1041
+ not isinstance(element.tag, str)
1042
+ or _local_name(element.tag) != "string-array"
1043
+ ):
1044
+ continue
1045
+ name = element.get("name", "").strip()
1046
+ base_resource = base.get(name)
1047
+ if base_resource is None or base_resource.kind != "string-array":
1048
+ continue
1049
+ item_count = sum(
1050
+ 1
1051
+ for item in element
1052
+ if isinstance(item.tag, str) and _local_name(item.tag) == "item"
1053
+ )
1054
+ expected_count = len(base_resource.values)
1055
+ if item_count < expected_count:
1056
+ root.remove(element)
1057
+ removed.append((name, item_count, expected_count))
1058
+ if removed:
1059
+ ET.indent(tree, space=" ")
1060
+ tree.write(path, encoding="utf-8", xml_declaration=True)
1061
+ return tuple(removed)
1062
+
1063
+
1064
+ # 校验生成路径和翻译资源兼容性。
1065
+ def validate_resources(
1066
+ config: Config,
1067
+ *,
1068
+ pull_path: Optional[Path] = None,
1069
+ fallback_incomplete_arrays: bool = False,
1070
+ ) -> tuple[str, ...]:
1071
+ destination = pull_path or config.pull_path
1072
+ base = parse_push_resources(config)
1073
+ expected = {
1074
+ render_export_path(
1075
+ config.pull_file_structure_template,
1076
+ language,
1077
+ config.namespace,
1078
+ ): language
1079
+ for language in config.target_languages
1080
+ }
1081
+ if destination.exists():
1082
+ unexpected = sorted(
1083
+ {
1084
+ p.relative_to(destination).parts[0]
1085
+ for p in destination.rglob("*")
1086
+ if p.is_file()
1087
+ and PurePosixPath(p.relative_to(destination).as_posix()) not in expected
1088
+ }
1089
+ )
1090
+ if unexpected:
1091
+ raise ScriptError(
1092
+ f"Generated resources contain unexpected={','.join(unexpected)}"
1093
+ )
1094
+ validated_languages = []
1095
+ fallbacks = []
1096
+ for relative, language in sorted(expected.items(), key=lambda item: str(item[0])):
1097
+ path = destination.joinpath(*relative.parts)
1098
+ if not path.is_file():
1099
+ continue
1100
+ if fallback_incomplete_arrays:
1101
+ fallbacks.extend(
1102
+ (language, name, actual, expected_count)
1103
+ for name, actual, expected_count in _remove_incomplete_arrays(
1104
+ path, base
1105
+ )
1106
+ )
1107
+ target = parse_android_resources(path)
1108
+ unknown = sorted(set(target) - set(base))
1109
+ if unknown:
1110
+ raise ScriptError(
1111
+ f"Generated resource contains unknown keys in {path}: {', '.join(unknown)}"
1112
+ )
1113
+ for key, resource in target.items():
1114
+ _validate_resource_value(key, base[key], resource, path)
1115
+ validated_languages.append(language)
1116
+ if fallbacks:
1117
+ details = ", ".join(
1118
+ f"{language}:{name}({actual}/{expected_count})"
1119
+ for language, name, actual, expected_count in fallbacks
1120
+ )
1121
+ print(f"Array fallback applied: {details}")
1122
+ print(
1123
+ f"Validation completed: languages={','.join(validated_languages) or 'none'} "
1124
+ f"directory={destination}"
1125
+ )
1126
+ return tuple(validated_languages)
1127
+
1128
+
1129
+ # 从已安装包元数据读取唯一版本。
1130
+ def installed_version() -> str:
1131
+ try:
1132
+ return version("lingora-cli")
1133
+ except PackageNotFoundError:
1134
+ return "0.0.0.dev0"
1135
+
1136
+
1137
+ # 只创建无密钥配置,可填入显式连接参数,不覆盖已有文件。
1138
+ def initialize_config(path: Path, overrides: dict[str, str] | None = None) -> None:
1139
+ template = (
1140
+ files("lingora_cli").joinpath("config.example.json").read_text(encoding="utf-8")
1141
+ )
1142
+ if overrides:
1143
+ if set(overrides) - {"apiUrl", "projectId", "branch"}:
1144
+ raise ScriptError(
1145
+ "init only accepts API URL, project ID and branch overrides"
1146
+ )
1147
+ data = json.loads(template)
1148
+ if "apiUrl" in overrides:
1149
+ _validate_url(overrides["apiUrl"], "apiUrl")
1150
+ if "projectId" in overrides:
1151
+ try:
1152
+ uuid.UUID(overrides["projectId"])
1153
+ except ValueError as exc:
1154
+ raise ScriptError(
1155
+ "Configuration field 'projectId' must be a UUID"
1156
+ ) from exc
1157
+ if any(not value.strip() for value in overrides.values()):
1158
+ raise ScriptError(
1159
+ "Initial configuration parameters must be non-empty strings"
1160
+ )
1161
+ data.update(overrides)
1162
+ template = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
1163
+ with path.expanduser().open("x", encoding="utf-8") as output:
1164
+ output.write(template)
1165
+ print(
1166
+ f"Created {path}; configure your API URL, project, branch and resource paths."
1167
+ )
1168
+
1169
+
1170
+ # 声明独立操作及完整参数,不编排消费项目的 CI。
1171
+ def build_parser() -> argparse.ArgumentParser:
1172
+ from .options import build_parser as parser
1173
+
1174
+ return parser(installed_version())
1175
+
1176
+
1177
+ # 按命令路径查询帮助,不加载配置或触发任何 I/O。
1178
+ def command_help(parser: argparse.ArgumentParser, topic: list[str]) -> str:
1179
+ for name in topic:
1180
+ children = next(
1181
+ (
1182
+ action
1183
+ for action in parser._actions
1184
+ if isinstance(action, argparse._SubParsersAction)
1185
+ ),
1186
+ None,
1187
+ )
1188
+ if children is None or name not in children.choices:
1189
+ raise ScriptError(f"Unknown help topic: {name}")
1190
+ parser = children.choices[name]
1191
+ return parser.format_help()
1192
+
1193
+
1194
+ # 执行一个独立操作并返回可序列化的数据;写操作保留各自的确认门禁。
1195
+ def execute(
1196
+ args: argparse.Namespace, parser: argparse.ArgumentParser
1197
+ ) -> tuple[object, int]:
1198
+ from . import commands, delivery, options
1199
+
1200
+ if not math.isfinite(args.timeout) or args.timeout <= 0:
1201
+ raise ScriptError("--timeout must be greater than zero")
1202
+ if args.config is not None and args.no_config:
1203
+ raise ScriptError("--config and --no-config cannot be combined")
1204
+ if args.command == "help":
1205
+ return {"usage": command_help(parser, args.topic)}, 0
1206
+ if args.command == "init":
1207
+ path = args.config or default_config_path()
1208
+ overrides = {
1209
+ key: value
1210
+ for key, value in (
1211
+ ("apiUrl", args.api_url),
1212
+ ("projectId", args.project_id),
1213
+ ("branch", args.branch),
1214
+ )
1215
+ if value is not None
1216
+ }
1217
+ initialize_config(path, overrides)
1218
+ return {"path": str(path.resolve())}, 0
1219
+ path, data = options.read_options(args)
1220
+ if args.command == "delivery":
1221
+ if args.url:
1222
+ if args.namespace is None or not args.languages:
1223
+ raise ScriptError(
1224
+ "delivery check --url requires --namespace and --languages (including base)"
1225
+ )
1226
+ return delivery.check_url(
1227
+ args.url, args.namespace, args.languages, args.timeout
1228
+ ), 0
1229
+ config = load_config(path, data=data, operation="delivery")
1230
+ return delivery.check_delivery(config, args.alias, args.timeout), 0
1231
+ if args.command in {"branch", "merge", "tag"}:
1232
+ connection = options.project_config(data)
1233
+ api_key = require_api_key(args.api_key)
1234
+ return getattr(commands, args.command)(connection, api_key, args), 0
1235
+ config = load_config(
1236
+ path, data=options.resource_config(args, data), operation=args.command
1237
+ )
1238
+ if args.command == "extract":
1239
+ result = commands.extracted(config)
1240
+ if args.extract_command == "check":
1241
+ return {"valid": True, "keyCount": len(result["keys"])}, 0
1242
+ return result, 0
1243
+ if args.command == "validate":
1244
+ return {
1245
+ "languages": list(validate_resources(config)),
1246
+ "path": str(config.pull_path),
1247
+ }, 0
1248
+ branch = select_branch(args.branch, config.branch)
1249
+ api_key = require_api_key(args.api_key)
1250
+ if args.command == "push":
1251
+ return push_resources(
1252
+ config,
1253
+ api_key,
1254
+ branch,
1255
+ config.push_force_mode,
1256
+ config.push_remove_other_keys,
1257
+ timeout=args.timeout,
1258
+ ), 0
1259
+ if args.command == "pull":
1260
+ return pull_resources(config, api_key, branch, timeout=args.timeout), 0
1261
+ if args.command == "compare":
1262
+ result = commands.compare(config, api_key, branch, args.timeout)
1263
+ in_sync = not result["added"] and not result["unused"]
1264
+ return {**result, "inSync": in_sync}, int(args.check and not in_sync)
1265
+ if args.command == "sync":
1266
+ return commands.sync(config, api_key, branch, args), 0
1267
+ raise ScriptError("Unsupported command")
1268
+
1269
+
1270
+ # 屏蔽环境密钥和命令行密钥,包括参数解析尚未成功的情况。
1271
+ def redact(text: str, tokens: list[str]) -> str:
1272
+ secrets = [os.environ.get("LINGORA_API_KEY", "")]
1273
+ for index, token in enumerate(tokens):
1274
+ if token == "--api-key" and index + 1 < len(tokens):
1275
+ secrets.append(tokens[index + 1])
1276
+ elif token.startswith("--api-key="):
1277
+ secrets.append(token.split("=", 1)[1])
1278
+ for secret in secrets:
1279
+ if secret:
1280
+ text = text.replace(secret, "[REDACTED]")
1281
+ return text
1282
+
1283
+
1284
+ # 先屏蔽字符串值再序列化,避免含引号或换行的密钥破坏 JSON 或绕过脱敏。
1285
+ def safe_json(value: object, tokens: list[str], *, indent: int | None = None) -> str:
1286
+ # 只递归清理数据,不修改固定协议字段的名称。
1287
+ def clean(item: object) -> object:
1288
+ if isinstance(item, str):
1289
+ return redact(item, tokens)
1290
+ if isinstance(item, dict):
1291
+ return {key: clean(child) for key, child in item.items()}
1292
+ if isinstance(item, (list, tuple)):
1293
+ return [clean(child) for child in item]
1294
+ return item
1295
+
1296
+ return json.dumps(clean(value), ensure_ascii=False, indent=indent)
1297
+
1298
+
1299
+ # 为人和 MCP 提供同一个入口;JSON 模式的 stdout 只包含一个结果,诊断输出保留在 stderr。
1300
+ def main(argv: Optional[list[str]] = None) -> int:
1301
+ tokens = list(sys.argv[1:] if argv is None else argv)
1302
+ parser = build_parser()
1303
+ defaults = argparse.Namespace(
1304
+ config=None,
1305
+ no_config=False,
1306
+ api_url=None,
1307
+ project_id=None,
1308
+ api_key=None,
1309
+ branch=None,
1310
+ timeout=DEFAULT_TIMEOUT_SECONDS,
1311
+ json=False,
1312
+ )
1313
+ parse_error = io.StringIO()
1314
+ try:
1315
+ with redirect_stderr(parse_error):
1316
+ args = parser.parse_args(tokens, namespace=defaults)
1317
+ except SystemExit as exc:
1318
+ if exc.code and "--json" in tokens:
1319
+ print(
1320
+ safe_json(
1321
+ {
1322
+ "ok": False,
1323
+ "command": None,
1324
+ "data": None,
1325
+ "error": {"message": parse_error.getvalue().strip()},
1326
+ "exitCode": 2,
1327
+ },
1328
+ tokens,
1329
+ )
1330
+ )
1331
+ return 2
1332
+ print(redact(parse_error.getvalue(), tokens), end="", file=sys.stderr)
1333
+ raise
1334
+ output = io.StringIO()
1335
+ warnings = io.StringIO()
1336
+ error = None
1337
+ data = None
1338
+ try:
1339
+ with redirect_stdout(output), redirect_stderr(warnings):
1340
+ data, status = execute(args, parser)
1341
+ if status:
1342
+ error = {"message": "Local and remote key sets differ"}
1343
+ except (ScriptError, OSError, zipfile.BadZipFile) as exc:
1344
+ status = 1
1345
+ error = {"message": str(exc), "details": getattr(exc, "details", {})}
1346
+ command = " ".join(
1347
+ filter(
1348
+ None,
1349
+ (
1350
+ args.command,
1351
+ getattr(args, "extract_command", None),
1352
+ getattr(args, "delivery_command", None),
1353
+ ),
1354
+ )
1355
+ )
1356
+ if args.json:
1357
+ print(
1358
+ safe_json(
1359
+ {
1360
+ "ok": status == 0,
1361
+ "command": command,
1362
+ "data": data,
1363
+ "error": error,
1364
+ "exitCode": status,
1365
+ },
1366
+ tokens,
1367
+ )
1368
+ )
1369
+ elif error:
1370
+ print(redact(f"error: {error['message']}", tokens), file=sys.stderr)
1371
+ elif args.command == "help":
1372
+ print(data["usage"], end="")
1373
+ elif output.getvalue():
1374
+ print(redact(output.getvalue(), tokens), end="")
1375
+ else:
1376
+ print(safe_json(data, tokens, indent=2))
1377
+ if warnings.getvalue():
1378
+ print(redact(warnings.getvalue(), tokens), end="", file=sys.stderr)
1379
+ return status
1380
+
1381
+
1382
+ if __name__ == "__main__":
1383
+ raise SystemExit(main())