elemctl 0.1.0__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.
- elemctl/__init__.py +25 -0
- elemctl/auth.py +142 -0
- elemctl/build.py +288 -0
- elemctl/cli.py +651 -0
- elemctl/client.py +557 -0
- elemctl/config.py +125 -0
- elemctl/deploy.py +236 -0
- elemctl/errors.py +54 -0
- elemctl/mcp_server.py +210 -0
- elemctl/transport.py +53 -0
- elemctl/versions.py +45 -0
- elemctl-0.1.0.dist-info/METADATA +201 -0
- elemctl-0.1.0.dist-info/RECORD +17 -0
- elemctl-0.1.0.dist-info/WHEEL +5 -0
- elemctl-0.1.0.dist-info/entry_points.txt +2 -0
- elemctl-0.1.0.dist-info/licenses/LICENSE +21 -0
- elemctl-0.1.0.dist-info/top_level.txt +1 -0
elemctl/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""elemctl – CLI, MCP-сервер и библиотека для Console API v2 платформы
|
|
2
|
+
1С:Предприятие.Элемент (1cmycloud).
|
|
3
|
+
|
|
4
|
+
Публичный API библиотеки:
|
|
5
|
+
|
|
6
|
+
from elemctl import Config, ElementClient
|
|
7
|
+
from elemctl.deploy import deploy_from_sources
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .client import ElementClient
|
|
11
|
+
from .config import Config
|
|
12
|
+
from .errors import ApiError, BuildError, ConfigError, ElemctlError, TransportError
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ApiError",
|
|
18
|
+
"BuildError",
|
|
19
|
+
"Config",
|
|
20
|
+
"ConfigError",
|
|
21
|
+
"ElemctlError",
|
|
22
|
+
"ElementClient",
|
|
23
|
+
"TransportError",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
elemctl/auth.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Получение и кеширование токена Console API.
|
|
2
|
+
|
|
3
|
+
Токен живёт около часа, поэтому кешируется в файле в системном каталоге
|
|
4
|
+
временных файлов с TTL один час; ключ кеша различает пары base_url +
|
|
5
|
+
client_id. При 401 клиент запрашивает токен принудительно (force=True).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .errors import ApiError
|
|
18
|
+
|
|
19
|
+
TOKEN_TTL = 3600.0
|
|
20
|
+
# Запас до истечения: кеш считается годным, если жить осталось больше запаса.
|
|
21
|
+
EXPIRY_MARGIN = 30.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_token(payload):
|
|
25
|
+
"""Достать токен из ответа сервера.
|
|
26
|
+
|
|
27
|
+
Токен лежит в первом непустом из полей id_token, token, value,
|
|
28
|
+
access_token. Особый случай: значение "Not implemented" токеном
|
|
29
|
+
не является и пропускается.
|
|
30
|
+
"""
|
|
31
|
+
if not isinstance(payload, dict):
|
|
32
|
+
return None
|
|
33
|
+
for key in ("id_token", "token", "value", "access_token"):
|
|
34
|
+
value = payload.get(key)
|
|
35
|
+
if not isinstance(value, str):
|
|
36
|
+
continue
|
|
37
|
+
value = value.strip()
|
|
38
|
+
if not value or value == "Not implemented":
|
|
39
|
+
continue
|
|
40
|
+
return value
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TokenManager:
|
|
45
|
+
"""Выдаёт действующий Bearer-токен, пряча кеширование и обновление."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, config, transport, cache_dir=None):
|
|
48
|
+
self._config = config
|
|
49
|
+
self._transport = transport
|
|
50
|
+
self._cache_dir = Path(cache_dir) if cache_dir else Path(tempfile.gettempdir())
|
|
51
|
+
self._token = None
|
|
52
|
+
self._expires_at = 0.0
|
|
53
|
+
|
|
54
|
+
def get_token(self, force=False):
|
|
55
|
+
"""Вернуть токен: из памяти, из файлового кеша или запросив новый."""
|
|
56
|
+
now = time.time()
|
|
57
|
+
if not force:
|
|
58
|
+
if self._token and now + EXPIRY_MARGIN < self._expires_at:
|
|
59
|
+
return self._token
|
|
60
|
+
cached = self._read_cache()
|
|
61
|
+
if cached and now + EXPIRY_MARGIN < cached.get("expires", 0):
|
|
62
|
+
self._token = cached["token"]
|
|
63
|
+
self._expires_at = cached["expires"]
|
|
64
|
+
return self._token
|
|
65
|
+
token = self._request_token()
|
|
66
|
+
self._token = token
|
|
67
|
+
self._expires_at = now + TOKEN_TTL
|
|
68
|
+
self._write_cache()
|
|
69
|
+
return token
|
|
70
|
+
|
|
71
|
+
# -- внутреннее -----------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _cache_path(self):
|
|
74
|
+
key = f"{self._config.base_url}|{self._config.client_id}"
|
|
75
|
+
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
|
|
76
|
+
return self._cache_dir / f"elemctl-token-{digest}.json"
|
|
77
|
+
|
|
78
|
+
def _read_cache(self):
|
|
79
|
+
try:
|
|
80
|
+
data = json.loads(self._cache_path().read_text(encoding="utf-8"))
|
|
81
|
+
except (OSError, ValueError):
|
|
82
|
+
return None
|
|
83
|
+
if isinstance(data, dict) and isinstance(data.get("token"), str):
|
|
84
|
+
return data
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def _write_cache(self):
|
|
88
|
+
try:
|
|
89
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
payload = {"token": self._token, "expires": self._expires_at}
|
|
91
|
+
self._cache_path().write_text(
|
|
92
|
+
json.dumps(payload), encoding="utf-8"
|
|
93
|
+
)
|
|
94
|
+
except OSError:
|
|
95
|
+
# Кеш – только ускорение; его недоступность не должна ломать работу.
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def _request_token(self):
|
|
99
|
+
config = self._config.require()
|
|
100
|
+
credentials = f"{config.client_id}:{config.client_secret}".encode("utf-8")
|
|
101
|
+
basic = base64.b64encode(credentials).decode("ascii")
|
|
102
|
+
url = f"{config.base_url}/console/sys/token"
|
|
103
|
+
response = self._transport.request(
|
|
104
|
+
"POST",
|
|
105
|
+
url,
|
|
106
|
+
headers={
|
|
107
|
+
"Authorization": f"Basic {basic}",
|
|
108
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
109
|
+
},
|
|
110
|
+
data=b"grant_type=client_credentials",
|
|
111
|
+
timeout=config.timeout,
|
|
112
|
+
)
|
|
113
|
+
if not 200 <= response.status < 300:
|
|
114
|
+
raise ApiError(
|
|
115
|
+
f"не удалось получить токен: HTTP {response.status}",
|
|
116
|
+
status=response.status,
|
|
117
|
+
method="POST",
|
|
118
|
+
url=url,
|
|
119
|
+
body=_safe_body(response),
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
payload = response.json()
|
|
123
|
+
except ValueError:
|
|
124
|
+
payload = None
|
|
125
|
+
token = extract_token(payload)
|
|
126
|
+
if not token:
|
|
127
|
+
raise ApiError(
|
|
128
|
+
"токен не найден в ответе сервера (ожидались поля id_token, token, value или access_token)",
|
|
129
|
+
status=response.status,
|
|
130
|
+
method="POST",
|
|
131
|
+
url=url,
|
|
132
|
+
body=payload if payload is not None else response.text(),
|
|
133
|
+
)
|
|
134
|
+
return token
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _safe_body(response):
|
|
138
|
+
"""Тело ответа для деталей ошибки: JSON, а если не разбирается – текст."""
|
|
139
|
+
try:
|
|
140
|
+
return response.json()
|
|
141
|
+
except ValueError:
|
|
142
|
+
return response.text()
|
elemctl/build.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Локальная сборка файла .xasm/.xlib из исходников проекта.
|
|
2
|
+
|
|
3
|
+
Файл сборки – ZIP-архив (deflate): в корне манифест Assembly.yaml, далее
|
|
4
|
+
файлы проекта путями {vendor}/{name}/... относительно корня репозитория
|
|
5
|
+
(раздел 5 спецификации). Разделители путей в архиве – прямые слэши.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import zipfile
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .errors import BuildError
|
|
18
|
+
from .versions import next_version
|
|
19
|
+
|
|
20
|
+
PROJECT_FILE = "Проект.yaml"
|
|
21
|
+
|
|
22
|
+
# Расширения, попадающие в архив: исходники, изображения, веб-ресурсы.
|
|
23
|
+
ALLOWED_EXTENSIONS = {
|
|
24
|
+
".yaml", ".xbsl", ".xbql", ".md", ".txt", ".json",
|
|
25
|
+
".png", ".svg", ".jpg", ".jpeg", ".gif", ".webp", ".ico",
|
|
26
|
+
".css", ".html", ".js", ".woff", ".woff2", ".ttf", ".eot",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Каталоги, исключаемые целиком (плюс все скрытые – с точки в начале).
|
|
30
|
+
EXCLUDED_DIRS = {".git", ".claude", ".github", "__pycache__", "node_modules", ".venv"}
|
|
31
|
+
|
|
32
|
+
# Файлы, исключаемые по точному имени.
|
|
33
|
+
EXCLUDED_FILES = {".gitignore", ".env", ".DS_Store"}
|
|
34
|
+
|
|
35
|
+
# Файлы, исключаемые по расширению (готовые архивы сборок).
|
|
36
|
+
EXCLUDED_SUFFIXES = (".xasm", ".xlib")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ProjectMeta:
|
|
41
|
+
"""Метаданные проекта из Проект.yaml и раскладки каталогов."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
vendor: str
|
|
45
|
+
base_version: str
|
|
46
|
+
kind: str # "Application" или "Library"
|
|
47
|
+
project_dir: Path
|
|
48
|
+
repo_root: Path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class BuildResult:
|
|
53
|
+
"""Результат локальной сборки архива."""
|
|
54
|
+
|
|
55
|
+
file: Path
|
|
56
|
+
name: str
|
|
57
|
+
vendor: str
|
|
58
|
+
version: str
|
|
59
|
+
kind: str
|
|
60
|
+
branch: str
|
|
61
|
+
commit: str
|
|
62
|
+
files: list = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_flat_yaml(text):
|
|
66
|
+
"""Разобрать плоские пары "ключ: значение" верхнего уровня YAML.
|
|
67
|
+
|
|
68
|
+
Вложенные строки (с отступом), пустые строки и комментарии пропускаются –
|
|
69
|
+
для Проект.yaml этого достаточно.
|
|
70
|
+
"""
|
|
71
|
+
values = {}
|
|
72
|
+
for raw_line in text.splitlines():
|
|
73
|
+
if not raw_line.strip() or raw_line.lstrip().startswith("#"):
|
|
74
|
+
continue
|
|
75
|
+
if raw_line[:1] in (" ", "\t"):
|
|
76
|
+
continue
|
|
77
|
+
if ":" not in raw_line:
|
|
78
|
+
continue
|
|
79
|
+
key, _, value = raw_line.partition(":")
|
|
80
|
+
key = key.strip()
|
|
81
|
+
value = value.strip()
|
|
82
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
83
|
+
value = value[1:-1]
|
|
84
|
+
if key:
|
|
85
|
+
values[key] = value
|
|
86
|
+
return values
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_project_dir(start=None):
|
|
90
|
+
"""Найти каталог проекта: первый каталог с Проект.yaml вглубь от start."""
|
|
91
|
+
base = Path(start) if start else Path.cwd()
|
|
92
|
+
if (base / PROJECT_FILE).is_file():
|
|
93
|
+
return base
|
|
94
|
+
for root, dirs, files in os.walk(base):
|
|
95
|
+
dirs[:] = sorted(d for d in dirs if not _is_excluded_dir(d))
|
|
96
|
+
if PROJECT_FILE in files:
|
|
97
|
+
return Path(root)
|
|
98
|
+
raise BuildError(f"каталог проекта не найден: нет {PROJECT_FILE} внутри {base}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def read_project_meta(project_dir):
|
|
102
|
+
"""Прочитать метаданные проекта и проверить раскладку каталогов.
|
|
103
|
+
|
|
104
|
+
Каталог проекта обязан лежать по схеме {repo}/{vendor}/{name}/Проект.yaml.
|
|
105
|
+
"""
|
|
106
|
+
project_dir = Path(project_dir).resolve()
|
|
107
|
+
project_file = project_dir / PROJECT_FILE
|
|
108
|
+
if not project_file.is_file():
|
|
109
|
+
raise BuildError(f"не найден {project_file}")
|
|
110
|
+
values = parse_flat_yaml(project_file.read_text(encoding="utf-8-sig"))
|
|
111
|
+
|
|
112
|
+
name = values.get("Имя", "").strip()
|
|
113
|
+
vendor = values.get("Поставщик", "").strip()
|
|
114
|
+
if not name or not vendor:
|
|
115
|
+
raise BuildError(
|
|
116
|
+
f'в {project_file} должны быть заполнены поля "Имя" и "Поставщик"'
|
|
117
|
+
)
|
|
118
|
+
if project_dir.name != name or project_dir.parent.name != vendor:
|
|
119
|
+
raise BuildError(
|
|
120
|
+
"каталог проекта обязан лежать по схеме {repo}/{vendor}/{name}/"
|
|
121
|
+
+ PROJECT_FILE
|
|
122
|
+
+ f": ожидался путь .../{vendor}/{name}, фактический – "
|
|
123
|
+
+ f"{project_dir.parent.name}/{project_dir.name}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
base_version = values.get("Версия", "").strip() or "1.0"
|
|
127
|
+
kind = "Library" if values.get("ВидПроекта", "").strip() == "Библиотека" else "Application"
|
|
128
|
+
return ProjectMeta(
|
|
129
|
+
name=name,
|
|
130
|
+
vendor=vendor,
|
|
131
|
+
base_version=base_version,
|
|
132
|
+
kind=kind,
|
|
133
|
+
project_dir=project_dir,
|
|
134
|
+
repo_root=project_dir.parent.parent,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def collect_project_files(project_dir):
|
|
139
|
+
"""Отобрать файлы проекта для архива по правилам раздела 5 спецификации."""
|
|
140
|
+
project_dir = Path(project_dir)
|
|
141
|
+
selected = []
|
|
142
|
+
for root, dirs, files in os.walk(project_dir):
|
|
143
|
+
dirs[:] = sorted(d for d in dirs if not _is_excluded_dir(d))
|
|
144
|
+
for file_name in sorted(files):
|
|
145
|
+
if _is_excluded_file(file_name):
|
|
146
|
+
continue
|
|
147
|
+
if Path(file_name).suffix.lower() not in ALLOWED_EXTENSIONS:
|
|
148
|
+
continue
|
|
149
|
+
selected.append(Path(root) / file_name)
|
|
150
|
+
return selected
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def git_metadata(project_dir):
|
|
154
|
+
"""Хэш коммита и имя ветки git-репозитория с каталогом проекта.
|
|
155
|
+
|
|
156
|
+
При недоступности git (нет команды, не репозиторий) – пустые строки.
|
|
157
|
+
"""
|
|
158
|
+
commit = _git_output(project_dir, "rev-parse", "HEAD")
|
|
159
|
+
branch = _git_output(project_dir, "rev-parse", "--abbrev-ref", "HEAD")
|
|
160
|
+
return commit, branch
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_manifest(*, kind, vendor, name, version, created, branch="", commit=""):
|
|
164
|
+
"""Собрать текст манифеста Assembly.yaml."""
|
|
165
|
+
lines = [
|
|
166
|
+
"ManifestVersion: 1.0",
|
|
167
|
+
f"ProjectKind: {kind}",
|
|
168
|
+
f"Vendor: {vendor}",
|
|
169
|
+
f"Name: {name}",
|
|
170
|
+
f"Version: {version}",
|
|
171
|
+
"Created: " + created.strftime("%Y.%m.%d %H:%M:%S"),
|
|
172
|
+
f"BranchName: {branch}",
|
|
173
|
+
f"CommitId: {commit}",
|
|
174
|
+
]
|
|
175
|
+
if kind == "Library":
|
|
176
|
+
lines.append("Release:")
|
|
177
|
+
return "\n".join(lines) + "\n"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def build_assembly(
|
|
181
|
+
project_dir=None,
|
|
182
|
+
*,
|
|
183
|
+
output_dir=None,
|
|
184
|
+
version="",
|
|
185
|
+
last_build_version="",
|
|
186
|
+
branch=None,
|
|
187
|
+
commit=None,
|
|
188
|
+
kind="",
|
|
189
|
+
now=None,
|
|
190
|
+
):
|
|
191
|
+
"""Собрать архив сборки из исходников проекта; вернуть BuildResult.
|
|
192
|
+
|
|
193
|
+
Версия: явная version, иначе автоинкремент от last_build_version,
|
|
194
|
+
а без обеих – "{базовая версия}-1". branch и commit переопределяют
|
|
195
|
+
git-метаданные (None – взять из git). kind переопределяет вид проекта
|
|
196
|
+
("application"/"library").
|
|
197
|
+
"""
|
|
198
|
+
directory = find_project_dir(project_dir) if project_dir else find_project_dir()
|
|
199
|
+
meta = read_project_meta(directory)
|
|
200
|
+
|
|
201
|
+
project_kind = _normalize_kind(kind) or meta.kind
|
|
202
|
+
build_version = version.strip() if version else next_version(
|
|
203
|
+
meta.base_version, last_build_version or None
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
git_commit, git_branch = ("", "")
|
|
207
|
+
if branch is None or commit is None:
|
|
208
|
+
git_commit, git_branch = git_metadata(meta.project_dir)
|
|
209
|
+
branch_name = git_branch if branch is None else branch
|
|
210
|
+
commit_id = git_commit if commit is None else commit
|
|
211
|
+
|
|
212
|
+
created = now or datetime.now(timezone.utc)
|
|
213
|
+
manifest = build_manifest(
|
|
214
|
+
kind=project_kind,
|
|
215
|
+
vendor=meta.vendor,
|
|
216
|
+
name=meta.name,
|
|
217
|
+
version=build_version,
|
|
218
|
+
created=created,
|
|
219
|
+
branch=branch_name,
|
|
220
|
+
commit=commit_id,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
extension = ".xlib" if project_kind == "Library" else ".xasm"
|
|
224
|
+
target_dir = Path(output_dir) if output_dir else Path.cwd()
|
|
225
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
archive_path = target_dir / f"{meta.name} {build_version}{extension}"
|
|
227
|
+
|
|
228
|
+
files = collect_project_files(meta.project_dir)
|
|
229
|
+
archive_names = []
|
|
230
|
+
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
231
|
+
archive.writestr("Assembly.yaml", manifest)
|
|
232
|
+
for file_path in files:
|
|
233
|
+
relative = file_path.relative_to(meta.project_dir)
|
|
234
|
+
arc_name = "/".join((meta.vendor, meta.name) + relative.parts)
|
|
235
|
+
archive.write(file_path, arcname=arc_name)
|
|
236
|
+
archive_names.append(arc_name)
|
|
237
|
+
|
|
238
|
+
return BuildResult(
|
|
239
|
+
file=archive_path,
|
|
240
|
+
name=meta.name,
|
|
241
|
+
vendor=meta.vendor,
|
|
242
|
+
version=build_version,
|
|
243
|
+
kind=project_kind,
|
|
244
|
+
branch=branch_name,
|
|
245
|
+
commit=commit_id,
|
|
246
|
+
files=archive_names,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# -- внутреннее ---------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _is_excluded_dir(name):
|
|
254
|
+
return name in EXCLUDED_DIRS or name.startswith(".")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _is_excluded_file(name):
|
|
258
|
+
if name in EXCLUDED_FILES:
|
|
259
|
+
return True
|
|
260
|
+
return name.lower().endswith(EXCLUDED_SUFFIXES)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _normalize_kind(kind):
|
|
264
|
+
value = (kind or "").strip().lower()
|
|
265
|
+
if not value:
|
|
266
|
+
return ""
|
|
267
|
+
if value in ("library", "библиотека"):
|
|
268
|
+
return "Library"
|
|
269
|
+
if value in ("application", "приложение"):
|
|
270
|
+
return "Application"
|
|
271
|
+
raise BuildError(f"неизвестный вид проекта: {kind} (ожидалось application или library)")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _git_output(directory, *args):
|
|
275
|
+
try:
|
|
276
|
+
completed = subprocess.run(
|
|
277
|
+
["git", "-C", str(directory), *args],
|
|
278
|
+
capture_output=True,
|
|
279
|
+
text=True,
|
|
280
|
+
encoding="utf-8",
|
|
281
|
+
errors="replace",
|
|
282
|
+
timeout=15,
|
|
283
|
+
)
|
|
284
|
+
except (OSError, subprocess.SubprocessError):
|
|
285
|
+
return ""
|
|
286
|
+
if completed.returncode != 0:
|
|
287
|
+
return ""
|
|
288
|
+
return completed.stdout.strip()
|