Initial project import

This commit is contained in:
2026-08-14 09:40:51 +03:00
parent 00040e5ce4
commit d7099bf80d
146 changed files with 30509 additions and 1055 deletions
+223 -3
View File
@@ -25,6 +25,11 @@ def normalized_text_sha1(text: str) -> str:
return hashlib.sha1(normalized.encode("utf-8")).hexdigest()
def _text_with_line_ending(text: str, line_ending: str) -> str:
"""Normalize caller text first, then render it in a stream's convention."""
return str(text or "").replace("\r\n", "\n").replace("\r", "\n").replace("\n", line_ending)
def decode_text(data: bytes) -> tuple[str | None, str | None]:
if data.startswith(b"\xef\xbb\xbf"):
try:
@@ -121,6 +126,188 @@ def stream_blocks_with_data(payload: bytes, *, limit: int = 100) -> list[dict[st
return blocks
def structural_stream_blocks_with_data(payload: bytes, *, limit: int = 100) -> list[dict[str, Any]]:
"""Read one contiguous stream chain without matching headers inside data.
Some 1C stream payloads legitimately contain the ASCII sequence used by a
stream header inside a binary member. ``stream_blocks_with_data`` remains
a discovery heuristic for legacy readers; this function follows only the
next header located exactly at the previous member's end and is suitable
for evidence-bearing module decoding.
"""
blocks: list[dict[str, Any]] = []
first = STREAM_HEADER_RE.search(payload)
if first is None:
return blocks
match = first
while match is not None and len(blocks) < limit:
declared_1 = int(match.group(1), 16)
declared_2 = int(match.group(2), 16)
data_offset = match.end()
data_end = data_offset + declared_2
if declared_2 <= 0 or data_end > len(payload):
break
data = payload[data_offset:data_end]
text, encoding = decode_text(data)
blocks.append(
{
"header_offset": match.start(),
"header_end": match.end(),
"data_offset": data_offset,
"data_end": data_end,
"declared_1": declared_1,
"declared_2": declared_2,
"bytes": len(data),
"sha1": sha1_hex(data),
"encoding": encoding,
"text": text,
"data": data,
"structural": True,
}
)
match = STREAM_HEADER_RE.match(payload, data_end)
return blocks
def extract_structural_stream_blocks(payload: bytes, *, include_text: bool = False, limit: int = 100) -> list[dict[str, Any]]:
"""Public structural stream view with the same shape as discovery blocks."""
result: list[dict[str, Any]] = []
for block in structural_stream_blocks_with_data(payload, limit=limit):
text = str(block.get("text") or "")
clean = text.replace("\x00", "")
item = {
**{key: value for key, value in block.items() if key not in {"text", "data"}},
"text_preview": clean[:500],
"has_bsl_marker": bool(text and any(marker in clean for marker in BSL_MARKERS)),
"has_html_marker": bool(text and any(marker in clean for marker in HTML_MARKERS)),
}
if include_text:
item["text"] = block.get("text")
result.append(item)
return result
def decode_declared_utf8_bsl_prefix(payload: bytes, stream_index: int) -> dict[str, Any]:
"""Decode a BSL prefix whose byte length is declared by a stream header.
Object-module containers observed in 1C keep the editable UTF-8 BSL bytes
in the first ``declared_1`` bytes of a fixed-size member. The remaining
member bytes are opaque platform metadata, not source text. This helper
is read-only evidence; it intentionally does not construct replacements.
"""
blocks = structural_stream_blocks_with_data(payload)
if stream_index < 0 or stream_index >= len(blocks):
return {"status": "not_found", "error": "stream_index_not_found"}
block = blocks[stream_index]
data = bytes(block["data"])
prefix_bytes = int(block["declared_1"])
if prefix_bytes <= 0 or prefix_bytes > len(data):
return {
"status": "unsupported",
"error": "invalid_declared_bsl_prefix_length",
"declared_1": prefix_bytes,
"member_bytes": len(data),
}
prefix = data[:prefix_bytes]
if not prefix.startswith(b"\xef\xbb\xbf"):
return {
"status": "unsupported",
"error": "declared_bsl_prefix_not_utf8_bom",
"declared_1": prefix_bytes,
"member_bytes": len(data),
}
try:
text = prefix.decode("utf-8-sig", errors="strict")
except UnicodeDecodeError as exc:
return {"status": "unsupported", "error": "declared_bsl_prefix_decode_error", "diagnostics": {"message": str(exc)}}
return {
"status": "ok",
"text": text,
"stream_index": stream_index,
"header_offset": block["header_offset"],
"data_offset": block["data_offset"],
"bsl_prefix_bytes": prefix_bytes,
"opaque_tail_bytes": len(data) - prefix_bytes,
"member_bytes": len(data),
"text_sha1": normalized_text_sha1(text),
"structural": True,
}
def replace_declared_utf8_bsl_prefix_same_width(
payload: bytes,
stream_index: int,
*,
text: str,
expected_text_sha1: str | None = None,
) -> tuple[bytes, dict[str, Any]]:
"""Replace a proven fixed-width BSL prefix without touching its tail.
This is deliberately narrower than ``replace_stream_block``. The report
object-module carrier has a fixed-size stream member whose first declared
bytes are UTF-8 source and whose remaining bytes are opaque. A shorter
source is right-padded with spaces *inside the declared source field*;
longer source is rejected. Consequently the member, every following
stream, and the opaque tail stay byte-for-byte identical.
It does not attempt to synthesize the platform's independent version
atoms. The caller remains responsible for the proven paired
``__configinfo`` SHA-1 update.
"""
decoded = decode_declared_utf8_bsl_prefix(payload, stream_index)
if decoded.get("status") != "ok":
raise ValueError(str(decoded.get("error") or "declared_bsl_prefix_unavailable"))
old_text = str(decoded["text"])
if not is_declared_utf8_bsl_source(old_text):
raise ValueError("declared_bsl_prefix_is_not_bsl_source")
old_sha1 = normalized_text_sha1(old_text)
if expected_text_sha1 and expected_text_sha1.lower() != old_sha1:
raise ValueError("expected_text_sha1 does not match declared BSL prefix")
line_ending = "\r\n" if "\r\n" in old_text else "\r" if "\r" in old_text else "\n"
rendered = _text_with_line_ending(text, line_ending)
encoded = b"\xef\xbb\xbf" + rendered.encode("utf-8")
prefix_bytes = int(decoded["bsl_prefix_bytes"])
if len(encoded) > prefix_bytes:
raise ValueError("replacement_declared_bsl_prefix_exceeds_fixed_width")
# BSL whitespace outside string literals is semantically inert. Padding
# is restricted to the fixed source field and is observable in readback.
padded = encoded + (b" " * (prefix_bytes - len(encoded)))
if len(padded) != prefix_bytes:
raise AssertionError("declared BSL prefix width changed")
data_offset = int(decoded["data_offset"])
new_payload = payload[:data_offset] + padded + payload[data_offset + prefix_bytes :]
if payload[data_offset + prefix_bytes :] != new_payload[data_offset + prefix_bytes :]:
raise AssertionError("opaque member tail changed")
return new_payload, {
"stream_index": stream_index,
"mode": "declared_utf8_bsl_prefix_same_width",
"old_text_sha1": old_sha1,
"new_text_sha1": normalized_text_sha1(rendered),
"old_bsl_prefix_bytes": prefix_bytes,
"new_bsl_source_bytes": len(encoded),
"padding_bytes": prefix_bytes - len(encoded),
"opaque_tail_bytes": int(decoded["opaque_tail_bytes"]),
"opaque_tail_preserved": True,
"old_text_preview": old_text[:500],
"new_text_preview": rendered[:500],
}
def is_declared_utf8_bsl_source(text: str) -> bool:
"""Recognize source evidence in a declared UTF-8 stream prefix.
A module may legitimately consist solely of comments, while other stream
members can also have a UTF-8 prefix (for example a brace descriptor).
The prefix is BSL evidence only when it has a normal BSL marker or every
nonblank source line is a BSL line comment.
"""
source = str(text or "").lstrip("\ufeff")
if any(marker in source for marker in BSL_MARKERS):
return True
lines = [line.strip() for line in source.replace("\r\n", "\n").replace("\r", "\n").split("\n") if line.strip()]
return bool(lines) and all(line.startswith("//") for line in lines)
def stream_header(size: int) -> bytes:
if size < 0 or size > 0xFFFFFFFF:
raise ValueError("stream size is outside 8-hex header range")
@@ -159,9 +346,20 @@ def replace_stream_block(
if not old:
raise ValueError("replace.old is required")
count = int(replace.get("count") or 1)
if old not in old_text:
# Public code.read normalizes BSL to LF while streams often retain
# CRLF. Treat that representation difference as irrelevant, but do
# not loosen matching of any other character (spaces/tabs remain
# exact). The replacement is rendered back in the stream's original
# line-ending convention to avoid unrelated formatting churn.
line_ending = "\r\n" if "\r\n" in old_text else "\r" if "\r" in old_text else "\n"
source_old = old
source_new = _text_with_line_ending(new, line_ending) if ("\n" in new or "\r" in new) else new
if source_old not in old_text:
source_old = _text_with_line_ending(old, line_ending)
source_new = _text_with_line_ending(new, line_ending)
if source_old not in old_text:
raise ValueError("replace.old was not found in stream text")
text = old_text.replace(old, new, count)
text = old_text.replace(source_old, source_new, count)
routine_edit = None
if routine is not None:
if old_text is None:
@@ -261,7 +459,29 @@ def classify_payload(data: bytes, *, include_text: bool = False, include_tree: b
decoded = decode_payload_lossless(data)
payload = decoded.get("payload") if isinstance(decoded.get("payload"), (bytes, bytearray)) else b""
markers = payload_markers(bytes(payload))
stream_blocks = extract_stream_blocks(bytes(payload), include_text=include_text)
# Prefer proven contiguous boundaries for normal container decoding. Keep
# the regex scan only as a discovery fallback for legacy irregular blobs.
stream_blocks = extract_structural_stream_blocks(bytes(payload), include_text=include_text)
if not stream_blocks and "stream_headers" in markers:
stream_blocks = extract_stream_blocks(bytes(payload), include_text=include_text)
# A report object module observed in ConfigCAS stores source in the
# declared UTF-8 prefix of a fixed-size stream member. The remainder is
# opaque platform state and must never be exposed as BSL. Keep this as
# read-only evidence: replacement still requires a separately proven
# reverse codec for that carrier.
if stream_blocks:
for stream_index, stream in enumerate(stream_blocks):
declared_prefix = decode_declared_utf8_bsl_prefix(bytes(payload), stream_index)
if declared_prefix.get("status") != "ok" or not is_declared_utf8_bsl_source(str(declared_prefix.get("text") or "")):
continue
stream["declared_utf8_bsl_prefix"] = {
key: declared_prefix[key]
for key in ("bsl_prefix_bytes", "opaque_tail_bytes", "member_bytes", "text_sha1", "structural")
if key in declared_prefix
}
if include_text:
stream["text"] = declared_prefix["text"]
stream["text_preview"] = str(declared_prefix["text"] or "").replace("\x00", "")[:500]
text = decoded.get("text")
tree = None
root = None
+43 -1
View File
@@ -2202,7 +2202,16 @@ def section_record_semantic_properties(row: dict[str, Any], parameters: list[dic
mapped: set[int] = set()
for index, (group, name) in SECTION_RECORD_SEMANTIC_PROPERTIES.items():
mapped.add(index)
add_grouped_property(groups, group, semantic_property(name, parameter_value(parameters, index), index=index))
value = parameter_value(parameters, index)
source = "form_payload"
# A managed-form record can store the localized title outside its
# direct parameter #3. The row decoder already resolves that exact
# title path, so expose it instead of misleading an agent with an
# empty semantic «Заголовок» beside a non-empty public row.title.
if index == 3 and value in {None, ""} and row.get("title") not in {None, ""}:
value = row.get("title")
source = "form_payload_title_path"
add_grouped_property(groups, group, semantic_property(name, value, index=index, source=source))
if row.get("category"):
add_grouped_property(groups, "Основные", semantic_property("Категория", row.get("category"), source="decoder"))
add_grouped_property(groups, "Основные", semantic_property("Вид", row.get("category"), source="decoder"))
@@ -3850,6 +3859,37 @@ def enrich_button_command_semantics(items: list[dict[str, Any]], links: list[dic
)
FORM_AUXILIARY_ITEM_TYPES = {
"Контекстное меню", "Расширенная подсказка", "SearchStringAddition",
"ViewStatusAddition", "SearchControlAddition",
}
def form_item_coverage_summary(items: list[dict[str, Any]]) -> dict[str, Any]:
"""Report semantic coverage without auxiliary form records hiding control quality."""
buckets = {
"all_items": {"items": 0, "mapped": 0, "unmapped": 0},
"interactive_items": {"items": 0, "mapped": 0, "unmapped": 0},
"auxiliary_items": {"items": 0, "mapped": 0, "unmapped": 0},
}
for item in items:
if not isinstance(item, dict):
continue
coverage = (item.get("semantic") or {}).get("coverage") if isinstance(item.get("semantic"), dict) else None
if not isinstance(coverage, dict):
continue
target = "auxiliary_items" if str(item.get("type_name") or "") in FORM_AUXILIARY_ITEM_TYPES else "interactive_items"
for bucket_name in ("all_items", target):
bucket = buckets[bucket_name]
bucket["items"] += 1
bucket["mapped"] += int(coverage.get("mapped") or 0)
bucket["unmapped"] += int(coverage.get("unmapped") or 0)
for bucket in buckets.values():
bucket["total"] = bucket["mapped"] + bucket["unmapped"]
bucket["status"] = "partial" if bucket["unmapped"] else "ok"
return buckets
def decode_form_payload(
tree: Any,
*,
@@ -3906,11 +3946,13 @@ def decode_form_payload(
form_parameters = form_common_parameters(tree, limit=max_parameters)
form_semantic = form_common_semantic(form_parameters, include_diagnostics=include_parameters)
enrich_form_common_semantic(form_semantic, items)
coverage_summary = form_item_coverage_summary(items)
result = {
"schema": "onec_form_payload_profile.v1",
"status": "ok" if root.get("root_marker") == "4" else "not_form_payload",
"root": root,
"form_semantic": form_semantic,
"item_coverage": coverage_summary,
**({"form_parameters": form_parameters} if include_parameters else {}),
"events": events,
"items": items,
+463
View File
@@ -0,0 +1,463 @@
"""Lossless read-only decoder for 1C Data Composition Schema SQL payloads.
The payload stored in ConfigCAS is commonly a compressed stream with a small
binary prefix followed by an XML ``SchemaFile`` document. This module does
not infer SCD semantics from names: every returned item is backed by an XML
node in that document.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
import xml.parsers.expat as expat
import html
import hashlib
import re
from typing import Any
from .payload import decode_payload_lossless
QUERY_PARAMETER_RE = re.compile(r"&([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)")
QUERY_SOURCE_RE = re.compile(r"(?:\bИЗ|\bFROM|\bJOIN|\bСОЕДИНЕНИЕ)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*(?:\.[A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)+)", re.IGNORECASE)
QUERY_SOURCE_BINDING_RE = re.compile(r"(?:\bИЗ|\bFROM|\bJOIN|\bСОЕДИНЕНИЕ)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*(?:\.[A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)+)(?:\s+(?:КАК|AS)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*))?", re.IGNORECASE)
QUERY_SELECT_RE = re.compile(r"\b(?:ВЫБРАТЬ|SELECT)\b(.*?)(?=\b(?:ИЗ|FROM)\b)", re.IGNORECASE | re.DOTALL)
QUERY_ALIAS_RE = re.compile(r"\b(?:КАК|AS)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)(?=\s*(?:,|\r?\n|$))", re.IGNORECASE)
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
def direct_child(node: ET.Element, name: str) -> ET.Element | None:
return next((child for child in node if local_name(child.tag) == name), None)
def child_text(node: ET.Element, *names: str) -> str:
for name in names:
child = direct_child(node, name)
if child is not None:
value = "".join(child.itertext()).strip()
if value:
return value
return ""
def query_without_line_comments(text: str) -> str:
"""Remove 1C query ``//`` comments without touching quoted string literals."""
result: list[str] = []
index = 0
quoted = False
while index < len(text):
char = text[index]
if char == '"':
result.append(char)
if quoted and index + 1 < len(text) and text[index + 1] == '"':
result.append('"')
index += 2
continue
quoted = not quoted
index += 1
continue
if not quoted and char == "/" and index + 1 < len(text) and text[index + 1] == "/":
line_end = text.find("\n", index)
if line_end < 0:
break
result.append("\n")
index = line_end + 1
continue
result.append(char)
index += 1
return "".join(result)
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}"
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)
if found:
return found
return None
return visit(root, "") or "/"
def xml_from_scd_payload(data: bytes) -> tuple[ET.Element | None, dict[str, Any]]:
decoded = decode_payload_lossless(data)
payload = decoded.get("payload")
if not isinstance(payload, (bytes, bytearray)):
return None, {"status": "undecodable", "code": "SCD_PAYLOAD_EMPTY"}
raw = bytes(payload)
start = raw.find(b"<?xml")
if start < 0:
start = raw.find(b"<SchemaFile")
if start < 0:
return None, {
"status": "undecodable",
"code": "SCD_XML_NOT_FOUND",
"compression": decoded.get("compression"),
"raw_bytes": decoded.get("raw_bytes"),
"payload_bytes": decoded.get("payload_bytes"),
}
# 1C appends a binary trailer after the XML document in some releases.
# ElementTree correctly rejects that trailer, so keep the exact XML range.
end_marker = b"</SchemaFile>"
end = raw.find(end_marker, start)
xml_bytes = raw[start : end + len(end_marker)] if end >= 0 else raw[start:]
try:
root = ET.fromstring(xml_bytes.decode("utf-8-sig"))
except (UnicodeDecodeError, ET.ParseError) as exc:
return None, {
"status": "undecodable",
"code": "SCD_XML_INVALID",
"message": str(exc),
"compression": decoded.get("compression"),
"raw_bytes": decoded.get("raw_bytes"),
"payload_bytes": decoded.get("payload_bytes"),
}
return root, {
"status": "ok",
"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": local_name(root.tag),
}
def scd_node_item(root: ET.Element, node: ET.Element, category: str) -> dict[str, Any]:
"""Return only direct, documented XML values for one SCD item."""
item_name = child_text(node, "name", "dataPath", "field")
if not item_name and not list(node):
item_name = (node.text or "").strip()
item: dict[str, Any] = {
"name": item_name,
"source": {"kind": "scd_xml", "path": node_path(root, node)},
}
expression = child_text(node, "expression")
if expression:
item["expression"] = expression
query = child_text(node, "query")
if query:
item["query"] = query
value_type_node = direct_child(node, "valueType")
if value_type_node is None:
value_type_node = direct_child(node, "type")
value_type = ""
if value_type_node is not None:
value_type = child_text(value_type_node, "type") or (value_type_node.text or "").strip()
if value_type:
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 ""
return item
def inspect_scd_payload(data: bytes, *, sections: list[str] | None = None) -> dict[str, Any]:
"""Decode a DataCompositionSchema XML stream from SQL storage.
Unknown or absent XML nodes become empty lists. They are deliberately not
synthesized from report code or form attributes.
"""
requested = sections or ["parameters", "datasets", "fields", "calculated_fields", "resources", "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}}
schema = next((node for node in root.iter() if local_name(node.tag) == "dataCompositionSchema"), None)
if schema is None:
return {
"status": "partial",
"container": {**container, "code": "SCD_SCHEMA_NODE_NOT_FOUND"},
"sections": {name: [] for name in requested},
}
node_names = {
"parameters": {"parameter"},
"datasets": {"dataSet"},
"fields": {"field"},
"calculated_fields": {"calculatedField"},
"resources": {"resource"},
"settings": {"settings", "Settings"},
"variants": {"settingsVariant", "variant"},
"total_fields": {"totalField"},
}
result: dict[str, list[dict[str, Any]]] = {}
skipped_unnamed: dict[str, int] = {}
for section in requested:
names = node_names.get(section)
if not names:
result[section] = []
continue
raw_items = [scd_node_item(schema, node, section) for node in schema.iter() if local_name(node.tag) in names]
result[section] = [item for item in raw_items if item.get("name")]
if len(raw_items) != len(result[section]):
skipped_unnamed[section] = len(raw_items) - len(result[section])
declared = [str(item.get("name")) for item in result.get("parameters") or [] if item.get("name")]
declared_by_normalized = {name.casefold(): name for name in declared}
query_references: list[dict[str, Any]] = []
referenced_normalized: set[str] = set()
for dataset in result.get("datasets") or []:
references: list[str] = []
for found in QUERY_PARAMETER_RE.finditer(query_without_line_comments(str(dataset.get("query") or ""))):
name = found.group(1)
if name.casefold() not in {value.casefold() for value in references}:
references.append(name)
referenced_normalized.add(name.casefold())
if references:
query_references.append({"dataset": dataset.get("name"), "parameters": references})
analysis = {
"kind": "raw_query_parameter_token_scan",
"declared_parameters": declared,
"query_parameter_references": query_references,
"referenced_not_declared_in_schema": sorted(
{name for item in query_references for name in item["parameters"] if name.casefold() not in declared_by_normalized},
key=str.casefold,
),
"declared_not_referenced_in_dataset_queries": [name for name in declared if name.casefold() not in referenced_normalized],
}
settings_tags = {
"groupings": {"groupItems", "grouping"},
"filters": {"selection", "filter"},
"orders": {"order", "sorting"},
"conditional_appearance": {"appearance", "conditionalAppearance"},
}
settings_context: dict[str, Any] = {"status": "not_present", "sections": {}}
for context_name, tags in settings_tags.items():
nodes = [node for node in schema.iter() if local_name(node.tag) in tags]
if not nodes:
continue
records: list[dict[str, Any]] = []
for node in nodes:
tokens = []
for child in node.iter():
if local_name(child.tag) not in {"field", "dataPath", "left", "right", "group"} or list(child):
continue
value = (child.text or "").strip()
if value and value.casefold() not in {item.casefold() for item in tokens}:
tokens.append(value)
if tokens:
records.append({"path": node_path(schema, node), "tokens": tokens})
if records:
settings_context["status"] = "found"
settings_context["sections"][context_name] = records
analysis["settings_context"] = settings_context
query_sources: list[dict[str, Any]] = []
query_output_aliases: list[dict[str, Any]] = []
for dataset in result.get("datasets") or []:
query = query_without_line_comments(str(dataset.get("query") or ""))
sources = list(dict.fromkeys(match.group(1) for match in QUERY_SOURCE_RE.finditer(query)))
if sources:
bindings = []
for match in QUERY_SOURCE_BINDING_RE.finditer(query):
source, alias = match.group(1), match.group(2)
item = {"source": source}
if alias:
item["alias"] = alias
if item not in bindings:
bindings.append(item)
query_sources.append({"dataset": dataset.get("name"), "sources": sources, "bindings": bindings})
select_match = QUERY_SELECT_RE.search(query)
if select_match:
aliases = list(dict.fromkeys(match.group(1) for match in QUERY_ALIAS_RE.finditer(select_match.group(1))))
if aliases:
query_output_aliases.append({"dataset": dataset.get("name"), "aliases": aliases})
if query_sources:
analysis["data_source_references"] = {"kind": "raw_query_source_token_scan", "datasets": query_sources}
direct_field_references: list[dict[str, Any]] = []
for dataset in query_sources:
query = query_without_line_comments(str(next((item.get("query") for item in result.get("datasets") or [] if item.get("name") == dataset.get("dataset")), "")))
references: list[dict[str, str]] = []
for binding in dataset.get("bindings") or []:
alias = str(binding.get("alias") or "")
if not alias:
continue
matcher = re.compile(r"\b" + re.escape(alias) + r"\.([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)(?![A-Za-z0-9_\u0400-\u04ff.])", re.IGNORECASE)
for match in matcher.finditer(query):
item = {"alias": alias, "field": match.group(1)}
if item not in references:
references.append(item)
if references:
direct_field_references.append({"dataset": dataset.get("dataset"), "references": references})
if direct_field_references:
analysis["query_direct_field_references"] = {"kind": "direct_alias_field_token_scan", "datasets": direct_field_references}
field_names = {str(item.get("name")).casefold(): str(item.get("name")) for item in result.get("fields") or [] if item.get("name")}
calculated_field_names = {str(item.get("name")).casefold(): str(item.get("name")) for item in result.get("calculated_fields") or [] if item.get("name")}
declared_field_names = {**field_names, **calculated_field_names}
total_names = [str(item.get("name")) for item in result.get("total_fields") or [] if item.get("name")]
if total_names:
analysis["total_field_references"] = {
"fields": total_names,
"missing_from_declared_fields": [name for name in total_names if name.casefold() not in declared_field_names],
"status": "checked" if "fields" in result and "calculated_fields" in result else "field_sections_not_requested",
}
if query_output_aliases:
analysis["query_output_aliases"] = {
"kind": "select_clause_alias_scan",
"datasets": query_output_aliases,
"not_declared_as_scd_fields": sorted(
{
alias
for dataset in query_output_aliases
for alias in dataset["aliases"]
if alias.casefold() not in declared_field_names
},
key=str.casefold,
),
}
return {
"status": "ok",
"container": container,
"sections": result,
"analysis": analysis,
"diagnostics": {"skipped_unnamed_xml_nodes": skipped_unnamed} if skipped_unnamed else {},
}
def plan_scd_scalar_patch(
data: bytes,
*,
section: str,
name: str,
property_name: str,
value: str,
) -> dict[str, Any]:
"""Build a byte-preserving patch for one direct scalar SCD XML property.
Only query/expression properties are accepted in this first writer layer.
The XML element span is collected by Expat from the original byte stream;
all bytes outside the scalar content stay unchanged, including the 1C
binary prefix/trailer. No database operation is performed here.
"""
allowed = {
"datasets": ({"dataSet"}, {"query"}),
"calculated_fields": ({"calculatedField"}, {"expression"}),
"resources": ({"resource"}, {"expression"}),
}
tags_and_properties = allowed.get(section)
if not tags_and_properties or property_name not in tags_and_properties[1]:
return {
"status": "invalid_argument",
"code": "SCD_PATCH_PROPERTY_UNSUPPORTED",
"message": "Only datasets.query, calculated_fields.expression, and resources.expression are writable.",
}
root, container = xml_from_scd_payload(data)
if root is None:
return {"status": "undecodable", "container": container}
decoded = decode_payload_lossless(data)
payload = bytes(decoded["payload"])
xml_start = payload.find(b"<?xml")
if xml_start < 0:
xml_start = payload.find(b"<SchemaFile")
xml_end_marker = b"</SchemaFile>"
xml_end = payload.find(xml_end_marker, xml_start)
if xml_start < 0 or xml_end < 0:
return {"status": "undecodable", "container": container}
xml_end += len(xml_end_marker)
xml = payload[xml_start:xml_end]
target_tags = tags_and_properties[0]
stack: list[dict[str, Any]] = []
records: list[dict[str, Any]] = []
def start_element(tag: str, _attrs: dict[str, str]) -> None:
local = local_name(tag)
position = parser.CurrentByteIndex
end = xml.find(b">", position)
frame: dict[str, Any] = {"tag": local, "depth": len(stack) + 1, "content_start": end + 1}
if local in target_tags:
frame["record"] = {"tag": local, "depth": len(stack) + 1, "properties": {}}
if stack:
parent_record = next((item.get("record") for item in reversed(stack) if item.get("record")), None)
if parent_record and len(stack) + 1 == parent_record["depth"] + 1 and local in {"name", "dataPath", property_name}:
frame["property_record"] = parent_record
stack.append(frame)
def end_element(_tag: str) -> None:
frame = stack.pop()
end = parser.CurrentByteIndex
property_record = frame.get("property_record")
if property_record is not None:
raw_text = xml[int(frame["content_start"]):end]
if b"<" not in raw_text:
property_record["properties"][frame["tag"]] = {
"start": int(frame["content_start"]),
"end": end,
"text": html.unescape(raw_text.decode("utf-8")),
}
record = frame.get("record")
if record is not None:
identity = record["properties"].get("name") or record["properties"].get("dataPath")
record["name"] = identity.get("text") if identity else ""
records.append(record)
parser = expat.ParserCreate()
parser.StartElementHandler = start_element
parser.EndElementHandler = end_element
try:
parser.Parse(xml, True)
except expat.ExpatError as exc:
return {"status": "undecodable", "container": container, "code": "SCD_XML_INVALID", "message": str(exc)}
matches = [record for record in records if str(record.get("name") or "") == name]
if not matches:
return {"status": "not_found", "code": "SCD_PATCH_TARGET_NOT_FOUND", "container": container}
if len(matches) > 1:
return {"status": "ambiguous", "code": "SCD_PATCH_TARGET_AMBIGUOUS", "container": container, "matches": len(matches)}
property_record = (matches[0].get("properties") or {}).get(property_name)
if not property_record:
return {"status": "not_found", "code": "SCD_PATCH_PROPERTY_NOT_FOUND", "container": container}
old = str(property_record["text"])
if old == value:
return {"status": "unchanged", "container": container, "old": old, "new": value}
escaped = html.escape(value, quote=False).encode("utf-8")
patched_xml = xml[: property_record["start"]] + escaped + xml[property_record["end"] :]
patched_payload = payload[:xml_start] + patched_xml + payload[xml_end:]
from .payload import encode_payload_lossless
patched_data = encode_payload_lossless(decoded, payload=patched_payload)
return {
"status": "planned",
"container": container,
"old": old,
"new": value,
"payload": patched_data,
"expected_sha1": hashlib.sha1(data).hexdigest(),
"result_sha1": hashlib.sha1(patched_data).hexdigest(),
"changed_bytes": len(patched_data) - len(data),
}
def compare_scd_semantics(active: dict[str, Any], saved: dict[str, Any]) -> dict[str, Any]:
"""Compare decoded SCD sections by semantic content, never by storage id."""
section_names = sorted(set((active.get("sections") or {}).keys()) | set((saved.get("sections") or {}).keys()))
sections: dict[str, dict[str, Any]] = {}
counts = {"added": 0, "removed": 0, "changed": 0, "unchanged": 0}
for section in section_names:
def index(items: Any) -> dict[str, Any]:
result: dict[str, Any] = {}
for ordinal, item in enumerate(items or []):
if not isinstance(item, dict):
continue
key = str(item.get("name") or f"#{ordinal}")
result[key] = {key: value for key, value in item.items() if key != "source"}
return result
active_items, saved_items = index((active.get("sections") or {}).get(section)), index((saved.get("sections") or {}).get(section))
added = sorted(set(saved_items) - set(active_items), key=str.casefold)
removed = sorted(set(active_items) - set(saved_items), key=str.casefold)
changed = sorted([name for name in set(active_items) & set(saved_items) if active_items[name] != saved_items[name]], key=str.casefold)
unchanged = len(set(active_items) & set(saved_items)) - len(changed)
sections[section] = {"added": added, "removed": removed, "changed": changed, "unchanged": unchanged}
counts["added"] += len(added); counts["removed"] += len(removed); counts["changed"] += len(changed); counts["unchanged"] += unchanged
return {"status": "unchanged" if not any(counts[key] for key in ("added", "removed", "changed")) else "changed", "sections": sections, "counts": counts}