elemctl 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (36) hide show
  1. {elemctl-0.2.0 → elemctl-0.3.0}/PKG-INFO +8 -1
  2. {elemctl-0.2.0 → elemctl-0.3.0}/README.md +7 -0
  3. {elemctl-0.2.0 → elemctl-0.3.0}/docs/SPEC.md +19 -2
  4. {elemctl-0.2.0 → elemctl-0.3.0}/pyproject.toml +1 -1
  5. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/__init__.py +1 -1
  6. elemctl-0.3.0/src/elemctl/__main__.py +14 -0
  7. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/cli.py +69 -14
  8. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/client.py +22 -2
  9. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/mcp_server.py +47 -12
  10. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl.egg-info/PKG-INFO +8 -1
  11. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl.egg-info/SOURCES.txt +1 -0
  12. elemctl-0.3.0/tests/test_cli.py +259 -0
  13. {elemctl-0.2.0 → elemctl-0.3.0}/tests/test_client.py +28 -0
  14. {elemctl-0.2.0 → elemctl-0.3.0}/tests/test_mcp.py +18 -0
  15. elemctl-0.2.0/tests/test_cli.py +0 -144
  16. {elemctl-0.2.0 → elemctl-0.3.0}/.env.example +0 -0
  17. {elemctl-0.2.0 → elemctl-0.3.0}/LICENSE +0 -0
  18. {elemctl-0.2.0 → elemctl-0.3.0}/MANIFEST.in +0 -0
  19. {elemctl-0.2.0 → elemctl-0.3.0}/NOTICE +0 -0
  20. {elemctl-0.2.0 → elemctl-0.3.0}/ORIGIN.md +0 -0
  21. {elemctl-0.2.0 → elemctl-0.3.0}/setup.cfg +0 -0
  22. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/auth.py +0 -0
  23. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/build.py +0 -0
  24. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/config.py +0 -0
  25. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/deploy.py +0 -0
  26. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/errors.py +0 -0
  27. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/transport.py +0 -0
  28. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl/versions.py +0 -0
  29. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl.egg-info/dependency_links.txt +0 -0
  30. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl.egg-info/entry_points.txt +0 -0
  31. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl.egg-info/requires.txt +0 -0
  32. {elemctl-0.2.0 → elemctl-0.3.0}/src/elemctl.egg-info/top_level.txt +0 -0
  33. {elemctl-0.2.0 → elemctl-0.3.0}/tests/test_build.py +0 -0
  34. {elemctl-0.2.0 → elemctl-0.3.0}/tests/test_config.py +0 -0
  35. {elemctl-0.2.0 → elemctl-0.3.0}/tests/test_deploy.py +0 -0
  36. {elemctl-0.2.0 → elemctl-0.3.0}/tests/test_versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: elemctl
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: CLI, MCP-сервер и библиотека для Console API v2 платформы 1С:Предприятие.Элемент (1cmycloud)
5
5
  Author: KeyFire
6
6
  License-Expression: MIT
@@ -112,6 +112,9 @@ elemctl apps list
112
112
  # карточка приложения (статус, uri, фактическая версия проекта)
113
113
  elemctl apps get <app-id>
114
114
 
115
+ # создать приложение, только если его ещё нет: {"id": ..., "created": true|false}
116
+ elemctl apps ensure acme-crm-dev --project-id <project-id> --latest-build --wait
117
+
115
118
  # полный цикл деплоя из исходников с проверкой применения
116
119
  elemctl deploy --app-id <app-id> --project-id <project-id> --project-dir acme/crm
117
120
 
@@ -184,6 +187,10 @@ Assembly.yaml # манифест: ProjectKind, Vendor, Name, Version,
184
187
  `elemctl apps create <имя> --project-id <id> --latest-build` (MCP-инструмент
185
188
  `create_app` подставляет последнюю сборку автоматически), а после создания -
186
189
  `elemctl deploy`.
190
+ - Удалённые приложения остаются в списке платформы со статусом `Deleted` и
191
+ прежним `id`, на котором `apps get` и `deploy` отвечают 404. `apps find` и
192
+ `apps ensure` их пропускают; вернуть прежний поиск - `apps find
193
+ --include-deleted`.
187
194
  - Приложение с неопубликованными правками в среде разработки платформа
188
195
  удалить не даёт (HTTP 400 `FAILED_PRECONDITION`), принудительного удаления
189
196
  в Console API нет - только через панель управления; elemctl подскажет это
@@ -84,6 +84,9 @@ elemctl apps list
84
84
  # карточка приложения (статус, uri, фактическая версия проекта)
85
85
  elemctl apps get <app-id>
86
86
 
87
+ # создать приложение, только если его ещё нет: {"id": ..., "created": true|false}
88
+ elemctl apps ensure acme-crm-dev --project-id <project-id> --latest-build --wait
89
+
87
90
  # полный цикл деплоя из исходников с проверкой применения
88
91
  elemctl deploy --app-id <app-id> --project-id <project-id> --project-dir acme/crm
89
92
 
@@ -156,6 +159,10 @@ Assembly.yaml # манифест: ProjectKind, Vendor, Name, Version,
156
159
  `elemctl apps create <имя> --project-id <id> --latest-build` (MCP-инструмент
157
160
  `create_app` подставляет последнюю сборку автоматически), а после создания -
158
161
  `elemctl deploy`.
162
+ - Удалённые приложения остаются в списке платформы со статусом `Deleted` и
163
+ прежним `id`, на котором `apps get` и `deploy` отвечают 404. `apps find` и
164
+ `apps ensure` их пропускают; вернуть прежний поиск - `apps find
165
+ --include-deleted`.
159
166
  - Приложение с неопубликованными правками в среде разработки платформа
160
167
  удалить не даёт (HTTP 400 `FAILED_PRECONDITION`), принудительного удаления
161
168
  в Console API нет - только через панель управления; elemctl подскажет это
@@ -255,13 +255,30 @@ stderr и код возврата 1.
255
255
  Команды (в скобках - существенные флаги):
256
256
 
257
257
  - `token` - получить и напечатать токен.
258
- - `apps list [--name]`, `apps get [APP_ID]`, `apps find NAME`,
258
+ - `apps list [--name]`, `apps get [APP_ID]`, `apps find NAME [--include-deleted]`,
259
259
  `apps create NAME [--project-id --version-id --latest-build --space-id
260
+ --tech-version --no-dev-mode --wait]`,
261
+ `apps ensure NAME [--project-id --version-id --latest-build --space-id
260
262
  --tech-version --no-dev-mode --wait]`, `apps delete APP_ID`,
261
263
  `apps start [APP_ID]`, `apps stop [APP_ID]`.
262
264
  - `apps find` ищет по точному совпадению (без учёта регистра) имени среди
263
265
  полей `name`, `display-name`, `publication-context`; вывод
264
- `{"id": ..., "found": true|false}`, код возврата 1 если не найдено.
266
+ `{"id": ..., "found": true|false}`, код возврата 0 в обоих случаях –
267
+ отсутствие приложения это ответ, а не ошибка. Ненулевой код возврата
268
+ означает сбой запроса и сопровождается JSON'ом с полем `error` в stderr.
269
+ В скриптах проверять поле `found`, а не код возврата.
270
+ - Удалённые приложения остаются в списке платформы со статусом `Deleted` и
271
+ прежним `id`. `apps find` их ПРОПУСКАЕТ: найденный id обязан быть пригоден
272
+ для работы, иначе вызывающий получает id, на котором `apps get` и `deploy`
273
+ отвечают 404. Флаг `--include-deleted` возвращает прежнее поведение – поиск
274
+ среди всех приложений, включая удалённые.
275
+ - `apps ensure` идемпотентно приводит приложение с данным именем в
276
+ существование: ищет по правилам `apps find` (удалённые не в счёт) и создаёт
277
+ только при отсутствии. Вывод `{"id": ..., "created": true|false}`;
278
+ `created: false` означает, что приложение уже было и НЕ трогалось. Флаги
279
+ создания те же, что у `apps create`, и действуют, только когда создание
280
+ происходит. Существующее приложение никогда не пересоздаётся: `delete` +
281
+ `create` дают новый URL и рвут внешние привязки к прежнему.
265
282
  - `--latest-build` - взять источником последнюю сборку проекта (защита от
266
283
  пустого каркаса, п. 6.2); `--wait` - дождаться готовности (п. 6.4) и
267
284
  вывести финальную карточку.
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "elemctl"
9
- version = "0.2.0"
9
+ version = "0.3.0"
10
10
  description = "CLI, MCP-сервер и библиотека для Console API v2 платформы 1С:Предприятие.Элемент (1cmycloud)"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -11,7 +11,7 @@ from .client import ElementClient
11
11
  from .config import Config
12
12
  from .errors import ApiError, BuildError, ConfigError, ElemctlError, TransportError
13
13
 
14
- __version__ = "0.2.0"
14
+ __version__ = "0.3.0"
15
15
 
16
16
  __all__ = [
17
17
  "ApiError",
@@ -0,0 +1,14 @@
1
+ """Запуск пакета как модуля: python -m elemctl <аргументы>.
2
+
3
+ Нужен тем, кто вызывает elemctl текущим интерпретатором, не полагаясь на
4
+ консольную точку входа в PATH (например, обёртки в чужих репозиториях).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ from .cli import main
12
+
13
+ if __name__ == "__main__":
14
+ sys.exit(main())
@@ -97,18 +97,32 @@ def cmd_apps_get(args):
97
97
 
98
98
 
99
99
  def cmd_apps_find(args):
100
+ """Поиск приложения по имени. Отсутствие приложения – это ответ, а не ошибка.
101
+
102
+ Код возврата 0 в обоих случаях: признак несёт поле found. Ненулевой код возврата
103
+ означает сбой запроса (нет доступа, сеть, конфигурация) и сопровождается JSON'ом
104
+ с полем error в stderr – иначе вызывающий не отличит "стенда нет" от "не смогли спросить".
105
+
106
+ Удалённые приложения (статус Deleted) по умолчанию пропускаются, чтобы найденный id
107
+ был пригоден для работы; флаг --include-deleted возвращает поиск среди всех приложений.
108
+ """
100
109
  client = make_client(_config(args))
101
- app = client.find_app(args.name)
110
+ app = client.find_app(args.name, include_deleted=args.include_deleted)
102
111
  if app is None:
103
112
  _emit({"id": None, "found": False})
104
- return 1
113
+ return 0
105
114
  _emit({"id": app.get("id"), "found": True})
106
115
  return 0
107
116
 
108
117
 
109
- def cmd_apps_create(args):
110
- config = _config(args)
111
- client = make_client(config)
118
+ def _create_app_from_args(client, config, args):
119
+ """Создать приложение по флагам создания; вернуть карточку.
120
+
121
+ Общая логика apps create и apps ensure. Источник – указанная сборка
122
+ (--version-id), последняя сборка проекта (--latest-build) либо проект
123
+ целиком (--project-id, чревато пустым каркасом). При --wait дожидается
124
+ готовности приложения.
125
+ """
112
126
  project_id = args.project_id or config.project_id
113
127
  version_id = args.version_id
114
128
 
@@ -143,7 +157,34 @@ def cmd_apps_create(args):
143
157
  app_id = (card or {}).get("id")
144
158
  if app_id:
145
159
  card = client.wait_app_ready(app_id, log=_progress)
146
- _emit(card)
160
+ return card
161
+
162
+
163
+ def cmd_apps_create(args):
164
+ config = _config(args)
165
+ client = make_client(config)
166
+ _emit(_create_app_from_args(client, config, args))
167
+ return 0
168
+
169
+
170
+ def cmd_apps_ensure(args):
171
+ """Идемпотентно привести приложение с данным именем в существование.
172
+
173
+ Ищет приложение по правилам apps find (удалённые в статусе Deleted не в
174
+ счёт): если оно уже есть – ничего не делает и возвращает created: false;
175
+ если нет – создаёт по флагам создания и возвращает created: true.
176
+ Существующее приложение никогда не пересоздаётся: delete + create дают
177
+ новый URL и рвут внешние привязки к прежнему. Код возврата 0 в обоих
178
+ случаях; сбой запроса – JSON с полем error в stderr и код возврата 1.
179
+ """
180
+ config = _config(args)
181
+ client = make_client(config)
182
+ existing = client.find_app(args.name)
183
+ if existing is not None:
184
+ _emit({"id": existing.get("id"), "created": False})
185
+ return 0
186
+ card = _create_app_from_args(client, config, args)
187
+ _emit({"id": (card or {}).get("id"), "created": True})
147
188
  return 0
148
189
 
149
190
 
@@ -394,6 +435,18 @@ def cmd_mcp(args):
394
435
  # -- парсер --------------------------------------------------------------------
395
436
 
396
437
 
438
+ def _add_create_flags(p):
439
+ """Добавить флаги источника и создания приложения (общие для apps create и apps ensure)."""
440
+ p.add_argument("name", metavar="NAME")
441
+ p.add_argument("--project-id", help="проект-источник")
442
+ p.add_argument("--version-id", help="id сборки-источника")
443
+ p.add_argument("--latest-build", action="store_true", help="источник – последняя сборка проекта")
444
+ p.add_argument("--space-id", help="пространство")
445
+ p.add_argument("--tech-version", help="версия технологии")
446
+ p.add_argument("--no-dev-mode", action="store_true", help="не создавать среду разработки")
447
+ p.add_argument("--wait", action="store_true", help="дождаться готовности приложения")
448
+
449
+
397
450
  def build_parser():
398
451
  parser = argparse.ArgumentParser(
399
452
  prog="elemctl",
@@ -425,19 +478,21 @@ def build_parser():
425
478
 
426
479
  p = apps_sub.add_parser("find", help="найти приложение по имени (точное совпадение без учёта регистра)")
427
480
  p.add_argument("name", metavar="NAME")
481
+ p.add_argument(
482
+ "--include-deleted",
483
+ action="store_true",
484
+ help="искать и среди удалённых приложений (по умолчанию пропускаются)",
485
+ )
428
486
  p.set_defaults(handler=cmd_apps_find)
429
487
 
430
488
  p = apps_sub.add_parser("create", help="создать приложение")
431
- p.add_argument("name", metavar="NAME")
432
- p.add_argument("--project-id", help="проект-источник")
433
- p.add_argument("--version-id", help="id сборки-источника")
434
- p.add_argument("--latest-build", action="store_true", help="источник – последняя сборка проекта")
435
- p.add_argument("--space-id", help="пространство")
436
- p.add_argument("--tech-version", help="версия технологии")
437
- p.add_argument("--no-dev-mode", action="store_true", help="не создавать среду разработки")
438
- p.add_argument("--wait", action="store_true", help="дождаться готовности и вывести карточку")
489
+ _add_create_flags(p)
439
490
  p.set_defaults(handler=cmd_apps_create)
440
491
 
492
+ p = apps_sub.add_parser("ensure", help="создать приложение, если его ещё нет (идемпотентно)")
493
+ _add_create_flags(p)
494
+ p.set_defaults(handler=cmd_apps_ensure)
495
+
441
496
  p = apps_sub.add_parser("delete", help="удалить приложение (необратимо, URL меняется при пересоздании)")
442
497
  p.add_argument("app_id", metavar="APP_ID")
443
498
  p.set_defaults(handler=cmd_apps_delete)
@@ -70,6 +70,21 @@ def _collapse_reference(value):
70
70
  return None
71
71
 
72
72
 
73
+ # Статус удалённого приложения (сравнение без учёта регистра).
74
+ DELETED_STATUS = "Deleted"
75
+
76
+
77
+ def _is_deleted(app):
78
+ """Признак удалённого приложения.
79
+
80
+ Платформа не убирает удалённые приложения из списка, а помечает их
81
+ статусом Deleted, сохраняя прежний id. На таком id последующие get и
82
+ deploy отвечают 404, поэтому поиск по умолчанию их пропускает.
83
+ """
84
+ status = app.get("status")
85
+ return isinstance(status, str) and status.strip().lower() == DELETED_STATUS.lower()
86
+
87
+
73
88
  class ElementClient:
74
89
  """Программный клиент Console API v2."""
75
90
 
@@ -167,11 +182,14 @@ class ElementClient:
167
182
  """Карточка приложения (статус, uri, source.project-version и др.)."""
168
183
  return self._api("GET", f"/applications/{app_id}")
169
184
 
170
- def find_app(self, name):
185
+ def find_app(self, name, *, include_deleted=False):
171
186
  """Найти приложение по точному совпадению имени без учёта регистра.
172
187
 
173
188
  Имя сверяется с полями name, display-name и publication-context.
174
- Возвращается карточка из списка либо None.
189
+ Удалённые приложения (статус Deleted) по умолчанию пропускаются: на их
190
+ прежнем id последующие get и deploy отвечают 404. include_deleted=True
191
+ возвращает прежнее поведение – поиск среди всех приложений, включая
192
+ удалённые. Возвращается карточка из списка либо None.
175
193
  """
176
194
  target = (name or "").strip().lower()
177
195
  if not target:
@@ -179,6 +197,8 @@ class ElementClient:
179
197
  for app in self.list_apps():
180
198
  if not isinstance(app, dict):
181
199
  continue
200
+ if not include_deleted and _is_deleted(app):
201
+ continue
182
202
  for key in ("name", "display-name", "publication-context"):
183
203
  value = app.get(key)
184
204
  if isinstance(value, str) and value.strip().lower() == target:
@@ -60,22 +60,24 @@ def create_server(config=None):
60
60
  return client().get_app(app_id)
61
61
 
62
62
  @server.tool()
63
- def find_app(name: str) -> dict:
64
- """Найти приложение по точному имени без учёта регистра; вернуть id и признак found."""
65
- app = client().find_app(name)
63
+ def find_app(name: str, include_deleted: bool = False) -> dict:
64
+ """Найти приложение по точному имени без учёта регистра; вернуть id и признак found.
65
+
66
+ Удалённые приложения (статус Deleted) по умолчанию пропускаются: на их
67
+ прежнем id get и deploy отвечают 404. include_deleted=True ищет среди
68
+ всех приложений, включая удалённые.
69
+ """
70
+ app = client().find_app(name, include_deleted=include_deleted)
66
71
  if app is None:
67
72
  return {"id": None, "found": False}
68
73
  return {"id": app.get("id"), "found": True, "application": app}
69
74
 
70
- @server.tool()
71
- def create_app(
72
- name: str,
73
- project_id: str = "",
74
- version_id: str = "",
75
- space_id: str = "",
76
- development_mode: bool = True,
77
- ) -> dict:
78
- """Создать приложение. При задании только project_id источником берётся последняя сборка проекта (создание из проекта целиком может дать пустой каркас)."""
75
+ def _create_app(name, project_id, version_id, space_id, development_mode):
76
+ """Создать приложение (общая логика create_app и ensure_app).
77
+
78
+ Источник – version_id (id сборки) либо последняя сборка project_id
79
+ (создание из проекта целиком может дать пустой каркас).
80
+ """
79
81
  source_version_id = version_id
80
82
  if not source_version_id:
81
83
  if not project_id:
@@ -93,6 +95,39 @@ def create_server(config=None):
93
95
  space_id=space_id or None,
94
96
  )
95
97
 
98
+ @server.tool()
99
+ def create_app(
100
+ name: str,
101
+ project_id: str = "",
102
+ version_id: str = "",
103
+ space_id: str = "",
104
+ development_mode: bool = True,
105
+ ) -> dict:
106
+ """Создать приложение. При задании только project_id источником берётся последняя сборка проекта (создание из проекта целиком может дать пустой каркас)."""
107
+ return _create_app(name, project_id, version_id, space_id, development_mode)
108
+
109
+ @server.tool()
110
+ def ensure_app(
111
+ name: str,
112
+ project_id: str = "",
113
+ version_id: str = "",
114
+ space_id: str = "",
115
+ development_mode: bool = True,
116
+ ) -> dict:
117
+ """Идемпотентно создать приложение по имени, если его ещё нет.
118
+
119
+ Существующее приложение НЕ пересоздаётся: при наличии возвращается
120
+ {"id": ..., "created": false} без изменений (delete + create дали бы
121
+ новый URL и порвали внешние привязки – OIDC redirect и т.п.). Удалённые
122
+ приложения (статус Deleted) не в счёт. Параметры создания – как у
123
+ create_app; они действуют, только когда создание происходит.
124
+ """
125
+ existing = client().find_app(name)
126
+ if existing is not None:
127
+ return {"id": existing.get("id"), "created": False}
128
+ card = _create_app(name, project_id, version_id, space_id, development_mode)
129
+ return {"id": (card or {}).get("id"), "created": True}
130
+
96
131
  @server.tool()
97
132
  def start_app(app_id: str) -> dict:
98
133
  """Запустить приложение."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: elemctl
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: CLI, MCP-сервер и библиотека для Console API v2 платформы 1С:Предприятие.Элемент (1cmycloud)
5
5
  Author: KeyFire
6
6
  License-Expression: MIT
@@ -112,6 +112,9 @@ elemctl apps list
112
112
  # карточка приложения (статус, uri, фактическая версия проекта)
113
113
  elemctl apps get <app-id>
114
114
 
115
+ # создать приложение, только если его ещё нет: {"id": ..., "created": true|false}
116
+ elemctl apps ensure acme-crm-dev --project-id <project-id> --latest-build --wait
117
+
115
118
  # полный цикл деплоя из исходников с проверкой применения
116
119
  elemctl deploy --app-id <app-id> --project-id <project-id> --project-dir acme/crm
117
120
 
@@ -184,6 +187,10 @@ Assembly.yaml # манифест: ProjectKind, Vendor, Name, Version,
184
187
  `elemctl apps create <имя> --project-id <id> --latest-build` (MCP-инструмент
185
188
  `create_app` подставляет последнюю сборку автоматически), а после создания -
186
189
  `elemctl deploy`.
190
+ - Удалённые приложения остаются в списке платформы со статусом `Deleted` и
191
+ прежним `id`, на котором `apps get` и `deploy` отвечают 404. `apps find` и
192
+ `apps ensure` их пропускают; вернуть прежний поиск - `apps find
193
+ --include-deleted`.
187
194
  - Приложение с неопубликованными правками в среде разработки платформа
188
195
  удалить не даёт (HTTP 400 `FAILED_PRECONDITION`), принудительного удаления
189
196
  в Console API нет - только через панель управления; elemctl подскажет это
@@ -7,6 +7,7 @@ README.md
7
7
  pyproject.toml
8
8
  docs/SPEC.md
9
9
  src/elemctl/__init__.py
10
+ src/elemctl/__main__.py
10
11
  src/elemctl/auth.py
11
12
  src/elemctl/build.py
12
13
  src/elemctl/cli.py
@@ -0,0 +1,259 @@
1
+ """Тесты CLI: локальные команды и коды возврата (без сети)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ import elemctl
14
+ from elemctl import cli
15
+ from elemctl.errors import ApiError
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def clean_environment(monkeypatch, tmp_path):
20
+ """Изолировать тесты от переменных окружения и .env разработчика."""
21
+ for key in (
22
+ "ELEMENT_BASE_URL",
23
+ "ELEMENT_CLIENT_ID",
24
+ "ELEMENT_CLIENT_SECRET",
25
+ "ELEMENT_APP_ID",
26
+ "ELEMENT_PROJECT_ID",
27
+ "ELEMENT_SPACE_ID",
28
+ ):
29
+ monkeypatch.delenv(key, raising=False)
30
+ monkeypatch.chdir(tmp_path)
31
+
32
+
33
+ def test_version_flag(capsys):
34
+ with pytest.raises(SystemExit) as excinfo:
35
+ cli.main(["--version"])
36
+ assert excinfo.value.code == 0
37
+ assert f"elemctl {elemctl.__version__}" in capsys.readouterr().out
38
+
39
+
40
+ def test_module_entry_point():
41
+ """python -m elemctl – запасной путь для вызывающих без консольной точки входа в PATH."""
42
+ import_root = Path(elemctl.__file__).resolve().parent.parent
43
+ env = {**os.environ, "PYTHONPATH": str(import_root)}
44
+ result = subprocess.run([sys.executable, "-m", "elemctl", "--version"],
45
+ capture_output=True, text=True, env=env)
46
+ assert result.returncode == 0, result.stderr
47
+ assert f"elemctl {elemctl.__version__}" in result.stdout
48
+
49
+
50
+ def test_build_command(project_factory, tmp_path, capsys):
51
+ project_dir = project_factory()
52
+ out_dir = tmp_path / "dist"
53
+
54
+ rc = cli.main(
55
+ ["build", "--project-dir", str(project_dir), "--output", str(out_dir), "--branch", "", "--commit", ""]
56
+ )
57
+
58
+ assert rc == 0
59
+ payload = json.loads(capsys.readouterr().out)
60
+ archive = Path(payload["file"])
61
+ assert archive.exists()
62
+ assert archive.name == "crm 1.0-1.xasm"
63
+
64
+
65
+ def test_deploy_dry_run_builds_only(project_factory, tmp_path, capsys):
66
+ project_dir = project_factory()
67
+ rc = cli.main(
68
+ [
69
+ "deploy",
70
+ "--dry-run",
71
+ "--project-dir",
72
+ str(project_dir),
73
+ "--output",
74
+ str(tmp_path / "dist"),
75
+ ]
76
+ )
77
+ assert rc == 0
78
+ payload = json.loads(capsys.readouterr().out)
79
+ assert Path(payload["file"]).exists()
80
+
81
+
82
+ def test_apps_find_found_and_not_found(monkeypatch, capsys):
83
+ class FakeClient:
84
+ def find_app(self, name, *, include_deleted=False):
85
+ if name == "site-dev":
86
+ return {"id": "app-42", "display-name": "site-dev"}
87
+ return None
88
+
89
+ monkeypatch.setattr(cli, "make_client", lambda config: FakeClient())
90
+
91
+ rc = cli.main(["apps", "find", "site-dev"])
92
+ assert rc == 0
93
+ assert json.loads(capsys.readouterr().out) == {"id": "app-42", "found": True}
94
+
95
+ # Отсутствие приложения – штатный ответ: код возврата 0, признак несёт поле found.
96
+ rc = cli.main(["apps", "find", "нет-такого"])
97
+ assert rc == 0
98
+ assert json.loads(capsys.readouterr().out) == {"id": None, "found": False}
99
+
100
+
101
+ def test_apps_find_request_failure_is_an_error(monkeypatch, capsys):
102
+ """Сбой запроса отличим от "не найдено": ненулевой код и error в stderr."""
103
+
104
+ class FailingClient:
105
+ def find_app(self, name, *, include_deleted=False):
106
+ raise ApiError("нет доступа", status=403)
107
+
108
+ monkeypatch.setattr(cli, "make_client", lambda config: FailingClient())
109
+
110
+ rc = cli.main(["apps", "find", "site-dev"])
111
+ assert rc == 1
112
+ captured = capsys.readouterr()
113
+ assert captured.out == ""
114
+ assert "error" in json.loads(captured.err)
115
+
116
+
117
+ def test_apps_find_skips_deleted_unless_flag(monkeypatch, capsys):
118
+ """По умолчанию удалённое приложение не находится; --include-deleted его возвращает."""
119
+
120
+ class FakeClient:
121
+ def find_app(self, name, *, include_deleted=False):
122
+ # Приложение есть в списке платформы, но удалено: без флага его нет.
123
+ if name == "site-old" and include_deleted:
124
+ return {"id": "app-del", "display-name": "site-old", "status": "Deleted"}
125
+ return None
126
+
127
+ monkeypatch.setattr(cli, "make_client", lambda config: FakeClient())
128
+
129
+ rc = cli.main(["apps", "find", "site-old"])
130
+ assert rc == 0
131
+ assert json.loads(capsys.readouterr().out) == {"id": None, "found": False}
132
+
133
+ rc = cli.main(["apps", "find", "site-old", "--include-deleted"])
134
+ assert rc == 0
135
+ assert json.loads(capsys.readouterr().out) == {"id": "app-del", "found": True}
136
+
137
+
138
+ def test_apps_ensure_existing_returns_created_false_without_creating(monkeypatch, capsys):
139
+ """Существующее приложение не пересоздаётся: created=false, create_app не вызывается."""
140
+
141
+ class FakeClient:
142
+ def find_app(self, name, *, include_deleted=False):
143
+ return {"id": "app-7", "display-name": name}
144
+
145
+ def create_app(self, *args, **kwargs):
146
+ raise AssertionError("создание не должно вызываться для существующего приложения")
147
+
148
+ monkeypatch.setattr(cli, "make_client", lambda config: FakeClient())
149
+
150
+ rc = cli.main(["apps", "ensure", "site-dev", "--version-id", "asm-1"])
151
+ assert rc == 0
152
+ assert json.loads(capsys.readouterr().out) == {"id": "app-7", "created": False}
153
+
154
+
155
+ def test_apps_ensure_missing_creates_and_returns_created_true(monkeypatch, capsys):
156
+ """Отсутствующее приложение создаётся: created=true, источником идёт указанная сборка."""
157
+
158
+ class FakeClient:
159
+ def find_app(self, name, *, include_deleted=False):
160
+ return None
161
+
162
+ def create_app(
163
+ self,
164
+ display_name,
165
+ *,
166
+ project_version_id=None,
167
+ image_id=None,
168
+ development_mode=True,
169
+ space_id=None,
170
+ technology_version=None,
171
+ ):
172
+ assert display_name == "site-new"
173
+ assert project_version_id == "asm-9"
174
+ assert image_id is None
175
+ return {"id": "app-new", "display-name": display_name}
176
+
177
+ monkeypatch.setattr(cli, "make_client", lambda config: FakeClient())
178
+
179
+ rc = cli.main(["apps", "ensure", "site-new", "--version-id", "asm-9"])
180
+ assert rc == 0
181
+ assert json.loads(capsys.readouterr().out) == {"id": "app-new", "created": True}
182
+
183
+
184
+ def test_apps_ensure_request_failure_is_an_error(monkeypatch, capsys):
185
+ """Сбой запроса в ensure: код возврата 1, пустой stdout, error в stderr."""
186
+
187
+ class FailingClient:
188
+ def find_app(self, name, *, include_deleted=False):
189
+ raise ApiError("нет доступа", status=403)
190
+
191
+ monkeypatch.setattr(cli, "make_client", lambda config: FailingClient())
192
+
193
+ rc = cli.main(["apps", "ensure", "site-dev"])
194
+ assert rc == 1
195
+ captured = capsys.readouterr()
196
+ assert captured.out == ""
197
+ assert "error" in json.loads(captured.err)
198
+
199
+
200
+ def test_error_is_json_on_stderr(capsys):
201
+ # app-id не задан ни аргументом, ни конфигурацией.
202
+ rc = cli.main(["apps", "get"])
203
+ assert rc == 1
204
+ captured = capsys.readouterr()
205
+ payload = json.loads(captured.err)
206
+ assert "error" in payload
207
+ assert "app-id" in payload["error"]
208
+
209
+
210
+ def test_deploy_exit_code_reflects_ok(monkeypatch, capsys, project_factory, tmp_path):
211
+ from tests.test_deploy import FakeDeployClient
212
+
213
+ # Успех: применённая версия совпала.
214
+ monkeypatch.setattr(
215
+ cli, "make_client", lambda config: FakeDeployClient(applied_version="1.0-1")
216
+ )
217
+ rc = cli.main(
218
+ [
219
+ "deploy",
220
+ "--app-id",
221
+ "app-1",
222
+ "--project-id",
223
+ "proj-1",
224
+ "--project-dir",
225
+ str(project_factory(repo_name="repo-ok")),
226
+ "--output",
227
+ str(tmp_path / "d1"),
228
+ "--build-version",
229
+ "1.0-1",
230
+ ]
231
+ )
232
+ captured = capsys.readouterr()
233
+ assert rc == 0
234
+ assert json.loads(captured.out)["ok"] is True
235
+
236
+ # Откат: версия не совпала – код возврата 1.
237
+ monkeypatch.setattr(
238
+ cli, "make_client", lambda config: FakeDeployClient(applied_version="1.0-0")
239
+ )
240
+ rc = cli.main(
241
+ [
242
+ "deploy",
243
+ "--app-id",
244
+ "app-1",
245
+ "--project-id",
246
+ "proj-1",
247
+ "--project-dir",
248
+ str(project_factory(repo_name="repo-fail")),
249
+ "--output",
250
+ str(tmp_path / "d2"),
251
+ "--build-version",
252
+ "1.0-1",
253
+ ]
254
+ )
255
+ captured = capsys.readouterr()
256
+ assert rc == 1
257
+ report = json.loads(captured.out)
258
+ assert report["ok"] is False
259
+ assert report["applied"] is False
@@ -63,6 +63,34 @@ def test_find_app_exact_case_insensitive(api):
63
63
  assert client.find_app("nope") is None
64
64
 
65
65
 
66
+ def test_find_app_skips_deleted(api):
67
+ client, transport = api
68
+ transport.add(
69
+ "GET",
70
+ f"{API}/applications",
71
+ [
72
+ {"id": "old", "display-name": "site", "status": "Deleted"},
73
+ {"id": "live", "display-name": "site", "status": "Running"},
74
+ ],
75
+ )
76
+ # Удалённая карточка пропускается – находится живое приложение с тем же именем.
77
+ assert client.find_app("site")["id"] == "live"
78
+
79
+
80
+ def test_find_app_include_deleted_returns_deleted(api):
81
+ client, transport = api
82
+ # Единственный ответ повторяется на оба вызова find_app.
83
+ transport.add(
84
+ "GET",
85
+ f"{API}/applications",
86
+ [{"id": "old", "display-name": "site-old", "status": "DELETED"}],
87
+ )
88
+ # По умолчанию удалённое не находится...
89
+ assert client.find_app("site-old") is None
90
+ # ...а с include_deleted=True возвращается прежнее поведение (сравнение статуса без учёта регистра).
91
+ assert client.find_app("site-old", include_deleted=True)["id"] == "old"
92
+
93
+
66
94
  def test_delete_failed_precondition_hint(api):
67
95
  client, transport = api
68
96
  transport.add(
@@ -15,6 +15,7 @@ EXPECTED_TOOLS = {
15
15
  "get_app",
16
16
  "find_app",
17
17
  "create_app",
18
+ "ensure_app",
18
19
  "start_app",
19
20
  "stop_app",
20
21
  "delete_app",
@@ -49,3 +50,20 @@ def test_delete_app_docstring_warns():
49
50
  tools = asyncio.run(server.list_tools())
50
51
  delete_tool = next(tool for tool in tools if tool.name == "delete_app")
51
52
  assert "URL" in (delete_tool.description or "")
53
+
54
+
55
+ def test_ensure_app_docstring_states_no_recreate():
56
+ server = create_server()
57
+ tools = asyncio.run(server.list_tools())
58
+ ensure_tool = next(tool for tool in tools if tool.name == "ensure_app")
59
+ description = ensure_tool.description or ""
60
+ assert "пересозда" in description # существующее приложение НЕ пересоздаётся
61
+ assert "created" in description
62
+
63
+
64
+ def test_find_app_exposes_include_deleted():
65
+ server = create_server()
66
+ tools = asyncio.run(server.list_tools())
67
+ find_tool = next(tool for tool in tools if tool.name == "find_app")
68
+ properties = (find_tool.inputSchema or {}).get("properties") or {}
69
+ assert "include_deleted" in properties
@@ -1,144 +0,0 @@
1
- """Тесты CLI: локальные команды и коды возврата (без сети)."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from pathlib import Path
7
-
8
- import pytest
9
-
10
- from elemctl import cli
11
-
12
-
13
- @pytest.fixture(autouse=True)
14
- def clean_environment(monkeypatch, tmp_path):
15
- """Изолировать тесты от переменных окружения и .env разработчика."""
16
- for key in (
17
- "ELEMENT_BASE_URL",
18
- "ELEMENT_CLIENT_ID",
19
- "ELEMENT_CLIENT_SECRET",
20
- "ELEMENT_APP_ID",
21
- "ELEMENT_PROJECT_ID",
22
- "ELEMENT_SPACE_ID",
23
- ):
24
- monkeypatch.delenv(key, raising=False)
25
- monkeypatch.chdir(tmp_path)
26
-
27
-
28
- def test_version_flag(capsys):
29
- with pytest.raises(SystemExit) as excinfo:
30
- cli.main(["--version"])
31
- assert excinfo.value.code == 0
32
- assert "elemctl 0.2.0" in capsys.readouterr().out
33
-
34
-
35
- def test_build_command(project_factory, tmp_path, capsys):
36
- project_dir = project_factory()
37
- out_dir = tmp_path / "dist"
38
-
39
- rc = cli.main(
40
- ["build", "--project-dir", str(project_dir), "--output", str(out_dir), "--branch", "", "--commit", ""]
41
- )
42
-
43
- assert rc == 0
44
- payload = json.loads(capsys.readouterr().out)
45
- archive = Path(payload["file"])
46
- assert archive.exists()
47
- assert archive.name == "crm 1.0-1.xasm"
48
-
49
-
50
- def test_deploy_dry_run_builds_only(project_factory, tmp_path, capsys):
51
- project_dir = project_factory()
52
- rc = cli.main(
53
- [
54
- "deploy",
55
- "--dry-run",
56
- "--project-dir",
57
- str(project_dir),
58
- "--output",
59
- str(tmp_path / "dist"),
60
- ]
61
- )
62
- assert rc == 0
63
- payload = json.loads(capsys.readouterr().out)
64
- assert Path(payload["file"]).exists()
65
-
66
-
67
- def test_apps_find_found_and_not_found(monkeypatch, capsys):
68
- class FakeClient:
69
- def find_app(self, name):
70
- if name == "site-dev":
71
- return {"id": "app-42", "display-name": "site-dev"}
72
- return None
73
-
74
- monkeypatch.setattr(cli, "make_client", lambda config: FakeClient())
75
-
76
- rc = cli.main(["apps", "find", "site-dev"])
77
- assert rc == 0
78
- assert json.loads(capsys.readouterr().out) == {"id": "app-42", "found": True}
79
-
80
- rc = cli.main(["apps", "find", "нет-такого"])
81
- assert rc == 1
82
- assert json.loads(capsys.readouterr().out) == {"id": None, "found": False}
83
-
84
-
85
- def test_error_is_json_on_stderr(capsys):
86
- # app-id не задан ни аргументом, ни конфигурацией.
87
- rc = cli.main(["apps", "get"])
88
- assert rc == 1
89
- captured = capsys.readouterr()
90
- payload = json.loads(captured.err)
91
- assert "error" in payload
92
- assert "app-id" in payload["error"]
93
-
94
-
95
- def test_deploy_exit_code_reflects_ok(monkeypatch, capsys, project_factory, tmp_path):
96
- from tests.test_deploy import FakeDeployClient
97
-
98
- # Успех: применённая версия совпала.
99
- monkeypatch.setattr(
100
- cli, "make_client", lambda config: FakeDeployClient(applied_version="1.0-1")
101
- )
102
- rc = cli.main(
103
- [
104
- "deploy",
105
- "--app-id",
106
- "app-1",
107
- "--project-id",
108
- "proj-1",
109
- "--project-dir",
110
- str(project_factory(repo_name="repo-ok")),
111
- "--output",
112
- str(tmp_path / "d1"),
113
- "--build-version",
114
- "1.0-1",
115
- ]
116
- )
117
- captured = capsys.readouterr()
118
- assert rc == 0
119
- assert json.loads(captured.out)["ok"] is True
120
-
121
- # Откат: версия не совпала – код возврата 1.
122
- monkeypatch.setattr(
123
- cli, "make_client", lambda config: FakeDeployClient(applied_version="1.0-0")
124
- )
125
- rc = cli.main(
126
- [
127
- "deploy",
128
- "--app-id",
129
- "app-1",
130
- "--project-id",
131
- "proj-1",
132
- "--project-dir",
133
- str(project_factory(repo_name="repo-fail")),
134
- "--output",
135
- str(tmp_path / "d2"),
136
- "--build-version",
137
- "1.0-1",
138
- ]
139
- )
140
- captured = capsys.readouterr()
141
- assert rc == 1
142
- report = json.loads(captured.out)
143
- assert report["ok"] is False
144
- assert report["applied"] is False
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes