kompas-kernel 0.0.1__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.
Files changed (77) hide show
  1. kompas_kernel/__init__.py +3 -0
  2. kompas_kernel/features/__init__.py +9 -0
  3. kompas_kernel/features/assembly_navigator/__init__.py +8 -0
  4. kompas_kernel/features/assembly_navigator/commands.py +37 -0
  5. kompas_kernel/features/assembly_navigator/ksapi/__init__.py +5 -0
  6. kompas_kernel/features/assembly_navigator/ksapi/mapping.py +122 -0
  7. kompas_kernel/features/assembly_navigator/ksapi/presentation.py +107 -0
  8. kompas_kernel/features/assembly_navigator/ksapi/raw.py +74 -0
  9. kompas_kernel/features/assembly_navigator/ksapi/reader.py +287 -0
  10. kompas_kernel/features/assembly_navigator/ksapi/resolver.py +35 -0
  11. kompas_kernel/features/assembly_navigator/models.py +218 -0
  12. kompas_kernel/features/assembly_navigator/queries.py +42 -0
  13. kompas_kernel/features/assembly_navigator/service.py +31 -0
  14. kompas_kernel/features/assembly_navigator/summary.py +90 -0
  15. kompas_kernel/features/document_session/__init__.py +5 -0
  16. kompas_kernel/features/document_session/checkpointed.py +165 -0
  17. kompas_kernel/features/document_session/commands.py +94 -0
  18. kompas_kernel/features/document_session/digest.py +126 -0
  19. kompas_kernel/features/document_session/ksapi/__init__.py +5 -0
  20. kompas_kernel/features/document_session/ksapi/context_reader.py +174 -0
  21. kompas_kernel/features/document_session/ksapi/screenshot.py +365 -0
  22. kompas_kernel/features/document_session/ksapi/snapshot.py +181 -0
  23. kompas_kernel/features/document_session/models.py +311 -0
  24. kompas_kernel/features/document_session/queries.py +62 -0
  25. kompas_kernel/features/document_session/registry.py +101 -0
  26. kompas_kernel/features/document_session/service.py +128 -0
  27. kompas_kernel/features/geometry_3d/__init__.py +9 -0
  28. kompas_kernel/features/geometry_3d/axis_math.py +50 -0
  29. kompas_kernel/features/geometry_3d/commands.py +68 -0
  30. kompas_kernel/features/geometry_3d/hex_detect.py +113 -0
  31. kompas_kernel/features/geometry_3d/ksapi/__init__.py +5 -0
  32. kompas_kernel/features/geometry_3d/ksapi/component_geometry.py +559 -0
  33. kompas_kernel/features/geometry_3d/ksapi/measurements.py +368 -0
  34. kompas_kernel/features/geometry_3d/ksapi/presentation.py +204 -0
  35. kompas_kernel/features/geometry_3d/models.py +216 -0
  36. kompas_kernel/features/geometry_3d/queries.py +96 -0
  37. kompas_kernel/features/geometry_3d/service.py +69 -0
  38. kompas_kernel/features/geometry_3d/wrench_zone.py +161 -0
  39. kompas_kernel/features/ksapi_automation/__init__.py +9 -0
  40. kompas_kernel/features/ksapi_automation/gate.py +282 -0
  41. kompas_kernel/features/ksapi_automation/models.py +75 -0
  42. kompas_kernel/features/ksapi_automation/namespace.py +12 -0
  43. kompas_kernel/features/ksapi_automation/run_python.py +133 -0
  44. kompas_kernel/features/ksapi_automation/runner.py +126 -0
  45. kompas_kernel/features/ksapi_automation/service.py +85 -0
  46. kompas_kernel/features/part_authoring/__init__.py +5 -0
  47. kompas_kernel/features/part_authoring/build_geometry.py +122 -0
  48. kompas_kernel/features/part_authoring/models.py +99 -0
  49. kompas_kernel/features/wrench_clearance/__init__.py +7 -0
  50. kompas_kernel/features/wrench_clearance/models.py +87 -0
  51. kompas_kernel/features/wrench_clearance/service.py +154 -0
  52. kompas_kernel/kompas/__init__.py +14 -0
  53. kompas_kernel/kompas/errors.py +11 -0
  54. kompas_kernel/kompas/models.py +68 -0
  55. kompas_kernel/kompas/objects.py +93 -0
  56. kompas_kernel/kompas/platform.py +40 -0
  57. kompas_kernel/kompas/runtime.py +186 -0
  58. kompas_kernel/kompas/session.py +195 -0
  59. kompas_kernel/kompas/units.py +48 -0
  60. kompas_kernel/ksapi_static/__init__.py +8 -0
  61. kompas_kernel/ksapi_static/facts.py +132 -0
  62. kompas_kernel/ksapi_static/inventory.py +185 -0
  63. kompas_kernel/ksapi_static/recipes.py +114 -0
  64. kompas_kernel/ksapi_static/wrapper_grep.py +83 -0
  65. kompas_kernel/observability/__init__.py +1 -0
  66. kompas_kernel/observability/logger.py +24 -0
  67. kompas_kernel/observability/setup.py +115 -0
  68. kompas_kernel/observability/taxonomy.py +27 -0
  69. kompas_kernel/py.typed +1 -0
  70. kompas_kernel/standards/__init__.py +1 -0
  71. kompas_kernel/standards/wrench_clearance.py +234 -0
  72. kompas_kernel/utils/__init__.py +1 -0
  73. kompas_kernel/utils/filesystem.py +82 -0
  74. kompas_kernel/utils/validation.py +21 -0
  75. kompas_kernel-0.0.1.dist-info/METADATA +56 -0
  76. kompas_kernel-0.0.1.dist-info/RECORD +77 -0
  77. kompas_kernel-0.0.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,282 @@
1
+ """Статический гейт v1: проверка сгенерированного Python ДО исполнения.
2
+
3
+ Гейт v1 начинался намеренно ЛЁГКИМ (`execute_python` dev-trusted, см. `todo-tasks/E2-hands`):
4
+ он ловит ОШИБКИ МОДЕЛИ, а не злонамеренный код. Полноценный sandbox — отдельный риск,
5
+ утолщается после live smoke. Здесь только чистый статический анализ AST:
6
+
7
+ - синтаксис (`ast.parse`);
8
+ - политика импортов (нет файловой системы/сети/процессов/нового CAD-подключения);
9
+ - нет создания нового подключения к КОМПАС (модель обязана использовать `app` из namespace);
10
+ - нет опасных builtin-вызовов (`eval`/`exec`/`open`/`__import__`);
11
+ - имя вызванного метода ksAPI существует в установленной сборке (`UNKNOWN_KSAPI_METHOD`);
12
+ - число аргументов вызова совпадает хотя бы с одной известной арностью имени (`KSAPI_ARITY`);
13
+ - имя константы `constants.*`/`constants3d.*` существует в установленной сборке
14
+ (`UNKNOWN_KSAPI_CONSTANT`);
15
+ - читаемое имя определено — в namespace `run_python`, в самом коде или как builtin
16
+ (`UNDEFINED_NAME`).
17
+
18
+ Три правила по ksAPI требуют `KsapiInventory` (см. `kompas_kernel.ksapi_static.inventory`) — без него
19
+ (нет `KOMPAS_BIN_DIR`) они молча выключены, остальной гейт, включая `UNDEFINED_NAME`, работает
20
+ как обычно (`todo-tasks/tech-debt/gate-ksapi-name-check.md`). Гейт НЕ выводит типы: «метод есть,
21
+ но не у ЭТОГО интерфейса» не ловится — см. границы в том же файле техдолга.
22
+
23
+ Живёт в `kompas_kernel.features.ksapi_automation` (единая точка проверки нужна ОБОИМ
24
+ вызывающим `run_python` — сырой escape hatch `runner.py` и `build_geometry`
25
+ `kompas_kernel.features.part_authoring.build_geometry`), см.
26
+ `todo-tasks/tech-debt/gate-at-single-choke-point.md`. `part_authoring` импортирует отсюда
27
+ `GateReport` — но ТОЛЬКО как DTO (`HandsResult.gate`) и для `validate_code` (сухой прогон
28
+ без исполнения) — это осознанный явный порт/DTO слоя `ksapi_automation`, а не внутренности
29
+ соседа.
30
+
31
+ Sandbox-политика — единый источник (GPC1/GPC4): меняется здесь, нигде не
32
+ дублируется. Переиспользуемые факты установленного KsAPI живут отдельно, физическим
33
+ владельцем в `kompas_kernel.ksapi_static.facts` (E17.04c), и подключаются к тому же
34
+ обходу AST без изменения порядка нарушений.
35
+ Чистая функция `check_code` (GPC3): инвентарь приходит параметром, ничего не читает с диска
36
+ и не исполняет.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import ast
42
+ import builtins
43
+ from enum import StrEnum
44
+ from typing import assert_never
45
+
46
+ from kompas_kernel.features.ksapi_automation.namespace import RUN_PYTHON_NAMESPACE_NAMES
47
+ from kompas_kernel.ksapi_static.facts import (
48
+ KsapiViolation,
49
+ KsapiViolationKind,
50
+ collect_ksapi_violations,
51
+ )
52
+ from kompas_kernel.ksapi_static.inventory import KsapiInventory
53
+ from kompas_kernel.utils.validation import CustomModel
54
+
55
+ # Модули, которых не должно быть в коде построения. Namespace `run_python` уже даёт
56
+ # app/doc/part/ksapi/constants/constants3d — импортировать SDK/IO не нужно.
57
+ FORBIDDEN_IMPORT_PREFIXES: frozenset[str] = frozenset(
58
+ {
59
+ "os",
60
+ "sys",
61
+ "subprocess",
62
+ "socket",
63
+ "shutil",
64
+ "pathlib",
65
+ "importlib",
66
+ "ctypes",
67
+ "winreg",
68
+ "multiprocessing",
69
+ "threading",
70
+ "asyncio",
71
+ "requests",
72
+ "urllib",
73
+ "http",
74
+ "ftplib",
75
+ "smtplib",
76
+ "win32com",
77
+ "win32api",
78
+ "win32gui",
79
+ "win32ui",
80
+ "win32con",
81
+ "pythoncom",
82
+ "pywintypes",
83
+ }
84
+ )
85
+
86
+ # Вызовы, создающие НОВОЕ подключение к КОМПАС: модель обязана работать с готовым `app`.
87
+ FORBIDDEN_CONNECTION_NAMES: frozenset[str] = frozenset(
88
+ {
89
+ "Dispatch",
90
+ "EnsureDispatch",
91
+ "GetActiveObject",
92
+ "GetKompas",
93
+ "OpenKompas",
94
+ "OpenKompasInvisible",
95
+ "CoInitialize",
96
+ "CoInitializeEx",
97
+ "CoCreateInstance",
98
+ }
99
+ )
100
+
101
+ # Опасные builtin-вызовы: динамическое исполнение и прямой доступ к ФС.
102
+ FORBIDDEN_CALL_NAMES: frozenset[str] = frozenset({"eval", "exec", "compile", "__import__", "open"})
103
+
104
+ # Имена, доступные в exec-namespace `run_python` без явного builtin/присваивания в коде.
105
+ _BUILTIN_NAMES: frozenset[str] = frozenset(dir(builtins)) | {"__builtins__"}
106
+
107
+
108
+ class GateViolationKind(StrEnum):
109
+ """Вид нарушения гейта."""
110
+
111
+ SYNTAX = "syntax"
112
+ FORBIDDEN_IMPORT = "forbidden_import"
113
+ NEW_CONNECTION = "new_connection"
114
+ FORBIDDEN_CALL = "forbidden_call"
115
+ UNKNOWN_KSAPI_METHOD = "unknown_ksapi_method"
116
+ KSAPI_ARITY = "ksapi_arity"
117
+ UNKNOWN_KSAPI_CONSTANT = "unknown_ksapi_constant"
118
+ UNDEFINED_NAME = "undefined_name"
119
+
120
+
121
+ class GateViolation(CustomModel):
122
+ """Одно нарушение гейта: вид + человекочитаемая причина."""
123
+
124
+ kind: GateViolationKind
125
+ message: str
126
+
127
+
128
+ class GateReport(CustomModel):
129
+ """Итог статической проверки: прошёл ли код и список нарушений (пустой при ok)."""
130
+
131
+ ok: bool
132
+ violations: tuple[GateViolation, ...] = ()
133
+
134
+
135
+ def check_code(code: str, *, ksapi_inventory: KsapiInventory | None) -> GateReport:
136
+ """Статически проверить Python-код построения. Чистый запрос (GPC3).
137
+
138
+ При синтаксической ошибке возвращает отчёт с единственным нарушением SYNTAX
139
+ (AST дальше не обходится — обходить нечего).
140
+
141
+ Args:
142
+ code: Проверяемый Python-скрипт (тот же, что уйдёт в исполнение через
143
+ `KsapiAutomationRunner.run_python`).
144
+ ksapi_inventory: Инвентарь имён установленной сборки (`build_ksapi_inventory`),
145
+ собранный ОДИН РАЗ при сборке приложения и переданный сюда явно (GPC1) — гейт
146
+ сам с диска ничего не читает. `None` (нет `KOMPAS_BIN_DIR`) молча выключает
147
+ `UNKNOWN_KSAPI_METHOD`/`KSAPI_ARITY`/`UNKNOWN_KSAPI_CONSTANT`; `UNDEFINED_NAME`
148
+ и остальной гейт от инвентаря не зависят.
149
+ """
150
+ try:
151
+ tree = ast.parse(code)
152
+ except SyntaxError as exc:
153
+ message = f"синтаксическая ошибка: {exc.msg} (строка {exc.lineno})"
154
+ return GateReport(ok=False, violations=(GateViolation(kind=GateViolationKind.SYNTAX, message=message),))
155
+
156
+ violations: list[GateViolation] = []
157
+ for node in ast.walk(tree):
158
+ _collect_import_violation(node, violations)
159
+ _collect_call_violation(node, violations)
160
+ violations.extend(
161
+ _to_gate_violation(violation)
162
+ for violation in collect_ksapi_violations(node, ksapi_inventory=ksapi_inventory)
163
+ )
164
+ _collect_undefined_name_violations(tree, violations)
165
+ return GateReport(ok=not violations, violations=tuple(violations))
166
+
167
+
168
+ def _collect_import_violation(node: ast.AST, violations: list[GateViolation]) -> None:
169
+ if isinstance(node, ast.Import):
170
+ for alias in node.names:
171
+ if alias.name.split(".")[0] in FORBIDDEN_IMPORT_PREFIXES:
172
+ violations.append(
173
+ GateViolation(
174
+ kind=GateViolationKind.FORBIDDEN_IMPORT,
175
+ message=f"импорт '{alias.name}' запрещён: построение не требует ФС/сети/процессов/CAD runtime",
176
+ )
177
+ )
178
+ elif isinstance(node, ast.ImportFrom):
179
+ module = node.module or ""
180
+ if module.split(".")[0] in FORBIDDEN_IMPORT_PREFIXES:
181
+ violations.append(
182
+ GateViolation(
183
+ kind=GateViolationKind.FORBIDDEN_IMPORT,
184
+ message=f"импорт из '{module}' запрещён: построение не требует ФС/сети/процессов/CAD runtime",
185
+ )
186
+ )
187
+
188
+
189
+ def _collect_call_violation(node: ast.AST, violations: list[GateViolation]) -> None:
190
+ if not isinstance(node, ast.Call):
191
+ return
192
+ name = _called_name(node.func)
193
+ if name is None:
194
+ return
195
+ if name in FORBIDDEN_CONNECTION_NAMES:
196
+ violations.append(
197
+ GateViolation(
198
+ kind=GateViolationKind.NEW_CONNECTION,
199
+ message=f"вызов '{name}(...)' создаёт новое подключение к КОМПАС; используй готовый app из namespace",
200
+ )
201
+ )
202
+ elif name in FORBIDDEN_CALL_NAMES:
203
+ violations.append(
204
+ GateViolation(
205
+ kind=GateViolationKind.FORBIDDEN_CALL,
206
+ message=f"вызов '{name}(...)' запрещён в коде построения",
207
+ )
208
+ )
209
+
210
+
211
+ def _to_gate_violation(violation: KsapiViolation) -> GateViolation:
212
+ """Translate a reusable KsAPI fact into the stable product gate DTO."""
213
+ match violation.kind:
214
+ case KsapiViolationKind.SYNTAX:
215
+ kind = GateViolationKind.SYNTAX
216
+ case KsapiViolationKind.UNKNOWN_KSAPI_METHOD:
217
+ kind = GateViolationKind.UNKNOWN_KSAPI_METHOD
218
+ case KsapiViolationKind.KSAPI_ARITY:
219
+ kind = GateViolationKind.KSAPI_ARITY
220
+ case KsapiViolationKind.UNKNOWN_KSAPI_CONSTANT:
221
+ kind = GateViolationKind.UNKNOWN_KSAPI_CONSTANT
222
+ case _:
223
+ assert_never(violation.kind)
224
+ return GateViolation(kind=kind, message=violation.message)
225
+
226
+
227
+ def _collect_undefined_name_violations(tree: ast.AST, violations: list[GateViolation]) -> None:
228
+ """Читаемые имена, которых нет ни в namespace `run_python`, ни в коде, ни в builtins.
229
+
230
+ Плоский (без учёта области видимости/порядка) сбор связанных имён — сознательный выбор:
231
+ цель правила — ловить имена, которых в коде нет ВООБЩЕ (остатки старого API вроде
232
+ `part5`/`kompas5`), а не точный scope-анализ уровня pyflakes (см. «Границы» в
233
+ `todo-tasks/tech-debt/gate-ksapi-name-check.md`). Одно имя репортится один раз, даже
234
+ если прочитано в коде многократно.
235
+ """
236
+ bound = _bound_names(tree) | RUN_PYTHON_NAMESPACE_NAMES | _BUILTIN_NAMES
237
+ reported: set[str] = set()
238
+ for node in ast.walk(tree):
239
+ if not isinstance(node, ast.Name) or not isinstance(node.ctx, ast.Load):
240
+ continue
241
+ if node.id in bound or node.id in reported:
242
+ continue
243
+ reported.add(node.id)
244
+ violations.append(
245
+ GateViolation(
246
+ kind=GateViolationKind.UNDEFINED_NAME,
247
+ message=f"имя '{node.id}' нигде не определено: не в namespace run_python, не в коде, не builtin",
248
+ )
249
+ )
250
+
251
+
252
+ def _bound_names(tree: ast.AST) -> frozenset[str]:
253
+ """Все имена, связанные ГДЕ УГОДНО в коде: присваивания, параметры, def/class, импорты.
254
+
255
+ `ast.walk` уже разворачивает цели `Tuple`/`List`/`Starred` (`a, b = ...`, `for a, b in
256
+ ...`) до отдельных `ast.Name(ctx=Store)` — отдельная рекурсия по целям не нужна.
257
+ """
258
+ bound: set[str] = set()
259
+ for node in ast.walk(tree):
260
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
261
+ bound.add(node.id)
262
+ elif isinstance(node, ast.arg):
263
+ bound.add(node.arg)
264
+ elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
265
+ bound.add(node.name)
266
+ elif isinstance(node, ast.Import | ast.ImportFrom):
267
+ for alias in node.names:
268
+ bound.add(alias.asname or alias.name.split(".")[0])
269
+ elif isinstance(node, ast.ExceptHandler) and node.name is not None:
270
+ bound.add(node.name)
271
+ elif isinstance(node, ast.Global | ast.Nonlocal):
272
+ bound.update(node.names)
273
+ return frozenset(bound)
274
+
275
+
276
+ def _called_name(func: ast.expr) -> str | None:
277
+ """Имя вызываемого: `foo(...)` -> 'foo', `obj.foo(...)` -> 'foo'. Иначе None."""
278
+ if isinstance(func, ast.Name):
279
+ return func.id
280
+ if isinstance(func, ast.Attribute):
281
+ return func.attr
282
+ return None
@@ -0,0 +1,75 @@
1
+ """DTO результата исполнения кода в сессии КОМПАС — `run_python`.
2
+
3
+ Отдельно от `kompas_kernel.features.document_session.models`: `RunResult` — не документная сессия,
4
+ а результат ОДНОГО прогона кода (гейт + исполнение), хотя и несёт `GeoDigest` оттуда.
5
+
6
+ `RunPythonOutcome`/`RunPythonResult` — типизированный итог domain-операции
7
+ `kompas_kernel.features.ksapi_automation.run_python.run_python` (E17.04f): закрытое множество
8
+ транзакционных исходов по образцу `part_authoring.models.BuildOutcome`/`HandsResult`, НО не тот
9
+ же набор. `RunResult.guard` остаётся на самом `RunResult`, а не только на `RunPythonResult.guard`,
10
+ потому что model-facing поверхность (`core/capabilities/ksapi_automation.py::run_python`) обязана
11
+ возвращать `RunResult | str` дословно как раньше — для success/script_error guard переносится
12
+ на возвращаемый `RunResult` копированием, а не остаётся только во внутреннем DTO.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from enum import StrEnum
18
+
19
+ from kompas_kernel.features.document_session.models import GeoDigest, GuardReport
20
+ from kompas_kernel.features.ksapi_automation.gate import GateReport
21
+ from kompas_kernel.utils.validation import CustomModel
22
+
23
+
24
+ class RunResult(CustomModel):
25
+ """Результат `run_python`: статус скрипта + дайджест до/после + честная дельта.
26
+
27
+ `ok` — отработал ли скрипт без исключения. `changed` — изменилась ли геометрия
28
+ по дайджесту (а не по словам скрипта). Их расхождение и есть сигнал «no-op,
29
+ который притворился успехом».
30
+ """
31
+
32
+ ok: bool
33
+ stdout: str = ""
34
+ error: str | None = None
35
+ digest_before: GeoDigest
36
+ digest_after: GeoDigest
37
+ changed: bool
38
+ guard: GuardReport | None = None
39
+
40
+
41
+ class RunPythonOutcome(StrEnum):
42
+ """Исход domain-операции `run_python` — транзакционная категория, не сырой `RunResult`.
43
+
44
+ В отличие от `part_authoring.models.BuildOutcome`, здесь НЕТ отдельной категории для
45
+ «исполнилось, но без изменений»: `RunResult.ok=True` — легитимный успех run_python
46
+ независимо от `changed` (читающий скрипт коммитится — см. model-facing докстринг тула
47
+ `run_python`), тогда как для построения детали тот же случай (`changed=False`)
48
+ классифицируется как `BuildOutcome.NO_OP` с откатом. Смешать два набора значит поменять
49
+ поведение одной из двух операций (подробности —
50
+ `todo-tasks/E17-kompas-kernel/04f-canonical-checkpoint-decorator.md`, шаг 2).
51
+ """
52
+
53
+ SUCCESS = "success" # код исполнился без исключения (RunResult.ok=True), независимо от changed
54
+ SCRIPT_ERROR = "script_error" # код бросил исключение (RunResult.ok=False)
55
+ GATE_REJECTED = "gate_rejected" # статический гейт отклонил код до исполнения
56
+ BRIDGE_ERROR = "bridge_error" # сбой моста/КОМПАС вне гейта — исполнение либо обязательный снимок
57
+
58
+ # намеренно нет TIMEOUT: в отличие от build_geometry, run_python не оборачивает вызов
59
+ # в asyncio.wait_for — это поведение сохранено как есть, таймаут в исходах не заводим.
60
+
61
+
62
+ class RunPythonResult(CustomModel):
63
+ """Типизированный итог domain-операции `run_python`: категория + сообщение + детали.
64
+
65
+ `run` присутствует, если код дошёл до исполнения (success/script_error). `gate`
66
+ присутствует, если код отклонён статически (gate_rejected). `guard` присутствует
67
+ всегда, когда результат прошёл через `@checkpointed` (весь публичный путь) — рассказывает,
68
+ был ли снят снимок и был ли выполнен откат.
69
+ """
70
+
71
+ outcome: RunPythonOutcome
72
+ message: str
73
+ run: RunResult | None = None
74
+ gate: GateReport | None = None
75
+ guard: GuardReport | None = None
@@ -0,0 +1,12 @@
1
+ """Единственный источник правды по namespace `run_python` (GPC1/GPC4).
2
+
3
+ Ровно эти шесть имён кладёт в exec-namespace `execute_user_code`
4
+ (`kompas_kernel.features.ksapi_automation.runner`), который проверяет себя на дрейф от
5
+ этого множества. Статический гейт (`kompas_kernel.features.ksapi_automation.gate`,
6
+ правило `UNDEFINED_NAME`) читает тот же список, чтобы не завести второй источник истины
7
+ про namespace `run_python`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ RUN_PYTHON_NAMESPACE_NAMES: frozenset[str] = frozenset({"app", "doc", "part", "ksapi", "constants", "constants3d"})
@@ -0,0 +1,133 @@
1
+ """Checkpoint-decorated `run_python` — canonical mutation policy for the raw KsAPI escape hatch.
2
+
3
+ Mirrors `kompas_kernel.features.part_authoring.build_geometry.run_build`: the shared
4
+ `@checkpointed` decorator (`kompas_kernel.features.document_session.checkpointed`) is applied
5
+ exactly ONCE, here, in the domain layer — transports (Copilot's Pydantic AI tool today, MCP
6
+ tomorrow) call this operation and cannot bypass the checkpoint by calling the raw
7
+ `KsapiCodeRunner` directly.
8
+
9
+ Главный риск (E17.04f, см. `todo-tasks/E17-kompas-kernel/04f-canonical-checkpoint-decorator.md`,
10
+ шаг 3): `GateRejectedError`/`KompasError` обязаны быть пойманы ВНУТРИ `_execute`, а не пролететь
11
+ наружу. Если дать им пролететь, общий `@checkpointed` поймает их в `except BaseException`,
12
+ выполнит ROLLBACK и перевозбудит исключение — то есть gate reject («документ не тронут») тихо
13
+ превратится в rollback («документ откачен»). `_execute` ниже — та же роль, что `_execute` в
14
+ `part_authoring.build_geometry`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable
20
+ from typing import Any, assert_never
21
+
22
+ from kompas_kernel.features.document_session.checkpointed import (
23
+ CheckpointAction,
24
+ CheckpointDecision,
25
+ CheckpointGuard,
26
+ checkpointed,
27
+ )
28
+ from kompas_kernel.features.document_session.models import GuardReport
29
+ from kompas_kernel.features.ksapi_automation.models import RunPythonOutcome, RunPythonResult
30
+ from kompas_kernel.features.ksapi_automation.runner import GateRejectedError, KsapiCodeRunner
31
+ from kompas_kernel.kompas.errors import KompasError
32
+
33
+
34
+ async def _execute(runner: KsapiCodeRunner, code: str) -> RunPythonResult:
35
+ try:
36
+ run = await runner.run_python(code)
37
+ except GateRejectedError as exc:
38
+ return RunPythonResult(outcome=RunPythonOutcome.GATE_REJECTED, message=str(exc), gate=exc.report)
39
+ except KompasError as exc:
40
+ return RunPythonResult(outcome=RunPythonOutcome.BRIDGE_ERROR, message=str(exc))
41
+ if run.ok:
42
+ return RunPythonResult(
43
+ outcome=RunPythonOutcome.SUCCESS,
44
+ message="Python-скрипт завершился без исключения.",
45
+ run=run,
46
+ )
47
+ return RunPythonResult(
48
+ outcome=RunPythonOutcome.SCRIPT_ERROR,
49
+ message="Python-скрипт завершился ошибкой после начала исполнения.",
50
+ run=run,
51
+ )
52
+
53
+
54
+ def _run_python_decision(result: RunPythonResult) -> CheckpointDecision:
55
+ match result.outcome:
56
+ case RunPythonOutcome.SUCCESS:
57
+ return CheckpointDecision(
58
+ action=CheckpointAction.COMMIT,
59
+ detail="Python-скрипт завершился без исключения.",
60
+ )
61
+ case RunPythonOutcome.GATE_REJECTED:
62
+ return CheckpointDecision(
63
+ action=CheckpointAction.NOT_EXECUTED,
64
+ detail="Код отклонён гейтом до исполнения — документ не менялся.",
65
+ )
66
+ case RunPythonOutcome.SCRIPT_ERROR:
67
+ return CheckpointDecision(
68
+ action=CheckpointAction.ROLLBACK,
69
+ detail="Исполнение Python-кода завершилось с ошибкой после начала исполнения.",
70
+ )
71
+ case RunPythonOutcome.BRIDGE_ERROR:
72
+ return CheckpointDecision(
73
+ action=CheckpointAction.ROLLBACK,
74
+ detail="Исполнение Python-кода завершилось с ошибкой моста.",
75
+ )
76
+ case _ as unreachable:
77
+ assert_never(unreachable)
78
+
79
+
80
+ def _attach_run_python_guard(result: RunPythonResult, report: GuardReport) -> RunPythonResult:
81
+ return result.model_copy(update={"guard": report})
82
+
83
+
84
+ def _blocked_run_python_result(report: GuardReport) -> RunPythonResult:
85
+ return RunPythonResult(
86
+ outcome=RunPythonOutcome.BRIDGE_ERROR,
87
+ message="обязательный снимок документа снять не удалось.",
88
+ guard=report,
89
+ )
90
+
91
+
92
+ def _run_python_guard(
93
+ runner: KsapiCodeRunner, code: str, *, guard: CheckpointGuard, session_id: str
94
+ ) -> CheckpointGuard:
95
+ _ = (runner, code, session_id)
96
+ return guard
97
+
98
+
99
+ def _run_python_session_id(runner: KsapiCodeRunner, code: str, *, guard: CheckpointGuard, session_id: str) -> str:
100
+ _ = (runner, code, guard)
101
+ return session_id
102
+
103
+
104
+ @checkpointed(
105
+ guard=_run_python_guard,
106
+ session_id=_run_python_session_id,
107
+ decide=_run_python_decision,
108
+ attach_guard=_attach_run_python_guard,
109
+ blocked_result=_blocked_run_python_result,
110
+ )
111
+ async def run_python(runner: KsapiCodeRunner, code: str, *, guard: CheckpointGuard, session_id: str) -> RunPythonResult:
112
+ """Execute already-gated Python while the shared decorator owns every checkpoint side effect."""
113
+ _ = (guard, session_id)
114
+ return await _execute(runner, code)
115
+
116
+
117
+ async def run_call[T](
118
+ runner: KsapiCodeRunner,
119
+ *,
120
+ operation_name: str,
121
+ call: Callable[..., T],
122
+ **kwargs: Any,
123
+ ) -> T:
124
+ """Run a trusted typed callback (bridge implementation) through the same session runner.
125
+
126
+ ``call`` — реализация моста из ``providers/kompas_api``: первым позиционным
127
+ аргументом получает `KompasContext`, остальные — ``**kwargs``. В отличие от
128
+ ``run_python`` здесь нет статического гейта и checkpoint: это пред-написанный
129
+ доверенный код, а не произвольный пользовательский сценарий; гейт — только
130
+ для кода модели (GPC1). Runtime-обёртка (``KompasError`` при сбое на воркере)
131
+ остаётся той же через ``KsapiCodeRunner.run_call``.
132
+ """
133
+ return await runner.run_call(operation_name=operation_name, call=call, **kwargs)
@@ -0,0 +1,126 @@
1
+ """The single gate and namespace-exec choke point for KsAPI automation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from collections.abc import Callable, Mapping
7
+ from contextlib import redirect_stdout
8
+ from dataclasses import dataclass
9
+ from typing import Any, Protocol
10
+
11
+ from kompas_kernel.features.ksapi_automation.gate import GateReport, check_code
12
+ from kompas_kernel.features.ksapi_automation.models import RunResult
13
+ from kompas_kernel.features.ksapi_automation.namespace import RUN_PYTHON_NAMESPACE_NAMES
14
+ from kompas_kernel.kompas.errors import KompasError
15
+ from kompas_kernel.kompas.objects import as_document_3d, constants3d_class, constants_class
16
+ from kompas_kernel.kompas.session import KompasContext
17
+ from kompas_kernel.ksapi_static.inventory import KsapiInventory
18
+
19
+
20
+ class KsapiCodeRunner(Protocol):
21
+ """Narrow consumer-owned contract shared by raw and guarded authoring paths."""
22
+
23
+ async def run_python(self, code: str) -> RunResult: ...
24
+
25
+ async def run_call[T](
26
+ self,
27
+ *,
28
+ operation_name: str,
29
+ call: Callable[..., T],
30
+ **kwargs: Any,
31
+ ) -> T: ...
32
+
33
+
34
+ class _KsapiExecutionBackend(Protocol):
35
+ async def run_python(self, code: str) -> RunResult: ...
36
+
37
+ async def run_call[T](
38
+ self,
39
+ *,
40
+ operation_name: str,
41
+ call: Callable[..., T],
42
+ **kwargs: Any,
43
+ ) -> T: ...
44
+
45
+
46
+ def _format_violations(report: GateReport) -> str:
47
+ lines = "\n".join(f"- {violation.kind.value}: {violation.message}" for violation in report.violations)
48
+ return f"код отклонён статическим гейтом до исполнения, нарушений — {len(report.violations)}:\n{lines}"
49
+
50
+
51
+ class GateRejectedError(Exception):
52
+ """User code was rejected before it reached the KOMPAS session."""
53
+
54
+ def __init__(self, report: GateReport) -> None:
55
+ self.report = report
56
+ super().__init__(_format_violations(report))
57
+
58
+
59
+ class KsapiAutomationRunner(KsapiCodeRunner):
60
+ """Apply the gate once, then delegate execution to the one session backend."""
61
+
62
+ def __init__(self, backend: _KsapiExecutionBackend, *, ksapi_inventory: KsapiInventory | None) -> None:
63
+ self._backend = backend
64
+ self._ksapi_inventory = ksapi_inventory
65
+
66
+ async def run_python(self, code: str) -> RunResult:
67
+ report = check_code(code, ksapi_inventory=self._ksapi_inventory)
68
+ if not report.ok:
69
+ raise GateRejectedError(report)
70
+ return await self._backend.run_python(code)
71
+
72
+ async def run_call[T](
73
+ self,
74
+ *,
75
+ operation_name: str,
76
+ call: Callable[..., T],
77
+ **kwargs: Any,
78
+ ) -> T:
79
+ """Delegate a trusted typed callback to the session backend — without the gate.
80
+
81
+ ``run_python`` гейтится как произвольный код модели; ``run_call`` — это
82
+ доверенные реализации моста (``providers/kompas_api``), описанные нами
83
+ же, поэтому статический гейт к ним неприменим (GPC1: один источник
84
+ правды для гейта, а не два дублирующих).
85
+ """
86
+ return await self._backend.run_call(operation_name=operation_name, call=call, **kwargs)
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class ExecOutcome:
91
+ """Result of user code execution inside the temporary KsAPI namespace."""
92
+
93
+ ok: bool
94
+ stdout: str
95
+ error: str | None
96
+
97
+
98
+ def execute_user_code(context: KompasContext, code: str) -> ExecOutcome:
99
+ """Execute code with the one allowed namespace; raw handles never leave this call."""
100
+ doc = context.app.GetActiveDocument()
101
+ doc3d = as_document_3d(context.modules, doc)
102
+ part = None if doc3d is None else doc3d.GetTopPart()
103
+ namespace: dict[str, Any] = {
104
+ "app": context.app,
105
+ "doc": doc,
106
+ "part": part,
107
+ "ksapi": context.modules.ksapi,
108
+ "constants": constants_class(context.modules),
109
+ "constants3d": constants3d_class(context.modules),
110
+ }
111
+ _validate_namespace(namespace)
112
+ buffer = io.StringIO()
113
+ try:
114
+ with redirect_stdout(buffer):
115
+ exec(code, namespace) # noqa: S102 - deliberate escape hatch behind the static gate.
116
+ except Exception as exc:
117
+ return ExecOutcome(ok=False, stdout=buffer.getvalue(), error=f"{type(exc).__name__}: {exc}")
118
+ return ExecOutcome(ok=True, stdout=buffer.getvalue(), error=None)
119
+
120
+
121
+ def _validate_namespace(namespace: Mapping[str, object]) -> None:
122
+ if namespace.keys() != RUN_PYTHON_NAMESPACE_NAMES:
123
+ raise KompasError(
124
+ f"run_python namespace {sorted(namespace)} разошёлся с RUN_PYTHON_NAMESPACE_NAMES "
125
+ f"{sorted(RUN_PYTHON_NAMESPACE_NAMES)} — гейт и исполнение должны видеть один набор имён"
126
+ )