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