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,132 @@
1
+ """Reusable syntax and installed-KsAPI facts, without product sandbox policy.
2
+
3
+ The checker validates attribute call names, call arities and constants against an
4
+ explicit :class:`KsapiInventory`. It does not know the ``run_python`` namespace,
5
+ forbidden imports, dangerous builtins or connection policy, so an external developer
6
+ can reuse these facts without inheriting Copilot's execution sandbox.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ from enum import StrEnum
13
+
14
+ from kompas_kernel.ksapi_static.inventory import KsapiInventory
15
+ from kompas_kernel.utils.validation import CustomModel
16
+
17
+ # Python container methods are not KsAPI typos. Build the set mechanically so it
18
+ # follows the runtime instead of becoming another hand-maintained inventory.
19
+ _BUILTIN_METHOD_ALLOWLIST: frozenset[str] = frozenset(
20
+ name for builtin_type in (list, dict, str, set, frozenset, tuple, bytes, bytearray) for name in dir(builtin_type)
21
+ )
22
+
23
+
24
+ class KsapiViolationKind(StrEnum):
25
+ """A reusable syntax or installed-KsAPI fact violation."""
26
+
27
+ SYNTAX = "syntax"
28
+ UNKNOWN_KSAPI_METHOD = "unknown_ksapi_method"
29
+ KSAPI_ARITY = "ksapi_arity"
30
+ UNKNOWN_KSAPI_CONSTANT = "unknown_ksapi_constant"
31
+
32
+
33
+ class KsapiViolation(CustomModel):
34
+ """One reusable static fact violation."""
35
+
36
+ kind: KsapiViolationKind
37
+ message: str
38
+
39
+
40
+ class KsapiReport(CustomModel):
41
+ """Result of checking only syntax and facts from an installed KsAPI inventory."""
42
+
43
+ ok: bool
44
+ violations: tuple[KsapiViolation, ...]
45
+
46
+
47
+ def check_ksapi_code(code: str, *, ksapi_inventory: KsapiInventory | None) -> KsapiReport:
48
+ """Check syntax and installed-KsAPI facts without applying product sandbox policy."""
49
+ try:
50
+ tree = ast.parse(code)
51
+ except SyntaxError as exc:
52
+ message = f"синтаксическая ошибка: {exc.msg} (строка {exc.lineno})"
53
+ violation = KsapiViolation(kind=KsapiViolationKind.SYNTAX, message=message)
54
+ return KsapiReport(ok=False, violations=(violation,))
55
+
56
+ violations = tuple(
57
+ violation
58
+ for node in ast.walk(tree)
59
+ for violation in collect_ksapi_violations(node, ksapi_inventory=ksapi_inventory)
60
+ )
61
+ return KsapiReport(ok=not violations, violations=violations)
62
+
63
+
64
+ def collect_ksapi_violations(
65
+ node: ast.AST,
66
+ *,
67
+ ksapi_inventory: KsapiInventory | None,
68
+ ) -> tuple[KsapiViolation, ...]:
69
+ """Return installed-KsAPI fact violations contributed by one AST node."""
70
+ violations: list[KsapiViolation] = []
71
+ method_violation = _ksapi_method_violation(node, ksapi_inventory)
72
+ if method_violation is not None:
73
+ violations.append(method_violation)
74
+ constant_violation = _ksapi_constant_violation(node, ksapi_inventory)
75
+ if constant_violation is not None:
76
+ violations.append(constant_violation)
77
+ return tuple(violations)
78
+
79
+
80
+ def _ksapi_method_violation(node: ast.AST, inventory: KsapiInventory | None) -> KsapiViolation | None:
81
+ """Validate ``x.Foo(...)`` by name and arity without trying to infer ``x``'s type."""
82
+ if inventory is None or not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
83
+ return None
84
+ name = node.func.attr
85
+ if name in _BUILTIN_METHOD_ALLOWLIST:
86
+ return None
87
+ if name not in inventory.known_call_names:
88
+ return KsapiViolation(
89
+ kind=KsapiViolationKind.UNKNOWN_KSAPI_METHOD,
90
+ message=(
91
+ f"'{name}' не найден среди методов/классов ksAPI установленной сборки — "
92
+ "проверь имя (grep_ksapi_wrappers/ask_ksapi_expert)"
93
+ ),
94
+ )
95
+ allowed_arities = inventory.method_arities.get(name)
96
+ if allowed_arities is None:
97
+ return None
98
+ call_arity = _call_arg_count(node)
99
+ if call_arity is None or call_arity in allowed_arities:
100
+ return None
101
+ allowed = ", ".join(str(count) for count in sorted(allowed_arities))
102
+ return KsapiViolation(
103
+ kind=KsapiViolationKind.KSAPI_ARITY,
104
+ message=f"'{name}(...)' вызван с {call_arity} аргумент(ами) — у ksAPI допустимо: {allowed}",
105
+ )
106
+
107
+
108
+ def _call_arg_count(node: ast.Call) -> int | None:
109
+ """Return a deterministic call arity, or ``None`` for ``*args``/``**kwargs``."""
110
+ if any(isinstance(arg, ast.Starred) for arg in node.args):
111
+ return None
112
+ if any(keyword.arg is None for keyword in node.keywords):
113
+ return None
114
+ return len(node.args) + len(node.keywords)
115
+
116
+
117
+ def _ksapi_constant_violation(node: ast.AST, inventory: KsapiInventory | None) -> KsapiViolation | None:
118
+ """Validate ``constants.*`` and ``constants3d.*`` names against the inventory."""
119
+ if inventory is None or not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name):
120
+ return None
121
+ namespace_name = node.value.id
122
+ if namespace_name == "constants3d" and node.attr not in inventory.constants3d_names:
123
+ return KsapiViolation(
124
+ kind=KsapiViolationKind.UNKNOWN_KSAPI_CONSTANT,
125
+ message=f"'constants3d.{node.attr}' не найдена среди констант установленной сборки",
126
+ )
127
+ if namespace_name == "constants" and node.attr not in inventory.constants_names:
128
+ return KsapiViolation(
129
+ kind=KsapiViolationKind.UNKNOWN_KSAPI_CONSTANT,
130
+ message=f"'constants.{node.attr}' не найдена среди констант установленной сборки",
131
+ )
132
+ return None
@@ -0,0 +1,185 @@
1
+ """Инвентарь имён ksAPI установленной сборки — источник истины для гейта по именам.
2
+
3
+ `build_ksapi_inventory` разбирает три generated wrapper-файла (`ksapi.py`/`constants.py`/
4
+ `constants3d.py`) через `ast`, БЕЗ импорта модулей и без запуска КОМПАС. Пути к этим трём
5
+ файлам сюда приходят уже РЕЗОЛВЛЕННЫМИ (`Path`, параметром) — модуль их не ищет и не
6
+ резолвит сам: единственный вызов
7
+ `kompas_kernel.ksapi_static.wrapper_grep.resolve_wrapper_paths` живёт в
8
+ `core/composition.py::_wire_ksapi_inventory` (composition — orchestration wiring, ему
9
+ можно импортировать любой слой), и та же пара путей идёт вторым потребителем в
10
+ `build_wrapper_grep_toolset` эксперта (GPC1/GPC4/GPC8 — один резолвер на двух
11
+ потребителей, не второй способ найти те же три файла). Резолвер и этот модуль физически
12
+ лежат в одном пакете `kompas_kernel.ksapi_static` (E17.04c) — этот модуль МОГ БЫ
13
+ импортировать `resolve_wrapper_paths` напрямую, но не делает этого: единственный вызов
14
+ остаётся в composition wiring, чтобы инвентарь не резолвил пути дважды по двум разным
15
+ вызовам.
16
+
17
+ Строится ОДИН РАЗ при сборке приложения (`core/composition.py`) и передаётся в
18
+ `features.ksapi_automation.gate.check_code` параметром — сам гейт с диска ничего не
19
+ читает (GPC3).
20
+
21
+ Арность метода — множество чисел аргументов ВЫЗОВА (без self/bound-инстанса), допустимых
22
+ хотя бы одним известным определением этого имени. На измеренной сборке (см.
23
+ `todo-tasks/tech-debt/gate-ksapi-name-check.md`) ни один non-dunder метод не использует
24
+ `*args`/`**kwargs`/значения по умолчанию — арность там вырождается в одно число; код при
25
+ этом остаётся корректным и для гипотетического метода с дефолтами (диапазон арностей).
26
+
27
+ Конструкторы интерфейсов (`ksapi.IFoo(x)` — привычный cast сырого COM-объекта к типу)
28
+ считаются ИЗВЕСТНЫМИ вызываемыми именами (`known_call_names` включает имена классов), но
29
+ их арность НЕ проверяется: `__init__` — dunder, исключён из разбора методов по тем же
30
+ правилам, что и остальные dunder'ы. Это осознанная граница, не недосмотр — проверка
31
+ «вызов x.Foo(...) существует» не требует знать, что `Foo` конструктор, а не метод.
32
+
33
+ `ksapi.py` держит известные имена не только в классах: 51 публичная функция МОДУЛЬНОГО
34
+ уровня (`GetKompas`/`OpenKompas`/`RunLoop`/конвертеры массивов `To*Vector`/`From*Vector` и
35
+ др., измерено на `kompas_bin_master/Release-x64`) вызываются точно так же — `ksapi.Foo(...)`
36
+ — и гейт (`obj.Foo(...)`) их не отличит от метода интерфейса. Пропуск этих функций был
37
+ дефектом: `ksapi.ToDoubleVector([1.0, 2.0])` — легальная передача массива через границу
38
+ ksAPI, ложно ловилась как `UNKNOWN_KSAPI_METHOD`. Модульные функции разбираются наравне с
39
+ методами (имя в `known_call_names`, арность — по факту сигнатуры, без self: `strip_leading=0`
40
+ у `_call_arities`, в отличие от `strip_leading=1` у методов класса). Приватные (leading `_`,
41
+ напр. `_initialize`) пропускаются — соглашение Python, не публичный API.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import ast
47
+ from collections.abc import Iterator, Mapping
48
+ from dataclasses import dataclass
49
+ from pathlib import Path
50
+
51
+ _DUNDER_PREFIX = "__"
52
+ _PRIVATE_PREFIX = "_"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class KsapiInventory:
57
+ """Снимок имён из `ksapi.py`/`constants.py`/`constants3d.py` установленной сборки.
58
+
59
+ Не Pydantic-модель: это внутренний build-time кеш композиции, не DTO на границе
60
+ (GPC4 — CustomModel зарезервирован для схем, пересекающих границу слоя/LLM).
61
+
62
+ - `known_call_names` — имена, легальные как `Foo` в `x.Foo(...)`: методы всех
63
+ интерфейсов + имена классов (конструкторы `ksapi.IFoo(...)`) + публичные функции
64
+ МОДУЛЬНОГО уровня `ksapi.py` (`GetKompas`, `ToDoubleVector`, ...) — гейт видит
65
+ `ksapi.Foo(...)` как атрибутный вызов и не отличает функцию модуля от метода
66
+ интерфейса. Без dunder-имён (методы) и без приватных (leading `_`, функции модуля).
67
+ - `method_arities` — имя -> множество допустимых чисел аргументов вызова (объединение
68
+ по ВСЕМ владельцам, где это имя встречается: интерфейсам-классам и/или самому модулю).
69
+ Имя без записи здесь — либо класс-конструктор, либо метод/функция с
70
+ неарифметизируемой сигнатурой (`*args`/`**kwargs`/`kwonly`) — для обоих случаев
71
+ арность не проверяется, только существование имени.
72
+ - `constants_names` — имена констант из `class constants:` в `constants.py` (2D/общие).
73
+ - `constants3d_names` — имена констант из `class constants:` в `constants3d.py` (3D).
74
+ """
75
+
76
+ known_call_names: frozenset[str]
77
+ method_arities: Mapping[str, frozenset[int]]
78
+ constants_names: frozenset[str]
79
+ constants3d_names: frozenset[str]
80
+
81
+
82
+ def build_ksapi_inventory(*, ksapi_path: Path, constants_path: Path, constants3d_path: Path) -> KsapiInventory:
83
+ """Разобрать три wrapper-файла в инвентарь имён. Только `ast.parse`, никакого импорта.
84
+
85
+ Args:
86
+ ksapi_path: `ksapi.py` установленной сборки — классы-интерфейсы с их методами
87
+ И публичные функции модульного уровня (`GetKompas`, `ToDoubleVector`, ...).
88
+ constants_path: `constants.py` — 2D/общие константы внутри `class constants:`.
89
+ constants3d_path: `constants3d.py` — 3D-константы, тоже внутри `class constants:`
90
+ (то же имя класса в обоих файлах — различает их только путь файла, не имя
91
+ класса, поэтому файлы разбираются раздельно, а не по имени класса).
92
+ """
93
+ known_call_names, method_arities = _parse_ksapi_callables(ksapi_path)
94
+ constants_names = _parse_constant_names(constants_path)
95
+ constants3d_names = _parse_constant_names(constants3d_path)
96
+ return KsapiInventory(
97
+ known_call_names=known_call_names,
98
+ method_arities=method_arities,
99
+ constants_names=constants_names,
100
+ constants3d_names=constants3d_names,
101
+ )
102
+
103
+
104
+ def _parse_ksapi_callables(path: Path) -> tuple[frozenset[str], Mapping[str, frozenset[int]]]:
105
+ """`ksapi.py` -> (все известные вызываемые имена, арности по имени).
106
+
107
+ Два источника «известного вызываемого имени», оба на верхнем уровне модуля:
108
+
109
+ - top-level классы — имя класса (конструктор `ksapi.IFoo(...)`) и non-dunder методы
110
+ в теле класса (арность без `self` — `strip_leading=1`);
111
+ - top-level функции модуля (`GetKompas`/`ToDoubleVector`/... — см. докстринг модуля):
112
+ арность без сдвига (`strip_leading=0`, у них нет `self`), приватные (leading `_`)
113
+ исключены.
114
+
115
+ В обоих случаях, если арность не считается детерминированно (`*args`/`**kwargs`/
116
+ keyword-only — на измеренной сборке таких 0, но код не полагается на это), имя всё
117
+ равно остаётся в `known_call_names`, просто без записи в `method_arities` —
118
+ существование и арность проверяются раздельно
119
+ (`kompas_kernel.ksapi_static.facts::_ksapi_method_violation`).
120
+ """
121
+ tree = ast.parse(path.read_text(encoding="utf-8"))
122
+ known_call_names: set[str] = set()
123
+ arities_by_name: dict[str, set[int]] = {}
124
+
125
+ def _record(name: str, args: ast.arguments, *, strip_leading: int) -> None:
126
+ known_call_names.add(name)
127
+ counts = _call_arities(args, strip_leading=strip_leading)
128
+ if counts:
129
+ arities_by_name.setdefault(name, set()).update(counts)
130
+
131
+ for node in ast.iter_child_nodes(tree):
132
+ if isinstance(node, ast.ClassDef):
133
+ known_call_names.add(node.name)
134
+ for item in node.body:
135
+ if isinstance(item, ast.FunctionDef) and not item.name.startswith(_DUNDER_PREFIX):
136
+ _record(item.name, item.args, strip_leading=1)
137
+ elif isinstance(node, ast.FunctionDef) and not node.name.startswith(_PRIVATE_PREFIX):
138
+ _record(node.name, node.args, strip_leading=0)
139
+
140
+ method_arities = {name: frozenset(counts) for name, counts in arities_by_name.items()}
141
+ return frozenset(known_call_names), method_arities
142
+
143
+
144
+ def _call_arities(args: ast.arguments, *, strip_leading: int) -> frozenset[int]:
145
+ """Возможные числа аргументов ВЫЗОВА одного определения — метода класса или функции модуля.
146
+
147
+ `strip_leading` — сколько ведущих позиционных параметров сигнатуры НЕ считать
148
+ аргументами вызова: 1 для метода класса (`self` передаётся вызовом через точку, в
149
+ скобках его нет), 0 для функции модуля (`ksapi.GetKompas()` — обычная функция, первый
150
+ параметр сигнатуры, если есть, реально нужно передавать в скобках).
151
+
152
+ Пустое множество означает «арность не считается детерминированно» (`*args`/`**kwargs`/
153
+ keyword-only параметры, либо параметров меньше, чем `strip_leading`) — вызывающая
154
+ сторона (`_parse_ksapi_callables`) тогда не добавляет запись в `method_arities`, но имя
155
+ всё равно остаётся среди `known_call_names`.
156
+ """
157
+ if args.vararg is not None or args.kwarg is not None or args.kwonlyargs:
158
+ return frozenset()
159
+ if len(args.args) < strip_leading:
160
+ return frozenset() # меньше параметров, чем ведущих (self и т.п.) — форма неожиданная
161
+ total = len(args.args) - strip_leading
162
+ required = total - len(args.defaults)
163
+ return frozenset(range(required, total + 1))
164
+
165
+
166
+ def _parse_constant_names(path: Path) -> frozenset[str]:
167
+ """Имена констант из ВСЕХ top-level классов файла (в обеих обёртках — ровно один
168
+ `class constants:`, но код не полагается на то, что класс единственный или так назван)."""
169
+ tree = ast.parse(path.read_text(encoding="utf-8"))
170
+ names: set[str] = set()
171
+ for node in ast.iter_child_nodes(tree):
172
+ if not isinstance(node, ast.ClassDef):
173
+ continue
174
+ for item in node.body:
175
+ names.update(_assign_target_names(item))
176
+ return frozenset(names)
177
+
178
+
179
+ def _assign_target_names(item: ast.stmt) -> Iterator[str]:
180
+ if isinstance(item, ast.Assign):
181
+ for target in item.targets:
182
+ if isinstance(target, ast.Name):
183
+ yield target.id
184
+ elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
185
+ yield item.target.id
@@ -0,0 +1,114 @@
1
+ """Framework-free recipe catalog and deterministic lookup helpers.
2
+
3
+ Каталог отдаётся ОРКЕСТРАТОРУ напрямую, а не через `ksapi_expert`: тело рецепта —
4
+ исполняемый текст, который через шаг уходит в `build_geometry`/`run_python` дословно,
5
+ поэтому пересказ под `output_type=str` его бы испортил тихо (гейт увидел бы валидный,
6
+ но другой скрипт), а `pitfalls`/`provenance` потерялись бы на границе строки. Эксперт
7
+ остаётся источником СИГНАТУР (RAG + grep generated-обёрток), где пересказ уместен.
8
+
9
+ Правило каталога (не «все приёмы KsAPI подряд»): рецепт заводится там, где у агента
10
+ НЕТ типизированного тула. Если приём уже закрыт портом `bridge`/`eyes`, рецепт на него
11
+ — вредный дубль: он уводит модель в `run_python` мимо DTO, нормализации единиц и
12
+ обработки `BridgeError`. Восемь таких дублей удалены 2026-07-26 (`read-assembly-
13
+ components`, `select-and-zoom-component`, `measure-transient-distance`,
14
+ `measure-face-distance`, `show-min-distance`, `probe-ray-neighbor`,
15
+ `inspect-fastener-axis`, `find-neighbors-collision`).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ import yaml
23
+ from pydantic import ConfigDict, Field
24
+
25
+ from kompas_kernel.utils.validation import CustomModel
26
+
27
+
28
+ class KsapiRecipeProvenance(CustomModel):
29
+ """Resolvable source plus the observed result that makes it evidence."""
30
+
31
+ model_config = ConfigDict(extra="forbid", frozen=True)
32
+
33
+ source: str = Field(min_length=1)
34
+ evidence: str = Field(min_length=1)
35
+
36
+
37
+ class KsapiRecipe(CustomModel):
38
+ """One derived recipe backed by an experiment or an explicitly recorded spike."""
39
+
40
+ model_config = ConfigDict(extra="forbid", frozen=True)
41
+
42
+ recipe_id: str = Field(pattern=r"^[a-z][a-z0-9-]*$")
43
+ scenario: str = Field(min_length=1)
44
+ body: str = Field(min_length=1)
45
+ provenance: tuple[KsapiRecipeProvenance, ...] = Field(min_length=1)
46
+ pitfalls: tuple[str, ...] = ()
47
+
48
+
49
+ class KsapiRecipeSummary(CustomModel):
50
+ """Cheap routing metadata returned before a full recipe is loaded."""
51
+
52
+ recipe_id: str
53
+ scenario: str
54
+
55
+
56
+ class KsapiRecipeCatalog:
57
+ """Immutable in-memory index loaded once at agent composition."""
58
+
59
+ def __init__(self, *, recipes: tuple[KsapiRecipe, ...]) -> None:
60
+ by_id = {recipe.recipe_id: recipe for recipe in recipes}
61
+ if len(by_id) != len(recipes):
62
+ raise ValueError("recipe_id values must be unique")
63
+ self._recipes = recipes
64
+ self._by_id = by_id
65
+
66
+ def summaries(self) -> tuple[KsapiRecipeSummary, ...]:
67
+ return tuple(
68
+ KsapiRecipeSummary(recipe_id=recipe.recipe_id, scenario=recipe.scenario) for recipe in self._recipes
69
+ )
70
+
71
+ def get(self, recipe_id: str) -> KsapiRecipe:
72
+ try:
73
+ return self._by_id[recipe_id]
74
+ except KeyError as exc:
75
+ available = ", ".join(self._by_id)
76
+ raise KeyError(f"unknown recipe_id {recipe_id!r}; available: {available}") from exc
77
+
78
+
79
+ def load_ksapi_recipe_catalog(*, recipes_dir: Path) -> KsapiRecipeCatalog:
80
+ """Load and validate all direct YAML children, failing fast on an invalid catalog.
81
+
82
+ A missing directory is a resource-provisioning problem (wrong install, stale
83
+ `KOMPAS_COPILOT__KSAPI_EXPERT__RECIPES_DIR` override), not a bare traceback: the
84
+ check below turns Python's `FileNotFoundError` into a message naming the exact
85
+ path and the two ways to fix it (same style as `_validate_frontend_dist` in
86
+ `transports/backend/app.py`).
87
+ """
88
+ if not recipes_dir.exists():
89
+ raise ValueError(
90
+ f"каталог KsAPI-рецептов не найден: {recipes_dir}; проверьте "
91
+ "ksapi_expert.recipes_dir (env KOMPAS_COPILOT__KSAPI_EXPERT__RECIPES_DIR) "
92
+ "или целостность установки"
93
+ )
94
+ root = recipes_dir.resolve(strict=True)
95
+ if not root.is_dir():
96
+ raise ValueError(f"KsAPI recipes path is not a directory: {root}")
97
+ paths = tuple(sorted(root.glob("*.yaml")))
98
+ if not paths:
99
+ raise ValueError(f"KsAPI recipes directory contains no .yaml files: {root}")
100
+ recipes: list[KsapiRecipe] = []
101
+ for path in paths:
102
+ try:
103
+ raw: object = yaml.safe_load(path.read_text(encoding="utf-8"))
104
+ except yaml.YAMLError as exc:
105
+ raise ValueError(f"invalid YAML in KsAPI recipe {path}: {exc}") from exc
106
+ try:
107
+ recipe = KsapiRecipe.model_validate(raw)
108
+ except ValueError as exc:
109
+ raise ValueError(f"invalid KsAPI recipe {path}: {exc}") from exc
110
+ if path.stem != recipe.recipe_id:
111
+ raise ValueError(f"KsAPI recipe filename must match recipe_id: {path.name!r} != {recipe.recipe_id!r}")
112
+ recipes.append(recipe)
113
+ recipes.sort(key=lambda recipe: recipe.recipe_id)
114
+ return KsapiRecipeCatalog(recipes=tuple(recipes))
@@ -0,0 +1,83 @@
1
+ """Framework-free deterministic generated-wrapper lookup helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from pydantic import Field
9
+
10
+ from kompas_kernel.utils.validation import CustomModel
11
+
12
+ WRAPPER_FILE_NAMES = ("ksapi.py", "constants.py", "constants3d.py")
13
+ _IDENTIFIER_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
14
+
15
+
16
+ class WrapperMatch(CustomModel):
17
+ """One exact identifier occurrence in an allowlisted generated wrapper."""
18
+
19
+ file: str
20
+ line: int = Field(ge=1)
21
+ text: str
22
+
23
+
24
+ class WrapperGrepResult(CustomModel):
25
+ """Bounded deterministic matches from the current KOMPAS build."""
26
+
27
+ query: str
28
+ matches: tuple[WrapperMatch, ...]
29
+ truncated: bool
30
+
31
+
32
+ def grep_wrapper_identifier(
33
+ *,
34
+ identifier: str,
35
+ wrapper_paths: tuple[Path, ...],
36
+ max_matches: int,
37
+ ) -> WrapperGrepResult:
38
+ """Return bounded line matches for one literal Python identifier."""
39
+ if _IDENTIFIER_PATTERN.fullmatch(identifier) is None:
40
+ raise ValueError("identifier must be one Python identifier, without regex or path syntax")
41
+ matches: list[WrapperMatch] = []
42
+ truncated = False
43
+ token_pattern = re.compile(rf"\b{re.escape(identifier)}\b")
44
+ for wrapper_path in wrapper_paths:
45
+ for line_number, line in enumerate(wrapper_path.read_text(encoding="utf-8").splitlines(), start=1):
46
+ if token_pattern.search(line) is None:
47
+ continue
48
+ if len(matches) == max_matches:
49
+ truncated = True
50
+ break
51
+ matches.append(
52
+ WrapperMatch(
53
+ file=wrapper_path.name,
54
+ line=line_number,
55
+ text=line.strip(),
56
+ )
57
+ )
58
+ if truncated:
59
+ break
60
+ return WrapperGrepResult(query=identifier, matches=tuple(matches), truncated=truncated)
61
+
62
+
63
+ def resolve_wrapper_paths(*, bin_dir: Path, file_names: tuple[str, ...]) -> tuple[Path, ...]:
64
+ """Resolve explicit wrapper file names directly under `bin_dir`, rejecting traversal.
65
+
66
+ Public (not module-private) because `ksapi_static/inventory.py` reuses the exact same
67
+ resolution — same allowlisted files, same `KOMPAS_BIN_DIR` root — to build the static
68
+ gate's name inventory (GPC4/GPC8: one resolver, not a second copy of this validation).
69
+ """
70
+ root = bin_dir.resolve(strict=True)
71
+ if not root.is_dir():
72
+ raise ValueError(f"KOMPAS_BIN_DIR is not a directory: {root}")
73
+ paths: list[Path] = []
74
+ for file_name in file_names:
75
+ if Path(file_name).name != file_name or Path(file_name).suffix != ".py":
76
+ raise ValueError(f"wrapper filename must be a direct .py child of KOMPAS_BIN_DIR: {file_name!r}")
77
+ path = (root / file_name).resolve(strict=True)
78
+ if path.parent != root:
79
+ raise ValueError(f"wrapper path escapes KOMPAS_BIN_DIR: {file_name!r}")
80
+ if not path.is_file():
81
+ raise ValueError(f"wrapper path is not a file: {path}")
82
+ paths.append(path)
83
+ return tuple(paths)
@@ -0,0 +1 @@
1
+ """Framework-neutral structured logging runtime shared by product executables."""
@@ -0,0 +1,24 @@
1
+ """Logger and run-context primitives without application configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any
7
+
8
+ import structlog
9
+ import structlog.contextvars
10
+
11
+
12
+ def new_run_id() -> str:
13
+ """Create an identifier that correlates logs and one run trace."""
14
+ return uuid.uuid4().hex
15
+
16
+
17
+ def bind_run_id(run_id: str) -> None:
18
+ """Bind a run identifier to all subsequent structlog events in this context."""
19
+ structlog.contextvars.bind_contextvars(run_id=run_id)
20
+
21
+
22
+ def get_logger(name: str | None = None) -> Any: # structlog's lazy proxy is dynamically typed
23
+ """Return a lazy structlog logger which is safe to create before configuration."""
24
+ return structlog.get_logger(name)
@@ -0,0 +1,115 @@
1
+ """Explicit process-wide structlog configuration without application settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from logging.handlers import RotatingFileHandler
8
+ from pathlib import Path
9
+ from typing import Protocol, TextIO, runtime_checkable
10
+
11
+ import structlog
12
+ import structlog.contextvars
13
+ import structlog.dev
14
+ import structlog.processors
15
+ import structlog.stdlib
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class LoggingConfig:
20
+ """Caller-owned values required to configure one executable process."""
21
+
22
+ level: str
23
+ log_file: Path
24
+ max_bytes: int
25
+ backup_count: int
26
+ console_stream: TextIO
27
+ utf8_streams: tuple[object, ...]
28
+
29
+
30
+ @runtime_checkable
31
+ class _Utf8Reconfigurable(Protocol):
32
+ def reconfigure(self, *, encoding: str, errors: str) -> None: ...
33
+
34
+
35
+ _configured = False
36
+
37
+
38
+ def _ensure_utf8_streams(streams: tuple[object, ...]) -> None:
39
+ for stream in streams:
40
+ if isinstance(stream, _Utf8Reconfigurable):
41
+ stream.reconfigure(encoding="utf-8", errors="replace")
42
+
43
+
44
+ def _shared_processors() -> list[structlog.types.Processor]:
45
+ """Build the processor chain shared by file and console renderers."""
46
+ return [
47
+ structlog.contextvars.merge_contextvars,
48
+ structlog.stdlib.add_log_level,
49
+ structlog.processors.TimeStamper(fmt="iso"),
50
+ structlog.processors.StackInfoRenderer(),
51
+ structlog.processors.format_exc_info,
52
+ ]
53
+
54
+
55
+ def _file_handler(config: LoggingConfig, level: int) -> logging.Handler | None:
56
+ """Create a rotating file handler, or omit it when its directory is unavailable."""
57
+ try:
58
+ config.log_file.parent.mkdir(parents=True, exist_ok=True)
59
+ handler: logging.Handler = RotatingFileHandler(
60
+ config.log_file,
61
+ maxBytes=config.max_bytes,
62
+ backupCount=config.backup_count,
63
+ encoding="utf-8",
64
+ )
65
+ except OSError:
66
+ return None
67
+ handler.setLevel(level)
68
+ handler.setFormatter(
69
+ structlog.stdlib.ProcessorFormatter(
70
+ processor=structlog.processors.JSONRenderer(ensure_ascii=False),
71
+ foreign_pre_chain=_shared_processors(),
72
+ )
73
+ )
74
+ return handler
75
+
76
+
77
+ def _console_handler(config: LoggingConfig, level: int) -> logging.Handler:
78
+ handler = logging.StreamHandler(config.console_stream)
79
+ handler.setLevel(level)
80
+ handler.setFormatter(
81
+ structlog.stdlib.ProcessorFormatter(
82
+ processor=structlog.dev.ConsoleRenderer(colors=True, sort_keys=False),
83
+ foreign_pre_chain=_shared_processors(),
84
+ )
85
+ )
86
+ return handler
87
+
88
+
89
+ def configure_observability(config: LoggingConfig) -> None:
90
+ """Configure stderr/file logging once for the current process."""
91
+ global _configured # noqa: PLW0603
92
+ if _configured:
93
+ return
94
+
95
+ _ensure_utf8_streams(config.utf8_streams)
96
+ level = getattr(logging, config.level.upper(), logging.INFO)
97
+ structlog.configure(
98
+ processors=[
99
+ *_shared_processors(),
100
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
101
+ ],
102
+ wrapper_class=structlog.stdlib.BoundLogger,
103
+ context_class=dict,
104
+ logger_factory=structlog.stdlib.LoggerFactory(),
105
+ cache_logger_on_first_use=True,
106
+ )
107
+
108
+ root = logging.getLogger()
109
+ root.handlers.clear()
110
+ root.addHandler(_console_handler(config, level))
111
+ file_handler = _file_handler(config, level)
112
+ if file_handler is not None:
113
+ root.addHandler(file_handler)
114
+ root.setLevel(level)
115
+ _configured = True