"""Universal semantic profile helpers for 1C Config brace trees. The rules here name repeatedly observed section paths, but every returned item keeps structural evidence. Runtime data still comes from the decoded Config tree and DBNames records of the requested base. """ from __future__ import annotations from dataclasses import asdict, dataclass from typing import Any from .child_records import ChildRecord, collect_evidence, declared_child_records from .config_object import find_identity from .config_sections import summarize_sections from .payload import GUID_RE, root_signature, scalar from .structured_metadata import get_by_path SECTION_RULES: dict[str, list[dict[str, Any]]] = { "AccountingRegister": [ {"path": "3", "category": "Dimension"}, {"path": "5", "category": "Resource"}, {"path": "7", "category": "Attribute"}, ], "AccumulationRegister": [ {"path": "5", "category": "Resource"}, {"path": "6", "category": "Attribute"}, {"path": "7", "category": "Dimension"}, ], "BusinessProcess": [{"path": "6", "category": "Attribute"}], "Catalog": [ {"path": "5", "category": "TabularSection"}, {"path": "6", "category": "Attribute"}, ], "ChartOfAccounts": [ {"path": "5", "category": "TabularSection"}, {"path": "7", "category": "Attribute"}, {"path": "8", "category": "AccountingFlag"}, ], "ChartOfCalculationTypes": [ {"path": "3", "category": "TabularSection"}, {"path": "4", "category": "Attribute"}, ], "CalculationRegister": [ {"path": "3", "category": "Attribute"}, {"path": "4", "category": "Recalculation"}, {"path": "6", "category": "Resource"}, {"path": "9", "category": "Dimension"}, ], "Document": [ {"path": "3", "category": "TabularSection"}, {"path": "5", "category": "Attribute"}, ], "Enum": [{"path": "6", "category": "EnumValue"}], "InformationRegister": [ {"path": "3", "category": "Resource"}, {"path": "4", "category": "Dimension"}, {"path": "5", "category": "Attribute"}, ], "Report": [{"path": "4", "category": "Attribute"}], "Task": [ {"path": "5", "category": "Attribute"}, {"path": "6", "category": "AddressingAttribute"}, {"path": "8", "category": "Command"}, ], } ROLE_ROUTE_KIND = { "Fld": "field", "TabularSection": "tabular_section", "VT": "tabular_section", "EnumValue": "enum_value", "Dimension": "dimension", "Resource": "resource", "Document": "object", "Reference": "object", "Enum": "object", "InfoRg": "object", "AccumRg": "object", "AccRg": "object", "BPr": "object", "Task": "object", } CATEGORY_ROUTE_KINDS = { "Attribute": {"field"}, "AddressingAttribute": {"field"}, "AccountingFlag": {"field"}, "Column": {"field"}, "Dimension": {"dimension", "field"}, "Resource": {"resource", "field"}, "TabularSection": {"tabular_section"}, "EnumValue": {"enum_value"}, } @dataclass(frozen=True) class DBNamesRoute: guid: str storage_role: str sql_number: int source: str route_kind: str physical_name_candidate: str | None def to_dict(self) -> dict[str, Any]: return asdict(self) def _children(node: Any) -> list[Any]: if isinstance(node, dict) and node.get("type") in {"list", "sequence"}: return node.get("items") or [] return [] def _ordered_scalars(node: Any, *, limit: int = 200) -> list[str]: values: list[str] = [] def walk(value: Any) -> None: if len(values) >= limit: return text = scalar(value) if text: values.append(text) return for child in _children(value): walk(child) walk(node) return values def _record_type(node: Any) -> dict[str, Any] | None: values = _ordered_scalars(node) try: index = values.index("Pattern") except ValueError: return None if index + 1 >= len(values): return None code = values[index + 1] result: dict[str, Any] = {"code": code} if code == "D": result.update({"kind": "date", "presentation": "Дата"}) elif code == "B": result.update({"kind": "boolean", "presentation": "Булево"}) elif code == "S": result.update({"kind": "string", "presentation": "Строка"}) if index + 2 < len(values): try: length = int(values[index + 2]) result["length"] = length if length > 0: result["presentation"] = f"Строка({length})" except ValueError: pass elif code == "N": result.update({"kind": "number", "presentation": "Число"}) if index + 2 < len(values): try: precision = int(values[index + 2]) result["precision"] = precision except ValueError: pass if index + 3 < len(values): try: scale = int(values[index + 3]) result["scale"] = scale except ValueError: pass if "precision" in result: scale = result.get("scale") result["presentation"] = f"Число({result['precision']}, {scale})" if scale is not None else f"Число({result['precision']})" elif code == "#": result.update({"kind": "reference", "presentation": "Ссылка"}) if index + 2 < len(values) and GUID_RE.fullmatch(values[index + 2]): result["type_guid"] = values[index + 2].lower() else: result.update({"kind": "unknown"}) return result def _record_title(record: ChildRecord, *, include_samples: bool = True) -> dict[str, Any]: identity = find_identity(record.node) strings = sorted(record.evidence.get("strings") or []) guids = sorted(record.evidence.get("guids") or []) likely_name = identity.name if identity else next((value for value in strings if value and not GUID_RE.fullmatch(value)), None) result = { "index": record.index, "path": record.path, "identity": identity.to_dict() if identity else None, "likely_name": likely_name, "type": _record_type(record.node), } if include_samples: result.update( { "strings_sample": strings[:12], "guids_sample": guids[:12], "string_count": len(strings), "guid_count": len(guids), } ) return result def dbnames_routes(records: list[Any] | None) -> dict[str, list[DBNamesRoute]]: result: dict[str, list[DBNamesRoute]] = {} for record in records or []: guid = str(getattr(record, "guid", "") or "").lower() if not guid: continue role = str(getattr(record, "storage_role", "") or "") sql_number = int(getattr(record, "sql_number", 0) or 0) route_kind = ROLE_ROUTE_KIND.get(role, "storage") physical = f"_{role}{sql_number}" if role and sql_number and route_kind != "object" else None result.setdefault(guid, []).append( DBNamesRoute( guid=guid, storage_role=role, sql_number=sql_number, source=str(getattr(record, "source", "") or ""), route_kind=route_kind, physical_name_candidate=physical, ) ) return result def _routes_for_evidence( evidence: dict[str, set[str]], routes_by_guid: dict[str, list[DBNamesRoute]], *, category: str | None = None, ) -> list[dict[str, Any]]: routes: list[dict[str, Any]] = [] seen: set[tuple[str, str, int]] = set() allowed_route_kinds = CATEGORY_ROUTE_KINDS.get(str(category or "")) for guid in sorted(evidence.get("guids") or []): for route in routes_by_guid.get(guid.lower(), []): if allowed_route_kinds and route.route_kind not in allowed_route_kinds: continue key = (route.guid, route.storage_role, route.sql_number) if key in seen: continue seen.add(key) routes.append(route.to_dict()) return routes def _routes_for_record( record: ChildRecord, routes_by_guid: dict[str, list[DBNamesRoute]], *, category: str | None = None, ) -> list[dict[str, Any]]: identity = find_identity(record.node) if identity: direct = _routes_for_evidence({"guids": {identity.guid}, "strings": set()}, routes_by_guid, category=category) if direct: return direct return _routes_for_evidence(record.evidence, routes_by_guid, category=category) def _section_profile( tree: Any, rule: dict[str, Any], routes_by_guid: dict[str, list[DBNamesRoute]], *, lightweight: bool = False, ) -> dict[str, Any]: path = str(rule["path"]) category = str(rule["category"]) node = get_by_path(tree, path) if node is None: return { "path": path, "category": category, "status": "missing", "declared_record_count": 0, "records": [], } records = declared_child_records(node, path, include_evidence=not lightweight) section_evidence = collect_evidence(node) if not lightweight else {"strings": set(), "guids": set()} def record_profile(record: ChildRecord) -> dict[str, Any]: item = { **_record_title(record, include_samples=not lightweight), "storage_routes": [] if lightweight else _routes_for_record(record, routes_by_guid, category=category), } if category == "TabularSection": item["columns"] = _tabular_section_columns(record, routes_by_guid) return item return { "path": path, "category": category, "status": "ok", "list_len": len(_children(node)), "declared_record_count": len(records), "storage_routes": _routes_for_evidence(section_evidence, routes_by_guid, category=category), "records": [record_profile(record) for record in records], } def _nested_record_containers(node: Any, path: str, *, max_depth: int = 5, include_root: bool = False) -> list[list[ChildRecord]]: containers: list[list[ChildRecord]] = [] def walk(value: Any, current_path: str, depth: int) -> None: records = declared_child_records(value, current_path) if records and (include_root or depth > 0): containers.append(records) if depth >= max_depth: return for index, child in enumerate(_children(value)): walk(child, f"{current_path}.{index}", depth + 1) walk(node, path, 0) return containers def _container_score(records: list[ChildRecord]) -> tuple[int, int, int]: titles = [_record_title(record) for record in records] identities = sum(1 for title in titles if title.get("identity")) names = sum(1 for title in titles if title.get("likely_name")) return identities, names, len(records) def _tabular_section_columns(record: ChildRecord, routes_by_guid: dict[str, list[DBNamesRoute]]) -> list[dict[str, Any]]: containers = _nested_record_containers(record.node, record.path, include_root=False) candidates = [records for records in containers if len(records) >= 1] if not candidates: return [] best = max(candidates, key=_container_score) columns = [] for column_record in best: title = _record_title(column_record) if not title.get("likely_name") and not title.get("identity"): continue columns.append( { **title, "storage_routes": _routes_for_record(column_record, routes_by_guid, category="Column"), } ) return columns def _generic_record_containers(tree: Any, *, max_depth: int = 3, limit: int = 200) -> list[dict[str, Any]]: containers: list[dict[str, Any]] = [] def walk(node: Any, path: list[int], depth: int) -> None: if len(containers) >= limit: return current_path = ".".join(str(part) for part in path) records = declared_child_records(node, current_path) if records: containers.append( { "path": current_path, "declared_record_count": len(records), "record_paths_sample": [record.path for record in records[:20]], } ) if depth >= max_depth: return for index, child in enumerate(_children(node)): walk(child, [*path, index], depth + 1) walk(tree, [], 0) return containers def decode_config_semantic( tree: Any, *, kind: str | None = None, dbnames_records: list[Any] | None = None, max_depth: int = 3, section_sample_limit: int = 200, include_generic: bool = True, categories: set[str] | list[str] | tuple[str, ...] | None = None, lightweight: bool = False, ) -> dict[str, Any]: """Return a structured, evidence-first profile for a Config object tree.""" identity = find_identity(tree) routes_by_guid = dbnames_routes(dbnames_records) object_routes = routes_by_guid.get((identity.guid if identity else "").lower(), []) wanted_categories = {str(category) for category in (categories or [])} rules = [ rule for rule in SECTION_RULES.get(str(kind or ""), []) if not wanted_categories or str(rule.get("category") or "") in wanted_categories ] sections = [_section_profile(tree, rule, routes_by_guid, lightweight=lightweight) for rule in rules] generic_sections = [item.to_dict() for item in summarize_sections(tree, max_depth=max_depth, limit=section_sample_limit)] if include_generic else [] generic_record_containers = _generic_record_containers(tree, max_depth=max_depth) if include_generic else [] return { "schema": "onec_config_semantic_profile.v1", "kind": kind, "root": root_signature(tree), "identity": identity.to_dict() if identity else None, "object_storage_routes": [route.to_dict() for route in object_routes], "section_rules": [ { **rule, "source": "built_in_observed_rules", "note": "Rule names an observed section path; returned records are decoded from the current live Config payload.", } for rule in rules ], "sections": sections, "generic_sections": generic_sections, "generic_record_containers": generic_record_containers, "counts": { "sections": len(sections), "ok_sections": sum(1 for section in sections if section.get("status") == "ok"), "generic_record_containers": len(generic_record_containers), }, }