"""DdicMixin — object CRUD, DDIC helpers, CDS, packages, ATC, pretty-print.""" from __future__ import annotations import logging import xml.etree.ElementTree as ET from typing import Any from sapcli.exceptions import CreateError, DeleteError from sapcli.types import ObjectTypeConfig, get_type_config logger = logging.getLogger("sapcli.client") def _local(tag: str) -> str: """返回 XML 标签/属性名的本地部分(去掉 ``{namespace}`` 前缀)。""" if tag and tag[0] == "{": return tag.split("}", 1)[1] return tag def _attr_local(el: ET.Element, name: str) -> str: """按本地名查找元素属性值(忽略命名空间)。 例如 ``adtcore:name`` 与 ``name`` 都能匹配 ``name``。 """ for key, val in el.attrib.items(): if _local(key) == name: return val return "" def _find_local(container: ET.Element, name: str) -> ET.Element | None: """返回容器(含自身)下首个本地标签名为 ``name`` 的元素。""" for el in container.iter(): if _local(el.tag) == name: return el return None # ADT lock 端点返回 ABAP 结构 XML。DDIC 对象(domain/dataelement/table/structure) # 的 lock 端点对默认 Accept: */* 返回 HTTP 406,必须显式请求 lock 结果类型。 # 参考 abap-adt-api objectcontents.ts 的 lock 实现。 LOCK_RESULT_ACCEPT = ( "application/*,application/vnd.sap.as+xml;charset=UTF-8;" "dataname=com.sap.adt.lock.result" ) class DdicMixin: """Object creation / deletion, DDIC operations, CDS, packages, ATC, pretty-print.""" # ------------------------------------------------------------------ # Object create / delete # ------------------------------------------------------------------ def delete_object( self, obj_uri: str, corr_nr: str | None = None, ) -> tuple[bool, str]: # DDIC 对象的 lock 端点对 Accept: */* 返回 406,需传 lock 结果专用 Accept 头 lock_handle, _ = self.lock(obj_uri, corr_nr, accept=LOCK_RESULT_ACCEPT) url = f"{self.host}{obj_uri}" params: dict[str, str] = {"lockHandle": lock_handle} if corr_nr: params["corrNr"] = corr_nr hdrs = self._headers() logger.info("DELETE: DELETE %s", url) resp = self.session.delete(url, headers=hdrs, params=params) logger.info("DELETE RESPONSE: HTTP %s", resp.status_code) if resp.status_code in (200, 204): logger.info("删除成功") # 修复:delete 成功后必须释放 enqueue 锁,否则残留锁导致后续 create 403 try: self.unlock(obj_uri, lock_handle) logger.info("删除后锁已释放") except Exception as e: logger.warning("删除后解锁失败(可能锁已自动释放): %s", e) return True, "" error_text = resp.text[:500] if resp.text else f"HTTP {resp.status_code}" logger.error("删除失败: %s", error_text) raise DeleteError(f"删除失败: {error_text}") def create_object( self, obj_type: str, name: str, description: str | None = None, corr_nr: str | None = None, source: str | None = None, package: str = "$TMP", ) -> tuple[str, str | None]: config = get_type_config(obj_type) params: dict[str, str] = {} if corr_nr: params["corrNr"] = corr_nr if obj_type == "function": if "/" not in name: raise CreateError("function 类型需要'函数组名/函数模块名' 格式") group, fm = name.split("/", 1) collection_url = config.format_collection_uri(group=group.lower()) obj_name = fm.upper() obj_uri = config.format_obj_uri(fm.lower(), group=group.lower()) src_uri = config.format_src_uri(fm.lower(), group=group.lower()) else: collection_url = config.collection_uri obj_name = name.upper() obj_uri = config.format_obj_uri(name.lower()) src_uri = config.format_src_uri(name.lower()) body = self._build_create_body(obj_type, obj_name, description, package) url = f"{self.host}{collection_url}" hdrs = self._headers(config.create_content_type) logger.info("CREATE: POST %s name=%s", url, obj_name) resp = self.session.post( url, headers=hdrs, params=params, data=body.encode("utf-8") ) logger.info("CREATE RESPONSE: HTTP %s", resp.status_code) if resp.status_code >= 400: logger.error("CREATE ERROR: %s", resp.text[:2000]) raise CreateError( f"创建对象失败: HTTP {resp.status_code} — {resp.text[:500]}" ) logger.info("对象已创建: %s", obj_uri) if source and src_uri: lock_handle, _ = self.lock(obj_uri, corr_nr) try: self.set_source(src_uri, source, lock_handle, corr_nr) finally: self.unlock(obj_uri, lock_handle) self.activate(obj_name, obj_uri, corr_nr) return obj_uri, src_uri def _build_create_body( self, obj_type: str, name: str, description: str | None, package: str = "$TMP", ) -> str: desc = description or name if obj_type == "report": return ( '' '' f'' "" ) elif obj_type == "class": return ( '' '' f'' "" ) elif obj_type == "function": return ( '' '' f'' "" ) elif obj_type == "functiongroup": return ( '' '' f'' "" ) elif obj_type == "interface": return ( '' '' f'' "" ) elif obj_type == "domain": return ( '' '' f'' "" ) elif obj_type == "dataelement": return ( '' '' f'' "" ) elif obj_type == "table": return ( '' '' f'' "" ) elif obj_type == "structure": # 结构体(TABL/DS):参考 table,但使用 structures 命名空间 return ( '' '' f'' "" ) elif obj_type == "tabletype": return ( '' '' f'' "" ) elif obj_type == "include": # POST /programs/includes/{name} — include 使用 programs/includes 命名空间, # 不能复用 report 的 (否则端点返回 400)。 # 参考 abap-adt-api objectcreator.ts: # rootName="include:abapInclude" # nameSpace="http://www.sap.com/adt/programs/includes" return ( '' '' f'' "" ) elif obj_type == "messageclass": # POST /oo/t100/messages/classes/{name} — body 含 class name + description # // TODO: verify XML structure on live SAP system return ( '' '' f'' "" ) elif obj_type == "view": # POST /ddic/views/{name}(数据库视图)— body 结构参考 table 的创建 # // TODO: verify XML structure on live SAP system return ( '' '' f'' "" ) elif obj_type == "searchhelp": # POST /ddic/searchhelps/{name} — body 含 search help 基本定义 # // TODO: verify XML structure on live SAP system return ( '' '' f'' "" ) elif obj_type == "lockobject": # POST /ddic/lockobjects/{name} — body 含 lock mode + table name # // TODO: verify XML structure on live SAP system (lock mode + table name) return ( '' '' f'' "" ) raise ValueError(f"不支持的对象类型: {obj_type}") # ------------------------------------------------------------------ # DDIC helpers # ------------------------------------------------------------------ def create_ddic_object( self, obj_type: str, name: str, definition_body: str, corr_nr: str | None = None, ) -> tuple[str, str | None]: config = get_type_config(obj_type) obj_uri = config.format_obj_uri(name.lower()) src_uri = config.format_src_uri(name.lower()) if obj_type in ("table", "structure"): # 先 POST 集合端点创建对象实体,再 PUT 写入 DDL 定义。 # 之前直接 _put_ddl_source,PUT 到尚未创建的对象 /source/main 返回 405。 obj_name = name.upper() body = self._build_create_body(obj_type, obj_name, obj_name) collection_url = config.collection_uri params: dict[str, str] = {} if corr_nr: params["corrNr"] = corr_nr hdrs = self._headers(config.create_content_type) logger.info( "CREATE DDIC ENTITY: POST %s name=%s", collection_url, obj_name, ) resp = self.session.post( f"{self.host}{collection_url}", headers=hdrs, params=params, data=body.encode("utf-8"), ) logger.info("CREATE DDIC ENTITY RESPONSE: HTTP %s", resp.status_code) if resp.status_code >= 400: raise CreateError( f"创建 DDIC 对象实体失败: HTTP {resp.status_code} — {resp.text[:500]}" ) self._put_ddl_source(src_uri, definition_body, "", corr_nr) else: obj_name = name.upper() body = self._build_create_body(obj_type, obj_name, obj_name) collection_url = config.collection_uri params: dict[str, str] = {} if corr_nr: params["corrNr"] = corr_nr hdrs = self._headers(config.create_content_type) logger.info( "CREATE DDIC: POST %s name=%s (with full definition)", collection_url, obj_name, ) resp = self.session.post( f"{self.host}{collection_url}", headers=hdrs, params=params, data=definition_body.encode("utf-8"), ) logger.info("CREATE DDIC RESPONSE: HTTP %s", resp.status_code) if resp.status_code >= 400: raise CreateError( f"创建 DDIC 对象失败: HTTP {resp.status_code} — {resp.text[:500]}" ) obj_name = name.upper() success, messages = self.activate(obj_name, obj_uri, corr_nr) if not success: errors = [m for m in messages if m["type"] == "E"] if errors: raise CreateError( f"DDIC 对象激活失败: {'; '.join(e['text'] for e in errors)}" ) logger.info("DDIC 对象已创建并激活: %s", obj_uri) return obj_uri, src_uri def _put_ddic_xml( self, obj_uri: str, xml_body: str, lock_handle: str = "", corr_nr: str | None = None, ) -> bool: url = f"{self.host}{obj_uri}" params: dict[str, str] = {} if lock_handle: params["lockHandle"] = lock_handle if corr_nr: params["corrNr"] = corr_nr hdrs = self._headers() hdrs["Accept"] = "*/*" logger.info("PUT DDIC XML: PUT %s (%d chars)", url, len(xml_body)) resp = self.session.put( url, headers=hdrs, params=params, data=xml_body.encode("utf-8") ) logger.info("PUT DDIC XML RESPONSE: HTTP %s", resp.status_code) if resp.status_code >= 400: raise CreateError( f"写入 DDIC XML 失败: HTTP {resp.status_code} — {resp.text[:500]}" ) return True def _put_ddl_with_auto_lock( self, src_uri: str, ddl_body: str, obj_uri: str, corr_nr: str | None = None, ) -> bool: try: lock_handle, _ = self.lock(obj_uri, corr_nr) except Exception: logger.debug("自动锁定失败,使用空锁句柄继续", exc_info=True) lock_handle = "" try: return self.set_source(src_uri, ddl_body, lock_handle, corr_nr) finally: if lock_handle: self.unlock(obj_uri, lock_handle) def _put_ddl_source( self, src_uri: str, ddl_body: str, lock_handle: str, corr_nr: str | None = None, ) -> bool: return self.set_source(src_uri, ddl_body, lock_handle, corr_nr) def get_object_status(self, obj_uri: str) -> dict[str, Any]: """查询对象在 SAP 系统中的状态。 Returns: {"exists": bool, "status": str, "corr_nr": str|None} status: "active" / "inactive" / "not_exists" """ url = f"{self.host}{obj_uri}" hdrs = self._headers() hdrs["Accept"] = "*/*" logger.info("GET OBJECT STATUS: GET %s", url) resp = self.session.get(url, headers=hdrs) logger.info("GET OBJECT STATUS RESPONSE: HTTP %s", resp.status_code) if resp.status_code == 404: return {"exists": False, "status": "not_exists", "corr_nr": None} if resp.status_code != 200: logger.warning( "Unexpected status %d for object status check: %s", resp.status_code, obj_uri, ) return {"exists": False, "status": "not_exists", "corr_nr": None} # 解析 XML 获取 version(active/inactive) status = "active" corr_nr: str | None = None try: root = ET.fromstring(resp.content) ns = {"adtcore": "http://www.sap.com/adt/core"} version = root.attrib.get(f"{{{ns['adtcore']}}}version", "") if version == "inactive": status = "inactive" elif version and version != "active": status = version except ET.ParseError: pass # 尝试获取 corr_nr:通过快速 lock → unlock 探测 try: lock_handle, detected_corr = self.lock(obj_uri) corr_nr = detected_corr self.unlock(obj_uri, lock_handle) except Exception: # 锁定失败也正常(可能权限问题),corr_nr 保持 None logger.debug("探测 corr_nr 失败", exc_info=True) return {"exists": True, "status": status, "corr_nr": corr_nr} # ------------------------------------------------------------------ # Function-group helpers # ------------------------------------------------------------------ def function_group_exists(self, group_name: str) -> bool: url = f"{self.host}/sap/bc/adt/functions/groups/{group_name.lower()}" hdrs = self._headers() logger.info("CHECK FG EXISTS: GET %s", url) resp = self.session.get(url, headers=hdrs) logger.info("CHECK FG EXISTS RESPONSE: HTTP %s", resp.status_code) return resp.status_code == 200 def create_function_group( self, group_name: str, description: str | None = None, corr_nr: str | None = None, ) -> bool: config = get_type_config("functiongroup") params: dict[str, str] = {"groupname": group_name.upper()} if corr_nr: params["corrNr"] = corr_nr desc = description or group_name body = self._build_create_body("functiongroup", group_name.upper(), desc) url = f"{self.host}{config.collection_uri}" hdrs = self._headers(config.create_content_type) logger.info("CREATE FG: POST %s name=%s", url, group_name.upper()) resp = self.session.post( url, headers=hdrs, params=params, data=body.encode("utf-8") ) logger.info("CREATE FG RESPONSE: HTTP %s", resp.status_code) if resp.status_code >= 400: logger.error("CREATE FG ERROR: %s", resp.text[:2000]) raise CreateError( f"创建函数组失败: HTTP {resp.status_code} — {resp.text[:500]}" ) return True # ------------------------------------------------------------------ # CDS View # ------------------------------------------------------------------ def get_cds_source(self, name: str) -> str: """读取 CDS View DDL 源码。 Args: name: CDS 名称。 Returns: DDL 源码字符串。 """ url = f"{self.host}/sap/bc/adt/ddic/ddlsources/{name.lower()}/source/main" hdrs = self._headers() hdrs["Accept"] = "text/plain" logger.info("GET CDS SOURCE: GET %s", url) resp = self.session.get(url, headers=hdrs) logger.info("GET CDS SOURCE RESPONSE: HTTP %s", resp.status_code) resp.raise_for_status() return resp.text def create_cds( self, name: str, description: str, ddl_source: str, ) -> tuple[str, str]: """创建 CDS View 并写入 DDL 源码。 Args: name: CDS 名称。 description: 描述。 ddl_source: DDL 源码。 Returns: ``(obj_uri, src_uri)`` 元组。 """ obj_uri = f"/sap/bc/adt/ddic/ddlsources/{name.lower()}" src_uri = f"/sap/bc/adt/ddic/ddlsources/{name.lower()}/source/main" # 1) 创建 CDS 对象 create_url = f"{self.host}/sap/bc/adt/ddic/ddlsources" desc = description or name body = ( '' '' '' "" ) hdrs = self._headers("application/xml") params: dict[str, str] = {"name": name.lower()} logger.info("CREATE CDS: POST %s name=%s", create_url, name) resp = self.session.post( create_url, headers=hdrs, params=params, data=body.encode("utf-8"), ) logger.info("CREATE CDS RESPONSE: HTTP %s", resp.status_code) if resp.status_code >= 400: logger.error("CREATE CDS ERROR: %s", resp.text[:2000]) raise CreateError( f"创建 CDS 失败: HTTP {resp.status_code} — {resp.text[:500]}" ) # 2) 写入 DDL 源码 lock_handle, corr_nr = self.lock(obj_uri) try: self.set_source(src_uri, ddl_source, lock_handle, corr_nr) finally: self.unlock(obj_uri, lock_handle) return obj_uri, src_uri # ------------------------------------------------------------------ # Package # ------------------------------------------------------------------ def create_package( self, name: str, description: str | None = None, superpackage: str | None = None, ) -> bool: """创建 ABAP 包。 Args: name: 包名。 description: 描述。 superpackage: 上级包名。 Returns: 是否成功。 """ url = f"{self.host}/sap/bc/adt/packages" desc = description or name body = ( '' '' else: body += "/>" hdrs = self._headers("application/xml") logger.info("CREATE PACKAGE: POST %s name=%s", url, name) resp = self.session.post(url, headers=hdrs, data=body.encode("utf-8")) logger.info("CREATE PACKAGE RESPONSE: HTTP %s", resp.status_code) if resp.status_code >= 400: logger.error("CREATE PACKAGE ERROR: %s", resp.text[:2000]) raise CreateError( f"创建包失败: HTTP {resp.status_code} — {resp.text[:500]}" ) return True def get_package_info(self, name: str) -> dict[str, str]: """获取 ABAP 包信息。 Args: name: 包名。 Returns: 字典含 ``name``, ``description``, ``owner``, ``superpackage`` 等。 """ url = f"{self.host}/sap/bc/adt/packages/{name}" hdrs = self._headers() hdrs["Accept"] = "application/xml" logger.info("GET PACKAGE INFO: GET %s", url) resp = self.session.get(url, headers=hdrs) logger.info("GET PACKAGE INFO RESPONSE: HTTP %s", resp.status_code) resp.raise_for_status() root = ET.fromstring(resp.content) core_ns = "http://www.sap.com/adt/core" info: dict[str, str] = { "name": name, "description": "", "owner": "", "superpackage": "", } # 尝试从根元素属性提取 info["description"] = root.attrib.get(f"{{{core_ns}}}description", "") info["owner"] = root.attrib.get(f"{{{core_ns}}}owner", "") # 上级包引用 pkg_ref = root.find(f".//{{{core_ns}}}packageRef") if pkg_ref is not None: info["superpackage"] = pkg_ref.attrib.get(f"{{{core_ns}}}name", "") return info # ------------------------------------------------------------------ # ATC / Quality # ------------------------------------------------------------------ def atc_check( self, name: str, obj_uri: str, variant: str | None = None, ) -> tuple[bool, list[dict[str, str]]]: """执行 ATC 代码检查。 Args: name: 对象名称。 obj_uri: 对象 ADT URI。 variant: 检查变体名称。 Returns: ``(success, findings)`` — success 表示无严重错误, findings 是发现项列表,每项含 ``type``, ``line``, ``text`` 等。 """ url = f"{self.host}/sap/bc/adt/atos/checks" params: dict[str, str] = {"context": obj_uri} if variant: params["variant"] = variant body = ( '' '' f'' "" ) hdrs = self._headers() logger.info("ATC CHECK: POST %s name=%s", url, name) resp = self.session.post( url, headers=hdrs, params=params, data=body.encode("utf-8") ) logger.info("ATC CHECK RESPONSE: HTTP %s", resp.status_code) resp.raise_for_status() findings: list[dict[str, str]] = [] has_error = False root = ET.fromstring(resp.content) core_ns = "http://www.sap.com/adt/core" for item in root.iter(): severity = item.attrib.get("severity", item.attrib.get("type", "")) line = item.attrib.get("line", "") text = item.attrib.get("message", item.text or "") if severity or text: findings.append({"type": severity, "line": line, "text": text}) if severity in ("E", "1"): has_error = True return not has_error, findings # ------------------------------------------------------------------ # Pretty printer # ------------------------------------------------------------------ def pretty_print(self, source: str) -> str: """调用 ABAP Pretty Printer 格式化源码。 Args: source: 原始 ABAP 源码。 Returns: 格式化后的源码。 """ url = f"{self.host}/sap/bc/adt/prettyprinter" hdrs = self._headers("text/plain; charset=utf-8") hdrs["Accept"] = "text/plain" logger.info("PRETTY PRINT: POST %s (%d chars)", url, len(source)) resp = self.session.post(url, headers=hdrs, data=source.encode("utf-8")) logger.info("PRETTY PRINT RESPONSE: HTTP %s", resp.status_code) resp.raise_for_status() return resp.text # ------------------------------------------------------------------ # Table structure / data query # ------------------------------------------------------------------ def get_table_fields(self, table_name: str) -> list[dict[str, str]]: """查询 DDIC 表的字段结构。 Args: table_name: 表名(不区分大小写)。 Returns: 字段列表,每项含 name, type, length, description, key_attribute。 """ url = f"{self.host}/sap/bc/adt/datapreview/ddic/{table_name.lower()}/metadata" hdrs = self._headers() hdrs["Accept"] = "*/*" logger.info("GET TABLE FIELDS: GET %s", url) resp = self.session.get(url, headers=hdrs) logger.info("GET TABLE FIELDS RESPONSE: HTTP %s", resp.status_code) resp.raise_for_status() root = ET.fromstring(resp.content) ns = {"dp": "http://www.sap.com/adt/dataPreview"} fields = [] for col in root.findall(".//dp:columns/dp:metadata", ns): field = { "name": col.attrib.get(f"{{{ns['dp']}}}name", ""), "type": col.attrib.get(f"{{{ns['dp']}}}type", ""), "length": col.attrib.get(f"{{{ns['dp']}}}length", ""), "description": col.attrib.get(f"{{{ns['dp']}}}description", ""), "key_attribute": col.attrib.get(f"{{{ns['dp']}}}keyAttribute", "false"), } fields.append(field) return fields def query_table_data(self, sql: str, max_rows: int = 200) -> dict: """通过 ADT freestyle SQL 查询表数据。 Args: sql: SELECT SQL 语句。 max_rows: 最大返回行数。 Returns: {"columns": [...字段名...], "rows": [[值1, 值2, ...], ...], "total_rows": int, "execution_time": str} """ url = f"{self.host}/sap/bc/adt/datapreview/freestyle" hdrs = self._headers("text/plain; charset=utf-8") hdrs["Accept"] = "*/*" params = {"rowNumber": str(max_rows)} logger.info("QUERY TABLE DATA: POST %s sql=%s maxRows=%d", url, sql[:80], max_rows) resp = self.session.post(url, headers=hdrs, params=params, data=sql.encode("utf-8")) logger.info("QUERY TABLE DATA RESPONSE: HTTP %s", resp.status_code) resp.raise_for_status() root = ET.fromstring(resp.content) ns = {"dp": "http://www.sap.com/adt/dataPreview"} # 提取列名(从第一组 columns/metadata) columns = [] for col in root.findall(".//dp:columns/dp:metadata", ns): name = col.attrib.get(f"{{{ns['dp']}}}name", "") if name: columns.append(name) # 提取数据 — ADT 按列存储(每个 columns 包含一列的 dataSet) # 需要转置为按行返回 col_data: list[list[str]] = [] for col_group in root.findall(".//dp:columns", ns): dataset = col_group.find("dp:dataSet", ns) if dataset is None: col_data.append([]) continue values = [v.text or "" for v in dataset.findall("dp:data", ns)] col_data.append(values) # 转置:列数据 → 行数据 max_len = max((len(c) for c in col_data), default=0) rows = [] for i in range(max_len): row = [] for c in col_data: row.append(c[i] if i < len(c) else "") rows.append(row) # 元数据 total_el = root.find("dp:totalRows", ns) time_el = root.find("dp:queryExecutionTime", ns) return { "columns": columns, "rows": rows, "total_rows": int(total_el.text) if total_el is not None and total_el.text else len(rows), "execution_time": time_el.text if time_el is not None else "", } # ------------------------------------------------------------------ # Run program # ------------------------------------------------------------------ def run_program(self, program_name: str) -> str: """远程执行 ABAP 程序并返回输出。 Args: program_name: 程序名(不区分大小写)。 Returns: 程序的标准输出文本(text/plain)。 """ url = f"{self.host}/sap/bc/adt/programs/programrun/{program_name.lower()}" hdrs = self._headers("application/xml") hdrs["Accept"] = "*/*" logger.info("RUN PROGRAM: POST %s", url) resp = self.session.post(url, headers=hdrs) logger.info("RUN PROGRAM RESPONSE: HTTP %s (%d bytes)", resp.status_code, len(resp.content)) resp.raise_for_status() return resp.text # ------------------------------------------------------------------ # ABAP Unit tests # ------------------------------------------------------------------ def run_unit_test(self, obj_uri: str) -> dict[str, Any]: """执行 ABAP Unit 测试。 Args: obj_uri: 被测对象的 ADT URI。 Returns: ``{"summary": {...}, "classes": [{"name", "methods": [...]}]}``。 每个方法项含 ``name``, ``duration``, ``alert``(失败原因), ``line``。 """ url = f"{self.host}/sap/bc/adt/abapunit/testruns" body = ( '' '' "" "" '' '' '' '' '' "" '' '' "" f'' "" "" "" "" ) hdrs = self._headers("application/*") hdrs["Accept"] = "application/*" logger.info("RUN UNIT TEST: POST %s uri=%s", url, obj_uri) resp = self.session.post(url, headers=hdrs, data=body.encode("utf-8")) logger.info("RUN UNIT TEST RESPONSE: HTTP %s", resp.status_code) resp.raise_for_status() return self._parse_aunit_result(resp.content) def _parse_aunit_result(self, content: bytes) -> dict[str, Any]: """解析 ABAP Unit 测试结果 XML。 响应结构(aunit + adtcore 命名空间):: 解析逻辑按本地标签名匹配,兼容命名空间前缀变化。 """ # TODO: verify XML structure on live SAP system root = ET.fromstring(content) summary = { "tests": "0", "failures": "0", "errors": "0", "skipped": "0", "duration": "0", } summary_el = _find_local(root, "summary") if summary_el is not None: for key in summary: summary[key] = summary_el.attrib.get(key, summary[key]) classes: list[dict[str, Any]] = [] for tc in root.iter(): if _local(tc.tag) != "testClass": continue cls_name = _attr_local(tc, "name") methods: list[dict[str, str]] = [] for tm in tc.iter(): if _local(tm.tag) != "testMethod": continue m_name = _attr_local(tm, "name") duration = tm.attrib.get("duration", "") alert_title = "" alert_line = "" for alert in tm.iter(): if _local(alert.tag) != "alert": continue if not alert_title: alert_title = alert.attrib.get("title", "") info = _find_local(alert, "info") if info is not None: line = info.attrib.get("line", "") if line: alert_line = line methods.append({ "name": m_name, "duration": duration, "alert": alert_title, "line": alert_line, }) classes.append({"name": cls_name, "methods": methods}) return {"summary": summary, "classes": classes} # ------------------------------------------------------------------ # Version history # ------------------------------------------------------------------ def get_object_versions(self, obj_uri: str) -> list[dict[str, str]]: """查询对象的版本历史。 Args: obj_uri: 对象 ADT URI。 Returns: 版本列表,每项含 ``version``, ``author``, ``date``, ``versionTitle``。 """ versions_href = self._get_versions_link(obj_uri) if not versions_href: return [] url = ( versions_href if versions_href.startswith("http") else f"{self.host}{versions_href}" ) hdrs = self._headers() hdrs["Accept"] = "application/xml" logger.info("GET VERSIONS: GET %s", url) resp = self.session.get(url, headers=hdrs) logger.info("GET VERSIONS RESPONSE: HTTP %s", resp.status_code) resp.raise_for_status() return self._parse_versions(resp.content) def _get_versions_link(self, obj_uri: str) -> str | None: """GET 对象结构 XML,提取 versions 关系链接的 href。""" url = f"{self.host}{obj_uri}" hdrs = self._headers() hdrs["Accept"] = "application/xml" logger.info("GET OBJECT STRUCTURE: GET %s", url) resp = self.session.get(url, headers=hdrs) logger.info("GET OBJECT STRUCTURE RESPONSE: HTTP %s", resp.status_code) if resp.status_code != 200: return None try: root = ET.fromstring(resp.content) except ET.ParseError: return None for el in root.iter(): if _local(el.tag) != "link": continue if "versions" in el.attrib.get("rel", ""): href = el.attrib.get("href", "") if href: return href return None def _parse_versions(self, content: bytes) -> list[dict[str, str]]: """解析版本历史 feed。 典型结构(Atom feed,每个 entry 含版本元数据属性):: 按本地名(忽略命名空间)匹配属性,兼容 ``versionId`` / ``version``。 """ # TODO: verify XML structure on live SAP system try: root = ET.fromstring(content) except ET.ParseError: return [] fields = ("version", "author", "date", "versionTitle") alias = {"versionId": "version"} def extract(container: ET.Element) -> dict[str, str]: v = {f: "" for f in fields} for el in container.iter(): for key, val in el.attrib.items(): field = alias.get(_local(key), _local(key)) if field in fields and not v[field]: v[field] = val return v entries = [e for e in root.iter() if _local(e.tag) == "entry"] if entries: return [extract(e) for e in entries] # 回退:无 entry 时,凡携带 version/versionId 的元素各成一行 versions: list[dict[str, str]] = [] for el in root.iter(): if any(_local(k) in ("version", "versionId") for k in el.attrib): versions.append(extract(el)) return versions # ------------------------------------------------------------------ # Enhancements # ------------------------------------------------------------------ def get_enhancements(self, obj_uri: str) -> list[dict[str, Any]]: """查询对象的增强实现(ENHO)。 Args: obj_uri: 被增强对象的 ADT URI。 Returns: 增强实现列表,每项含 ``name``, ``type``, ``enhanced_name``, ``enhanced_type``, ``elements``(含 ``name``/``type``/``mode``/``replacing``)。 """ url = f"{self.host}/sap/bc/adt/enhancements" body = ( '' '' "" f'' "" "" ) hdrs = self._headers() logger.info("GET ENHANCEMENTS: POST %s uri=%s", url, obj_uri) resp = self.session.post(url, headers=hdrs, data=body.encode("utf-8")) logger.info("GET ENHANCEMENTS RESPONSE: HTTP %s", resp.status_code) resp.raise_for_status() return self._parse_enhancements(resp.content) def _parse_enhancements(self, content: bytes) -> list[dict[str, Any]]: """解析增强实现列表 XML。 元素标签名与命名空间在不同 SAP 版本差异较大,故按本地名 + ENHO 类型码 匹配增强实现,再在其下查找被增强对象与插件元素。 """ # TODO: verify XML structure on live SAP system try: root = ET.fromstring(content) except ET.ParseError: return [] results: list[dict[str, Any]] = [] for el in root.iter(): local = _local(el.tag) otype = _attr_local(el, "type") is_enh = local in ( "enhancement", "enhancementImplementation", "enhancementSpotUse", ) or "ENHO" in otype if not is_enh: continue enhanced_obj = _find_local(el, "enhancedObject") if enhanced_obj is None: enhanced_obj = _find_local(el, "enhanced") elements: list[dict[str, str]] = [] for pe in el.iter(): if _local(pe.tag) not in ("pluginElement", "element", "sourceCodePluginElement"): continue elements.append({ "name": _attr_local(pe, "elementName") or _attr_local(pe, "name"), "type": _attr_local(pe, "elementType") or _attr_local(pe, "type"), "mode": _attr_local(pe, "mode"), "replacing": _attr_local(pe, "replacing"), }) results.append({ "name": _attr_local(el, "name"), "type": otype or "ENHO", "enhanced_name": _attr_local(enhanced_obj, "name") if enhanced_obj is not None else "", "enhanced_type": _attr_local(enhanced_obj, "type") if enhanced_obj is not None else "", "elements": elements, }) return results