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 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()