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/__init__.py +1 -0
- lingora_cli/__main__.py +5 -0
- lingora_cli/cli.py +1383 -0
- lingora_cli/commands.py +351 -0
- lingora_cli/config.example.json +40 -0
- lingora_cli/delivery.py +156 -0
- lingora_cli/options.py +307 -0
- lingora_cli-0.1.0rc1.dist-info/METADATA +98 -0
- lingora_cli-0.1.0rc1.dist-info/RECORD +12 -0
- lingora_cli-0.1.0rc1.dist-info/WHEEL +5 -0
- lingora_cli-0.1.0rc1.dist-info/entry_points.txt +2 -0
- lingora_cli-0.1.0rc1.dist-info/top_level.txt +1 -0
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())
|