1209 lines
48 KiB
Python
1209 lines
48 KiB
Python
"""DdicMixin — object CRUD, DDIC helpers, CDS, packages, ATC, pretty-print."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
import xml.etree.ElementTree as ET
|
||
from typing import Any
|
||
|
||
from sapcli.exceptions import CreateError, DeleteError, SapCliError
|
||
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 (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<program:abapProgram xmlns:program="http://www.sap.com/adt/programs/programs"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
' adtcore:type="PROG/P"'
|
||
f' adtcore:description="{desc}">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</program:abapProgram>"
|
||
)
|
||
elif obj_type == "class":
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<class:abapClass xmlns:class="http://www.sap.com/adt/oo/classes"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
' adtcore:type="CLAS/OC"'
|
||
f' adtcore:description="{desc}">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</class:abapClass>"
|
||
)
|
||
elif obj_type == "function":
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<fm:abapFunctionModule xmlns:fm="http://www.sap.com/adt/functions/fmodules"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
' adtcore:type="FUNC/F"'
|
||
f' adtcore:description="{desc}">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</fm:abapFunctionModule>"
|
||
)
|
||
elif obj_type == "functiongroup":
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<fg:abapFunctionGroup xmlns:fg="http://www.sap.com/adt/functions/groups"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
' adtcore:type="FUGR/F"'
|
||
f' adtcore:description="{desc}">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</fg:abapFunctionGroup>"
|
||
)
|
||
elif obj_type == "interface":
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<intf:abapInterface xmlns:intf="http://www.sap.com/adt/oo/interfaces"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
' adtcore:type="INTF/OI"'
|
||
f' adtcore:description="{desc}">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</intf:abapInterface>"
|
||
)
|
||
elif obj_type == "domain":
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<doma:domain xmlns:doma="http://www.sap.com/dictionary/domain"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
f' adtcore:type="DOMA/DD"'
|
||
f' adtcore:description="{desc}"'
|
||
' adtcore:language="EN">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</doma:domain>"
|
||
)
|
||
elif obj_type == "dataelement":
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<dtel:dataElement xmlns:dtel="http://www.sap.com/adt/dictionary/dataelements"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
f' adtcore:type="DTEL/DE"'
|
||
f' adtcore:description="{desc}"'
|
||
' adtcore:language="EN">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</dtel:dataElement>"
|
||
)
|
||
elif obj_type == "table":
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<tabl:table xmlns:tabl="http://www.sap.com/adt/dictionary/tables"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
f' adtcore:type="TABL/TT"'
|
||
f' adtcore:description="{desc}"'
|
||
' adtcore:language="EN">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</tabl:table>"
|
||
)
|
||
elif obj_type == "structure":
|
||
# 结构体(TABL/DS):参考 table,但使用 structures 命名空间
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<struc:structure xmlns:struc="http://www.sap.com/adt/dictionary/structures"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
f' adtcore:type="TABL/DS"'
|
||
f' adtcore:description="{desc}"'
|
||
' adtcore:language="EN">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</struc:structure>"
|
||
)
|
||
elif obj_type == "tabletype":
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<ttyp:abapTableType xmlns:ttyp="http://www.sap.com/adt/ddic/tabletypes"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
' adtcore:type="TTYP"'
|
||
f' adtcore:description="{desc}">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</ttyp:abapTableType>"
|
||
)
|
||
elif obj_type == "include":
|
||
# POST /programs/includes/{name} — include 使用 programs/includes 命名空间,
|
||
# 不能复用 report 的 <program:abapProgram>(否则端点返回 400)。
|
||
# 参考 abap-adt-api objectcreator.ts:
|
||
# rootName="include:abapInclude"
|
||
# nameSpace="http://www.sap.com/adt/programs/includes"
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<include:abapInclude xmlns:include="http://www.sap.com/adt/programs/includes"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
' adtcore:type="PROG/I"'
|
||
f' adtcore:description="{desc}">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</include:abapInclude>"
|
||
)
|
||
elif obj_type == "messageclass":
|
||
# POST /oo/t100/messages/classes/{name} — body 含 class name + description
|
||
# // TODO: verify XML structure on live SAP system
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<msgcls:t100MessageClass xmlns:msgcls="http://www.sap.com/adt/t100/message/classes"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
' adtcore:type="MSAG"'
|
||
f' adtcore:description="{desc}">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</msgcls:t100MessageClass>"
|
||
)
|
||
elif obj_type == "view":
|
||
# POST /ddic/views/{name}(数据库视图)— body 结构参考 table 的创建
|
||
# // TODO: verify XML structure on live SAP system
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<view:view xmlns:view="http://www.sap.com/adt/dictionary/views"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
f' adtcore:type="VIEW"'
|
||
f' adtcore:description="{desc}"'
|
||
' adtcore:language="EN">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</view:view>"
|
||
)
|
||
elif obj_type == "searchhelp":
|
||
# POST /ddic/searchhelps/{name} — body 含 search help 基本定义
|
||
# // TODO: verify XML structure on live SAP system
|
||
return (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<shlp:searchHelp xmlns:shlp="http://www.sap.com/adt/dictionary/searchhelps"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
f' adtcore:type="SHLP"'
|
||
f' adtcore:description="{desc}">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</shlp:searchHelp>"
|
||
)
|
||
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 (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<lock:lockObject xmlns:lock="http://www.sap.com/adt/dictionary/lockobjects"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
f' adtcore:type="LOCK"'
|
||
f' adtcore:description="{desc}">'
|
||
f'<adtcore:packageRef adtcore:name="{package}"/>'
|
||
"</lock:lockObject>"
|
||
)
|
||
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,
|
||
package: str = "$TMP",
|
||
) -> 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, package)
|
||
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, package)
|
||
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 = (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<ddlsrc:abapSource xmlns:ddlsrc="http://www.sap.com/adt/ddic/ddlsources"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name.lower()}"'
|
||
' adtcore:type="DDLS/DF"'
|
||
f' adtcore:description="{desc}">'
|
||
'<adtcore:packageRef adtcore:name="$TMP"/>'
|
||
"</ddlsrc:abapSource>"
|
||
)
|
||
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 = (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<pkg:package xmlns:pkg="http://www.sap.com/adt/packages"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core"'
|
||
f' adtcore:name="{name}"'
|
||
f' adtcore:description="{desc}"'
|
||
)
|
||
if superpackage:
|
||
body += f'><adtcore:packageRef adtcore:name="{superpackage}"/></pkg:package>'
|
||
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 = (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<adtcore:objectReferences xmlns:adtcore="http://www.sap.com/adt/core">'
|
||
f'<adtcore:objectReference adtcore:uri="{obj_uri}" adtcore:name="{name}"/>'
|
||
"</adtcore:objectReferences>"
|
||
)
|
||
|
||
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)
|
||
if resp.status_code == 400:
|
||
sap_msg = ""
|
||
try:
|
||
m = re.search(r"<message[^>]*>(.*?)</message>", resp.text, re.S)
|
||
if m:
|
||
sap_msg = m.group(1).strip()
|
||
except Exception:
|
||
pass
|
||
hint = (
|
||
"ADT Data Preview 拒绝该 SQL(HTTP 400)。常见原因:"
|
||
"① WHERE 运算符两侧缺空格(写 f = 'x',不要 f='x';多值过滤推荐 IN);"
|
||
"② SELECT 列表未用逗号分隔;③ 列名不存在(如 DD02T 的语言列是 DDLANGUAGE)。"
|
||
)
|
||
if sap_msg:
|
||
raise SapCliError(f"{hint}\n SAP 原文: {sap_msg}")
|
||
raise SapCliError(hint)
|
||
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 = (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<aunit:runConfiguration xmlns:aunit="http://www.sap.com/adt/aunit">'
|
||
"<external><coverage active=\"false\"/></external>"
|
||
"<options>"
|
||
'<uriType value="semantic"/>'
|
||
'<testDeterminationStrategy sameProgram="true" assignedTests="false"/>'
|
||
'<testRiskLevels harmless="true" dangerous="true" critical="true"/>'
|
||
'<testDurations short="true" medium="true" long="true"/>'
|
||
'<withNavigationUri enabled="true"/>'
|
||
"</options>"
|
||
'<adtcore:objectSets xmlns:adtcore="http://www.sap.com/adt/core">'
|
||
'<objectSet kind="inclusive">'
|
||
"<adtcore:objectReferences>"
|
||
f'<adtcore:objectReference adtcore:uri="{obj_uri}"/>'
|
||
"</adtcore:objectReferences>"
|
||
"</objectSet>"
|
||
"</adtcore:objectSets>"
|
||
"</aunit:runConfiguration>"
|
||
)
|
||
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 命名空间)::
|
||
|
||
<aunit:runResult>
|
||
<summary tests="3" failures="1" errors="0" skipped="0" duration="450"/>
|
||
<program adtcore:name="...">
|
||
<testClasses>
|
||
<testClass adtcore:name="ZCL_MY_TEST">
|
||
<testMethods>
|
||
<testMethod adtcore:name="test_foo" duration="0.45">
|
||
<alert kind="t" title="assertion error">
|
||
<stackInfo><info line="42"/></stackInfo>
|
||
</alert>
|
||
</testMethod>
|
||
</testMethods>
|
||
</testClass>
|
||
</testClasses>
|
||
</program>
|
||
</aunit:runResult>
|
||
|
||
解析逻辑按本地标签名匹配,兼容命名空间前缀变化。
|
||
"""
|
||
# 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 含版本元数据属性)::
|
||
|
||
<atom:feed>
|
||
<atom:entry>
|
||
<app:control>
|
||
<d:version d:versionId="0001" d:author="DEVUSER"
|
||
d:date="2026-06-10T14:20:00" d:versionTitle="Initial"/>
|
||
</app:control>
|
||
</atom:entry>
|
||
</atom:feed>
|
||
|
||
按本地名(忽略命名空间)匹配属性,兼容 ``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 = (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<enh:enhancementSearch xmlns:enh="http://www.sap.com/adt/enhancements"'
|
||
' xmlns:adtcore="http://www.sap.com/adt/core">'
|
||
"<adtcore:objectReferences>"
|
||
f'<adtcore:objectReference adtcore:uri="{obj_uri}"/>'
|
||
"</adtcore:objectReferences>"
|
||
"</enh:enhancementSearch>"
|
||
)
|
||
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
|