Files
sap-cli-skill/assets/sapcli/client/_ddic.py
T

1214 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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_sourcePUT 到尚未创建的对象 /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 获取 versionactive/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 拒绝该 SQLHTTP 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))
if resp.status_code != 200:
# Log response body to aid diagnosing server-side dumps
logger.error("RUN PROGRAM ERROR BODY: %s", resp.text[:4000])
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)
if resp.status_code != 200 or b"<classes" not in resp.content:
logger.error("UNIT TEST BODY: %s", resp.text[:3000])
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