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,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
+ """