Files
sap-cli-skill/assets/sapcli/client/_ddic.py
T
吴让宇 c06e350f6d
CI / test (3.10) (push) Waiting to run
CI / test (3.11) (push) Waiting to run
CI / test (3.12) (push) Waiting to run
feat: bgRFC/FM 支撑能力 + ADT 后缀剥离与 CLAS 激活修复(B1 配套)
新增能力:
- remote-enable 子命令:设置函数模块处理类型为远程启用(bgRFC/RFC 执行体必需)。
  实现要点均按实机验证:PUT fmodule:processingType="rfc"(枚举值仅小写 rfc 有效,
  remoteEnabled/remote/RFC 等均 400);lockHandle 走 query 参数(放 header 报
  ParameterNotFound);PUT 后 GET 复核回显;DDIC 结构参数才可远程启用(类本地类型
  报"针对 RFC 不允许使用类或接口的类型")。
- create --type functiongroup 透传 --package / --corr_nr(此前 package 被静默写成 $TMP,
  导致函数组无法归目标包)。

修复:
- activate 请求体补重复引用:ADT CLAS 激活端点对 objectReferences 只有 1 个引用时
  返回 HTTP 200 + 空 body 且不执行激活;引用数 ≥2 才真正激活(同对象重复亦可)。
- scanner 剥离 ADT 文件后缀(xxx.clas.abap 解析错误)。

守卫测试:
- tests/unit/test_bgrfc_support.py(17 例):函数组 package 透传、processingType 助手、
  remote-enable 命令(含 lockHandle 走 query/小写 rfc/错误透传/复核失配判失败)、
  函数 URI 组装不得用组名冒充模块名。
- test_activate.py 增加激活请求体引用数守卫;test_repo_guards.py 增加后缀剥离守卫。

测试:tests/unit 676 + tests/test_sapcli.py 69 = 745 全绿
(PYTHONPATH=assets python -m unittest discover -s tests/unit -t tests)
2026-09-15 22:51:35 +08:00

1340 lines
53 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
def _set_processing_type(xml_text: str, value: str) -> str:
"""把 FM 元数据 XML 的 ``processingType`` 字段改为 ``value``(元素/属性两种形态兼容)。
按本地名匹配(``fm:``/``fmodule:``/无前缀均命中),回写前注册已知命名空间
前缀,尽量保持与原 XML 一致。实测(NW 7.52):ADT 只认小写 ``rfc``
``remoteEnabled``/``remote``/``RFC`` 等值全 400。
"""
ET.register_namespace("fm", "http://www.sap.com/adt/functions/fmodules")
ET.register_namespace("adtcore", "http://www.sap.com/adt/core")
root = ET.fromstring(xml_text)
changed = False
# 元素形态:<fm:processingType>normal</fm:processingType>
for el in root.iter():
if _local(el.tag) == "processingType":
el.text = value
changed = True
# 属性形态:processingType="normal"(含命名空间前缀)
if not changed:
for el in root.iter():
for key in list(el.attrib.keys()):
if _local(key) == "processingType":
el.attrib[key] = value
changed = True
if not changed:
raise SapCliError("函数模块 XML 中未找到 processingType 字段,无法设置远程启用")
return ET.tostring(root, encoding="unicode", xml_declaration=True)
def _get_processing_type(xml_text: str) -> str:
"""从 FM 元数据 XML 读取 ``processingType`` 当前值(无则返回空串)。"""
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return ""
for el in root.iter():
if _local(el.tag) == "processingType":
return (el.text or "").strip()
for el in root.iter():
for key, val in el.attrib.items():
if _local(key) == "processingType":
return val
return ""
# 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,
package: str = "$TMP",
) -> 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, package)
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
# ------------------------------------------------------------------
# Function module remote-enablebgRFC/RFC 执行体)
# ------------------------------------------------------------------
def get_function_module_xml(self, group: str, fm: str) -> str:
"""GET 函数模块元数据 XML(含 processingType)。
Args:
group: 函数组名。
fm: 函数模块名。
Returns:
FM 元数据 XML 字符串。
"""
obj_uri = get_type_config("function").format_obj_uri(
fm.lower(), group=group.lower()
)
url = f"{self.host}{obj_uri}"
hdrs = self._headers()
hdrs["Accept"] = "application/vnd.sap.adt.functions.fmodules.v2+xml"
logger.info("GET FM XML: GET %s", url)
resp = self.session.get(url, headers=hdrs)
logger.info("GET FM XML RESPONSE: HTTP %s", resp.status_code)
if resp.status_code != 200:
raise SapCliError(
f"读取函数模块元数据失败: HTTP {resp.status_code}{resp.text[:500]}"
)
return resp.text
def set_function_remote_enabled(
self,
group: str,
fm: str,
corr_nr: str | None = None,
) -> tuple[bool, str]:
"""远程启用函数模块:GET 元数据 → ``processingType=rfc`` → PUT → GET 复核。
实测(NW 7.52)唯一可行方案:PUT 原 XML 且 ``processingType`` 取小写
``rfc````remoteEnabled``/``remote``/``RFC`` 等值全 400);lockHandle 必须
走 query 参数(放 header 报 ``ExceptionParameterNotFound``)。类本地类型参数的
FM 会 500「针对 RFC 不允许使用类或接口的类型」,错误透传给调用方。
复核后 ``TFDIR.FMODE='R'`` 的最终确认留给调用方 read-table(文档已写清)。
Returns:
``(success, error_message)``。
"""
obj_uri = get_type_config("function").format_obj_uri(
fm.lower(), group=group.lower()
)
# 1) 锁定
lock_handle, _ = self.lock(obj_uri, corr_nr)
try:
# 2) GET 原 XML
xml_text = self.get_function_module_xml(group, fm)
# 3) 改 processingType=rfc
new_xml = _set_processing_type(xml_text, "rfc")
# 4) PUTlockHandle 走 query 参数)
url = f"{self.host}{obj_uri}"
params: dict[str, str] = {"lockHandle": lock_handle}
if corr_nr:
params["corrNr"] = corr_nr
hdrs = self._headers(
"application/vnd.sap.adt.functions.fmodules.v2+xml"
)
hdrs["Accept"] = "*/*"
logger.info("SET FM REMOTE-ENABLE: PUT %s", url)
resp = self.session.put(
url, headers=hdrs, params=params, data=new_xml.encode("utf-8")
)
logger.info("SET FM REMOTE-ENABLE RESPONSE: HTTP %s", resp.status_code)
if resp.status_code >= 400:
msg = resp.text[:500].strip() if resp.text else f"HTTP {resp.status_code}"
return False, f"远程启用失败: HTTP {resp.status_code}{msg}"
# 5) GET 复核回显
verify_xml = self.get_function_module_xml(group, fm)
if _get_processing_type(verify_xml) != "rfc":
return False, "远程启用失败: 复核回显 processingType 不是 rfc"
return True, ""
finally:
self.unlock(obj_uri, lock_handle)
# ------------------------------------------------------------------
# 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