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.
- kompas_kernel/__init__.py +3 -0
- kompas_kernel/features/__init__.py +9 -0
- kompas_kernel/features/assembly_navigator/__init__.py +8 -0
- kompas_kernel/features/assembly_navigator/commands.py +37 -0
- kompas_kernel/features/assembly_navigator/ksapi/__init__.py +5 -0
- kompas_kernel/features/assembly_navigator/ksapi/mapping.py +122 -0
- kompas_kernel/features/assembly_navigator/ksapi/presentation.py +107 -0
- kompas_kernel/features/assembly_navigator/ksapi/raw.py +74 -0
- kompas_kernel/features/assembly_navigator/ksapi/reader.py +287 -0
- kompas_kernel/features/assembly_navigator/ksapi/resolver.py +35 -0
- kompas_kernel/features/assembly_navigator/models.py +218 -0
- kompas_kernel/features/assembly_navigator/queries.py +42 -0
- kompas_kernel/features/assembly_navigator/service.py +31 -0
- kompas_kernel/features/assembly_navigator/summary.py +90 -0
- kompas_kernel/features/document_session/__init__.py +5 -0
- kompas_kernel/features/document_session/checkpointed.py +165 -0
- kompas_kernel/features/document_session/commands.py +94 -0
- kompas_kernel/features/document_session/digest.py +126 -0
- kompas_kernel/features/document_session/ksapi/__init__.py +5 -0
- kompas_kernel/features/document_session/ksapi/context_reader.py +174 -0
- kompas_kernel/features/document_session/ksapi/screenshot.py +365 -0
- kompas_kernel/features/document_session/ksapi/snapshot.py +181 -0
- kompas_kernel/features/document_session/models.py +311 -0
- kompas_kernel/features/document_session/queries.py +62 -0
- kompas_kernel/features/document_session/registry.py +101 -0
- kompas_kernel/features/document_session/service.py +128 -0
- kompas_kernel/features/geometry_3d/__init__.py +9 -0
- kompas_kernel/features/geometry_3d/axis_math.py +50 -0
- kompas_kernel/features/geometry_3d/commands.py +68 -0
- kompas_kernel/features/geometry_3d/hex_detect.py +113 -0
- kompas_kernel/features/geometry_3d/ksapi/__init__.py +5 -0
- kompas_kernel/features/geometry_3d/ksapi/component_geometry.py +559 -0
- kompas_kernel/features/geometry_3d/ksapi/measurements.py +368 -0
- kompas_kernel/features/geometry_3d/ksapi/presentation.py +204 -0
- kompas_kernel/features/geometry_3d/models.py +216 -0
- kompas_kernel/features/geometry_3d/queries.py +96 -0
- kompas_kernel/features/geometry_3d/service.py +69 -0
- kompas_kernel/features/geometry_3d/wrench_zone.py +161 -0
- kompas_kernel/features/ksapi_automation/__init__.py +9 -0
- kompas_kernel/features/ksapi_automation/gate.py +282 -0
- kompas_kernel/features/ksapi_automation/models.py +75 -0
- kompas_kernel/features/ksapi_automation/namespace.py +12 -0
- kompas_kernel/features/ksapi_automation/run_python.py +133 -0
- kompas_kernel/features/ksapi_automation/runner.py +126 -0
- kompas_kernel/features/ksapi_automation/service.py +85 -0
- kompas_kernel/features/part_authoring/__init__.py +5 -0
- kompas_kernel/features/part_authoring/build_geometry.py +122 -0
- kompas_kernel/features/part_authoring/models.py +99 -0
- kompas_kernel/features/wrench_clearance/__init__.py +7 -0
- kompas_kernel/features/wrench_clearance/models.py +87 -0
- kompas_kernel/features/wrench_clearance/service.py +154 -0
- kompas_kernel/kompas/__init__.py +14 -0
- kompas_kernel/kompas/errors.py +11 -0
- kompas_kernel/kompas/models.py +68 -0
- kompas_kernel/kompas/objects.py +93 -0
- kompas_kernel/kompas/platform.py +40 -0
- kompas_kernel/kompas/runtime.py +186 -0
- kompas_kernel/kompas/session.py +195 -0
- kompas_kernel/kompas/units.py +48 -0
- kompas_kernel/ksapi_static/__init__.py +8 -0
- kompas_kernel/ksapi_static/facts.py +132 -0
- kompas_kernel/ksapi_static/inventory.py +185 -0
- kompas_kernel/ksapi_static/recipes.py +114 -0
- kompas_kernel/ksapi_static/wrapper_grep.py +83 -0
- kompas_kernel/observability/__init__.py +1 -0
- kompas_kernel/observability/logger.py +24 -0
- kompas_kernel/observability/setup.py +115 -0
- kompas_kernel/observability/taxonomy.py +27 -0
- kompas_kernel/py.typed +1 -0
- kompas_kernel/standards/__init__.py +1 -0
- kompas_kernel/standards/wrench_clearance.py +234 -0
- kompas_kernel/utils/__init__.py +1 -0
- kompas_kernel/utils/filesystem.py +82 -0
- kompas_kernel/utils/validation.py +21 -0
- kompas_kernel-0.0.1.dist-info/METADATA +56 -0
- kompas_kernel-0.0.1.dist-info/RECORD +77 -0
- 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
|
+
)
|