352 lines
10 KiB
Python
352 lines
10 KiB
Python
"""Разбор XML метаданных объектов 1С (выгрузка EDT)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
from dataclasses import asdict, dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
from .config import FIELD_RICH_TYPES, OBJECT_TYPE_FOLDERS
|
|
|
|
MD = "{http://v8.1c.ru/8.3/MDClasses}"
|
|
V8 = "{http://v8.1c.ru/8.1/data/core}"
|
|
|
|
REF_TYPE_RE = re.compile(
|
|
r"^cfg:(CatalogRef|DocumentRef|EnumRef|ChartOfCharacteristicTypesRef|"
|
|
r"ChartOfAccountsRef|ChartOfCalculationTypesRef|ExchangePlanRef|"
|
|
r"BusinessProcessRef|TaskRef|DefinedType)\.(.+)$"
|
|
)
|
|
|
|
FIELD_TAGS = frozenset(
|
|
{
|
|
"Attribute",
|
|
"Dimension",
|
|
"Resource",
|
|
"TabularSection",
|
|
"Column",
|
|
"EnumValue",
|
|
"AddressingAttribute",
|
|
}
|
|
)
|
|
|
|
|
|
def local(tag: str) -> str:
|
|
return tag.split("}")[-1] if "}" in tag else tag
|
|
|
|
|
|
def text_of(el: ET.Element | None) -> str:
|
|
if el is None:
|
|
return ""
|
|
return (el.text or "").strip()
|
|
|
|
|
|
def child_text(props: ET.Element, tag: str) -> str:
|
|
node = props.find(f"{MD}{tag}")
|
|
if node is None:
|
|
# без namespace — на всякий случай
|
|
for ch in props:
|
|
if local(ch.tag) == tag:
|
|
return (ch.text or "").strip()
|
|
return ""
|
|
return (node.text or "").strip()
|
|
|
|
|
|
def collect_localized(props: ET.Element, tag: str) -> dict[str, str]:
|
|
"""Синоним / подсказка: lang → content."""
|
|
node = props.find(f"{MD}{tag}")
|
|
if node is None:
|
|
return {}
|
|
out: dict[str, str] = {}
|
|
for item in node.findall(f"{V8}item"):
|
|
lang = text_of(item.find(f"{V8}lang")) or "ru"
|
|
content = text_of(item.find(f"{V8}content"))
|
|
if content:
|
|
out[lang] = content
|
|
return out
|
|
|
|
|
|
def synonym_str(loc: dict[str, str]) -> str:
|
|
if not loc:
|
|
return ""
|
|
if "ru" in loc:
|
|
parts = [loc["ru"]] + [f"{k}:{v}" for k, v in loc.items() if k != "ru"]
|
|
return " | ".join(parts)
|
|
return " | ".join(f"{k}:{v}" for k, v in loc.items())
|
|
|
|
|
|
def collect_types(props: ET.Element) -> list[str]:
|
|
typ = props.find(f"{MD}Type")
|
|
if typ is None:
|
|
return []
|
|
types: list[str] = []
|
|
for t in typ.findall(f".//{V8}Type"):
|
|
raw = (t.text or "").strip()
|
|
if raw:
|
|
types.append(raw)
|
|
return types
|
|
|
|
|
|
def parse_ref_targets(types: Iterable[str]) -> list[dict[str, str]]:
|
|
refs: list[dict[str, str]] = []
|
|
for t in types:
|
|
m = REF_TYPE_RE.match(t)
|
|
if m:
|
|
refs.append({"kind": m.group(1), "name": m.group(2), "raw": t})
|
|
elif t.startswith("cfg:") and "Ref." in t:
|
|
# прочие cfg:*Ref.*
|
|
body = t[4:]
|
|
if "." in body:
|
|
kind, name = body.split(".", 1)
|
|
refs.append({"kind": kind, "name": name, "raw": t})
|
|
return refs
|
|
|
|
|
|
@dataclass
|
|
class FieldRec:
|
|
kind: str # Attribute | Dimension | Resource | Column | EnumValue | ...
|
|
name: str
|
|
synonym: str = ""
|
|
comment: str = ""
|
|
tooltip: str = ""
|
|
types: list[str] = field(default_factory=list)
|
|
refs: list[dict[str, str]] = field(default_factory=list)
|
|
tabular_section: str = ""
|
|
|
|
|
|
@dataclass
|
|
class ObjectRec:
|
|
config: str
|
|
object_type: str
|
|
name: str
|
|
synonym: str = ""
|
|
comment: str = ""
|
|
tooltip: str = ""
|
|
explanation: str = ""
|
|
uuid: str = ""
|
|
path: str = ""
|
|
fields: list[FieldRec] = field(default_factory=list)
|
|
owners: list[str] = field(default_factory=list)
|
|
register_records: list[str] = field(default_factory=list)
|
|
based_on: list[str] = field(default_factory=list)
|
|
input_by_string: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def full_name(self) -> str:
|
|
return f"{self.object_type}.{self.name}"
|
|
|
|
def search_text(self) -> str:
|
|
parts = [
|
|
self.object_type,
|
|
self.name,
|
|
self.synonym,
|
|
self.comment,
|
|
self.tooltip,
|
|
self.explanation,
|
|
]
|
|
for f in self.fields:
|
|
parts.extend([f.kind, f.name, f.synonym, f.comment, f.tooltip, f.tabular_section])
|
|
parts.extend(f.types)
|
|
parts.extend(self.owners)
|
|
parts.extend(self.register_records)
|
|
parts.extend(self.based_on)
|
|
return "\n".join(p for p in parts if p)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
d = asdict(self)
|
|
d["full_name"] = self.full_name
|
|
return d
|
|
|
|
|
|
def _parse_field_props(props: ET.Element, kind: str, tabular: str = "") -> FieldRec | None:
|
|
name = child_text(props, "Name")
|
|
if not name:
|
|
return None
|
|
types = collect_types(props)
|
|
return FieldRec(
|
|
kind=kind,
|
|
name=name,
|
|
synonym=synonym_str(collect_localized(props, "Synonym")),
|
|
comment=child_text(props, "Comment"),
|
|
tooltip=synonym_str(collect_localized(props, "ToolTip")),
|
|
types=types,
|
|
refs=parse_ref_targets(types),
|
|
tabular_section=tabular,
|
|
)
|
|
|
|
|
|
def _walk_child_objects(co: ET.Element | None, object_type: str) -> list[FieldRec]:
|
|
if co is None or object_type not in FIELD_RICH_TYPES:
|
|
return []
|
|
fields: list[FieldRec] = []
|
|
|
|
for child in co:
|
|
tag = local(child.tag)
|
|
if tag == "TabularSection":
|
|
ts_props = child.find(f"{MD}Properties")
|
|
ts_name = child_text(ts_props, "Name") if ts_props is not None else ""
|
|
if ts_props is not None and ts_name:
|
|
fields.append(
|
|
FieldRec(
|
|
kind="TabularSection",
|
|
name=ts_name,
|
|
synonym=synonym_str(collect_localized(ts_props, "Synonym")),
|
|
comment=child_text(ts_props, "Comment"),
|
|
)
|
|
)
|
|
inner = child.find(f"{MD}ChildObjects")
|
|
if inner is not None:
|
|
for col in inner:
|
|
if local(col.tag) != "Attribute":
|
|
continue
|
|
cprops = col.find(f"{MD}Properties")
|
|
if cprops is None:
|
|
continue
|
|
rec = _parse_field_props(cprops, "Column", tabular=ts_name)
|
|
if rec:
|
|
fields.append(rec)
|
|
continue
|
|
|
|
if tag not in FIELD_TAGS:
|
|
continue
|
|
props = child.find(f"{MD}Properties")
|
|
if props is None:
|
|
continue
|
|
rec = _parse_field_props(props, tag)
|
|
if rec:
|
|
fields.append(rec)
|
|
|
|
return fields
|
|
|
|
|
|
_META_PREFIXES = (
|
|
"Catalog.",
|
|
"Document.",
|
|
"InformationRegister.",
|
|
"AccumulationRegister.",
|
|
"Enum.",
|
|
"Constant.",
|
|
"ChartOfCharacteristicTypes.",
|
|
"ExchangePlan.",
|
|
"BusinessProcess.",
|
|
"Task.",
|
|
"DocumentJournal.",
|
|
)
|
|
|
|
|
|
def _list_from_props(props: ET.Element, tag: str) -> list[str]:
|
|
node = props.find(f"{MD}{tag}")
|
|
if node is None:
|
|
return []
|
|
items: list[str] = []
|
|
for el in node.iter():
|
|
t = (el.text or "").strip()
|
|
if not t or "." not in t:
|
|
continue
|
|
if any(t.startswith(p) for p in _META_PREFIXES):
|
|
items.append(t)
|
|
seen: set[str] = set()
|
|
out: list[str] = []
|
|
for x in items:
|
|
if x not in seen:
|
|
seen.add(x)
|
|
out.append(x)
|
|
return out
|
|
|
|
|
|
def parse_metadata_xml(
|
|
path: Path,
|
|
*,
|
|
config: str,
|
|
object_type: str,
|
|
) -> ObjectRec | None:
|
|
try:
|
|
tree = ET.parse(path)
|
|
except ET.ParseError:
|
|
return None
|
|
root = tree.getroot()
|
|
|
|
# Корневой элемент объекта: <Catalog>, <Document>, ...
|
|
obj_el: ET.Element | None = None
|
|
for ch in root:
|
|
if local(ch.tag) == object_type:
|
|
obj_el = ch
|
|
break
|
|
if obj_el is None:
|
|
for ch in root:
|
|
if ch.find(f"{MD}Properties") is not None or ch.find("Properties") is not None:
|
|
obj_el = ch
|
|
break
|
|
if obj_el is None:
|
|
return None
|
|
|
|
props = obj_el.find(f"{MD}Properties")
|
|
if props is None:
|
|
props = obj_el.find("Properties")
|
|
if props is None:
|
|
return None
|
|
|
|
name = child_text(props, "Name")
|
|
if not name:
|
|
return None
|
|
|
|
uuid = obj_el.attrib.get("uuid", "")
|
|
|
|
fields = _walk_child_objects(obj_el.find(f"{MD}ChildObjects"), object_type)
|
|
|
|
# Для констант / определяемых типов тип лежит в Properties
|
|
if object_type in {"Constant", "DefinedType", "CommonAttribute", "SessionParameter"} and not fields:
|
|
types = collect_types(props)
|
|
if types:
|
|
fields.append(
|
|
FieldRec(
|
|
kind="ValueType",
|
|
name="Type",
|
|
types=types,
|
|
refs=parse_ref_targets(types),
|
|
)
|
|
)
|
|
|
|
return ObjectRec(
|
|
config=config,
|
|
object_type=object_type,
|
|
name=name,
|
|
synonym=synonym_str(collect_localized(props, "Synonym")),
|
|
comment=child_text(props, "Comment"),
|
|
tooltip=synonym_str(collect_localized(props, "ToolTip")),
|
|
explanation=synonym_str(collect_localized(props, "Explanation")),
|
|
uuid=uuid,
|
|
path=str(path),
|
|
fields=fields,
|
|
owners=_list_from_props(props, "Owners"),
|
|
register_records=_list_from_props(props, "RegisterRecords"),
|
|
based_on=_list_from_props(props, "BasedOn"),
|
|
input_by_string=_list_from_props(props, "InputByString"),
|
|
)
|
|
|
|
|
|
def iter_object_xml_files(
|
|
src: Path,
|
|
types: set[str] | None = None,
|
|
skip_types: set[str] | None = None,
|
|
) -> list[tuple[str, Path]]:
|
|
"""Список (object_type, xml_path) только верхний уровень src/<Folder>/*.xml."""
|
|
skip = skip_types or set()
|
|
out: list[tuple[str, Path]] = []
|
|
if not src.is_dir():
|
|
return out
|
|
for folder in sorted(src.iterdir()):
|
|
if not folder.is_dir():
|
|
continue
|
|
otype = OBJECT_TYPE_FOLDERS.get(folder.name)
|
|
if otype is None:
|
|
continue
|
|
if otype in skip:
|
|
continue
|
|
if types is not None and otype not in types:
|
|
continue
|
|
for xml in sorted(folder.glob("*.xml")):
|
|
out.append((otype, xml))
|
|
return out
|