feat: bgRFC/FM 支撑能力 + ADT 后缀剥离与 CLAS 激活修复(B1 配套)
CI / test (3.10) (push) Waiting to run
CI / test (3.11) (push) Waiting to run
CI / test (3.12) (push) Waiting to run

新增能力:
- 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)
This commit is contained in:
吴让宇
2026-09-15 22:51:35 +08:00
parent 04efa7388a
commit c06e350f6d
13 changed files with 697 additions and 4 deletions
+2
View File
@@ -42,6 +42,7 @@ from sapcli.commands import (
cmd_history,
cmd_clone,
cmd_enhancement,
cmd_remote_enable,
)
from sapcli.auth import cmd_auth_login, cmd_auth_logout, cmd_auth_status
from sapcli.exceptions import SapCliError, ConfigError
@@ -164,6 +165,7 @@ def main() -> None:
"unit-test": cmd_unit_test,
"history": cmd_history,
"enhancement": cmd_enhancement,
"remote-enable": cmd_remote_enable,
}
# 批量 sync 参数校验
+5
View File
@@ -292,4 +292,9 @@ CDS View:
sp_enh.add_argument("--name", required=True, help="对象名称")
sp_enh.add_argument("--type", required=True, help="对象类型")
# ── remote-enable (函数模块远程启用,bgRFC/RFC 执行体) ──
sp_remote = subparsers.add_parser("remote-enable", help="远程启用函数模块(bgRFC/RFC 执行体)")
sp_remote.add_argument("--name", required=True, help="函数模块名 (函数组/函数模块 格式)")
sp_remote.add_argument("--corr_nr", default=None, help="传输请求编号")
return parser
+127 -1
View File
@@ -39,6 +39,50 @@ def _find_local(container: ET.Element, name: str) -> ET.Element | None:
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 实现。
@@ -531,13 +575,14 @@ class DdicMixin:
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)
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())
@@ -552,6 +597,87 @@ class DdicMixin:
)
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
# ------------------------------------------------------------------
+10 -1
View File
@@ -208,10 +208,19 @@ class SourceMixin:
obj_uri: str,
corr_nr: str | None = None,
) -> tuple[bool, list[dict[str, str]]]:
# 实测(本机 SAP_BASIS 7522026-09-15 受控实验):
# objectReferences 里只有 1 个 objectReference 时,服务端返回
# HTTP 200 + 空 body(无 content-type),且根本不执行激活;
# 引用数为 2 及以上才真正激活(同一对象重复 2 次亦可)。
# 故单对象请求补一个重复引用,使请求体始终含 2 个引用。
refs = (
f'<adtcore:objectReference adtcore:uri="{obj_uri}" adtcore:name="{name}"/>'
f'<adtcore:objectReference adtcore:uri="{obj_uri}" adtcore:name="{name}"/>'
)
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}"/>'
f'{refs}'
"</adtcore:objectReferences>"
)
self._stateful = False
+4
View File
@@ -73,6 +73,9 @@ from sapcli.commands.clone import (
from sapcli.commands.enhancement import (
cmd_enhancement,
)
from sapcli.commands.remote_enable import (
cmd_remote_enable,
)
__all__ = [
"cmd_create",
@@ -106,4 +109,5 @@ __all__ = [
"cmd_history",
"cmd_clone",
"cmd_enhancement",
"cmd_remote_enable",
]
+2 -1
View File
@@ -939,7 +939,8 @@ def cmd_create(args: argparse.Namespace, client: ADTClient) -> None:
if obj_type == "functiongroup":
print(f"\n → 正在创建函数组...")
try:
created_uri = client.create_function_group(name, description, corr_nr)
package = getattr(args, "package", None) or "$TMP"
created_uri = client.create_function_group(name, description, corr_nr, package)
except Exception as e:
print(f" ✗ 创建失败: {e}")
raise CreateError(str(e)) from e
+55
View File
@@ -0,0 +1,55 @@
"""remote-enable 命令 — 远程启用函数模块(bgRFC/RFC 执行体)。
背景:bgRFC 的执行体必须是远程启用的函数模块(processingType=rfc)。
本命令封装 ADT 的 GET→改 processingType=rfc→PUT→GET 复核闭环,
不负责激活、也不负责 TFDIR.FMODE='R' 的最终确认(后者留给调用方 read-table)。
"""
from __future__ import annotations
import argparse
import logging
from sapcli.client import ADTClient
from sapcli.exceptions import InvalidNameError, SapCliError
logger = logging.getLogger("sapcli.commands.remote_enable")
def cmd_remote_enable(args: argparse.Namespace, client: ADTClient) -> None:
"""远程启用函数模块。"""
name: str = args.name
corr_nr: str | None = getattr(args, "corr_nr", None)
if "/" not in name:
print(" ✗ remote-enable 需要'函数组/函数模块'格式,例如: ZGROUP/Z_FUNC")
raise InvalidNameError(
"remote-enable 需要'函数组/函数模块'格式,例如: ZGROUP/Z_FUNC"
)
group, fm = name.split("/", 1)
group = group.strip()
fm = fm.strip()
if not group or not fm:
print(" ✗ 函数组名和函数模块名不能为空")
raise InvalidNameError("函数组名和函数模块名不能为空")
print("=" * 60)
print(" SAP ADT 函数模块远程启用(bgRFC/RFC)")
print("=" * 60)
print(f" 函数组: {group}")
print(f" 函数模块: {fm}")
if corr_nr:
print(f" 传输请求: {corr_nr}")
print("\n → 正在远程启用(processingType=rfc...")
ok, msg = client.set_function_remote_enabled(group, fm, corr_nr)
if not ok:
print(f"{msg}")
raise SapCliError(msg)
print(" ✓ 远程启用成功")
print(" ✓ 复核回显 processingType=rfc")
print()
print(" 注意:")
print(" - 参数类型只允许 DDIC 对象;类本地类型会 500「针对 RFC 不允许使用类或接口的类型」")
print(" - TFDIR.FMODE='R' 的最终确认请用 read-table 复核(见 SKILL.md")
print(" - 此命令只改属性不激活;如需激活再执行 activate")
+38 -1
View File
@@ -79,6 +79,43 @@ SRC_DIR = "src"
# ADT 式函数模块文件名分隔符:<fugr>.fugr.<fm>.abap
FUNC_ADT_SEP = ".fugr."
# ADT/abapGit 风格的**对象类型后缀**`<name>.<suffix>.abap`)。
# 来源:ADT 链接格式与 abapGit 序列化——项目仓命名规范(如 ABAP_Integrated
# 《命名规范》§4)即按此写文件名,故扫描器必须剥掉后缀才能得到对象名。
# 仅剥与所在目录 obj_type 匹配的那一个后缀,避免误伤含点的对象名。
ADT_TYPE_SUFFIX: dict[str, str] = {
"class": "clas",
"interface": "intf",
"report": "prog",
"domain": "doma",
"dataelement": "dtel",
"table": "tabl",
"structure": "stru",
"tabletype": "ttyp",
"include": "cinc",
"cdsview": "ddls",
"messageclass": "msag",
"searchhelp": "shlp",
"lockobject": "enqu",
"dcl": "dcls",
"ddlX": "ddlx",
}
def _strip_adt_suffix(base: str, obj_type: str) -> str:
"""剥掉 ADT 类型后缀(`zcl_demo.clas` → `zcl_demo`)。
只在该类型有约定后缀、且文件名确实以 `.<该后缀>` 结尾时剥,
其余情况原样返回(含点的对象名不受影响)。
"""
suffix = ADT_TYPE_SUFFIX.get(obj_type)
if not suffix:
return base
tail = "." + suffix
if base.lower().endswith(tail) and len(base) > len(tail):
return base[: -len(tail)]
return base
@dataclass
class ScannedObject:
@@ -176,7 +213,7 @@ def _scan_flat_directory(
if not os.path.isfile(os.path.join(dir_path, filename)):
continue
obj_name = filename[:-5].upper() # 去 .abap,转大写
obj_name = _strip_adt_suffix(filename[:-5], obj_type).upper() # 去 .abap + ADT 类型后缀
_add(results, seen, obj_name, obj_type, _rel(rel_prefix, dir_name, filename))