#!/usr/bin/env python3 # form-validate v1.15 — Validate 1C managed form # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import os import re import sys from lxml import etree # Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet] # регистр не различают, в argparse совпадение точное. def ci_parse_args(parser, argv=None): """parse_args по правилам PS: имена параметров и значения choices регистронезависимы.""" argv = list(sys.argv[1:] if argv is None else argv) names = {s.lower(): s for a in parser._actions for s in a.option_strings} for i, tok in enumerate(argv): if tok.startswith('-') and tok.lower() in names: argv[i] = names[tok.lower()] # choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр choice_map = {} for a in parser._actions: if a.choices: for s in a.option_strings: choice_map[s] = {str(c).lower(): c for c in a.choices} for i in range(len(argv) - 1): m = choice_map.get(argv[i]) if m and argv[i + 1].lower() in m: argv[i + 1] = m[argv[i + 1].lower()] return parser.parse_args(argv) F_NS = "http://v8.1c.ru/8.3/xcf/logform" V8_NS = "http://v8.1c.ru/8.1/data/core" NSMAP = {"f": F_NS, "v8": V8_NS} KNOWN_INVALID_TYPES = { 'FormDataStructure', 'FormDataCollection', 'FormDataTree', 'FormDataTreeItem', 'FormDataCollectionItem', 'FormGroup', 'FormField', 'FormButton', 'FormDecoration', 'FormTable', } VALID_CLOSED_TYPES = { 'xs:boolean', 'xs:string', 'xs:decimal', 'xs:dateTime', 'xs:binary', 'v8:FillChecking', 'v8:Null', 'v8:StandardPeriod', 'v8:StandardBeginningDate', 'v8:Type', 'v8:TypeDescription', 'v8:UUID', 'v8:ValueListType', 'v8:ValueTable', 'v8:ValueTree', 'v8:Universal', 'v8:FixedArray', 'v8:FixedStructure', 'v8ui:Color', 'v8ui:Font', 'v8ui:FormattedString', 'v8ui:HorizontalAlign', 'v8ui:Picture', 'v8ui:SizeChangeMode', 'v8ui:VerticalAlign', 'dcsset:DataCompositionComparisonType', 'dcsset:DataCompositionFieldPlacement', 'dcsset:Filter', 'dcsset:SettingsComposer', 'dcsset:DataCompositionSettings', 'dcssch:DataCompositionSchema', 'dcscor:DataCompositionComparisonType', 'dcscor:DataCompositionGroupType', 'dcscor:DataCompositionPeriodAdditionType', 'dcscor:DataCompositionSortDirection', 'dcscor:Field', 'ent:AccountType', 'ent:AccumulationRecordType', 'ent:AccountingRecordType', } VALID_CFG_PREFIXES = { 'AccountingRegisterRecordSet', 'AccumulationRegisterRecordSet', 'BusinessProcessObject', 'BusinessProcessRef', 'CatalogObject', 'CatalogRef', 'ChartOfAccountsObject', 'ChartOfAccountsRef', 'ChartOfCalculationTypesObject', 'ChartOfCalculationTypesRef', 'ChartOfCharacteristicTypesObject', 'ChartOfCharacteristicTypesRef', 'ConstantsSet', 'DataProcessorObject', 'DocumentObject', 'DocumentRef', 'DynamicList', 'EnumRef', 'ExchangePlanObject', 'ExchangePlanRef', 'ExternalDataProcessorObject', 'ExternalReportObject', 'InformationRegisterRecordManager', 'InformationRegisterRecordSet', 'ReportObject', 'TaskObject', 'TaskRef', } # Корень автономной внешней обработки/отчёта. Копия общего эталона (семья # support-guard: is_external_root, авторитет — cf-edit). def _sg_is_external_root(xml_path): if not os.path.isfile(xml_path): return False try: mx = etree.parse(xml_path).getroot() for child in mx: if isinstance(child.tag, str): return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport") except Exception: return False return False # Версия формата выгрузки. Копия общего эталона (семья detect_format_version, авторитет — # form-compile): та же ветка для автономной EPF/ERF, где версию несёт корень обработки. def detect_format_version(d): while d: # Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт # корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17. ext_path = d + ".xml" if os.path.isfile(ext_path): with open(ext_path, "r", encoding="utf-8-sig") as f: ext_head = f.read(2000) if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head): m = re.search(r']+version="(\d+\.\d+)"', ext_head) if m: return m.group(1) cfg_path = os.path.join(d, "Configuration.xml") if os.path.isfile(cfg_path): with open(cfg_path, "r", encoding="utf-8-sig") as f: head = f.read(2000) m = re.search(r']+version="(\d+\.\d+)"', head) if m: return m.group(1) parent = os.path.dirname(d) if parent == d: break d = parent return "2.17" def localname(el): return etree.QName(el.tag).localname def main(): sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser(description="Validate 1C managed form", allow_abbrev=False) parser.add_argument("-FormPath", "-Path", required=True) parser.add_argument("-Detailed", action="store_true") parser.add_argument("-MaxErrors", type=int, default=30) args = ci_parse_args(parser) form_path = args.FormPath detailed = args.Detailed max_errors = args.MaxErrors if not os.path.isabs(form_path): form_path = os.path.join(os.getcwd(), form_path) # A: Directory → Ext/Form.xml if os.path.isdir(form_path): form_path = os.path.join(form_path, 'Ext', 'Form.xml') # B1: Missing Ext/ (e.g. Forms/Форма/Form.xml → Forms/Форма/Ext/Form.xml) if not os.path.exists(form_path): fn = os.path.basename(form_path) if fn == 'Form.xml': c = os.path.join(os.path.dirname(form_path), 'Ext', fn) if os.path.exists(c): form_path = c # B2: Descriptor (Forms/Форма.xml → Forms/Форма/Ext/Form.xml) if not os.path.exists(form_path) and form_path.endswith('.xml'): stem = os.path.splitext(os.path.basename(form_path))[0] parent = os.path.dirname(form_path) c = os.path.join(parent, stem, 'Ext', 'Form.xml') if os.path.exists(c): form_path = c if not os.path.isfile(form_path): print(f"File not found: {form_path}", file=sys.stderr) sys.exit(1) # --- Load XML --- try: xml_parser = etree.XMLParser(remove_blank_text=True) tree = etree.parse(form_path, xml_parser) except Exception as e: print(f"[ERROR] XML parse error: {e}") print() print("---") print("Errors: 1, Warnings: 0") sys.exit(1) root = tree.getroot() # Detect context: config vs EPF/ERF is_config_context = False config_xml_path = '' version_anchor = '' walk_dir = os.path.dirname(os.path.abspath(form_path)) for _ in range(15): parent = os.path.dirname(walk_dir) if parent == walk_dir: break # Порядок проверок тот же, что у detect_format_version: сначала корень автономной обработки, # потом Configuration.xml — иначе форма внутри EPF, лежащей в дереве конфигурации, взяла бы # версию конфигурации. ext_root = walk_dir + '.xml' if not version_anchor: if _sg_is_external_root(ext_root): # Ближайший якорь побеждает: автономная обработка остаётся автономной, даже если её # исходники лежат внутри дерева с Configuration.xml (типовая раскладка проекта: # src/cf рядом с src/epf). Иначе её собственные External*-типы считались бы ошибкой. version_anchor = ext_root break if os.path.isfile(os.path.join(walk_dir, 'Configuration.xml')): is_config_context = True config_xml_path = os.path.join(walk_dir, 'Configuration.xml') if not version_anchor: version_anchor = config_xml_path break walk_dir = parent errors = 0 warnings = 0 ok_count = 0 stopped = False output_lines = [] def report_ok(msg): nonlocal ok_count ok_count += 1 if detailed: output_lines.append(f"[OK] {msg}") def report_error(msg): nonlocal errors, stopped errors += 1 output_lines.append(f"[ERROR] {msg}") if errors >= max_errors: stopped = True def report_warn(msg): nonlocal warnings warnings += 1 output_lines.append(f"[WARN] {msg}") # --- Form name from path --- form_name = os.path.splitext(os.path.basename(form_path))[0] parent_dir = os.path.dirname(form_path) if parent_dir: ext_dir = os.path.basename(parent_dir) if ext_dir == "Ext": form_dir = os.path.dirname(parent_dir) if form_dir: form_name = os.path.basename(form_dir) output_lines.append(f"=== Validation: Form.{form_name} ===") output_lines.append("") # Early BaseForm detection has_base_form = root.find(f"{{{F_NS}}}BaseForm") is not None # --- Check 1: Root element and version --- if localname(root) != "Form": report_error(f"Root element is '{localname(root)}', expected 'Form'") else: version = root.get("version", "") # Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27). if version in ("2.17", "2.18", "2.19", "2.20"): report_ok(f"Root element: Form version={version}") elif version: report_warn(f"Form version='{version}' (expected 2.17-2.20)") else: report_warn("Form version attribute missing") # --- Check 2: AutoCommandBar --- if not stopped: acb = root.find(f"{{{F_NS}}}AutoCommandBar") if acb is not None: acb_name = acb.get("name", "") acb_id = acb.get("id", "") # id=-1 — соглашение, а не требование: в корпусе УТ/БП/ERP так у 21 094 форм из 21 097, # но три формы платформа выгружает с обычным id и грузит их без нареканий. Поэтому # предупреждение; ошибка — только если id вовсе не число. if acb_id == "-1": report_ok(f"AutoCommandBar: name='{acb_name}', id={acb_id}") elif re.match(r'^-?\d+$', acb_id): report_warn(f"AutoCommandBar id='{acb_id}', usually '-1'") else: report_error(f"AutoCommandBar id='{acb_id}' is not a number") else: report_error("AutoCommandBar element missing") # --- Collect all elements with IDs --- element_ids = {} # id -> name element_names = {} # name -> id (имена элементов уникальны в пределах формы) all_elements = [] # list of dicts {Name, Tag, Id, ParentName, Node} def collect_elements(node, parent_name): nonlocal stopped for child in node: if not isinstance(child.tag, str): continue name = child.get("name", "") eid = child.get("id", "") if name and eid: tag = localname(child) all_elements.append({ "Name": name, "Tag": tag, "Id": eid, "ParentName": parent_name, "Node": child, }) if eid != "-1": if eid in element_ids: report_error(f"Duplicate element id={eid}: '{name}' and '{element_ids[eid]}'") else: element_ids[eid] = name # Имена элементов уникальны (требование 1С) if name in element_names: report_error(f"Duplicate element name '{name}': id={eid} and id={element_names[name]}") else: element_names[name] = eid child_items = child.find(f"{{{F_NS}}}ChildItems") if child_items is not None: collect_elements(child_items, name) child_items_root = root.find(f"{{{F_NS}}}ChildItems") if child_items_root is not None: collect_elements(child_items_root, "(root)") acb = root.find(f"{{{F_NS}}}AutoCommandBar") if acb is not None: acb_children = acb.find(f"{{{F_NS}}}ChildItems") if acb_children is not None: collect_elements(acb_children, "\u0424\u043e\u0440\u043c\u0430\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c") # --- Check 3: Unique element IDs --- if not stopped: # Duplicates already reported during collection dup_count = 0 id_counts = {} for el in all_elements: eid = el["Id"] if eid == "-1": continue id_counts[eid] = id_counts.get(eid, 0) + 1 dup_count = sum(1 for v in id_counts.values() if v > 1) if dup_count == 0: report_ok(f"Unique element IDs: {len(element_ids)} elements") # --- Collect attributes (separate ID pool) --- attr_map = {} # name -> node attr_ids = {} # id -> name attr_nodes_parent = root.find(f"{{{F_NS}}}Attributes") attr_nodes = [] if attr_nodes_parent is not None: attr_nodes = attr_nodes_parent.findall(f"{{{F_NS}}}Attribute") for attr in attr_nodes: attr_name = attr.get("name", "") attr_id = attr.get("id", "") if attr_name: # Имена реквизитов уникальны среди реквизитов (отдельный неймспейс от элементов) if attr_name in attr_map: report_error(f"Duplicate attribute name '{attr_name}': id={attr_id} and id={attr_map[attr_name].get('id', '')}") attr_map[attr_name] = attr if attr_id: if attr_id in attr_ids: report_error(f"Duplicate attribute id={attr_id}: '{attr_name}' and '{attr_ids[attr_id]}'") else: attr_ids[attr_id] = attr_name # Column IDs uniqueness within parent col_ids = {} col_names = {} # имена колонок уникальны в пределах своего реквизита columns = attr.find(f"{{{F_NS}}}Columns") if columns is not None: for col in columns.findall(f"{{{F_NS}}}Column"): col_id = col.get("id", "") col_name = col.get("name", "") if col_id: if col_id in col_ids: report_error(f"Duplicate column id={col_id} in '{attr_name}': '{col_name}' and '{col_ids[col_id]}'") else: col_ids[col_id] = col_name if col_name: if col_name in col_names: report_error(f"Duplicate column name '{col_name}' in '{attr_name}': id={col_id} and id={col_names[col_name]}") else: col_names[col_name] = col_id if not stopped: if attr_ids: report_ok(f"Unique attribute IDs: {len(attr_ids)} entries") # --- Collect commands (separate ID pool) --- cmd_map = {} # name -> node cmd_ids = {} # id -> name cmd_nodes_parent = root.find(f"{{{F_NS}}}Commands") cmd_nodes = [] if cmd_nodes_parent is not None: cmd_nodes = cmd_nodes_parent.findall(f"{{{F_NS}}}Command") for cmd in cmd_nodes: cmd_name = cmd.get("name", "") cmd_id = cmd.get("id", "") if cmd_name: # Имена команд уникальны среди команд (отдельный неймспейс) if cmd_name in cmd_map: report_error(f"Duplicate command name '{cmd_name}': id={cmd_id} and id={cmd_map[cmd_name].get('id', '')}") cmd_map[cmd_name] = cmd if cmd_id: if cmd_id in cmd_ids: report_error(f"Duplicate command id={cmd_id}: '{cmd_name}' and '{cmd_ids[cmd_id]}'") else: cmd_ids[cmd_id] = cmd_name if not stopped: if cmd_ids: report_ok(f"Unique command IDs: {len(cmd_ids)} entries") # --- Collect parameters (separate name pool, без id) --- param_names = {} # name -> True (имена параметров уникальны среди параметров) params_parent = root.find(f"{{{F_NS}}}Parameters") if params_parent is not None: for param in params_parent.findall(f"{{{F_NS}}}Parameter"): param_name = param.get("name", "") if param_name: if param_name in param_names: report_error(f"Duplicate parameter name '{param_name}'") else: param_names[param_name] = True # --- Check 4: Companion elements --- companion_rules = { "InputField": ["ContextMenu", "ExtendedTooltip"], "CheckBoxField": ["ContextMenu", "ExtendedTooltip"], "LabelDecoration": ["ContextMenu", "ExtendedTooltip"], "LabelField": ["ContextMenu", "ExtendedTooltip"], "PictureDecoration": ["ContextMenu", "ExtendedTooltip"], "PictureField": ["ContextMenu", "ExtendedTooltip"], "CalendarField": ["ContextMenu", "ExtendedTooltip"], "UsualGroup": ["ExtendedTooltip"], "Pages": ["ExtendedTooltip"], "Page": ["ExtendedTooltip"], "Button": ["ExtendedTooltip"], "Table": ["ContextMenu", "AutoCommandBar", "SearchStringAddition", "ViewStatusAddition", "SearchControlAddition"], } if not stopped: companion_errors = 0 companion_checked = 0 for el in all_elements: if stopped: break tag = el["Tag"] el_name = el["Name"] node = el["Node"] if tag not in companion_rules: continue required = companion_rules[tag] companion_checked += 1 for comp_tag in required: comp_node = node.find(f"{{{F_NS}}}{comp_tag}") if comp_node is None: report_error(f"[{tag}] '{el_name}': missing companion <{comp_tag}>") companion_errors += 1 if companion_errors == 0 and companion_checked > 0: report_ok(f"Companion elements: {companion_checked} elements checked") # --- Check 5: DataPath -> Attribute references --- if not stopped: path_errors = 0 path_checked = 0 path_base_skipped = 0 # All data-binding tags whose value is an attribute path (root must exist in ). binding_tags = ["DataPath", "TitleDataPath", "FooterDataPath", "HeaderDataPath", "MultipleValueDataPath", "MultipleValuePresentDataPath", "RowPictureDataPath", "MultipleValuePictureDataPath"] skip_tags = {"ContextMenu", "ExtendedTooltip", "AutoCommandBar", "SearchStringAddition", "ViewStatusAddition", "SearchControlAddition"} for el in all_elements: if stopped: break tag = el["Tag"] el_name = el["Name"] node = el["Node"] if tag in skip_tags: continue if has_base_form and el["Id"]: try: if int(el["Id"]) < 1000000: path_base_skipped += 1 continue except (ValueError, TypeError): pass for b_tag in binding_tags: if stopped: break dp_node = node.find(f"{{{F_NS}}}{b_tag}") if dp_node is None: continue data_path = (dp_node.text or "").strip() if not data_path: continue # Opaque platform-internal shapes — not validatable from Form.xml alone: # - bare numeric (e.g. "10", "1000003") — internal index # - "N/M:" — metadata reference by UUID if re.match(r'^\d+$', data_path) or re.match(r'^\d+/\d+:[0-9a-fA-F-]+$', data_path): continue path_checked += 1 clean_path = re.sub(r'\[\d+\]', '', data_path) # Strip leading '~' (current row of DynamicList: ~Список.Поле) if clean_path.startswith('~'): clean_path = clean_path[1:] segments = clean_path.split(".") root_attr = segments[0] # Resolve Items..CurrentData.... — table element, not attribute. # Разрешаем ЦЕПОЧКОЙ: таблица во вложенной таблице сама привязана через Items.*, и один # шаг оставлял корнем литерал «Items» — форма платформы объявлялась битой (типовые # НастройкаПравилОбработкиЗаявокСотрудников в БП и ERP). items_hops = 0 items_broken = False while root_attr == 'Items': items_hops += 1 if items_hops > 10: # страховка от кольца ссылок items_broken = True break if len(segments) < 3 or segments[2] != 'CurrentData': report_warn(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — unknown Items.* shape, expected Items..CurrentData.*") items_broken = True break table_name = segments[1] table_el = None for candidate in all_elements: if candidate["Tag"] == 'Table' and candidate["Name"] == table_name: table_el = candidate break if table_el is None: report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — table element '{table_name}' not found") path_errors += 1 items_broken = True break table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath") if table_dp_node is None or not (table_dp_node.text or "").strip(): items_broken = True break table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip()) if table_dp.startswith('~'): table_dp = table_dp[1:] segments = table_dp.split(".") root_attr = segments[0] if items_broken: continue if root_attr not in attr_map: report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — attribute '{root_attr}' not found") path_errors += 1 path_msg = "" if path_checked > 0: path_msg = f"{path_checked} paths checked" if path_base_skipped > 0: skip_note = f"{path_base_skipped} base skipped" path_msg = f"{path_msg}, {skip_note}" if path_msg else skip_note if path_errors == 0 and path_msg: report_ok(f"Data bindings: {path_msg}") elif path_errors == 0: report_ok("Data bindings: none") # --- Check 6: Button command references --- if not stopped: cmd_errors = 0 cmd_checked = 0 for el in all_elements: if stopped: break tag = el["Tag"] el_name = el["Name"] node = el["Node"] if tag != "Button": continue cmd_node = node.find(f"{{{F_NS}}}CommandName") if cmd_node is None: continue cmd_ref = (cmd_node.text or "").strip() if not cmd_ref: continue m = re.match(r'^Form\.Command\.(.+)$', cmd_ref) if m: cmd_name_ref = m.group(1) cmd_checked += 1 if cmd_name_ref not in cmd_map: report_error(f"[Button] '{el_name}': CommandName='{cmd_ref}' \u2014 command '{cmd_name_ref}' not found in Commands") cmd_errors += 1 if cmd_errors == 0 and cmd_checked > 0: report_ok(f"Command references: {cmd_checked} buttons checked") elif cmd_checked == 0: report_ok("Command references: none") # --- Check 7: Events have handler names --- if not stopped: event_errors = 0 event_checked = 0 # Form-level events form_events = root.find(f"{{{F_NS}}}Events") if form_events is not None: for evt in form_events.findall(f"{{{F_NS}}}Event"): evt_name = evt.get("name", "") handler = (evt.text or "").strip() event_checked += 1 if not handler: report_error(f"Form event '{evt_name}': empty handler name") event_errors += 1 # Element-level events for el in all_elements: if stopped: break tag = el["Tag"] el_name = el["Name"] node = el["Node"] events_node = node.find(f"{{{F_NS}}}Events") if events_node is None: continue for evt in events_node.findall(f"{{{F_NS}}}Event"): evt_name = evt.get("name", "") handler = (evt.text or "").strip() event_checked += 1 if not handler: report_error(f"[{tag}] '{el_name}' event '{evt_name}': empty handler name") event_errors += 1 if event_errors == 0 and event_checked > 0: report_ok(f"Event handlers: {event_checked} events checked") elif event_checked == 0: report_ok("Event handlers: none") # --- Check 8: Command actions --- if not stopped: action_errors = 0 action_checked = 0 # Предупреждение, а не ошибка: может назначаться в рантайме # (`Команда.Действие = "Подключаемый_…"` в ПриСозданииНаСервере) — приём типовых конфигураций # там, где обработчик существует не во всякой сборке. Назначать может и чужой модуль # (переопределяемый слой, подключаемые команды), так что по одному Form.xml не решить. # Корпус УТ/БП/ERP: 406 таких команд на 275 формах, произведённых платформой. for cmd in cmd_nodes: if stopped: break cmd_name = cmd.get("name", "") action_node = cmd.find(f"{{{F_NS}}}Action") action_checked += 1 if action_node is None or not (action_node.text or "").strip(): report_warn(f"Command '{cmd_name}': no Action — handler must be assigned at runtime, otherwise the command does nothing") action_errors += 1 if action_errors == 0 and action_checked > 0: report_ok(f"Command actions: {action_checked} commands checked") elif action_checked == 0: report_ok("Command actions: none") # --- Check 9: MainAttribute count --- if not stopped: main_count = 0 for attr in attr_nodes: main_node = attr.find(f"{{{F_NS}}}MainAttribute") if main_node is not None and (main_node.text or "") == "true": main_count += 1 if main_count <= 1: main_info = "1 main attribute" if main_count == 1 else "no main attribute" report_ok(f"MainAttribute: {main_info}") else: report_error(f"Multiple MainAttribute=true ({main_count} found, expected 0 or 1)") # --- Check 10: Title must be multilingual XML --- if not stopped: title_node = root.find(f"{{{F_NS}}}Title") if title_node is not None: v8_items = title_node.findall(f"{{{V8_NS}}}item") if len(v8_items) == 0 and (title_node.text or "").strip(): report_error(f"Form Title is plain text ('{(title_node.text or '').strip()}') \u2014 must be multilingual XML (). Use top-level 'title' key in form-compile DSL.") else: report_ok("Title: multilingual XML") # --- Check 11: Extension-specific validations --- base_form_node = root.find(f"{{{F_NS}}}BaseForm") is_extension = base_form_node is not None if not stopped and is_extension: # 11a. BaseForm version bf_version = base_form_node.get("version", "") if bf_version: report_ok(f"BaseForm: version={bf_version}") else: report_warn("BaseForm: version attribute missing") # 11b. callType values validation valid_call_types = {"Before", "After", "Override"} ct_errors = 0 ct_checked = 0 form_events_node = root.find(f"{{{F_NS}}}Events") if form_events_node is not None: for evt in form_events_node.findall(f"{{{F_NS}}}Event"): ct = evt.get("callType", "") if ct: ct_checked += 1 if ct not in valid_call_types: report_error(f"Form event '{evt.get('name', '')}': invalid callType='{ct}' (expected: Before, After, Override)") ct_errors += 1 for el in all_elements: if stopped: break events_node = el["Node"].find(f"{{{F_NS}}}Events") if events_node is None: continue for evt in events_node.findall(f"{{{F_NS}}}Event"): ct = evt.get("callType", "") if ct: ct_checked += 1 if ct not in valid_call_types: report_error(f"[{el['Tag']}] '{el['Name']}' event '{evt.get('name', '')}': invalid callType='{ct}'") ct_errors += 1 for cmd in cmd_nodes: if stopped: break cmd_name = cmd.get("name", "") for action in cmd.findall(f"{{{F_NS}}}Action"): ct = action.get("callType", "") if ct: ct_checked += 1 if ct not in valid_call_types: report_error(f"Command '{cmd_name}' Action: invalid callType='{ct}'") ct_errors += 1 if not stopped and ct_errors == 0 and ct_checked > 0: report_ok(f"callType values: {ct_checked} checked") # 11c. Extension ID ranges base_attr_names = set() base_cmd_names = set() bf_attrs = base_form_node.find(f"{{{F_NS}}}Attributes") if bf_attrs is not None: for b_attr in bf_attrs.findall(f"{{{F_NS}}}Attribute"): ba_name = b_attr.get("name", "") if ba_name: base_attr_names.add(ba_name) bf_cmds = base_form_node.find(f"{{{F_NS}}}Commands") if bf_cmds is not None: for b_cmd in bf_cmds.findall(f"{{{F_NS}}}Command"): bc_name = b_cmd.get("name", "") if bc_name: base_cmd_names.add(bc_name) id_warn_count = 0 for attr in attr_nodes: a_name = attr.get("name", "") a_id = attr.get("id", "") if a_name and a_name not in base_attr_names and a_id: try: int_id = int(a_id) if int_id < 1000000: report_warn(f"Attribute '{a_name}' (id={a_id}): extension-added attribute has id < 1000000") id_warn_count += 1 except (ValueError, TypeError): pass for cmd in cmd_nodes: c_name = cmd.get("name", "") c_id = cmd.get("id", "") if c_name and c_name not in base_cmd_names and c_id: try: int_id = int(c_id) if int_id < 1000000: report_warn(f"Command '{c_name}' (id={c_id}): extension-added command has id < 1000000") id_warn_count += 1 except (ValueError, TypeError): pass if not stopped and id_warn_count == 0: ext_attr_count = sum(1 for a in attr_nodes if a.get("name", "") not in base_attr_names) ext_cmd_count = sum(1 for c in cmd_nodes if c.get("name", "") not in base_cmd_names) if (ext_attr_count + ext_cmd_count) > 0: report_ok(f"Extension ID ranges: {ext_attr_count} attr(s), {ext_cmd_count} cmd(s) \u2014 all >= 1000000") # 11d. \u041f\u0443\u0442\u0438 \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442, \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0444\u043e\u0440\u043c\u0430 \u043d\u0435 \u043e\u0431\u044a\u044f\u0432\u043b\u044f\u0435\u0442. # Check 5 \u0442\u0430\u043a\u043e\u0435 \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u0442: \u0443 \u0437\u0430\u0438\u043c\u0441\u0442\u0432\u043e\u0432\u0430\u043d\u043d\u043e\u0439 \u0444\u043e\u0440\u043c\u044b \u043e\u043d \u043d\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u0442 \u0431\u0430\u0437\u043e\u0432\u044b\u0435 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b (id < 1000000), # \u0430 \u043f\u0440\u0438\u0432\u044f\u0437\u043a\u0438 \u0432 \u0432\u043e\u043e\u0431\u0449\u0435 \u0432\u043d\u0435 \u0435\u0433\u043e \u0441\u043f\u0438\u0441\u043a\u0430 \u0442\u0435\u0433\u043e\u0432. \u041c\u0435\u0436\u0434\u0443 \u0442\u0435\u043c \u044d\u0442\u043e \u0440\u043e\u0432\u043d\u043e \u0442\u043e\u0442 \u0441\u043b\u0443\u0447\u0430\u0439, \u043d\u0430 \u043a\u043e\u0442\u043e\u0440\u043e\u043c # \u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u0430 \u043e\u0442\u0432\u0435\u0440\u0433\u0430\u0435\u0442 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0443: \u00ab\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0443\u0442\u044c \u043a \u043f\u043e\u043b\u044e - \u041e\u0431\u044a\u0435\u043a\u0442.X\u00bb. \u041f\u0440\u0430\u0432\u0438\u043b\u043e: \u0435\u0441\u043b\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442 # \u043d\u0435 \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d \u0432 \u0444\u043e\u0440\u043c\u044b, \u043b\u044e\u0431\u043e\u0439 \u043f\u0443\u0442\u044c \u0441 \u043a\u043e\u0440\u043d\u0435\u043c \u00ab\u041e\u0431\u044a\u0435\u043a\u0442\u00bb \u043d\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0441\u044f. main_attr_declared = False for attr in attr_nodes: ma_node = attr.find(f"{{{F_NS}}}MainAttribute") if ma_node is not None and (ma_node.text or "").strip() == "true": main_attr_declared = True break if not main_attr_declared: # \u0417\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u0440\u0438\u0432\u044f\u0437\u043e\u043a \u0438\u0449\u0435\u043c \u0442\u0435\u043a\u0441\u0442\u043e\u043c: \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u044e\u0442 \u0438 \u043e\u0431\u044b\u0447\u043d\u044b\u0435 \u0442\u0435\u0433\u0438, \u0438 \u0432\u043d\u0443\u0442\u0440\u0438 # , \u0430 \u0442\u0435 \u0436\u0438\u0432\u0443\u0442 \u0432 \u0447\u0443\u0436\u043e\u043c \u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0435 \u0438\u043c\u0451\u043d. with open(form_path, "r", encoding="utf-8-sig") as fh: raw_form = fh.read() main_base = base_form_node.find(f"{{{F_NS}}}Attributes/{{{F_NS}}}Attribute[{{{F_NS}}}MainAttribute='true']") dangling_paths = set(re.findall(r'<(?:\w+:)?\w*DataPath[^>]*>(\u041e\u0431\u044a\u0435\u043a\u0442\.[^<]+)', raw_form)) if dangling_paths: shown = sorted(dangling_paths) sample = ", ".join(shown[:3]) suffix = f" (\u0438 \u0435\u0449\u0451 {len(shown) - 3})" if len(shown) > 3 else "" report_error(f"Path(s) rooted at '\u041e\u0431\u044a\u0435\u043a\u0442' but the form declares no MainAttribute: {sample}{suffix}") elif main_base is not None: report_ok("Object paths: none dangling (MainAttribute not declared)") # Check callType without BaseForm if not stopped and not is_extension: call_type_without_base = False fe_node = root.find(f"{{{F_NS}}}Events") if fe_node is not None: for evt in fe_node.findall(f"{{{F_NS}}}Event"): if evt.get("callType"): call_type_without_base = True break if not call_type_without_base: for cmd in cmd_nodes: for action in cmd.findall(f"{{{F_NS}}}Action"): if action.get("callType"): call_type_without_base = True break if call_type_without_base: break if call_type_without_base: report_warn("callType attributes found but no BaseForm \u2014 possible incorrect structure") # --- Check 12: Type validation --- if not stopped: type_nodes = root.xpath('//v8:Type', namespaces={'v8': V8_NS}) type_error_count = 0 type_warn_count = 0 type_count = len(type_nodes) for tn in type_nodes: if stopped: break tv = (tn.text or "").strip() if not tv: continue if tv in KNOWN_INVALID_TYPES: report_error(f'12. Type "{tv}": invalid runtime/UI type (not valid in XDTO schema)') type_error_count += 1 elif tv in VALID_CLOSED_TYPES: pass # OK elif tv.startswith("cfg:"): suffix = tv[4:] # after "cfg:" prefix = suffix.split(".")[0] if prefix in VALID_CFG_PREFIXES or suffix == "DynamicList": # ExternalDataProcessorObject/ExternalReportObject valid only in EPF/ERF context if is_config_context and prefix in ('ExternalDataProcessorObject', 'ExternalReportObject'): report_error(f'12. Type "{tv}": External* type in configuration context (use DataProcessorObject/ReportObject instead)') type_error_count += 1 else: report_warn(f'12. Type "{tv}": unrecognized cfg prefix') type_warn_count += 1 elif ":" in tv: pass # unknown namespace, pass through else: report_warn(f'12. Type "{tv}": bare type without namespace prefix') type_warn_count += 1 if type_error_count == 0 and type_warn_count == 0: if type_count > 0: report_ok(f'12. Types: {type_count} values, all valid') else: report_ok('12. Types: no type values to check') # --- Check 13: префиксы в значениях объявлены в самом файле --- # `cfg:DataProcessorObject.X` в при незадекларированном xmlns:cfg — валидный XML, который # платформа не читает вовсе: «Исключение XDTO произошло при чтении файла». Ошибка типична для # рукописного XML: префикс скопирован из чужой формы, а объявление в корне забыто. Область видимости # считаем по узлу (nsmap элемента), а не по корню: локальная xmlns на элементе законна. if not stopped: prefix_errors = 0 prefix_checked = 0 prefix_re = re.compile(r'^([A-Za-z_][A-Za-z0-9_.-]*):.+$') for node in root.iter(): if not isinstance(node.tag, str): continue ln = localname(node) values = [] if ln in ('Type', 'TypeSet'): values.append((node.text or '').strip()) xsi_type = node.get(f'{{{"http://www.w3.org/2001/XMLSchema-instance"}}}type') if xsi_type: values.append(xsi_type.strip()) for val in values: if not val: continue m = prefix_re.match(val) if not m: continue prefix_checked += 1 pfx = m.group(1) if pfx not in node.nsmap: kind = "xsi:type" if val == xsi_type else "Type" report_error(f"13. {kind} '{val}': namespace prefix '{pfx}:' is not declared " "— the platform cannot read the file (XDTO)") prefix_errors += 1 if prefix_checked == 0: report_ok('13. Namespace prefixes: nothing to check') elif prefix_errors == 0: report_ok(f'13. Namespace prefixes: {prefix_checked} values, all declared') # --- Check 14: версия формата формы совпадает с версией выгрузки --- # Версию задаёт платформа, которой выгружали, и в пределах одной выгрузки она едина. Форма из # другой версии — «Неизвестная версия формата N загружаемого файла»: платформа не читает файл, # который новее её самой. Источник версии ищем общим helper-ом: он же покрывает автономную # внешнюю обработку/отчёт, где Configuration.xml нет и версию несёт корень самой обработки. if not stopped and version_anchor: form_ver = root.get('version', '') dump_ver = detect_format_version(os.path.dirname(os.path.abspath(form_path))) if not form_ver: report_ok('14. Format version: not comparable') elif form_ver != dump_ver: report_error(f'14. Format version {form_ver} differs from the dump ({dump_ver}) ' '— a dump carries one version, the platform refuses a file it cannot read') else: report_ok(f'14. Format version: {form_ver}, matches the dump') # --- Finalize --- checks = ok_count + errors + warnings if errors == 0 and warnings == 0 and not detailed: result = f"=== Validation OK: Form.{form_name} ({checks} checks) ===" else: output_lines.append("") output_lines.append(f"=== Result: {errors} errors, {warnings} warnings ({checks} checks) ===") result = "\n".join(output_lines) print(result) if errors > 0: sys.exit(1) else: sys.exit(0) if __name__ == "__main__": main()