Compare commits
29 Commits
d7099bf80d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e20881f52d | |||
| b379dd427b | |||
| 3b2db2caba | |||
| 6b3e865f8f | |||
| 31caa492f9 | |||
| a646b53aec | |||
| 24c6c6e170 | |||
| 7cd24bffd4 | |||
| 9f46d8e39e | |||
| 1a3624f3e0 | |||
| 9d8dccc993 | |||
| 863bda9436 | |||
| 945ba28c4b | |||
| 9f60a7da2d | |||
| a8a7ea2aaa | |||
| ae11b5e227 | |||
| c6384ff753 | |||
| 3222ecee6e | |||
| 8a6610f30c | |||
| 5615e262a8 | |||
| 61c0ed1726 | |||
| 643d79ebb5 | |||
| 41a3f47c69 | |||
| adc5d88e5f | |||
| 832d159f37 | |||
| 03fed309c6 | |||
| b7858d480e | |||
| 829e19dc3a | |||
| d3d9d8a97f |
@@ -7,6 +7,16 @@
|
||||
- Portainer: `http://docker-test.cin.su:9000/`, user `admin`
|
||||
- Do not store the password in repositories or project files; use an SSH key for persistent access.
|
||||
|
||||
## Project Git workflow
|
||||
|
||||
- The canonical remote for this project is `https://git.cin.su/m/llm.git`
|
||||
(`origin`), with `main` as the shared branch.
|
||||
- Use HTTPS authentication through Git Credential Manager; never put a
|
||||
password or token into a repository file, remote URL, script, or commit.
|
||||
- After a coherent change has been verified, create a focused commit and push
|
||||
it to `origin/main`. Do not commit unfinished work, unrelated user changes,
|
||||
generated large artifacts, or secrets.
|
||||
|
||||
## GPU Docker Host
|
||||
|
||||
- This project works with local LLM models and uses GPU resources.
|
||||
|
||||
@@ -83,6 +83,31 @@ overlay, it then removes only receipt-recorded rows after hash precondition
|
||||
checks. A successful result proves SQL readback and cleanup only; it does not
|
||||
prove Configurator visibility, acceptance, or activation.
|
||||
|
||||
## Report And Appearance Inspection
|
||||
|
||||
`report.inspect` is the read-only entry point for a report component graph.
|
||||
It accepts a public report name and returns only independently confirmed
|
||||
modules, forms, templates and SCD components. A form called
|
||||
`ОсновнаяСхемаКомпоновкиДанных` remains a form; it is never promoted to an SCD
|
||||
from its name or a guessed `.0` stream.
|
||||
|
||||
```json
|
||||
{"method":"report.inspect","payload":{"base_id":"upo_test","report":"ФинансовыйРезультат"}}
|
||||
```
|
||||
|
||||
`appearance.inspect` is a separate read-only reader for a base
|
||||
`CommonTemplate` whose `.0` carrier has the `AppearanceTemplate` XML root.
|
||||
This selects the payload reader only; it does not establish the 1C platform
|
||||
`TemplateType`, which remains unresolved until its metadata descriptor is
|
||||
decoded.
|
||||
It returns raw XML-backed `item(parameter, value)` rules and an XML tag
|
||||
outline. It has no write operation, and an extension template returns explicit
|
||||
`unsupported` until an extension carrier route is proven.
|
||||
|
||||
```json
|
||||
{"method":"appearance.inspect","payload":{"base_id":"upo_test","template":"ОформлениеОтчетовБежевый"}}
|
||||
```
|
||||
|
||||
## User Identity And Access Terminology
|
||||
|
||||
An unqualified user request means an **infobase user**: the platform identity
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# Программа доказательного codec-а отчётов и макетов
|
||||
|
||||
## Цель
|
||||
|
||||
Построить для SQL-адаптера раздельные, evidence-first decoder/encoder пути
|
||||
для отчёта и каждого типа его макета. Публичный API принимает только имя
|
||||
расширения, отчёта и макета; физические таблицы, ключи и контейнеры остаются
|
||||
внутренними деталями реализации.
|
||||
|
||||
## Первая live-фикстура
|
||||
|
||||
База: `upo_test`; расширение: `фс_Отчеты`;
|
||||
отчёт: `_ПоступлениеТовара`;
|
||||
GUID отчёта: `c428f629-785a-4141-b038-f2192bb4580d`.
|
||||
|
||||
Наблюдаемое состояние на 2026-08-14:
|
||||
|
||||
- extension object search подтверждает сохранённый override в `ConfigCASSave`;
|
||||
- в saved слое присутствуют descriptor, `.0` и extension `__configinfo`;
|
||||
- active manifest содержит только descriptor и `.0` stream;
|
||||
- `metadata.object.components` подтверждает четыре формы, среди них форму
|
||||
`ОсновнаяСхемаКомпоновкиДанных`;
|
||||
- `metadata.object.templates` и `scd.inspect` не подтверждают макет
|
||||
`DataCompositionSchema` для этого объекта.
|
||||
|
||||
Следствие: имя формы не является доказательством существования одноимённого
|
||||
макета СКД. Нельзя направлять запрос или XML-кодек в `.0` stream.
|
||||
|
||||
## Матрица типов макетов
|
||||
|
||||
| Платформенный тип | Состояние codec-а | Допустимое действие до fixture proof |
|
||||
| --- | --- | --- |
|
||||
| Табличный документ | частичный MOXCEL decoder | read-only structure/evidence |
|
||||
| Текстовый документ | типизированное текстовое исследование | read-only до round-trip fixture |
|
||||
| Двоичные данные | opaque | hash/size only |
|
||||
| Active document | opaque | hash/size only |
|
||||
| HTML документ | частичное текстовое/HTML evidence | read-only до round-trip fixture |
|
||||
| Географическая схема | opaque typed candidate | read-only |
|
||||
| Графическая схема | opaque typed candidate | read-only |
|
||||
| Схема компоновки данных | XML decoder + scalar byte-preserving patch | `read_and_scalar_patch_supported`; write only after public route and saved-state codec proof |
|
||||
| Макет оформления компоновки данных | отдельный XML family | read-only до own fixture proof |
|
||||
| Внешняя компонента | opaque archive | hash/size only |
|
||||
|
||||
Источник taxonomy: [1C:Enterprise Template wizard](https://kb.1ci.com/1C_Enterprise_Platform/Guides/Developer_Guides/1C_Enterprise_8.3.23_Developer_Guide/Chapter_31._Development_tools/31.14._Template_wizard/).
|
||||
|
||||
## Protocol gates
|
||||
|
||||
1. Создать либо выбрать отдельную live fixture для одного вида макета.
|
||||
2. Снять SQL before snapshot всех связанных payload-ов и их SHA-1.
|
||||
3. Выполнить ровно одно контролируемое изменение через Configurator.
|
||||
4. Снять after snapshot и доказать точное соответствие object → manifest →
|
||||
payload → saved-state overlay.
|
||||
5. Реализовать lossless decoder и обратный encoder только для изменённого
|
||||
участка; неизвестные байты сохраняются дословно.
|
||||
6. Выполнить `apply_and_rollback` в `upo_test`, проверить SQL readback и
|
||||
очистку adapter-created rows.
|
||||
7. Отдельно получить человеческое подтверждение видимости в Configurator.
|
||||
|
||||
Для СКД XML-семантика включает datasets, links, calculated fields, resources,
|
||||
parameters, layouts, nested schemas и settings. Макет оформления — другой XML
|
||||
контракт: регионы, шрифты, цвета, границы и правила оформления; его нельзя
|
||||
обрабатывать методом `scd.patch`.
|
||||
|
||||
## Долгосрочный план и критерии готовности
|
||||
|
||||
| Этап | Результат | Критерий перехода |
|
||||
| --- | --- | --- |
|
||||
| 1. Инвентарь | Реестр `Report → Template` с identity, container, SHA-1 и состоянием active/saved | У каждого fixture есть подтверждённая цепочка owner → template → payload, без имени формы в качестве подстановки. |
|
||||
| 2. СКД read | Lossless контейнерный decoder с XML-путями для datasets, links, calculated fields, resources, parameters, layouts, nested schemas и settings | XML корректно читается на нескольких живых схемах; неизвестные XML узлы сохраняются в raw-carrier и не теряются. |
|
||||
| 3. СКД write | Минимальный scalar writer (`dataset.query`, expression) и resolver `Report + Template` | `plan`, точная проверка SHA-1 и `apply_and_rollback` проходят на выделенной fixture; активность подтверждает человек в Configurator. |
|
||||
| 4. Оформление СКД | Отдельный decoder/encoder XML оформления: regions, style rules, fonts, colors, borders, indents | Не используется путь `scd.patch`; один контролируемый before/after fixture доказывает carrier и round-trip. |
|
||||
| 5. Прочие типы | Отдельная карточка codec-а на каждый из десяти platform types | Для Spreadsheet/Text/HTML/Geo/Graphical доказан собственный round-trip или выставлен explicit `unsupported`; Binary/Active/AddIn остаются opaque до отдельного доказательства. |
|
||||
| 6. Report aggregate | API `report.inspect` возвращает identity, modules/forms/templates, main-SCD relation, overlay state и capability по каждому компоненту | Отчёт без подтверждённой СКД возвращает `unresolved`, а не выбирает форму или первый поток. |
|
||||
| 7. Регрессия и выпуск | Fixture manifest, hash-baselines, unit tests, live SQL smoke и документация публичного контракта | Каждый write codec проходит план → rollback → SQL readback; ни один результат не заявляет применение в Configurator без ручного подтверждения. |
|
||||
|
||||
Работа выполняется последовательно по этому реестру: новый тип не получает
|
||||
write endpoint до собственной fixture и доказанного обратного carrier codec-а.
|
||||
|
||||
## Live XML evidence: базовая СКД
|
||||
|
||||
14.08.2026 read-only diagnostic read подтвердил carrier для
|
||||
`Report.ФинансовыйРезультат / ОсновнаяСхемаКомпоновкиДанных`:
|
||||
|
||||
- Template GUID: `e3140fc8-1688-4640-a68a-e0f7675c68e9`; payload: `.0`;
|
||||
- контейнер: 3 391 байт raw-deflate, после распаковки 23 097 байт;
|
||||
- XML envelope: `SchemaFile`, 12 941 символ до закрывающего тега;
|
||||
- прямые дочерние секции `dataCompositionSchema`: `dataSource` (1),
|
||||
`dataSet` (1), `parameter` (4), `settingsVariant` (1), `totalField` (4);
|
||||
- `dataSource` имеет доказанные прямые поля `name=ИсточникДанных1` и
|
||||
`dataSourceType=Local`.
|
||||
|
||||
На этом доказательстве в decoder добавлена секция `data_sources` и
|
||||
`schema_outline` с точными XML тегами, путями и количеством. Наличие
|
||||
`dataSetLink`, layouts или nested schema в другой СКД не предполагается: они
|
||||
будут добавлены после отдельной live fixture с соответствующим тегом.
|
||||
|
||||
## Live fixtures остальных типов
|
||||
|
||||
Read-only diagnostic inventory `CommonTemplate` в `upo_test` уже дал три
|
||||
отдельных carrier fixture. Имена нужны только для повторного поиска; тип
|
||||
подтверждён содержимым `.0`, а не именем.
|
||||
|
||||
| Fixture | Template GUID | Доказательство типа | Разрешённое действие |
|
||||
| --- | --- | --- | --- |
|
||||
| `ОформлениеОтчетовБежевый` | `f49709d3-13ea-4d67-9934-9d93f8ba1c21` | XML root `AppearanceTemplate`, DCS appearance namespace; platform TemplateType descriptor ещё не декодирован | read-only appearance decoder; platform type unresolved |
|
||||
| `ШаблонHTMLПредставленияTXTФайла` | `e3eb9ae8-7381-400b-a933-861b1f4efcff` | UTF-8 XML/HTML root `html` | existing safe text/HTML export only |
|
||||
| `КомпонентаXMLDSIG` | `db062a63-2759-409a-b78b-132fe226f6f1` | carrier `{#base64:...}` with ZIP signature `PK` after decoding; metadata part has no `ExternalComponent` declaration | opaque archive candidate, hash/size only |
|
||||
|
||||
Из этого следует, что `AppearanceTemplate` нельзя относить к spreadsheet
|
||||
только из-за вложенного упоминания `SpreadsheetDocument` в XML. Равным
|
||||
образом XML root нельзя объявлять платформенным типом макета: он выбирает
|
||||
только decoder payload. Платформенный `TemplateType` допускается публиковать
|
||||
лишь после разбора metadata descriptor или подтверждённой template identity.
|
||||
Для carrier нужен отдельный XML decoder; обратная запись по-прежнему
|
||||
запрещена до controlled before/after fixture.
|
||||
|
||||
Текущая реализация публикует найденный в descriptor числовой `type code` как
|
||||
`descriptor_type_evidence` без имени типа. В живой базе код `7` встретился у
|
||||
carrier `AppearanceTemplate`, `4` — у HTML carrier, `1` — у ZIP carrier.
|
||||
Это корреляции, а не таблица типов: назначать этим кодам платформенные имена
|
||||
можно только после controlled fixture каждого типа.
|
||||
|
||||
Reader реализован в `plugins/1c/parser/appearance_payload.py`: он возвращает
|
||||
контейнер, все верхнеуровневые правила `item(parameter, value)` и точный
|
||||
outline XML-тегов. Он не разбирает строковое значение style как произвольный
|
||||
формат и всегда сообщает `unsupported_until_reverse_codec_fixture` для write.
|
||||
|
||||
ZIP carrier сам по себе не доказывает platform type `ExternalComponent`:
|
||||
fixture выше нельзя использовать для утверждения этого типа или для encoder-а.
|
||||
Для внешней компоненты нужен отдельный metadata declaration либо controlled
|
||||
Configurator fixture.
|
||||
|
||||
## Ближайшая реализация
|
||||
|
||||
Первый кодовый результат — resolver, который умеет различать форму и макет,
|
||||
а затем находит `MainDataCompositionSchema` по подтверждённой template identity
|
||||
в extension active/saved layer. При отсутствии доказанного template payload он
|
||||
возвращает `unresolved`, а не fallback на форму или stream `.0`.
|
||||
|
||||
## Среда Configurator и текущий блокер fixture-изменений
|
||||
|
||||
На рабочей станции подтверждены локальные исполняемые файлы Configurator:
|
||||
`C:\\Program Files\\1cv8\\8.3.24.1548\\bin\\1cv8.exe` и
|
||||
`C:\\Program Files\\1cv8\\8.5.1.1236\\bin\\1cv8.exe`. В списке информационных
|
||||
баз зарегистрирован `upo_test` с соединением `Srvr="wsr";Ref="upo_test"`.
|
||||
|
||||
14.08.2026 выполнена только read-only попытка
|
||||
`DESIGNER /DumpConfigToFiles` в отдельный каталог отчётов. Сервер вернул
|
||||
`Пользователь ИБ не идентифицирован`; выгрузка не началась и база не
|
||||
изменялась. Поэтому автоматизированные before/after изменения в
|
||||
Configurator пока не запускаются: требуется штатная учётная запись ИБ или
|
||||
интерактивный вход человека. Это не ослабляет SQL-only правило runtime
|
||||
адаптера и не является разрешением подбирать либо сохранять пароли.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Контролируемые fixtures: типы макетов 1С
|
||||
|
||||
## Цель
|
||||
|
||||
Зафиксировать platform-dependent таблицу `Template descriptor type code ->
|
||||
TemplateType` для конкретной сборки 1С. До выполнения каждого опыта код не
|
||||
является именем типа, а carrier payload не является источником типа.
|
||||
|
||||
## Условия опыта
|
||||
|
||||
- База: `upo_test`; изменения допускаются только в отдельном расширении
|
||||
`codex_TemplateCodecFixtures`.
|
||||
- Для каждого типа создаётся ровно один пустой макет с именем
|
||||
`CodecFixture_<EnglishType>` и один минимальный, но непустой вариант.
|
||||
- До создания, после создания и после сохранения конфигурации снимаются
|
||||
descriptor `{GUID}` и все части `{GUID}.*` из SQL saved state.
|
||||
- Для каждого снимка сохраняются SHA-1, размер, compression, descriptor tree,
|
||||
descriptor type code, XML root/сигнатура carrier и экспорт Конфигуратора.
|
||||
- Обратная запись запрещена, пока для того же типа не доказан byte-preserving
|
||||
round trip и не подтверждён результат человеком в Конфигураторе.
|
||||
|
||||
## Матрица
|
||||
|
||||
| Fixture suffix | Тип из мастера | Минимальное содержимое | Нужный артефакт |
|
||||
| --- | --- | --- | --- |
|
||||
| `Spreadsheet` | Табличный документ | Одна ячейка `X` | `.mxl` + SQL carrier |
|
||||
| `Text` | Текстовый документ | `X` | `.txt` + SQL carrier |
|
||||
| `Binary` | Двоичные данные | 3 байта `01 02 03` | `.bin` + SQL carrier |
|
||||
| `Active` | Active document | минимальный выбранный Active object | SQL carrier, тип OLE |
|
||||
| `Html` | HTML документ | `<p>X</p>` | `.htm` + SQL carrier |
|
||||
| `Geo` | Географическая схема | пустая схема | `.geo` + SQL carrier |
|
||||
| `Graphical` | Графическая схема | один прямоугольник | `.grs` + SQL carrier |
|
||||
| `Dcs` | Схема компоновки данных | параметр `P` и пустой dataset | XML + SQL carrier |
|
||||
| `DcsAppearance` | Макет оформления компоновки данных | одно правило оформления | XML + SQL carrier |
|
||||
| `AddIn` | Внешняя компонента | тестовый ZIP с манифестом | ZIP + SQL carrier |
|
||||
|
||||
## Критерий принятия одной строки таблицы
|
||||
|
||||
Строка считается `proven` только когда одновременно совпали:
|
||||
|
||||
1. тип, выбранный в мастере Конфигуратора;
|
||||
2. descriptor type code из сохранённого объекта;
|
||||
3. снимок carrier `{GUID}.0` и export соответствующего редактора;
|
||||
4. повторное открытие и сохранение fixture в Конфигураторе без ошибки.
|
||||
|
||||
Любое отсутствие пункта оставляет строку `unresolved_mapping`. Имена макетов,
|
||||
XML root и архивная сигнатура являются вспомогательными evidence и не заменяют
|
||||
эти условия.
|
||||
@@ -163,7 +163,7 @@ ONEC_TEMPLATE_PLATFORM_TYPES = [
|
||||
"name": "Схема компоновки данных",
|
||||
"xml_type": "DataCompositionSchema",
|
||||
"decoder": "data_composition_schema",
|
||||
"status": "not_implemented",
|
||||
"status": "read_and_scalar_patch_supported",
|
||||
"aliases": ["СхемаКомпоновкиДанных", "DataCompositionSchema"],
|
||||
},
|
||||
{
|
||||
@@ -762,7 +762,9 @@ METHODS = [
|
||||
{"name": "metadata.object.components", "transport": "POST /rpc", "description": "Read-only confirmed component graph for one metadata object. Returns only modules, forms, templates, and SCD nodes confirmed by the existing live-SQL decoders, with public child selectors and explicit unresolved areas. It never infers absent children from an object kind."},
|
||||
{"name": "metadata.object.modules", "transport": "POST /rpc", "description": "1C-facing BSL module list for a metadata object, including owned form modules. Exact extension objects are resolved from public kind/name or ref through a live-validated route cache. include_storage must be a JSON boolean true/false, string values are invalid. Physical module ids are hidden unless include_storage=true."},
|
||||
{"name": "metadata.object.related", "transport": "POST /rpc", "description": "1C-facing related metadata objects such as forms and templates. Physical record paths are hidden unless include_storage=true."},
|
||||
{"name": "report.inspect", "transport": "POST /rpc", "description": "Read-only aggregate of one report's confirmed modules, forms, templates and Data Composition Schemas. Does not infer an SCD from a same-named form or stream."},
|
||||
{"name": "scd.inspect", "transport": "POST /rpc", "description": "Read-only inspection entry point for a report Data Composition Schema. Resolves a base report from Config or an extension report from ConfigCAS by public names, and returns partial evidence instead of inventing undecoded parameters, datasets, resources, or variants."},
|
||||
{"name": "appearance.inspect", "transport": "POST /rpc", "description": "Read-only inspection of a base CommonTemplate whose payload has AppearanceTemplate XML root. That root selects a reader only, not the 1C TemplateType. Returns XML-backed appearance rules; no write route is exposed."},
|
||||
{"name": "scd.prepare", "transport": "POST /rpc", "description": "Prepare the complete SQL saved-state file set required for one report SCD. For base reports it includes both report files and the separately stored Template payload group, while keeping SQL file identifiers internal."},
|
||||
{"name": "scd.prepare.rollback", "transport": "POST /rpc", "description": "Remove exactly the rows inserted by one SCD preparation receipt after hash precondition checks. This is intended for controlled test cleanup and never touches active Config/ConfigCAS."},
|
||||
{"name": "scd.compare", "transport": "POST /rpc", "description": "Read-only semantic comparison of active and prepared saved-state SCD XML: Config ↔ ConfigSave for base reports and ConfigCAS ↔ ConfigCASSave for extensions. Compares named parameters, datasets, fields, expressions, totals, and variants rather than storage hashes."},
|
||||
@@ -4097,11 +4099,30 @@ def declared_template_platform_type_from_part(part: dict[str, Any]) -> dict[str,
|
||||
return None
|
||||
|
||||
|
||||
def template_descriptor_type_code_from_part(part: dict[str, Any]) -> str | None:
|
||||
"""Return an unlabelled type code from the proven Template descriptor shape.
|
||||
|
||||
The code is useful evidence, but its numeric-to-TemplateType mapping is
|
||||
intentionally not guessed from the payload carrier or a template name.
|
||||
"""
|
||||
classification = part.get("classification") if isinstance(part.get("classification"), dict) else {}
|
||||
role = str(part.get("role") or classification.get("role") or "")
|
||||
if role != "metadata_payload":
|
||||
return None
|
||||
tree = classification.get("tree")
|
||||
if not isinstance(tree, dict):
|
||||
return None
|
||||
if config_tree_scalar_at_path(tree, (0,)) != "1" or config_tree_scalar_at_path(tree, (1, 0)) != "4":
|
||||
return None
|
||||
value = config_tree_scalar_at_path(tree, (1, 2))
|
||||
return value if value and value.isdecimal() else None
|
||||
|
||||
|
||||
def template_type_candidates_from_features(features: dict[str, Any], content_parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
candidates: list[dict[str, Any]] = []
|
||||
declared = normalize_onec_template_platform_type(features.get("declared_platform_type_id") or features.get("declared_platform_type"))
|
||||
if declared:
|
||||
declared.update({"confidence": "high", "evidence": ["TemplateType metadata"]})
|
||||
declared.update({"confidence": "high", "evidence": [str(features.get("declared_platform_type_evidence") or "TemplateType metadata")]})
|
||||
candidates.append(declared)
|
||||
|
||||
def append_candidate(candidate: dict[str, Any]) -> None:
|
||||
@@ -4129,12 +4150,41 @@ def template_type_candidates_from_features(features: dict[str, Any], content_par
|
||||
return candidates
|
||||
|
||||
|
||||
def public_template_summary(parts: list[dict[str, Any]], *, include_storage: bool = False) -> dict[str, Any]:
|
||||
def public_template_summary(
|
||||
parts: list[dict[str, Any],],
|
||||
*,
|
||||
include_storage: bool = False,
|
||||
confirmed_platform_type_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
content_parts = [part for part in parts if part.get("role") != "metadata_payload"]
|
||||
declared_platform_type = next(
|
||||
(template_type for template_type in (declared_template_platform_type_from_part(part) for part in parts) if template_type),
|
||||
None,
|
||||
)
|
||||
# A payload carrier (for example XML root AppearanceTemplate) selects a
|
||||
# safe reader, but is not the 1C TemplateType. The latter is determined
|
||||
# only by a decoded template descriptor / confirmed template identity.
|
||||
declared_type_evidence = "TemplateType metadata"
|
||||
xml_roots = sorted(
|
||||
{
|
||||
str((part.get("classification") or {}).get("xml_root"))
|
||||
for part in parts
|
||||
if isinstance(part.get("classification"), dict)
|
||||
and (part.get("classification") or {}).get("xml_root")
|
||||
}
|
||||
)
|
||||
descriptor_type_codes = sorted(
|
||||
{
|
||||
code
|
||||
for code in (
|
||||
str(part.get("descriptor_type_code")) if str(part.get("descriptor_type_code") or "").isdecimal()
|
||||
else template_descriptor_type_code_from_part(part)
|
||||
for part in parts
|
||||
)
|
||||
if code is not None
|
||||
},
|
||||
key=int,
|
||||
)
|
||||
features = {
|
||||
"tabular_document": any(((part.get("features") or {}).get("tabular_document")) for part in content_parts),
|
||||
"html": any(((part.get("features") or {}).get("html")) for part in content_parts),
|
||||
@@ -4144,19 +4194,46 @@ def public_template_summary(parts: list[dict[str, Any]], *, include_storage: boo
|
||||
"declared_platform_type": declared_platform_type.get("name"),
|
||||
"declared_platform_type_id": declared_platform_type.get("id"),
|
||||
"declared_platform_xml_type": declared_platform_type.get("xml_type"),
|
||||
"declared_platform_type_evidence": declared_type_evidence,
|
||||
}
|
||||
if declared_platform_type
|
||||
else {}
|
||||
),
|
||||
}
|
||||
template_type_candidates = template_type_candidates_from_features(features, content_parts)
|
||||
confirmed_platform_type = onec_template_type_by_id(confirmed_platform_type_id) if confirmed_platform_type_id else None
|
||||
if confirmed_platform_type and confirmed_platform_type.get("id") != confirmed_platform_type_id:
|
||||
confirmed_platform_type = None
|
||||
if confirmed_platform_type:
|
||||
# This classification comes from the report's decoded Template record,
|
||||
# not a marker guess in the compressed content stream. In particular,
|
||||
# an SCD stream has no MOXCEL/HTML marker and must not be presented as
|
||||
# a low-confidence binary template merely because it is compressed.
|
||||
confirmed_platform_type.update({"confidence": "high", "evidence": ["confirmed Template identity"]})
|
||||
template_type_candidates = [confirmed_platform_type] + [
|
||||
candidate for candidate in template_type_candidates if candidate.get("id") != confirmed_platform_type.get("id")
|
||||
]
|
||||
summary: dict[str, Any] = {
|
||||
"features": features,
|
||||
"known_platform_types": ONEC_TEMPLATE_PLATFORM_TYPES,
|
||||
"template_type_candidates": template_type_candidates,
|
||||
"counts": {"parts": len(parts), "content_parts": len(content_parts)},
|
||||
}
|
||||
if features.get("tabular_document"):
|
||||
if xml_roots:
|
||||
summary["content_type_evidence"] = {
|
||||
"xml_roots": xml_roots,
|
||||
"meaning": "payload_carrier_only_not_platform_template_type",
|
||||
}
|
||||
if descriptor_type_codes and not declared_platform_type and not confirmed_platform_type_id:
|
||||
summary["descriptor_type_evidence"] = {
|
||||
"codes": descriptor_type_codes,
|
||||
"status": "unresolved_mapping",
|
||||
"meaning": "Template descriptor type code; numeric mapping to 1C TemplateType is not yet fixture-proven.",
|
||||
}
|
||||
if confirmed_platform_type:
|
||||
summary["format"] = confirmed_platform_type.get("xml_type") or confirmed_platform_type.get("id")
|
||||
summary["platform_type"] = confirmed_platform_type.get("name")
|
||||
elif features.get("tabular_document"):
|
||||
summary["format"] = "ТабличныйДокумент"
|
||||
summary["platform_type"] = "Табличный документ"
|
||||
summary["preview"] = {
|
||||
@@ -36040,6 +36117,79 @@ def metadata_object_related(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def appearance_inspect(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Read a proven base CommonTemplate appearance XML carrier by public name."""
|
||||
method = "appearance.inspect"
|
||||
base_id_or_error = require_base_id(payload, method)
|
||||
if isinstance(base_id_or_error, dict):
|
||||
return base_id_or_error
|
||||
base_id = base_id_or_error
|
||||
if payload.get("extension") not in {None, ""} or payload.get("extension_guid") not in {None, ""}:
|
||||
return {
|
||||
"schema": "onec_appearance_inspect.v1", "method": method, "status": "unsupported", "base_id": base_id,
|
||||
"diagnostics": {"code": "APPEARANCE_EXTENSION_ROUTE_UNPROVEN", "message": "Only a base CommonTemplate carrier is proven for this reader."},
|
||||
}
|
||||
template = str(payload.get("template") or payload.get("name") or payload.get("guid") or "").strip()
|
||||
if not template:
|
||||
return invalid_argument(method, "template", "template is required as a public CommonTemplate name or GUID.")
|
||||
timeout_seconds = int(payload.get("timeout_seconds") or 60)
|
||||
resolved = get_object("CommonTemplate", template, base_id=base_id, view="effective", limit=2, include_storage=True, include_semantic=False, timeout_seconds=timeout_seconds, table="Config")
|
||||
if resolved.get("status") != "ok" or not isinstance(resolved.get("object"), dict):
|
||||
return {"schema": "onec_appearance_inspect.v1", "method": method, "status": resolved.get("status") or "not_found", "base_id": base_id, "diagnostics": resolved.get("diagnostics") or {}}
|
||||
object_card = resolved["object"]
|
||||
guid = str(object_card.get("guid") or "").strip()
|
||||
if not guid:
|
||||
return {"schema": "onec_appearance_inspect.v1", "method": method, "status": "unresolved", "base_id": base_id, "diagnostics": {"code": "APPEARANCE_GUID_NOT_PROVEN"}}
|
||||
data, _, read_error = read_storage_file_bytes(base_id, "Config", f"{guid}.0", timeout_seconds=min(timeout_seconds, 60))
|
||||
if read_error or data is None:
|
||||
return {"schema": "onec_appearance_inspect.v1", "method": method, "status": "not_found", "base_id": base_id, "diagnostics": (read_error or {}).get("diagnostics") or {"code": "APPEARANCE_PAYLOAD_NOT_FOUND"}}
|
||||
from parser.appearance_payload import inspect_appearance_payload
|
||||
decoded = inspect_appearance_payload(data)
|
||||
return {
|
||||
"schema": "onec_appearance_inspect.v1",
|
||||
"method": method,
|
||||
"status": decoded.get("status"),
|
||||
"base_id": base_id,
|
||||
"template": public_metadata_row(object_card),
|
||||
"appearance": decoded,
|
||||
"template_type": {
|
||||
"status": "unresolved",
|
||||
"reason": "AppearanceTemplate XML root selects this read-only decoder but does not prove the 1C TemplateType; a decoded metadata descriptor is required.",
|
||||
},
|
||||
"activation_state": "working_not_runtime_applied",
|
||||
}
|
||||
|
||||
|
||||
def report_inspect(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the confirmed component graph of a report without filling gaps."""
|
||||
method = "report.inspect"
|
||||
base_id_or_error = require_base_id(payload, method)
|
||||
if isinstance(base_id_or_error, dict):
|
||||
return base_id_or_error
|
||||
report = str(payload.get("report") or payload.get("name") or "").strip()
|
||||
if not report:
|
||||
return invalid_argument(method, "report", "report is required as a public 1C report name.")
|
||||
component_payload = {"base_id": base_id_or_error, "kind": "Report", "name": report, "include_storage": False, "timeout_seconds": int(payload.get("timeout_seconds") or 60)}
|
||||
for key in ("extension", "extension_guid"):
|
||||
if payload.get(key) not in {None, ""}:
|
||||
component_payload[key] = payload[key]
|
||||
components_result = metadata_object_components(component_payload)
|
||||
if not isinstance(components_result.get("object"), dict):
|
||||
return {"schema": "onec_report_inspect.v1", "method": method, "status": components_result.get("status") or "not_found", "base_id": base_id_or_error, "unresolved": components_result.get("unresolved") or [], "diagnostics": components_result.get("diagnostics") or {}}
|
||||
components = [item for item in components_result.get("components") or [] if isinstance(item, dict)]
|
||||
return {
|
||||
"schema": "onec_report_inspect.v1", "method": method, "status": "ok", "base_id": base_id_or_error,
|
||||
"report": public_metadata_row(components_result["object"]),
|
||||
"components": components,
|
||||
"scd": [item for item in components if item.get("component_kind") == "scd"],
|
||||
"templates": [item for item in components if item.get("component_kind") in {"scd", "template"}],
|
||||
"forms": [item for item in components if item.get("component_kind") == "form"],
|
||||
"modules": [item for item in components if item.get("component_kind") == "module"],
|
||||
"unresolved": components_result.get("unresolved") or [],
|
||||
"diagnostics": {"rule": "Only components proven by the individual live-SQL readers are included; a form is never promoted to an SCD by name."},
|
||||
}
|
||||
|
||||
|
||||
def scd_inspect(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Resolve and decode one report SCD from its SQL ConfigCAS payload."""
|
||||
method = "scd.inspect"
|
||||
@@ -36051,7 +36201,7 @@ def scd_inspect(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not report:
|
||||
return invalid_argument(method, "report", "report is required as a public 1C report name.")
|
||||
schema_name = str(payload.get("schema") or "").strip()
|
||||
requested_sections = payload.get("sections") or ["parameters", "datasets", "fields", "calculated_fields", "resources", "settings", "variants", "total_fields"]
|
||||
requested_sections = payload.get("sections") or ["data_sources", "parameters", "datasets", "fields", "calculated_fields", "resources", "groupings", "settings", "variants", "total_fields"]
|
||||
if not isinstance(requested_sections, list) or not all(isinstance(item, str) for item in requested_sections):
|
||||
return invalid_argument(method, "sections", "sections must be an array of strings.")
|
||||
resolve_metadata, resolve_metadata_error = strict_bool_argument(payload, "resolve_metadata", method=method, default=True)
|
||||
@@ -42269,9 +42419,31 @@ def metadata_object_templates(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
)
|
||||
if direct.get("status") != "ok":
|
||||
return direct
|
||||
parts_result = metadata_object_parts(
|
||||
{
|
||||
"base_id": base_id, "guid": guid, "kind": "Template", "table": table,
|
||||
"include_text": bool(include_text), "include_tree": bool(include_tree),
|
||||
"include_storage": include_storage, "evidence_mode": evidence_mode,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
}
|
||||
)
|
||||
classified_parts = []
|
||||
for raw_part in parts_result.get("parts") or []:
|
||||
if not isinstance(raw_part, dict):
|
||||
continue
|
||||
classified_part = dict(raw_part)
|
||||
classification = classified_part.get("classification") if isinstance(classified_part.get("classification"), dict) else {}
|
||||
if classification.get("role"):
|
||||
classified_part["role"] = classification.get("role")
|
||||
descriptor_type_code = template_descriptor_type_code_from_part(classified_part)
|
||||
if descriptor_type_code is not None:
|
||||
classified_part["descriptor_type_code"] = descriptor_type_code
|
||||
classified_parts.append(classified_part)
|
||||
common_summary = public_template_summary(classified_parts, include_storage=include_storage) if classified_parts else {}
|
||||
templates = []
|
||||
for template_item in direct.get("templates") or []:
|
||||
item = dict(template_item)
|
||||
item.update(common_summary)
|
||||
item["name"] = item.get("name") or (object_card or {}).get("name")
|
||||
item["kind"] = "CommonTemplate"
|
||||
item["ref"] = (object_card or {}).get("ref") or object_selector_ref("CommonTemplate", str(item.get("name") or ""))
|
||||
@@ -42351,6 +42523,11 @@ def metadata_object_templates(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
for part in parts_result.get("parts") or []:
|
||||
classification = part.get("classification") or {}
|
||||
public_part = payload_public_properties(classification)
|
||||
if classification.get("role"):
|
||||
public_part["role"] = classification.get("role")
|
||||
descriptor_type_code = template_descriptor_type_code_from_part(part)
|
||||
if descriptor_type_code is not None:
|
||||
public_part["descriptor_type_code"] = descriptor_type_code
|
||||
public_part["undecoded_evidence"] = payload_public_undecoded_evidence(
|
||||
classification,
|
||||
include_text_preview=bool(include_text),
|
||||
@@ -42368,9 +42545,17 @@ def metadata_object_templates(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
)
|
||||
parts.append(public_part)
|
||||
category_platform_type = {
|
||||
"DataCompositionSchema": "data_composition_schema",
|
||||
"DataCompositionAppearanceTemplate": "data_composition_appearance_template",
|
||||
}.get(str(item.get("category") or ""))
|
||||
template = {
|
||||
**public_child_identity(item),
|
||||
**public_template_summary(parts, include_storage=include_storage),
|
||||
**public_template_summary(
|
||||
parts,
|
||||
include_storage=include_storage,
|
||||
confirmed_platform_type_id=category_platform_type,
|
||||
),
|
||||
**({"kind": "data_composition_schema"} if item.get("category") == "DataCompositionSchema" else {}),
|
||||
}
|
||||
if wanted:
|
||||
@@ -70367,6 +70552,10 @@ def call_method_impl(method: str, payload: dict[str, Any] | None) -> dict[str, A
|
||||
return metadata_object_modules(payload)
|
||||
if method == "metadata.object.related":
|
||||
return metadata_object_related(payload)
|
||||
if method == "report.inspect":
|
||||
return report_inspect(payload)
|
||||
if method == "appearance.inspect":
|
||||
return appearance_inspect(payload)
|
||||
if method == "scd.inspect":
|
||||
return scd_inspect(payload)
|
||||
if method == "scd.prepare":
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Read-only decoder for 1C DCS appearance-template payloads.
|
||||
|
||||
Appearance templates are a distinct XML family (``AppearanceTemplate``), not
|
||||
DataCompositionSchema and not a spreadsheet document. This decoder returns
|
||||
only XML-backed rules and preserves the original carrier for any future
|
||||
lossless writer work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from .payload import decode_payload_lossless
|
||||
|
||||
|
||||
def _local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def _child_text(node: ET.Element, name: str) -> str:
|
||||
child = next((item for item in node if _local_name(item.tag) == name), None)
|
||||
return "".join(child.itertext()).strip() if child is not None else ""
|
||||
|
||||
|
||||
def inspect_appearance_payload(data: bytes) -> dict[str, Any]:
|
||||
"""Decode a compressed appearance-template carrier without changing it."""
|
||||
|
||||
decoded = decode_payload_lossless(data)
|
||||
payload = bytes(decoded.get("payload") or b"")
|
||||
start = payload.find(b"<?xml")
|
||||
if start < 0:
|
||||
start = payload.find(b"<AppearanceTemplate")
|
||||
end_marker = b"</AppearanceTemplate>"
|
||||
end = payload.find(end_marker, start) if start >= 0 else -1
|
||||
if start < 0 or end < 0:
|
||||
return {
|
||||
"status": "undecodable",
|
||||
"container": {"compression": decoded.get("compression"), "raw_bytes": decoded.get("raw_bytes"), "payload_bytes": decoded.get("payload_bytes")},
|
||||
"diagnostics": {"code": "APPEARANCE_XML_NOT_FOUND"},
|
||||
}
|
||||
xml_bytes = payload[start : end + len(end_marker)]
|
||||
try:
|
||||
root = ET.fromstring(xml_bytes.decode("utf-8-sig"))
|
||||
except (UnicodeDecodeError, ET.ParseError) as exc:
|
||||
return {"status": "undecodable", "diagnostics": {"code": "APPEARANCE_XML_INVALID", "message": str(exc)}}
|
||||
if _local_name(root.tag) != "AppearanceTemplate":
|
||||
return {"status": "unsupported", "diagnostics": {"code": "APPEARANCE_ROOT_NOT_CONFIRMED", "xml_root": _local_name(root.tag)}}
|
||||
items = []
|
||||
for ordinal, item in enumerate((node for node in root if _local_name(node.tag) == "item"), start=1):
|
||||
parameter, value = _child_text(item, "parameter"), _child_text(item, "value")
|
||||
items.append({"ordinal": ordinal, "parameter": parameter, "value": value})
|
||||
appearances = []
|
||||
for ordinal, appearance in enumerate((node for node in root.iter() if _local_name(node.tag) == "appearance"), start=1):
|
||||
rules = []
|
||||
for item in (node for node in appearance if _local_name(node.tag) == "item"):
|
||||
value_node = next((child for child in item if _local_name(child.tag) == "value"), None)
|
||||
rule: dict[str, Any] = {"parameter": _child_text(item, "parameter"), "value": _child_text(item, "value")}
|
||||
if value_node is not None:
|
||||
value_type = value_node.attrib.get("{http://www.w3.org/2001/XMLSchema-instance}type") or value_node.attrib.get("type")
|
||||
if value_type:
|
||||
rule["value_type"] = value_type
|
||||
if value_node.attrib:
|
||||
rule["value_attributes"] = {key.rsplit("}", 1)[-1]: value for key, value in value_node.attrib.items()}
|
||||
nested_values = []
|
||||
for child in value_node.iter():
|
||||
if child is value_node or list(child):
|
||||
continue
|
||||
nested_values.append({
|
||||
"tag": _local_name(child.tag),
|
||||
"value": "".join(child.itertext()).strip(),
|
||||
**({"attributes": {key.rsplit("}", 1)[-1]: value for key, value in child.attrib.items()}} if child.attrib else {}),
|
||||
})
|
||||
if nested_values:
|
||||
rule["value_children"] = nested_values
|
||||
rules.append(rule)
|
||||
appearances.append({"ordinal": ordinal, "rules": rules})
|
||||
tag_counts = Counter(_local_name(node.tag) for node in root.iter())
|
||||
return {
|
||||
"status": "ok",
|
||||
"container": {"compression": decoded.get("compression"), "raw_bytes": decoded.get("raw_bytes"), "payload_bytes": decoded.get("payload_bytes"), "xml_offset": start, "xml_bytes": len(xml_bytes), "xml_root": "AppearanceTemplate"},
|
||||
"items": items,
|
||||
"appearances": appearances,
|
||||
"outline": {"tag_counts": dict(sorted(tag_counts.items(), key=lambda item: item[0].casefold()))},
|
||||
"diagnostics": {"write": "unsupported_until_reverse_codec_fixture"},
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import hashlib
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
XML_ROOT_RE = re.compile(r"^\s*(?:<\?xml[^>]*>\s*)?<([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)\b")
|
||||
|
||||
from .payload import collect_strings, decode_payload_lossless, encode_text, parse_brace_text, root_signature, scalar
|
||||
|
||||
|
||||
@@ -483,6 +486,11 @@ def classify_payload(data: bytes, *, include_text: bool = False, include_tree: b
|
||||
stream["text"] = declared_prefix["text"]
|
||||
stream["text_preview"] = str(declared_prefix["text"] or "").replace("\x00", "")[:500]
|
||||
text = decoded.get("text")
|
||||
xml_root = None
|
||||
if isinstance(text, str):
|
||||
match = XML_ROOT_RE.match(text.lstrip("\ufeff"))
|
||||
if match:
|
||||
xml_root = match.group(1).rsplit(":", 1)[-1]
|
||||
tree = None
|
||||
root = None
|
||||
base64_decoded: list[dict[str, Any]] = []
|
||||
@@ -505,6 +513,7 @@ def classify_payload(data: bytes, *, include_text: bool = False, include_tree: b
|
||||
"sha1": sha1_hex(data),
|
||||
"payload_sha1": sha1_hex(bytes(payload)),
|
||||
"markers": markers,
|
||||
**({"xml_root": xml_root} if xml_root else {}),
|
||||
"root": root,
|
||||
"role": classify_role(root_marker, markers, text, stream_blocks, base64_decoded),
|
||||
"strings_sample": strings_sample,
|
||||
|
||||
@@ -75,22 +75,20 @@ def query_without_line_comments(text: str) -> str:
|
||||
def node_path(root: ET.Element, target: ET.Element) -> str:
|
||||
"""Produce a stable, human-readable evidence path without XML prefixes."""
|
||||
|
||||
def visit(node: ET.Element, prefix: str) -> str | None:
|
||||
name = local_name(node.tag)
|
||||
current = f"{prefix}/{name}" if prefix else f"/{name}"
|
||||
def visit(node: ET.Element, current: str) -> str | None:
|
||||
if node is target:
|
||||
return current
|
||||
positions: dict[str, int] = {}
|
||||
for child in node:
|
||||
child_name = local_name(child.tag)
|
||||
positions[child_name] = positions.get(child_name, 0) + 1
|
||||
child_prefix = f"{current}[{positions[child_name]}]"
|
||||
found = visit(child, child_prefix)
|
||||
suffix = f"[{positions[child_name]}]" if positions[child_name] > 1 else ""
|
||||
found = visit(child, f"{current}/{child_name}{suffix}")
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
return visit(root, "") or "/"
|
||||
return visit(root, f"/{local_name(root.tag)}[1]") or "/"
|
||||
|
||||
|
||||
def xml_from_scd_payload(data: bytes) -> tuple[ET.Element | None, dict[str, Any]]:
|
||||
@@ -150,6 +148,9 @@ def scd_node_item(root: ET.Element, node: ET.Element, category: str) -> dict[str
|
||||
expression = child_text(node, "expression")
|
||||
if expression:
|
||||
item["expression"] = expression
|
||||
presentation = child_text(node, "presentation")
|
||||
if presentation:
|
||||
item["presentation"] = presentation
|
||||
query = child_text(node, "query")
|
||||
if query:
|
||||
item["query"] = query
|
||||
@@ -163,6 +164,10 @@ def scd_node_item(root: ET.Element, node: ET.Element, category: str) -> dict[str
|
||||
item["value_type"] = value_type
|
||||
if category == "datasets":
|
||||
item["type"] = node.attrib.get("{http://www.w3.org/2001/XMLSchema-instance}type") or node.attrib.get("type") or ""
|
||||
if category == "data_sources":
|
||||
data_source_type = child_text(node, "dataSourceType")
|
||||
if data_source_type:
|
||||
item["data_source_type"] = data_source_type
|
||||
return item
|
||||
|
||||
|
||||
@@ -173,7 +178,7 @@ def inspect_scd_payload(data: bytes, *, sections: list[str] | None = None) -> di
|
||||
synthesized from report code or form attributes.
|
||||
"""
|
||||
|
||||
requested = sections or ["parameters", "datasets", "fields", "calculated_fields", "resources", "settings", "variants", "total_fields"]
|
||||
requested = sections or ["data_sources", "parameters", "datasets", "fields", "calculated_fields", "resources", "groupings", "settings", "variants", "total_fields"]
|
||||
root, container = xml_from_scd_payload(data)
|
||||
if root is None:
|
||||
return {"status": "partial", "container": container, "sections": {name: [] for name in requested}}
|
||||
@@ -184,12 +189,29 @@ def inspect_scd_payload(data: bytes, *, sections: list[str] | None = None) -> di
|
||||
"container": {**container, "code": "SCD_SCHEMA_NODE_NOT_FOUND"},
|
||||
"sections": {name: [] for name in requested},
|
||||
}
|
||||
# Keep a complete, evidence-backed map of the XML shape alongside the
|
||||
# curated semantic sections below. 1C releases and SCD variants may add
|
||||
# nodes that this adapter does not yet assign a business meaning to. A
|
||||
# tag/count/path outline makes those nodes visible to callers without
|
||||
# guessing that (for example) a form property is a dataset or layout.
|
||||
top_level_counts: dict[str, int] = {}
|
||||
top_level_paths: dict[str, list[str]] = {}
|
||||
for child in schema:
|
||||
tag = local_name(child.tag)
|
||||
top_level_counts[tag] = top_level_counts.get(tag, 0) + 1
|
||||
top_level_paths.setdefault(tag, []).append(node_path(schema, child))
|
||||
xml_tag_counts: dict[str, int] = {}
|
||||
for node in schema.iter():
|
||||
tag = local_name(node.tag)
|
||||
xml_tag_counts[tag] = xml_tag_counts.get(tag, 0) + 1
|
||||
node_names = {
|
||||
"data_sources": {"dataSource"},
|
||||
"parameters": {"parameter"},
|
||||
"datasets": {"dataSet"},
|
||||
"fields": {"field"},
|
||||
"calculated_fields": {"calculatedField"},
|
||||
"resources": {"resource"},
|
||||
"groupings": {"group"},
|
||||
"settings": {"settings", "Settings"},
|
||||
"variants": {"settingsVariant", "variant"},
|
||||
"total_fields": {"totalField"},
|
||||
@@ -220,6 +242,14 @@ def inspect_scd_payload(data: bytes, *, sections: list[str] | None = None) -> di
|
||||
query_references.append({"dataset": dataset.get("name"), "parameters": references})
|
||||
analysis = {
|
||||
"kind": "raw_query_parameter_token_scan",
|
||||
"schema_outline": {
|
||||
"kind": "exact_xml_tag_inventory",
|
||||
"top_level": [
|
||||
{"tag": tag, "count": top_level_counts[tag], "paths": top_level_paths[tag]}
|
||||
for tag in sorted(top_level_counts, key=str.casefold)
|
||||
],
|
||||
"all_tag_counts": dict(sorted(xml_tag_counts.items(), key=lambda item: item[0].casefold())),
|
||||
},
|
||||
"declared_parameters": declared,
|
||||
"query_parameter_references": query_references,
|
||||
"referenced_not_declared_in_schema": sorted(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Read-only manifest collector for a 1C SQL configuration storage.
|
||||
|
||||
The REST endpoint caps a single storage.files.list response. GUID-prefix
|
||||
partitioning keeps every request below that cap and makes the manifest
|
||||
reproducible without SQL credentials in source control.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PREFIXES = [*"0123456789abcdef", "root"]
|
||||
|
||||
|
||||
def rpc(url: str, method: str, payload: dict) -> dict:
|
||||
request = urllib.request.Request(
|
||||
url.rstrip("/") + "/rpc",
|
||||
data=json.dumps({"method": method, "payload": payload}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=180) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--url", required=True)
|
||||
parser.add_argument("--base-id", required=True)
|
||||
parser.add_argument("--table", default="Config")
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
files: list[dict] = []
|
||||
partitions: list[dict] = []
|
||||
for prefix in PREFIXES:
|
||||
result = rpc(args.url, "storage.files.list", {
|
||||
"base_id": args.base_id, "table": args.table, "prefix": prefix,
|
||||
"limit": 5000, "diagnostic": True,
|
||||
})
|
||||
rows = result.get("files") if result.get("status") == "ok" else None
|
||||
if not isinstance(rows, list):
|
||||
raise RuntimeError(f"{prefix}: {result}")
|
||||
if len(rows) >= 5000:
|
||||
raise RuntimeError(f"{prefix}: response reached the cap; split this prefix further")
|
||||
files.extend(rows)
|
||||
partitions.append({"prefix": prefix, "files": len(rows)})
|
||||
|
||||
suffixes = Counter(Path(str(row.get("FileName") or "")).suffix or "<descriptor>" for row in files)
|
||||
manifest = {
|
||||
"schema": "onec_config_storage_manifest.v1",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source": {"base_id": args.base_id, "table": args.table},
|
||||
"partitions": partitions,
|
||||
"counts": {"files": len(files), "compressed_bytes": sum(int(row.get("Bytes") or 0) for row in files), "suffixes": dict(sorted(suffixes.items()))},
|
||||
"files": files,
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Checkpointed, read-only report inventory through the adapter RPC."""
|
||||
from __future__ import annotations
|
||||
import argparse, json, urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
def call(url, method, payload):
|
||||
req=urllib.request.Request(url.rstrip('/')+'/rpc', data=json.dumps({'method':method,'payload':payload}).encode(), headers={'Content-Type':'application/json'})
|
||||
with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read())
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(); p.add_argument('--url',required=True); p.add_argument('--base-id',required=True); p.add_argument('--output',type=Path,required=True); a=p.parse_args()
|
||||
state=json.loads(a.output.read_text('utf-8')) if a.output.exists() else {'schema':'onec_report_inventory.v1','reports':{}}
|
||||
listed=call(a.url,'metadata.objects.list',{'base_id':a.base_id,'kind':'Report','limit':500})
|
||||
for item in listed.get('objects',[]):
|
||||
name=item.get('name')
|
||||
if not name or state['reports'].get(name, {}).get('status') == 'ok': continue
|
||||
try: state['reports'][name]=call(a.url,'report.inspect',{'base_id':a.base_id,'report':name,'timeout_seconds':60})
|
||||
except Exception as exc: state['reports'][name]={'status':'transport_error','diagnostics':str(exc)}
|
||||
a.output.parent.mkdir(parents=True,exist_ok=True); a.output.write_text(json.dumps(state,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
if __name__=='__main__': main()
|
||||
@@ -38,6 +38,7 @@ from parser.bsl_validation import replace_routine_text, routine_blocks, text_sha
|
||||
from parser.form_payload import command_handler_links, data_path_reference_from_node, decode_form_payload, dynamic_list_fields, dynamic_list_settings, embedded_table_addition_row, enrich_form_common_semantic, enrich_item_data_paths, enrich_item_reference_semantics, event_handlers, event_handlers_from_node, form_common_semantic, form_item_coverage_summary, section_record_semantic_properties # noqa: E402
|
||||
from parser.form_xml import decode_form_xml # noqa: E402
|
||||
from parser.scd_payload import compare_scd_semantics, inspect_scd_payload, plan_scd_scalar_patch # noqa: E402
|
||||
from parser.appearance_payload import inspect_appearance_payload # noqa: E402
|
||||
from parser.dbnames import parse_dbnames_bytes, parse_dbnames_version_bytes # noqa: E402
|
||||
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector"))
|
||||
import adapter_1c_server as adapter_server # noqa: E402
|
||||
@@ -211,14 +212,16 @@ def test_dbnames_version_parser_accepts_utf8_bom_scalar_version() -> None:
|
||||
def test_scd_payload_decoder_reads_xml_after_platform_prefix() -> None:
|
||||
xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<SchemaFile><dataCompositionSchema>
|
||||
<dataSource><name>ИсточникДанных1</name><dataSourceType>Local</dataSourceType></dataSource>
|
||||
<dataSet xsi:type="DataSetQuery" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><name>ОсновнойНабор</name><query>ВЫБРАТЬ &Период КАК Период // &Скрытый КАК Скрытый</query></dataSet>
|
||||
<parameter><name>Период</name><valueType>xs:dateTime</valueType></parameter>
|
||||
<field><dataPath>Период</dataPath></field>
|
||||
<field><dataPath>Сумма</dataPath></field>
|
||||
<calculatedField><name>СуммаСНалогом</name><expression>Сумма * 1.2</expression></calculatedField>
|
||||
<resource><name>Сумма</name><expression>Сумма</expression></resource>
|
||||
<group>Период</group>
|
||||
<totalField>Сумма</totalField>
|
||||
<settingsVariant><name>Основной</name></settingsVariant>
|
||||
<settingsVariant><name>Основной</name><presentation><item><lang>ru</lang><content>Основной вариант</content></item></presentation></settingsVariant>
|
||||
</dataCompositionSchema></SchemaFile>"""
|
||||
payload = b"\x00\x00\x00\x00\x01\x00\x00\x00" + xml.encode("utf-8")
|
||||
|
||||
@@ -226,16 +229,85 @@ def test_scd_payload_decoder_reads_xml_after_platform_prefix() -> None:
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["container"]["compression"] == "raw_deflate"
|
||||
assert result["sections"]["data_sources"] == [{"name": "ИсточникДанных1", "source": {"kind": "scd_xml", "path": "/dataCompositionSchema[1]/dataSource"}, "data_source_type": "Local"}]
|
||||
assert result["sections"]["parameters"] == [{"name": "Период", "source": {"kind": "scd_xml", "path": "/dataCompositionSchema[1]/parameter"}, "value_type": "xs:dateTime"}]
|
||||
assert result["sections"]["datasets"][0]["query"] == "ВЫБРАТЬ &Период КАК Период // &Скрытый КАК Скрытый"
|
||||
assert result["sections"]["calculated_fields"][0]["expression"] == "Сумма * 1.2"
|
||||
assert result["sections"]["groupings"] == [{"name": "Период", "source": {"kind": "scd_xml", "path": "/dataCompositionSchema[1]/group"}}]
|
||||
assert result["sections"]["variants"][0]["name"] == "Основной"
|
||||
assert result["sections"]["variants"][0]["presentation"] == "ruОсновной вариант"
|
||||
assert result["analysis"]["query_parameter_references"] == [{"dataset": "ОсновнойНабор", "parameters": ["Период"]}]
|
||||
assert result["analysis"]["schema_outline"] == {
|
||||
"kind": "exact_xml_tag_inventory",
|
||||
"top_level": [
|
||||
{"tag": "calculatedField", "count": 1, "paths": ["/dataCompositionSchema[1]/calculatedField"]},
|
||||
{"tag": "dataSet", "count": 1, "paths": ["/dataCompositionSchema[1]/dataSet"]},
|
||||
{"tag": "dataSource", "count": 1, "paths": ["/dataCompositionSchema[1]/dataSource"]},
|
||||
{"tag": "field", "count": 2, "paths": ["/dataCompositionSchema[1]/field", "/dataCompositionSchema[1]/field[2]"]},
|
||||
{"tag": "group", "count": 1, "paths": ["/dataCompositionSchema[1]/group"]},
|
||||
{"tag": "parameter", "count": 1, "paths": ["/dataCompositionSchema[1]/parameter"]},
|
||||
{"tag": "resource", "count": 1, "paths": ["/dataCompositionSchema[1]/resource"]},
|
||||
{"tag": "settingsVariant", "count": 1, "paths": ["/dataCompositionSchema[1]/settingsVariant"]},
|
||||
{"tag": "totalField", "count": 1, "paths": ["/dataCompositionSchema[1]/totalField"]},
|
||||
],
|
||||
"all_tag_counts": {"calculatedField": 1, "content": 1, "dataCompositionSchema": 1, "dataPath": 2, "dataSet": 1, "dataSource": 1, "dataSourceType": 1, "expression": 2, "field": 2, "group": 1, "item": 1, "lang": 1, "name": 6, "parameter": 1, "presentation": 1, "query": 1, "resource": 1, "settingsVariant": 1, "totalField": 1, "valueType": 1},
|
||||
}
|
||||
assert result["analysis"]["referenced_not_declared_in_schema"] == []
|
||||
assert result["analysis"]["total_field_references"] == {"fields": ["Сумма"], "missing_from_declared_fields": [], "status": "checked"}
|
||||
assert result["analysis"]["settings_context"] == {"status": "not_present", "sections": {}}
|
||||
|
||||
|
||||
def test_appearance_payload_decoder_reads_xml_rules() -> None:
|
||||
xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<AppearanceTemplate><item><parameter>Заголовок</parameter><value>style:Title</value></item><appearance><item><parameter>ЦветФона</parameter><value type="Line" ref="style:Blue"><style type="Solid">Solid</style></value></item></appearance><style><name>Title</name></style></AppearanceTemplate>"""
|
||||
|
||||
result = inspect_appearance_payload(compress_payload(b"\x00\x01" + xml.encode("utf-8"), "raw_deflate"))
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["items"] == [{"ordinal": 1, "parameter": "Заголовок", "value": "style:Title"}]
|
||||
assert result["appearances"] == [{"ordinal": 1, "rules": [{"parameter": "ЦветФона", "value": "Solid", "value_type": "Line", "value_attributes": {"type": "Line", "ref": "style:Blue"}, "value_children": [{"tag": "style", "value": "Solid", "attributes": {"type": "Solid"}}]}]}]
|
||||
assert result["outline"]["tag_counts"] == {"AppearanceTemplate": 1, "appearance": 1, "item": 2, "name": 1, "parameter": 2, "style": 2, "value": 2}
|
||||
assert result["diagnostics"]["write"] == "unsupported_until_reverse_codec_fixture"
|
||||
|
||||
|
||||
def test_appearance_inspect_resolves_public_common_template(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
source = compress_payload('<?xml version="1.0"?><AppearanceTemplate><item><parameter>Заголовок</parameter><value>x</value></item></AppearanceTemplate>'.encode("utf-8"), "raw_deflate")
|
||||
monkeypatch.setattr(adapter_server, "get_object", lambda *_args, **_kwargs: {"status": "ok", "object": {"guid": "a" * 36, "kind": "CommonTemplate", "name": "Оформление"}})
|
||||
monkeypatch.setattr(adapter_server, "read_storage_file_bytes", lambda *_args, **_kwargs: (source, {}, None))
|
||||
|
||||
result = adapter_server.appearance_inspect({"base_id": "upo_test", "template": "Оформление"})
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["template"]["name"] == "Оформление"
|
||||
assert result["appearance"]["items"][0]["parameter"] == "Заголовок"
|
||||
assert result["template_type"]["status"] == "unresolved"
|
||||
|
||||
|
||||
def test_report_inspect_keeps_forms_separate_from_scd(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(adapter_server, "metadata_object_components", lambda _payload: {
|
||||
"status": "ok", "object": {"kind": "Report", "name": "Продажи", "guid": "r" * 36},
|
||||
"components": [
|
||||
{"component_kind": "form", "name": "ОсновнаяСхемаКомпоновкиДанных"},
|
||||
{"component_kind": "scd", "name": "ОсновнаяСхема", "guid": "s" * 36},
|
||||
], "unresolved": [{"area": "templates", "status": "partial"}],
|
||||
})
|
||||
|
||||
result = adapter_server.report_inspect({"base_id": "upo_test", "report": "Продажи"})
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert [item["name"] for item in result["forms"]] == ["ОсновнаяСхемаКомпоновкиДанных"]
|
||||
assert [item["name"] for item in result["scd"]] == ["ОсновнаяСхема"]
|
||||
assert result["unresolved"][0]["area"] == "templates"
|
||||
|
||||
|
||||
def test_rpc_dispatches_report_and_appearance_inspection(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(adapter_server, "report_inspect", lambda payload: {"status": "ok", "method": "report.inspect", "report": payload["report"]})
|
||||
monkeypatch.setattr(adapter_server, "appearance_inspect", lambda payload: {"status": "ok", "method": "appearance.inspect", "template": payload["template"]})
|
||||
|
||||
assert call_method("report.inspect", {"base_id": "upo_test", "report": "Продажи"})["report"] == "Продажи"
|
||||
assert call_method("appearance.inspect", {"base_id": "upo_test", "template": "Оформление"})["template"] == "Оформление"
|
||||
|
||||
|
||||
def test_scd_scalar_patch_preserves_platform_prefix_and_trailer() -> None:
|
||||
xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<SchemaFile><dataCompositionSchema>
|
||||
@@ -6897,6 +6969,7 @@ def test_public_template_summary_exposes_known_platform_types() -> None:
|
||||
assert summary["template_type_candidates"][0]["confidence"] == "high"
|
||||
assert "Текстовый документ" in {item["name"] for item in summary["known_platform_types"]}
|
||||
assert "Внешняя компонента" in {item["name"] for item in summary["known_platform_types"]}
|
||||
assert next(item for item in summary["known_platform_types"] if item["id"] == "data_composition_schema")["status"] == "read_and_scalar_patch_supported"
|
||||
assert {item["name"] for item in summary["known_platform_types"]} == {
|
||||
"Табличный документ",
|
||||
"Текстовый документ",
|
||||
@@ -6932,6 +7005,92 @@ def test_public_template_summary_uses_declared_template_type_alias() -> None:
|
||||
assert summary["template_type_candidates"][0]["confidence"] == "high"
|
||||
|
||||
|
||||
def test_public_template_summary_recognizes_live_appearance_xml_root() -> None:
|
||||
summary = adapter_server.public_template_summary(
|
||||
[{"role": "content", "classification": {"xml_root": "AppearanceTemplate"}, "features": {}}]
|
||||
)
|
||||
|
||||
assert summary["platform_type"] == "Двоичные данные"
|
||||
assert summary["template_type_candidates"][0]["id"] == "binary_data"
|
||||
assert summary["content_type_evidence"] == {
|
||||
"xml_roots": ["AppearanceTemplate"],
|
||||
"meaning": "payload_carrier_only_not_platform_template_type",
|
||||
}
|
||||
|
||||
|
||||
def test_public_template_summary_exposes_unmapped_descriptor_type_code() -> None:
|
||||
summary = adapter_server.public_template_summary(
|
||||
[
|
||||
{
|
||||
"role": "metadata_payload",
|
||||
"classification": {
|
||||
"tree": {
|
||||
"type": "list",
|
||||
"items": [
|
||||
{"type": "atom", "value": "1"},
|
||||
{"type": "list", "items": [
|
||||
{"type": "atom", "value": "4"},
|
||||
{"type": "list", "items": []},
|
||||
{"type": "atom", "value": "7"},
|
||||
]},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{"role": "content", "classification": {"xml_root": "AppearanceTemplate"}, "features": {}},
|
||||
]
|
||||
)
|
||||
|
||||
assert summary["platform_type"] == "Двоичные данные"
|
||||
assert summary["descriptor_type_evidence"]["codes"] == ["7"]
|
||||
assert summary["descriptor_type_evidence"]["status"] == "unresolved_mapping"
|
||||
|
||||
|
||||
def test_common_template_inventory_uses_parts_classifier(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(adapter_server, "resolve_object_guid", lambda *_args, **_kwargs: ("a" * 36, None, {"name": "Оформление", "ref": "CommonTemplate.Оформление"}, None))
|
||||
monkeypatch.setattr(adapter_server, "read_template_by_guid", lambda _payload: {"status": "ok", "source": {"kind": "live_sql"}, "templates": [{"guid": "a" * 36}]})
|
||||
monkeypatch.setattr(adapter_server, "metadata_object_parts", lambda _payload: {"parts": [{"role": "content", "classification": {"xml_root": "AppearanceTemplate"}, "features": {}}]})
|
||||
|
||||
result = adapter_server.metadata_object_templates({"base_id": "upo_test", "kind": "CommonTemplate", "name": "Оформление"})
|
||||
|
||||
assert result["templates"][0]["platform_type"] == "Двоичные данные"
|
||||
assert result["templates"][0]["content_type_evidence"]["xml_roots"] == ["AppearanceTemplate"]
|
||||
|
||||
|
||||
def test_common_template_inventory_preserves_unmapped_descriptor_code(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(adapter_server, "resolve_object_guid", lambda *_args, **_kwargs: ("a" * 36, None, {"name": "Оформление", "ref": "CommonTemplate.Оформление"}, None))
|
||||
monkeypatch.setattr(adapter_server, "read_template_by_guid", lambda _payload: {"status": "ok", "source": {"kind": "live_sql"}, "templates": [{"guid": "a" * 36}]})
|
||||
monkeypatch.setattr(adapter_server, "metadata_object_parts", lambda _payload: {"parts": [{
|
||||
"classification": {"role": "metadata_payload", "tree": {"type": "list", "items": [
|
||||
{"type": "atom", "value": "1"},
|
||||
{"type": "list", "items": [
|
||||
{"type": "atom", "value": "4"}, {"type": "list", "items": []}, {"type": "atom", "value": "7"},
|
||||
]},
|
||||
]}},
|
||||
}, {"classification": {"role": "binary_or_unknown_payload", "xml_root": "AppearanceTemplate"}, "features": {}}]})
|
||||
|
||||
result = adapter_server.metadata_object_templates({"base_id": "upo_test", "kind": "CommonTemplate", "name": "Оформление"})
|
||||
|
||||
assert result["templates"][0]["descriptor_type_evidence"]["codes"] == ["7"]
|
||||
assert result["templates"][0]["name"] == "Оформление"
|
||||
|
||||
|
||||
def test_top_level_common_template_summary_preserves_descriptor_code(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(adapter_server, "resolve_object_guid", lambda *_args, **_kwargs: ("a" * 36, None, {"name": "Оформление", "ref": "CommonTemplate.Оформление"}, None))
|
||||
monkeypatch.setattr(adapter_server, "read_template_by_guid", lambda _payload: {"status": "ok", "source": {"kind": "live_sql"}, "templates": [{"guid": "a" * 36}]})
|
||||
monkeypatch.setattr(adapter_server, "metadata_object_parts", lambda _payload: {"parts": [{
|
||||
"classification": {"role": "metadata_payload", "tree": {"type": "list", "items": [
|
||||
{"type": "atom", "value": "1"}, {"type": "list", "items": [
|
||||
{"type": "atom", "value": "4"}, {"type": "list", "items": []}, {"type": "atom", "value": "7"},
|
||||
]},
|
||||
]}},
|
||||
}, {"classification": {"role": "binary_or_unknown_payload", "xml_root": "AppearanceTemplate"}}]})
|
||||
|
||||
result = adapter_server.metadata_object_templates({"base_id": "upo_test", "kind": "CommonTemplate", "name": "Оформление"})
|
||||
|
||||
assert result["templates"][0]["descriptor_type_evidence"]["codes"] == ["7"]
|
||||
|
||||
|
||||
def test_moxel_template_artifact_kind_tracks_coordinate_hint_schema() -> None:
|
||||
assert adapter_server.MOXEL_TEMPLATE_ARTIFACT_KIND == "template_part_moxel_v11"
|
||||
|
||||
@@ -10384,6 +10543,12 @@ def test_metadata_object_templates_includes_decoded_data_composition_schema(monk
|
||||
assert result["templates"][0]["name"] == "ОсновнаяСхемаКомпоновкиДанных"
|
||||
assert result["templates"][0]["kind"] == "data_composition_schema"
|
||||
assert result["templates"][0]["match_by"] == "name_exact"
|
||||
assert result["templates"][0]["platform_type"] == "Схема компоновки данных"
|
||||
assert result["templates"][0]["template_type_candidates"] == [{
|
||||
**adapter_server.onec_template_type_by_id("data_composition_schema"),
|
||||
"confidence": "high",
|
||||
"evidence": ["confirmed Template identity"],
|
||||
}]
|
||||
|
||||
|
||||
def test_templates_bindings_preserves_data_composition_schema_kind(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
Reference in New Issue
Block a user