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,93 @@
|
|
|
1
|
+
"""Small shared KsAPI helpers reused by capability modules.
|
|
2
|
+
|
|
3
|
+
Only read-only queries live here: constants class access, `IKompasDocument3D`
|
|
4
|
+
cast, document path, string index parsing, and `IPart.GetGabarit` normalization.
|
|
5
|
+
Everything is kept in one place (GPC8 reuse-first / GPC4 single source of truth)
|
|
6
|
+
instead of duplicated per module.
|
|
7
|
+
|
|
8
|
+
Перенесено из `bridge/kompas_ksapi/common.py` (E16.04) вместе с алиасом `Gabarit`
|
|
9
|
+
(раньше жил в `bridge/kompas_ksapi/raw/assembly.py`), чтобы `features/*` не
|
|
10
|
+
импортировали `kompas_copilot.bridge`. Прежде приватные имена (`_constants_class` и
|
|
11
|
+
т.п.) стали публичными без смены поведения — сигнатуры и логика не менялись.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, cast
|
|
17
|
+
|
|
18
|
+
from kompas_kernel.kompas.runtime import KsApiModules
|
|
19
|
+
|
|
20
|
+
_GABARIT_VALUE_COUNT = 6
|
|
21
|
+
|
|
22
|
+
# Габарит компонента из GetGabarit - кортеж (x1, y1, z1, x2, y2, z2) в мм.
|
|
23
|
+
Gabarit = tuple[float, float, float, float, float, float]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def constants_class(modules: KsApiModules) -> Any:
|
|
27
|
+
module = cast(Any, modules.constants)
|
|
28
|
+
return module.constants
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def constants3d_class(modules: KsApiModules) -> Any:
|
|
32
|
+
module = cast(Any, modules.constants3d)
|
|
33
|
+
return module.constants
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def as_document_3d(modules: KsApiModules, doc: Any) -> Any | None:
|
|
37
|
+
if doc is None:
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
doc3d = modules.ksapi.IKompasDocument3D(doc)
|
|
41
|
+
except Exception:
|
|
42
|
+
return None
|
|
43
|
+
return doc3d if doc3d else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def doc_path(doc: Any) -> str | None:
|
|
47
|
+
raw = doc.GetFullPath()
|
|
48
|
+
text = str(raw).strip() if raw is not None else ""
|
|
49
|
+
return text or None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def doc_name(doc: Any) -> str | None:
|
|
53
|
+
"""Короткое имя документа (`GetName()` — имя файла с расширением) или `None`.
|
|
54
|
+
|
|
55
|
+
У несохранённого документа КОМПАС отдаёт пустую строку (проверено живьём
|
|
56
|
+
2026-08-02: новый `ksDocumentPart` даёт `''` и в `GetName`, и в `GetFullPath`,
|
|
57
|
+
и в `GetPath`), поэтому пустая строка нормализуется в честный `None` — тем же
|
|
58
|
+
способом, что `doc_path` выше.
|
|
59
|
+
"""
|
|
60
|
+
raw = doc.GetName()
|
|
61
|
+
text = str(raw).strip() if raw is not None else ""
|
|
62
|
+
return text or None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_index(identifier: str) -> int | None:
|
|
66
|
+
"""Идентификатор компонента — строковый индекс из read_assembly. Нечисловой → None."""
|
|
67
|
+
try:
|
|
68
|
+
return int(identifier)
|
|
69
|
+
except (ValueError, TypeError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def part_gabarit(part: Any, body_count: int) -> Gabarit | None:
|
|
74
|
+
if body_count == 0:
|
|
75
|
+
return None
|
|
76
|
+
raw = part.GetGabarit(True, True, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
|
77
|
+
if raw is None:
|
|
78
|
+
return None
|
|
79
|
+
values = tuple(raw)
|
|
80
|
+
if len(values) < _GABARIT_VALUE_COUNT + 1:
|
|
81
|
+
return None
|
|
82
|
+
ok = bool(values[0])
|
|
83
|
+
if not ok:
|
|
84
|
+
return None
|
|
85
|
+
coords = values[-_GABARIT_VALUE_COUNT:]
|
|
86
|
+
return (
|
|
87
|
+
float(coords[0]),
|
|
88
|
+
float(coords[1]),
|
|
89
|
+
float(coords[2]),
|
|
90
|
+
float(coords[3]),
|
|
91
|
+
float(coords[4]),
|
|
92
|
+
float(coords[5]),
|
|
93
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Единственное место в `kompas/`, знающее, как называются файлы моста на этой ОС.
|
|
2
|
+
|
|
3
|
+
KsAPICLink **кроссплатформенный**: тот же `ksapi.py`, который мы импортируем из
|
|
4
|
+
`KOMPAS_BIN_DIR`, сам выбирает библиотеку строкой
|
|
5
|
+
`LIB_NAME = 'ksAPICLink.dll' if os.name == 'nt' else 'libksAPICLink.so'`. На Linux
|
|
6
|
+
установка лежит как `/opt/ascon/kompas3d-vXX/Bin/libksAPICLink.so` — тот же каталог
|
|
7
|
+
`Bin` рядом с генерированными обёртками, что и на Windows. Поэтому продукт НЕ
|
|
8
|
+
отказывает по имени ОС: отказ бывает только один — «в этом каталоге нет нужных
|
|
9
|
+
файлов» (`runtime.validate_ksapi_runtime`).
|
|
10
|
+
|
|
11
|
+
Здесь живёт ровно та же развилка, что у вендора, и больше ничего. Вынесена она
|
|
12
|
+
отдельным файлом потому, что архитектурный гейт
|
|
13
|
+
(`tests/architecture/test_layer_boundaries.py`) держит знание об ОС в счётном числе
|
|
14
|
+
мест: иначе `os.name` расползётся по слоям и «поддержка Linux» станет непроверяемой.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
# Windows: библиотека моста плюс сам `KOMPAS.exe` — он здесь признак «каталог тот
|
|
22
|
+
# самый», а не то, что мы запускаем (КОМПАС поднимает пользователь).
|
|
23
|
+
_WINDOWS_RUNTIME_FILES: tuple[str, ...] = ("ksAPICLink.dll", "KOMPAS.exe")
|
|
24
|
+
|
|
25
|
+
# Прочие ОС: только библиотека. Имя исполняемого файла КОМПАС на Linux в продукте НЕ
|
|
26
|
+
# зашито — оно не проверено живьём, а угаданное имя дало бы ложный отказ на исправной
|
|
27
|
+
# установке. Признака «каталог тот самый» достаточно от самой `libksAPICLink.so`.
|
|
28
|
+
_POSIX_RUNTIME_FILES: tuple[str, ...] = ("libksAPICLink.so",)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def runtime_file_names(*, os_name: str) -> tuple[str, ...]:
|
|
32
|
+
"""Платформенные файлы моста для `os_name` (значения — как у `os.name`).
|
|
33
|
+
|
|
34
|
+
Аргумент явный (GPC1), чтобы обе ветки проверялись тестами на любой машине, а не
|
|
35
|
+
только та, на которой сейчас идёт прогон.
|
|
36
|
+
"""
|
|
37
|
+
return _WINDOWS_RUNTIME_FILES if os_name == "nt" else _POSIX_RUNTIME_FILES
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
PLATFORM_RUNTIME_FILES: tuple[str, ...] = runtime_file_names(os_name=os.name)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Generated KsAPICLink loader and the single sequential worker runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib.util
|
|
7
|
+
import queue
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from concurrent.futures import Future
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import ModuleType
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from kompas_kernel.kompas.errors import KompasError
|
|
18
|
+
from kompas_kernel.kompas.platform import PLATFORM_RUNTIME_FILES
|
|
19
|
+
from kompas_kernel.observability.logger import get_logger
|
|
20
|
+
from kompas_kernel.observability.taxonomy import LogDomain, LogEventType, event_name
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
# Генерированные SWIG-обёртки одинаковы на любой ОС; библиотека моста (и на Windows
|
|
25
|
+
# `KOMPAS.exe`) зависит от платформы — их имена приходят из `kompas/platform.py`,
|
|
26
|
+
# единственного владельца этого знания.
|
|
27
|
+
_GENERATED_WRAPPER_FILES: tuple[str, ...] = ("ksapi.py", "constants.py", "constants3d.py")
|
|
28
|
+
|
|
29
|
+
REQUIRED_RUNTIME_FILES: tuple[str, ...] = (*_GENERATED_WRAPPER_FILES, *PLATFORM_RUNTIME_FILES)
|
|
30
|
+
|
|
31
|
+
_WORKER_PHASE = "ksapi_worker"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class KsApiRuntimeConfig:
|
|
36
|
+
"""Explicit path and required files for one KOMPAS binary tree."""
|
|
37
|
+
|
|
38
|
+
bin_dir: Path
|
|
39
|
+
required_files: tuple[str, ...] = REQUIRED_RUNTIME_FILES
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class KsApiModules:
|
|
44
|
+
"""Generated wrapper modules loaded from one KOMPAS binary tree."""
|
|
45
|
+
|
|
46
|
+
ksapi: ModuleType
|
|
47
|
+
constants: ModuleType
|
|
48
|
+
constants3d: ModuleType
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class _Task:
|
|
53
|
+
"""One heterogeneous callback queued for the worker thread."""
|
|
54
|
+
|
|
55
|
+
fn: Callable[[], Any]
|
|
56
|
+
future: Future[Any]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class KsApiWorker:
|
|
60
|
+
"""One background thread serializing calls into one KOMPAS application."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, *, thread_name: str, stop_timeout_s: float) -> None:
|
|
63
|
+
self._queue: queue.SimpleQueue[_Task | None] = queue.SimpleQueue()
|
|
64
|
+
self._thread = threading.Thread(target=self._run, name=thread_name, daemon=True)
|
|
65
|
+
self._stop_timeout_s = stop_timeout_s
|
|
66
|
+
self._started = False
|
|
67
|
+
self._stopped = False
|
|
68
|
+
|
|
69
|
+
def start(self) -> None:
|
|
70
|
+
"""Start exactly once."""
|
|
71
|
+
if self._started:
|
|
72
|
+
raise RuntimeError("KsApiWorker already started")
|
|
73
|
+
self._started = True
|
|
74
|
+
self._thread.start()
|
|
75
|
+
logger.info(event_name(LogDomain.BRIDGE, _WORKER_PHASE, LogEventType.STARTED), thread=self._thread.name)
|
|
76
|
+
|
|
77
|
+
def _run(self) -> None:
|
|
78
|
+
while True:
|
|
79
|
+
task = self._queue.get()
|
|
80
|
+
if task is None:
|
|
81
|
+
return
|
|
82
|
+
if not task.future.set_running_or_notify_cancel():
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
result = task.fn()
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
task.future.set_exception(exc)
|
|
88
|
+
else:
|
|
89
|
+
task.future.set_result(result)
|
|
90
|
+
|
|
91
|
+
def is_current_thread(self) -> bool:
|
|
92
|
+
"""Whether the caller is executing inside this worker's callback."""
|
|
93
|
+
return threading.current_thread() is self._thread
|
|
94
|
+
|
|
95
|
+
def _enqueue[T](self, fn: Callable[[], T]) -> Future[T]:
|
|
96
|
+
if not self._started or self._stopped:
|
|
97
|
+
raise RuntimeError("KsApiWorker is not running")
|
|
98
|
+
future: Future[T] = Future()
|
|
99
|
+
self._queue.put(_Task(fn=fn, future=future))
|
|
100
|
+
return future
|
|
101
|
+
|
|
102
|
+
def submit[T](self, fn: Callable[[], T]) -> T:
|
|
103
|
+
"""Run ``fn`` on the worker and block the calling thread."""
|
|
104
|
+
return self._enqueue(fn).result()
|
|
105
|
+
|
|
106
|
+
async def submit_async[T](self, fn: Callable[[], T]) -> T:
|
|
107
|
+
"""Run ``fn`` on the worker without blocking the event loop."""
|
|
108
|
+
return await asyncio.wrap_future(self._enqueue(fn))
|
|
109
|
+
|
|
110
|
+
def stop(self) -> None:
|
|
111
|
+
"""Stop once; repeated close calls are harmless."""
|
|
112
|
+
if not self._started or self._stopped:
|
|
113
|
+
return
|
|
114
|
+
self._stopped = True
|
|
115
|
+
self._queue.put(None)
|
|
116
|
+
self._thread.join(timeout=self._stop_timeout_s)
|
|
117
|
+
logger.info(event_name(LogDomain.BRIDGE, _WORKER_PHASE, LogEventType.COMPLETED), thread=self._thread.name)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def runtime_config_from_bin_dir(bin_dir: Path | None) -> KsApiRuntimeConfig:
|
|
121
|
+
"""Build fail-fast runtime config from an explicit settings value."""
|
|
122
|
+
if bin_dir is None:
|
|
123
|
+
raise KompasError(
|
|
124
|
+
"KOMPAS_BIN_DIR is not set. Set it to the KOMPAS Bin(x64) directory "
|
|
125
|
+
"with ksapi.py, constants.py, constants3d.py, ksAPICLink.dll and KOMPAS.exe."
|
|
126
|
+
)
|
|
127
|
+
return KsApiRuntimeConfig(bin_dir=bin_dir)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def validate_ksapi_runtime(config: KsApiRuntimeConfig) -> None:
|
|
131
|
+
"""Validate the binary tree before importing generated wrappers."""
|
|
132
|
+
if not config.bin_dir.exists():
|
|
133
|
+
raise KompasError(f"KOMPAS_BIN_DIR does not exist: {config.bin_dir}")
|
|
134
|
+
if not config.bin_dir.is_dir():
|
|
135
|
+
raise KompasError(f"KOMPAS_BIN_DIR is not a directory: {config.bin_dir}")
|
|
136
|
+
|
|
137
|
+
missing = [name for name in config.required_files if not (config.bin_dir / name).is_file()]
|
|
138
|
+
if missing:
|
|
139
|
+
raise KompasError(f"KOMPAS_BIN_DIR is missing required runtime files: {', '.join(missing)}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_ksapi_runtime(config: KsApiRuntimeConfig) -> KsApiModules:
|
|
143
|
+
"""Import generated wrapper modules from an explicit runtime directory."""
|
|
144
|
+
validate_ksapi_runtime(config)
|
|
145
|
+
bin_dir = config.bin_dir.resolve()
|
|
146
|
+
_ensure_sys_path(bin_dir)
|
|
147
|
+
return KsApiModules(
|
|
148
|
+
constants=_import_runtime_module("constants", bin_dir / "constants.py"),
|
|
149
|
+
constants3d=_import_runtime_module("constants3d", bin_dir / "constants3d.py"),
|
|
150
|
+
ksapi=_import_runtime_module("ksapi", bin_dir / "ksapi.py"),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _ensure_sys_path(bin_dir: Path) -> None:
|
|
155
|
+
text = str(bin_dir)
|
|
156
|
+
if text not in sys.path:
|
|
157
|
+
sys.path.insert(0, text)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _import_runtime_module(name: str, path: Path) -> ModuleType:
|
|
161
|
+
existing = sys.modules.get(name)
|
|
162
|
+
if existing is not None:
|
|
163
|
+
existing_file = _module_file(existing)
|
|
164
|
+
if existing_file == path.resolve():
|
|
165
|
+
return existing
|
|
166
|
+
raise KompasError(
|
|
167
|
+
f"Cannot load KOMPAS runtime module {name!r} from {path}: module already loaded from {existing_file}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
171
|
+
if spec is None or spec.loader is None:
|
|
172
|
+
raise KompasError(f"Cannot create import spec for KOMPAS runtime module {name!r}: {path}")
|
|
173
|
+
|
|
174
|
+
module = importlib.util.module_from_spec(spec)
|
|
175
|
+
sys.modules[name] = module
|
|
176
|
+
try:
|
|
177
|
+
spec.loader.exec_module(module)
|
|
178
|
+
except Exception:
|
|
179
|
+
sys.modules.pop(name, None)
|
|
180
|
+
raise
|
|
181
|
+
return module
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _module_file(module: ModuleType) -> Path | None:
|
|
185
|
+
raw = getattr(module, "__file__", None)
|
|
186
|
+
return None if raw is None else Path(str(raw)).resolve()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""One lifecycle owner and execution boundary for a visible KOMPAS instance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from kompas_kernel.kompas.errors import KompasError, KompasReentryError
|
|
11
|
+
from kompas_kernel.kompas.runtime import (
|
|
12
|
+
KsApiModules,
|
|
13
|
+
KsApiRuntimeConfig,
|
|
14
|
+
KsApiWorker,
|
|
15
|
+
load_ksapi_runtime,
|
|
16
|
+
runtime_config_from_bin_dir,
|
|
17
|
+
)
|
|
18
|
+
from kompas_kernel.observability.logger import get_logger
|
|
19
|
+
from kompas_kernel.observability.taxonomy import LogDomain, LogEventType, event_name
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
KSAPI_WORKER_THREAD_NAME = "kompas-ksapi-sequential"
|
|
24
|
+
KSAPI_WORKER_STOP_TIMEOUT_S = 5.0
|
|
25
|
+
_OPERATION_PHASE = "kompas_session_operation"
|
|
26
|
+
|
|
27
|
+
type RuntimeLoader = Callable[[KsApiRuntimeConfig], KsApiModules]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class KompasContext:
|
|
32
|
+
"""Raw SDK context valid only for the duration of one session callback."""
|
|
33
|
+
|
|
34
|
+
modules: KsApiModules
|
|
35
|
+
app: Any
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class KompasSession:
|
|
39
|
+
"""Own one worker, generated runtime modules, and one visible KOMPAS app."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
worker: KsApiWorker,
|
|
45
|
+
config: KsApiRuntimeConfig,
|
|
46
|
+
runtime_loader: RuntimeLoader,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._worker = worker
|
|
49
|
+
self._config = config
|
|
50
|
+
self._runtime_loader = runtime_loader
|
|
51
|
+
self._modules: KsApiModules | None = None
|
|
52
|
+
self._app: Any | None = None
|
|
53
|
+
self._connected = False
|
|
54
|
+
self._closed = False
|
|
55
|
+
|
|
56
|
+
async def connect(self) -> None:
|
|
57
|
+
"""Load wrappers and attach to the already-open visible KOMPAS exactly once."""
|
|
58
|
+
if self._connected:
|
|
59
|
+
raise KompasError("KOMPAS session is already connected")
|
|
60
|
+
if self._closed:
|
|
61
|
+
raise KompasError("KOMPAS session is already closed")
|
|
62
|
+
self._worker.start()
|
|
63
|
+
try:
|
|
64
|
+
modules, app = await self._worker.submit_async(self._load_and_connect_on_thread)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
self._closed = True
|
|
67
|
+
self._worker.stop()
|
|
68
|
+
self._raise_as_kompas("session.connect", exc)
|
|
69
|
+
self._modules = modules
|
|
70
|
+
self._app = app
|
|
71
|
+
self._connected = True
|
|
72
|
+
logger.info(event_name(LogDomain.CAD, "ksapi_connect", LogEventType.COMPLETED))
|
|
73
|
+
|
|
74
|
+
def _load_and_connect_on_thread(self) -> tuple[KsApiModules, Any]:
|
|
75
|
+
modules = self._runtime_loader(self._config)
|
|
76
|
+
app = modules.ksapi.GetKompas()
|
|
77
|
+
if app is None:
|
|
78
|
+
raise KompasError(
|
|
79
|
+
"Откройте КОМПАС: KsAPICLink не видит открытый visible KOMPAS.exe. Backend не запускает КОМПАС сам."
|
|
80
|
+
)
|
|
81
|
+
return modules, app
|
|
82
|
+
|
|
83
|
+
async def aclose(self) -> None:
|
|
84
|
+
"""Stop only the worker; never close the user's KOMPAS application."""
|
|
85
|
+
self._ensure_not_reentrant("session.close")
|
|
86
|
+
if self._closed:
|
|
87
|
+
return
|
|
88
|
+
self._closed = True
|
|
89
|
+
self._connected = False
|
|
90
|
+
self._modules = None
|
|
91
|
+
self._app = None
|
|
92
|
+
self._worker.stop()
|
|
93
|
+
|
|
94
|
+
async def run[T](
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
operation_name: str,
|
|
98
|
+
operation: Callable[[KompasContext], T],
|
|
99
|
+
) -> T:
|
|
100
|
+
"""Execute a complete typed operation as one atomic worker queue item."""
|
|
101
|
+
self._validate_operation(operation_name)
|
|
102
|
+
self._ensure_not_reentrant(operation_name)
|
|
103
|
+
self._ensure_connected()
|
|
104
|
+
logger.info(
|
|
105
|
+
event_name(LogDomain.BRIDGE, _OPERATION_PHASE, LogEventType.STARTED),
|
|
106
|
+
operation_name=operation_name,
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
result = await self._worker.submit_async(lambda: operation(self._context()))
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
logger.warning(
|
|
112
|
+
event_name(LogDomain.BRIDGE, _OPERATION_PHASE, LogEventType.FAILED),
|
|
113
|
+
operation_name=operation_name,
|
|
114
|
+
error=str(exc),
|
|
115
|
+
)
|
|
116
|
+
self._raise_as_kompas(operation_name, exc)
|
|
117
|
+
logger.info(
|
|
118
|
+
event_name(LogDomain.BRIDGE, _OPERATION_PHASE, LogEventType.COMPLETED),
|
|
119
|
+
operation_name=operation_name,
|
|
120
|
+
)
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
def run_sync[T](
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
operation_name: str,
|
|
127
|
+
operation: Callable[[KompasContext], T],
|
|
128
|
+
) -> T:
|
|
129
|
+
"""Synchronous form of :meth:`run` for non-async integration seams."""
|
|
130
|
+
self._validate_operation(operation_name)
|
|
131
|
+
self._ensure_not_reentrant(operation_name)
|
|
132
|
+
self._ensure_connected()
|
|
133
|
+
logger.info(
|
|
134
|
+
event_name(LogDomain.BRIDGE, _OPERATION_PHASE, LogEventType.STARTED),
|
|
135
|
+
operation_name=operation_name,
|
|
136
|
+
)
|
|
137
|
+
try:
|
|
138
|
+
result = self._worker.submit(lambda: operation(self._context()))
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
logger.warning(
|
|
141
|
+
event_name(LogDomain.BRIDGE, _OPERATION_PHASE, LogEventType.FAILED),
|
|
142
|
+
operation_name=operation_name,
|
|
143
|
+
error=str(exc),
|
|
144
|
+
)
|
|
145
|
+
self._raise_as_kompas(operation_name, exc)
|
|
146
|
+
logger.info(
|
|
147
|
+
event_name(LogDomain.BRIDGE, _OPERATION_PHASE, LogEventType.COMPLETED),
|
|
148
|
+
operation_name=operation_name,
|
|
149
|
+
)
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
def _context(self) -> KompasContext:
|
|
153
|
+
modules = self._modules
|
|
154
|
+
app = self._app
|
|
155
|
+
if modules is None or app is None:
|
|
156
|
+
raise KompasError("KOMPAS session is not connected; call connect() first")
|
|
157
|
+
return KompasContext(modules=modules, app=app)
|
|
158
|
+
|
|
159
|
+
def _ensure_connected(self) -> None:
|
|
160
|
+
if not self._connected or self._closed:
|
|
161
|
+
raise KompasError("KOMPAS session is not connected; call connect() first")
|
|
162
|
+
|
|
163
|
+
def _ensure_not_reentrant(self, operation_name: str) -> None:
|
|
164
|
+
if self._worker.is_current_thread():
|
|
165
|
+
raise KompasReentryError(f"KOMPAS session re-entry is forbidden from a worker callback: {operation_name}")
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _validate_operation(operation_name: str) -> None:
|
|
169
|
+
if not operation_name.strip():
|
|
170
|
+
raise ValueError("operation_name must be a non-empty string")
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _raise_as_kompas(operation_name: str, exc: Exception) -> None:
|
|
174
|
+
if isinstance(exc, KompasError):
|
|
175
|
+
raise exc
|
|
176
|
+
raise KompasError(f"{operation_name}: KsAPI error — {type(exc).__name__}: {exc}") from exc
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def create_kompas_session(config: KsApiRuntimeConfig) -> KompasSession:
|
|
180
|
+
"""Create and connect one session with one worker; no singleton or second owner."""
|
|
181
|
+
session = KompasSession(
|
|
182
|
+
worker=KsApiWorker(
|
|
183
|
+
thread_name=KSAPI_WORKER_THREAD_NAME,
|
|
184
|
+
stop_timeout_s=KSAPI_WORKER_STOP_TIMEOUT_S,
|
|
185
|
+
),
|
|
186
|
+
config=config,
|
|
187
|
+
runtime_loader=load_ksapi_runtime,
|
|
188
|
+
)
|
|
189
|
+
await session.connect()
|
|
190
|
+
return session
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def create_kompas_session_from_bin_dir(bin_dir: Path | None) -> KompasSession:
|
|
194
|
+
"""Build an explicit runtime config and return its connected session."""
|
|
195
|
+
return await create_kompas_session(runtime_config_from_bin_dir(bin_dir))
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Normalize raw CAD-kernel values to the public millimetre contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import assert_never
|
|
7
|
+
|
|
8
|
+
MM_PER_CM = 10.0
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SourceUnit(StrEnum):
|
|
12
|
+
"""Unit attached to a value read from the CAD kernel."""
|
|
13
|
+
|
|
14
|
+
MM = "mm"
|
|
15
|
+
CM = "cm"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def length_to_mm(value: float, source: SourceUnit) -> float:
|
|
19
|
+
"""Convert a length to millimetres."""
|
|
20
|
+
match source:
|
|
21
|
+
case SourceUnit.MM:
|
|
22
|
+
return value
|
|
23
|
+
case SourceUnit.CM:
|
|
24
|
+
return value * MM_PER_CM
|
|
25
|
+
case _:
|
|
26
|
+
assert_never(source)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def area_to_mm2(value: float, source: SourceUnit) -> float:
|
|
30
|
+
"""Convert an area to square millimetres."""
|
|
31
|
+
match source:
|
|
32
|
+
case SourceUnit.MM:
|
|
33
|
+
return value
|
|
34
|
+
case SourceUnit.CM:
|
|
35
|
+
return value * MM_PER_CM * MM_PER_CM
|
|
36
|
+
case _:
|
|
37
|
+
assert_never(source)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def volume_to_mm3(value: float, source: SourceUnit) -> float:
|
|
41
|
+
"""Convert a volume to cubic millimetres."""
|
|
42
|
+
match source:
|
|
43
|
+
case SourceUnit.MM:
|
|
44
|
+
return value
|
|
45
|
+
case SourceUnit.CM:
|
|
46
|
+
return value * MM_PER_CM * MM_PER_CM * MM_PER_CM
|
|
47
|
+
case _:
|
|
48
|
+
assert_never(source)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Static knowledge of the installed KsAPI build: recipes, wrapper grep, name inventory
|
|
2
|
+
and reusable fact violations. This package does NOT know the RAG runtime or a live
|
|
3
|
+
`KompasSession` — those stay in Copilot (`kompas_copilot.knowledge`). The `run_python`
|
|
4
|
+
sandbox policy (import/call allowlists, namespace-drift check) is a separate kernel
|
|
5
|
+
package, `kompas_kernel.features.ksapi_automation` (E17.04e), which consumes these
|
|
6
|
+
reusable facts as a parameter rather than duplicating them. No eager re-export, same as
|
|
7
|
+
the rest of `kompas_kernel`.
|
|
8
|
+
"""
|