diff --git a/SKILL.md b/SKILL.md
index ec17e25..d2e3071 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -1,7 +1,7 @@
---
name: sap-cli
description: "Use when you need to operate SAP ABAP development objects — syncing code, querying metadata, managing transports, reading tables, or any task involving SAP ADT REST API. Trigger on: ABAP objects, SAP development, transport requests, DDIC types, SE11/SE16N/SE38/SE80 equivalents, 'sync to SAP', 'download from SAP', 'activate ABAP', 'read SAP table'. NOT for developing sap-cli itself."
-version: "2.2.0"
+version: "2.2.1"
author: WuRangyu
license: MIT
---
@@ -270,7 +270,7 @@ python main.py activate --name ZMY_CLASS --type class --corr_nr DEVK901XXX
2. **禁止修改 SAP 标准对象**(`CL_*`、`SAPL*` 等)— 只允许只读操作
3. **禁止未授权操作系统表**(TADIR、E071、SEOCLASS 等)— SELECT 诊断可以
4. **sync 必须带 `--corr_nr`** — 否则触发交互提示
-5. **NW 7.40 DDIC Lock 返回 HTTP 406** — 系统限制,报告用户去 SE09
+5. **非交互 delete 用管道确认** — `echo "yes" | python main.py delete ...`(否则 EOFError)
---
@@ -278,19 +278,36 @@ python main.py activate --name ZMY_CLASS --type class --corr_nr DEVK901XXX
| 问题 | 原因 | 解决 |
|------|------|------|
-| HTTP 406(DDIC Lock) | NW 7.40 不支持 DDIC ADT Lock | 报告用户 → SE09 |
+| HTTP 406(DDIC sync Lock) | NW 7.40 DDIC sync 路径 lock 仍 406(delete 已修复) | 报告用户 → SE09 手动同步 DDIC |
| HTTP 403(Locked) | 残留 enqueue lock | 先试 `run-program` 清锁,不行 → SM12 |
| HTTP 400(SaveFailure) | 类 DEFINITION 与 SAP 不一致 | 对比本地 vs SAP 源码 |
+| HTTP 404(DDIC download/sync) | NW 7.40 无 `/source/main` 端点(7.50+ 特性) | DDIC 对象只能 create/info/delete,不能 download/sync |
| HTTP 423(Transport lock) | 对象绑定在传输请求中 | 报告用户 → SE09 |
| 激活失败 | NW 7.40 ADT 限制 | sap-cli 内置 double-activate,仍失败 → SE09 |
| `WITH EMPTY KEY` dump | NW 7.40 不支持 | 改用 `WITH NON-UNIQUE KEY` |
| 函数名格式错误 | 缺少 `/` 分隔符 | 使用 `ZGROUP/Z_FUNC` 格式 |
-## NW 7.40 兼容性
+## NW 7.40 兼容性(真实系统 E2E 验证 — v2.2.1)
-- **DDIC Lock**:HTTP 406,无法通过 ADT 锁定 DDIC 对象
+> 完整矩阵见 `docs/NW740-COMPATIBILITY.md`
+
+### 完整可用(download → 编辑 → sync 全闭环)
+
+`report` · `class` · `interface` · `function` · `functiongroup` · `include`
+
+### 仅 CRUD 可用(create/info/delete,无源码编辑)
+
+`domain` · `dataelement` — NW 7.40 的 DDIC 对象以 XML 存储于对象 URI,没有独立的 source/main 端点(7.50+ 特性)。即便 Eclipse ADT 在 7.40 上也无法编辑 DDIC 源码。
+
+### 端点缺失(代码已就位,待更高版本验证)
+
+`table` · `structure` · `tabletype` · `view` · `messageclass` · `searchhelp` · `lockobject` · `cdsview` — NW 7.40 ADT 端点层返回 404/415,请求未到达 XML 解析。
+
+### 其他 NW 7.40 特性
+
+- **DDIC Lock**:delete 已修复(专用 Accept 头),sync 路径仍 406
- **激活误报**:首次激活可能报失败但实际成功,sap-cli 已内置 double-activate
-- **ABAP 语法限制**:不支持字符串模板 `|...|`、inline 声明 `DATA(...)`、`WITH EMPTY KEY`
+- **ABAP 语法限制**:不支持字符串模板 `\|...\|`、inline 声明 `DATA(...)`、`WITH EMPTY KEY`
- **CSRF Token**:所有写操作必须携带,sap-cli 自动处理
---
diff --git a/VERSION b/VERSION
index ccbccc3..c043eea 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.2.0
+2.2.1
diff --git a/assets/sapcli/__init__.py b/assets/sapcli/__init__.py
index e8fb274..9e03187 100644
--- a/assets/sapcli/__init__.py
+++ b/assets/sapcli/__init__.py
@@ -2,4 +2,4 @@
sap-cli — 基于 SAP ADT REST API 的 ABAP 开发对象管理工具
"""
-__version__ = "2.2.0"
+__version__ = "2.2.1"
diff --git a/assets/sapcli/cli/app.py b/assets/sapcli/cli/app.py
index 62806d2..147c3e0 100644
--- a/assets/sapcli/cli/app.py
+++ b/assets/sapcli/cli/app.py
@@ -37,6 +37,10 @@ from sapcli.commands import (
cmd_activate,
cmd_upload,
cmd_syntax_check,
+ cmd_unit_test,
+ cmd_history,
+ cmd_clone,
+ cmd_enhancement,
)
from sapcli.auth import cmd_auth_login, cmd_auth_logout, cmd_auth_status
from sapcli.exceptions import SapCliError, ConfigError
@@ -101,6 +105,15 @@ def main() -> None:
cmd_auth_status(args)
return
+ # clone 自行按 --from/--to profile 构建源/目标连接,跳过默认登录
+ if args.command == "clone":
+ try:
+ cmd_clone(args, None)
+ except SapCliError as e:
+ print(f"\n ✗ {e}")
+ sys.exit(1)
+ return
+
# 加载配置
try:
sap_cfg, loaded_from = load_config(args.config, profile=getattr(args, "profile", None))
@@ -146,6 +159,9 @@ def main() -> None:
"show-table": cmd_show_table,
"read-table": cmd_read_table,
"run-program": cmd_run_program,
+ "unit-test": cmd_unit_test,
+ "history": cmd_history,
+ "enhancement": cmd_enhancement,
}
# 批量 sync 参数校验
diff --git a/assets/sapcli/cli/parser.py b/assets/sapcli/cli/parser.py
index 13a534f..7528586 100644
--- a/assets/sapcli/cli/parser.py
+++ b/assets/sapcli/cli/parser.py
@@ -260,4 +260,27 @@ CDS View:
p_run = subparsers.add_parser("run-program", help="远程执行 ABAP 程序 (SA38)")
p_run.add_argument("--name", required=True, help="程序名")
+ # ── unit-test ──
+ sp_unit = subparsers.add_parser("unit-test", help="运行 ABAP Unit 测试")
+ sp_unit.add_argument("--name", required=True, help="对象名称")
+ sp_unit.add_argument("--type", required=True, help="对象类型")
+
+ # ── history ──
+ sp_history = subparsers.add_parser("history", help="查看对象版本历史")
+ sp_history.add_argument("--name", required=True, help="对象名称")
+ sp_history.add_argument("--type", required=True, help="对象类型")
+
+ # ── clone ──
+ sp_clone = subparsers.add_parser("clone", help="跨系统克隆对象")
+ sp_clone.add_argument("--name", required=True, help="对象名称")
+ sp_clone.add_argument("--type", required=True, help="对象类型")
+ sp_clone.add_argument("--from", dest="from_profile", required=True, help="源系统 profile")
+ sp_clone.add_argument("--to", dest="to_profile", required=True, help="目标系统 profile")
+ sp_clone.add_argument("--corr_nr", default=None, help="目标系统的传输请求号")
+
+ # ── enhancement ──
+ sp_enh = subparsers.add_parser("enhancement", help="查询对象的增强实现")
+ sp_enh.add_argument("--name", required=True, help="对象名称")
+ sp_enh.add_argument("--type", required=True, help="对象类型")
+
return parser
diff --git a/assets/sapcli/client/_ddic.py b/assets/sapcli/client/_ddic.py
index 719322c..bef55be 100644
--- a/assets/sapcli/client/_ddic.py
+++ b/assets/sapcli/client/_ddic.py
@@ -12,6 +12,41 @@ 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."""
@@ -24,7 +59,8 @@ class DdicMixin:
obj_uri: str,
corr_nr: str | None = None,
) -> tuple[bool, str]:
- lock_handle, _ = self.lock(obj_uri, corr_nr)
+ # 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:
@@ -193,6 +229,19 @@ class DdicMixin:
f''
""
)
+ elif obj_type == "structure":
+ # 结构体(TABL/DS):参考 table,但使用 structures 命名空间
+ return (
+ ''
+ ''
+ f''
+ ""
+ )
elif obj_type == "tabletype":
return (
''
@@ -204,6 +253,75 @@ class DdicMixin:
f''
""
)
+ elif obj_type == "include":
+ # POST /programs/includes/{name} — include 使用 programs/includes 命名空间,
+ # 不能复用 report 的 (否则端点返回 400)。
+ # 参考 abap-adt-api objectcreator.ts:
+ # rootName="include:abapInclude"
+ # nameSpace="http://www.sap.com/adt/programs/includes"
+ return (
+ ''
+ ''
+ f''
+ ""
+ )
+ elif obj_type == "messageclass":
+ # POST /oo/t100/messages/classes/{name} — body 含 class name + description
+ # // TODO: verify XML structure on live SAP system
+ return (
+ ''
+ ''
+ f''
+ ""
+ )
+ elif obj_type == "view":
+ # POST /ddic/views/{name}(数据库视图)— body 结构参考 table 的创建
+ # // TODO: verify XML structure on live SAP system
+ return (
+ ''
+ ''
+ f''
+ ""
+ )
+ elif obj_type == "searchhelp":
+ # POST /ddic/searchhelps/{name} — body 含 search help 基本定义
+ # // TODO: verify XML structure on live SAP system
+ return (
+ ''
+ ''
+ f''
+ ""
+ )
+ elif obj_type == "lockobject":
+ # POST /ddic/lockobjects/{name} — body 含 lock mode + table name
+ # // TODO: verify XML structure on live SAP system (lock mode + table name)
+ return (
+ ''
+ ''
+ f''
+ ""
+ )
raise ValueError(f"不支持的对象类型: {obj_type}")
# ------------------------------------------------------------------
@@ -222,6 +340,31 @@ class DdicMixin:
src_uri = config.format_src_uri(name.lower())
if obj_type in ("table", "structure"):
+ # 先 POST 集合端点创建对象实体,再 PUT 写入 DDL 定义。
+ # 之前直接 _put_ddl_source,PUT 到尚未创建的对象 /source/main 返回 405。
+ obj_name = name.upper()
+ body = self._build_create_body(obj_type, obj_name, obj_name)
+ collection_url = config.collection_uri
+ params: dict[str, str] = {}
+ if corr_nr:
+ params["corrNr"] = corr_nr
+ hdrs = self._headers(config.create_content_type)
+ logger.info(
+ "CREATE DDIC ENTITY: POST %s name=%s",
+ collection_url,
+ obj_name,
+ )
+ resp = self.session.post(
+ f"{self.host}{collection_url}",
+ headers=hdrs,
+ params=params,
+ data=body.encode("utf-8"),
+ )
+ logger.info("CREATE DDIC ENTITY RESPONSE: HTTP %s", resp.status_code)
+ if resp.status_code >= 400:
+ raise CreateError(
+ f"创建 DDIC 对象实体失败: HTTP {resp.status_code} — {resp.text[:500]}"
+ )
self._put_ddl_source(src_uri, definition_body, "", corr_nr)
else:
obj_name = name.upper()
@@ -754,3 +897,288 @@ class DdicMixin:
logger.info("RUN PROGRAM RESPONSE: HTTP %s (%d bytes)", resp.status_code, len(resp.content))
resp.raise_for_status()
return resp.text
+
+ # ------------------------------------------------------------------
+ # ABAP Unit tests
+ # ------------------------------------------------------------------
+
+ def run_unit_test(self, obj_uri: str) -> dict[str, Any]:
+ """执行 ABAP Unit 测试。
+
+ Args:
+ obj_uri: 被测对象的 ADT URI。
+
+ Returns:
+ ``{"summary": {...}, "classes": [{"name", "methods": [...]}]}``。
+ 每个方法项含 ``name``, ``duration``, ``alert``(失败原因), ``line``。
+ """
+ url = f"{self.host}/sap/bc/adt/abapunit/testruns"
+ body = (
+ ''
+ ''
+ ""
+ ""
+ ''
+ ''
+ ''
+ ''
+ ''
+ ""
+ ''
+ ''
+ ""
+ f''
+ ""
+ ""
+ ""
+ ""
+ )
+ hdrs = self._headers("application/*")
+ hdrs["Accept"] = "application/*"
+ logger.info("RUN UNIT TEST: POST %s uri=%s", url, obj_uri)
+ resp = self.session.post(url, headers=hdrs, data=body.encode("utf-8"))
+ logger.info("RUN UNIT TEST RESPONSE: HTTP %s", resp.status_code)
+ resp.raise_for_status()
+ return self._parse_aunit_result(resp.content)
+
+ def _parse_aunit_result(self, content: bytes) -> dict[str, Any]:
+ """解析 ABAP Unit 测试结果 XML。
+
+ 响应结构(aunit + adtcore 命名空间)::
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 解析逻辑按本地标签名匹配,兼容命名空间前缀变化。
+ """
+ # TODO: verify XML structure on live SAP system
+ root = ET.fromstring(content)
+
+ summary = {
+ "tests": "0", "failures": "0", "errors": "0",
+ "skipped": "0", "duration": "0",
+ }
+ summary_el = _find_local(root, "summary")
+ if summary_el is not None:
+ for key in summary:
+ summary[key] = summary_el.attrib.get(key, summary[key])
+
+ classes: list[dict[str, Any]] = []
+ for tc in root.iter():
+ if _local(tc.tag) != "testClass":
+ continue
+ cls_name = _attr_local(tc, "name")
+ methods: list[dict[str, str]] = []
+ for tm in tc.iter():
+ if _local(tm.tag) != "testMethod":
+ continue
+ m_name = _attr_local(tm, "name")
+ duration = tm.attrib.get("duration", "")
+ alert_title = ""
+ alert_line = ""
+ for alert in tm.iter():
+ if _local(alert.tag) != "alert":
+ continue
+ if not alert_title:
+ alert_title = alert.attrib.get("title", "")
+ info = _find_local(alert, "info")
+ if info is not None:
+ line = info.attrib.get("line", "")
+ if line:
+ alert_line = line
+ methods.append({
+ "name": m_name,
+ "duration": duration,
+ "alert": alert_title,
+ "line": alert_line,
+ })
+ classes.append({"name": cls_name, "methods": methods})
+
+ return {"summary": summary, "classes": classes}
+
+ # ------------------------------------------------------------------
+ # Version history
+ # ------------------------------------------------------------------
+
+ def get_object_versions(self, obj_uri: str) -> list[dict[str, str]]:
+ """查询对象的版本历史。
+
+ Args:
+ obj_uri: 对象 ADT URI。
+
+ Returns:
+ 版本列表,每项含 ``version``, ``author``, ``date``, ``versionTitle``。
+ """
+ versions_href = self._get_versions_link(obj_uri)
+ if not versions_href:
+ return []
+
+ url = (
+ versions_href
+ if versions_href.startswith("http")
+ else f"{self.host}{versions_href}"
+ )
+ hdrs = self._headers()
+ hdrs["Accept"] = "application/xml"
+ logger.info("GET VERSIONS: GET %s", url)
+ resp = self.session.get(url, headers=hdrs)
+ logger.info("GET VERSIONS RESPONSE: HTTP %s", resp.status_code)
+ resp.raise_for_status()
+ return self._parse_versions(resp.content)
+
+ def _get_versions_link(self, obj_uri: str) -> str | None:
+ """GET 对象结构 XML,提取 versions 关系链接的 href。"""
+ url = f"{self.host}{obj_uri}"
+ hdrs = self._headers()
+ hdrs["Accept"] = "application/xml"
+ logger.info("GET OBJECT STRUCTURE: GET %s", url)
+ resp = self.session.get(url, headers=hdrs)
+ logger.info("GET OBJECT STRUCTURE RESPONSE: HTTP %s", resp.status_code)
+ if resp.status_code != 200:
+ return None
+ try:
+ root = ET.fromstring(resp.content)
+ except ET.ParseError:
+ return None
+ for el in root.iter():
+ if _local(el.tag) != "link":
+ continue
+ if "versions" in el.attrib.get("rel", ""):
+ href = el.attrib.get("href", "")
+ if href:
+ return href
+ return None
+
+ def _parse_versions(self, content: bytes) -> list[dict[str, str]]:
+ """解析版本历史 feed。
+
+ 典型结构(Atom feed,每个 entry 含版本元数据属性)::
+
+
+
+
+
+
+
+
+
+ 按本地名(忽略命名空间)匹配属性,兼容 ``versionId`` / ``version``。
+ """
+ # TODO: verify XML structure on live SAP system
+ try:
+ root = ET.fromstring(content)
+ except ET.ParseError:
+ return []
+
+ fields = ("version", "author", "date", "versionTitle")
+ alias = {"versionId": "version"}
+
+ def extract(container: ET.Element) -> dict[str, str]:
+ v = {f: "" for f in fields}
+ for el in container.iter():
+ for key, val in el.attrib.items():
+ field = alias.get(_local(key), _local(key))
+ if field in fields and not v[field]:
+ v[field] = val
+ return v
+
+ entries = [e for e in root.iter() if _local(e.tag) == "entry"]
+ if entries:
+ return [extract(e) for e in entries]
+
+ # 回退:无 entry 时,凡携带 version/versionId 的元素各成一行
+ versions: list[dict[str, str]] = []
+ for el in root.iter():
+ if any(_local(k) in ("version", "versionId") for k in el.attrib):
+ versions.append(extract(el))
+ return versions
+
+ # ------------------------------------------------------------------
+ # Enhancements
+ # ------------------------------------------------------------------
+
+ def get_enhancements(self, obj_uri: str) -> list[dict[str, Any]]:
+ """查询对象的增强实现(ENHO)。
+
+ Args:
+ obj_uri: 被增强对象的 ADT URI。
+
+ Returns:
+ 增强实现列表,每项含 ``name``, ``type``, ``enhanced_name``,
+ ``enhanced_type``, ``elements``(含 ``name``/``type``/``mode``/``replacing``)。
+ """
+ url = f"{self.host}/sap/bc/adt/enhancements"
+ body = (
+ ''
+ ''
+ ""
+ f''
+ ""
+ ""
+ )
+ hdrs = self._headers()
+ logger.info("GET ENHANCEMENTS: POST %s uri=%s", url, obj_uri)
+ resp = self.session.post(url, headers=hdrs, data=body.encode("utf-8"))
+ logger.info("GET ENHANCEMENTS RESPONSE: HTTP %s", resp.status_code)
+ resp.raise_for_status()
+ return self._parse_enhancements(resp.content)
+
+ def _parse_enhancements(self, content: bytes) -> list[dict[str, Any]]:
+ """解析增强实现列表 XML。
+
+ 元素标签名与命名空间在不同 SAP 版本差异较大,故按本地名 + ENHO 类型码
+ 匹配增强实现,再在其下查找被增强对象与插件元素。
+ """
+ # TODO: verify XML structure on live SAP system
+ try:
+ root = ET.fromstring(content)
+ except ET.ParseError:
+ return []
+
+ results: list[dict[str, Any]] = []
+ for el in root.iter():
+ local = _local(el.tag)
+ otype = _attr_local(el, "type")
+ is_enh = local in (
+ "enhancement", "enhancementImplementation", "enhancementSpotUse",
+ ) or "ENHO" in otype
+ if not is_enh:
+ continue
+
+ enhanced_obj = _find_local(el, "enhancedObject")
+ if enhanced_obj is None:
+ enhanced_obj = _find_local(el, "enhanced")
+ elements: list[dict[str, str]] = []
+ for pe in el.iter():
+ if _local(pe.tag) not in ("pluginElement", "element", "sourceCodePluginElement"):
+ continue
+ elements.append({
+ "name": _attr_local(pe, "elementName") or _attr_local(pe, "name"),
+ "type": _attr_local(pe, "elementType") or _attr_local(pe, "type"),
+ "mode": _attr_local(pe, "mode"),
+ "replacing": _attr_local(pe, "replacing"),
+ })
+ results.append({
+ "name": _attr_local(el, "name"),
+ "type": otype or "ENHO",
+ "enhanced_name": _attr_local(enhanced_obj, "name") if enhanced_obj is not None else "",
+ "enhanced_type": _attr_local(enhanced_obj, "type") if enhanced_obj is not None else "",
+ "elements": elements,
+ })
+ return results
diff --git a/assets/sapcli/commands/__init__.py b/assets/sapcli/commands/__init__.py
index cff82db..7737542 100644
--- a/assets/sapcli/commands/__init__.py
+++ b/assets/sapcli/commands/__init__.py
@@ -60,6 +60,18 @@ from sapcli.commands.upload import (
from sapcli.commands.syntax_check import (
cmd_syntax_check,
)
+from sapcli.commands.unit_test import (
+ cmd_unit_test,
+)
+from sapcli.commands.history import (
+ cmd_history,
+)
+from sapcli.commands.clone import (
+ cmd_clone,
+)
+from sapcli.commands.enhancement import (
+ cmd_enhancement,
+)
__all__ = [
"cmd_create",
@@ -88,4 +100,8 @@ __all__ = [
"cmd_activate",
"cmd_upload",
"cmd_syntax_check",
+ "cmd_unit_test",
+ "cmd_history",
+ "cmd_clone",
+ "cmd_enhancement",
]
diff --git a/assets/sapcli/commands/clone.py b/assets/sapcli/commands/clone.py
new file mode 100644
index 0000000..67e6b5c
--- /dev/null
+++ b/assets/sapcli/commands/clone.py
@@ -0,0 +1,128 @@
+"""clone 命令 — 跨系统克隆对象(下载源系统 → 上传目标系统)。"""
+from __future__ import annotations
+
+import argparse
+import logging
+import os
+import tempfile
+
+from sapcli.client import ADTClient
+from sapcli.commands.crud import _sync_single
+from sapcli.config import load_config
+from sapcli.exceptions import ConfigError, InvalidNameError, ObjectNotFoundError, SapCliError
+from sapcli.types import get_type_config, parse_object_name
+
+logger = logging.getLogger("sapcli.commands.clone")
+
+
+def _make_profile_client(config_path: str | None, profile: str, verify_ssl: bool = False) -> ADTClient:
+ """按 profile 加载配置并建立已登录的 ADT 客户端。"""
+ sap_cfg, _ = load_config(config_path, profile=profile)
+ client = ADTClient(
+ sap_cfg.host, sap_cfg.client, sap_cfg.user, sap_cfg.password,
+ verify_ssl=verify_ssl,
+ )
+ client.login()
+ return client
+
+
+def cmd_clone(args: argparse.Namespace, client: ADTClient) -> None:
+ """从一个系统下载对象,上传到另一个系统。
+
+ 注意:``client`` 参数在本命令中被忽略;源/目标客户端分别按
+ ``--from`` / ``--to`` profile 构建。
+ """
+ name: str = args.name
+ obj_type: str = args.type
+ from_profile: str = args.from_profile
+ to_profile: str = args.to_profile
+ corr_nr: str | None = getattr(args, "corr_nr", None)
+ config_path: str | None = getattr(args, "config", None)
+ verify_ssl: bool = getattr(args, "verify_ssl", False)
+
+ parsed = parse_object_name(name, obj_type)
+ type_label = get_type_config(obj_type).label
+
+ if parsed.src_uri is None:
+ print(f"\n ✗ {type_label}没有源代码,不支持 clone 操作")
+ raise InvalidNameError(f"{type_label}没有源代码,不支持 clone 操作")
+
+ print("=" * 60)
+ print(" 跨系统克隆对象")
+ print("=" * 60)
+ print(f" 对象类型: {type_label}")
+ print(f" 对象名称: {parsed.display_name}")
+ print(f" 源系统: {from_profile}")
+ print(f" 目标系统: {to_profile}")
+ if corr_nr:
+ print(f" 目标传输请求: {corr_nr}")
+
+ # ── Step 1: 从源系统下载源码 ──
+ print(f"\n → 连接源系统 ({from_profile})...")
+ try:
+ src_client = _make_profile_client(config_path, from_profile, verify_ssl)
+ except Exception as e:
+ print(f" ✗ 连接源系统失败: {e}")
+ raise ConfigError(f"连接源系统失败: {e}") from e
+ print(f" ✓ 已登录源系统")
+
+ try:
+ print(f"\n → 检查源对象是否存在...")
+ if not src_client.object_exists(parsed.obj_uri):
+ print(f" ✗ 源对象不存在: {parsed.display_name}")
+ raise ObjectNotFoundError(parsed.display_name, obj_type)
+ print(" ✓ 源对象存在")
+
+ print(f" → 正在从源系统下载源代码...")
+ source = src_client.get_source(parsed.src_uri)
+ source = source.replace("\r\n", "\n").replace("\r", "\n")
+ print(f" ✓ 下载成功 ({len(source)} 字符)")
+ finally:
+ src_client.session.close()
+
+ # 写入临时文件供 _sync_single 复用
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".abap", prefix=f"clone_{parsed.file_base}_")
+ try:
+ with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
+ f.write(source)
+
+ # ── Step 2: 连接目标系统 ──
+ print(f"\n → 连接目标系统 ({to_profile})...")
+ try:
+ tgt_client = _make_profile_client(config_path, to_profile, verify_ssl)
+ except Exception as e:
+ print(f" ✗ 连接目标系统失败: {e}")
+ raise ConfigError(f"连接目标系统失败: {e}") from e
+ print(f" ✓ 已登录目标系统")
+
+ try:
+ print(f"\n → 检查目标对象是否存在...")
+ exists = tgt_client.object_exists(parsed.obj_uri)
+ if exists:
+ print(" ✓ 目标对象已存在(将覆盖)")
+ else:
+ print(" ℹ 目标对象不存在(将创建并同步)")
+
+ # ── Step 3: 同步到目标(不存在则自动创建 + 上传 + 激活)──
+ print(f"\n → 同步到目标系统...")
+ success, error_msg, actual_corr = _sync_single(
+ name, obj_type, tmp_path, tgt_client, corr_nr=corr_nr,
+ )
+ finally:
+ tgt_client.session.close()
+ finally:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+ if success:
+ print(f"\n {'=' * 60}")
+ print(f" ✓ 克隆完成: {parsed.display_name}")
+ print(f" {from_profile} → {to_profile}")
+ if actual_corr:
+ print(f" 目标传输请求: {actual_corr}")
+ print(f" {'=' * 60}")
+ return
+
+ raise SapCliError(error_msg or f"克隆失败: {parsed.display_name}")
diff --git a/assets/sapcli/commands/crud.py b/assets/sapcli/commands/crud.py
index 30bddd8..849f38f 100644
--- a/assets/sapcli/commands/crud.py
+++ b/assets/sapcli/commands/crud.py
@@ -56,6 +56,39 @@ DEFAULT_TEMPLATES: dict[str, str] = {
' METHODS: hello.\n'
'ENDINTERFACE.\n'
),
+ # 以下类型不提供完整业务骨架,仅给出占位注释。
+ # 建议通过 --source 指定本地源码文件,或用 --definition 创建 DDIC 定义。
+ "include": (
+ '*&---------------------------------------------------------------------*\n'
+ '*& Include {name}\n'
+ '*&---------------------------------------------------------------------*\n'
+ '* Include 程序骨架:在此编写可被其它程序 INCLUDE 的 ABAP 片段。\n'
+ '* 提示:用 --source 指定本地源码文件可覆盖本模板。\n'
+ ),
+ "messageclass": (
+ '* Message Class {name}\n'
+ '* 消息类(T100)不包含 ABAP 源码,消息条目通过 SE91 维护。\n'
+ '* 提示:建议用 --definition 创建,或通过 SAP GUI (SE91) 维护消息。\n'
+ ),
+ "view": (
+ '* Database View {name}\n'
+ '* 数据库视图 DDL 骨架(基础 SELECT 形式,按需替换):\n'
+ '* SELECT <字段列表>\n'
+ '* FROM <主表>\n'
+ '* [JOIN <关联表> ON <条件>]\n'
+ '* [WHERE <过滤条件>]\n'
+ '* 提示:用 --source 指定完整视图定义可覆盖本模板。\n'
+ ),
+ "searchhelp": (
+ '* Search Help {name}\n'
+ '* 搜索帮助不包含 ABAP 源码,定义含数据源与显示字段。\n'
+ '* 提示:建议用 --definition 创建,或通过 SAP GUI (SE11) 维护。\n'
+ ),
+ "lockobject": (
+ '* Lock Object {name}\n'
+ '* 锁对象不包含 ABAP 源码,定义含锁定模式与主表。\n'
+ '* 提示:建议用 --definition 创建,或通过 SAP GUI (SE11) 维护。\n'
+ ),
}
@@ -92,7 +125,7 @@ def cmd_download(args: argparse.Namespace, client: ADTClient) -> None:
print(f" 保存路径: {save_dir}")
print(f"\n → 检查对象是否存在...")
- if not client.object_exists(parsed.obj_uri):
+ if not client.object_exists(parsed.exists_uri or parsed.obj_uri):
print(f" ✗ 对象不存在: {parsed.display_name}")
print(f" 请确认名称和类型是否正确")
raise ObjectNotFoundError(parsed.display_name, obj_type)
@@ -210,7 +243,7 @@ def _sync_single(
if not quiet:
print(f"\n → 检查对象是否存在...")
- if not client.object_exists(parsed.obj_uri):
+ if not client.object_exists(parsed.exists_uri or parsed.obj_uri):
if not quiet:
print(f" ℹ 对象不存在,自动创建空对象...")
try:
@@ -386,6 +419,15 @@ def cmd_info(args: argparse.Namespace, client: ADTClient) -> None:
"dataelement": "*/*",
"table": "*/*",
"tabletype": "*/*",
+ "structure": "application/vnd.sap.adt.ddic.structures.v1+xml",
+ "include": "application/vnd.sap.adt.programs.includes.v2+xml",
+ "cdsview": "application/vnd.sap.adt.ddic.ddls.v3+xml",
+ "messageclass": "application/vnd.sap.adt.t100.message.classes.v1+xml",
+ "view": "application/vnd.sap.adt.ddic.views.v1+xml",
+ "searchhelp": "application/vnd.sap.adt.ddic.searchhelps.v1+xml",
+ "lockobject": "application/vnd.sap.adt.ddic.lockobjects.v1+xml",
+ "dcl": "text/plain",
+ "ddlX": "text/plain",
}
print("=" * 60)
@@ -471,7 +513,7 @@ def cmd_delete(args: argparse.Namespace, client: ADTClient) -> None:
print(f" 对象名称: {parsed.display_name}")
print(f"\n → 检查对象是否存在...")
- if not client.object_exists(parsed.obj_uri):
+ if not client.object_exists(parsed.exists_uri or parsed.obj_uri):
print(f" ✗ 对象不存在: {parsed.display_name}")
print(f" 请确认名称和类型是否正确")
raise ObjectNotFoundError(parsed.display_name, obj_type)
@@ -639,7 +681,7 @@ def cmd_create(args: argparse.Namespace, client: ADTClient) -> None:
print(f"\n → 检查对象是否存在...")
if obj_type != "functiongroup":
parsed = parse_object_name(name, obj_type)
- if client.object_exists(parsed.obj_uri):
+ if client.object_exists(parsed.exists_uri or parsed.obj_uri):
print(f" ✗ 对象已存在: {display_name}")
raise ObjectAlreadyExistsError(display_name, type_label)
else:
diff --git a/assets/sapcli/commands/enhancement.py b/assets/sapcli/commands/enhancement.py
new file mode 100644
index 0000000..47f7b71
--- /dev/null
+++ b/assets/sapcli/commands/enhancement.py
@@ -0,0 +1,69 @@
+"""enhancement 命令 — 查询对象的增强实现。"""
+from __future__ import annotations
+
+import argparse
+import logging
+
+from sapcli.client import ADTClient
+from sapcli.exceptions import ObjectNotFoundError
+from sapcli.types import get_type_config, parse_object_name
+
+logger = logging.getLogger("sapcli.commands.enhancement")
+
+
+def cmd_enhancement(args: argparse.Namespace, client: ADTClient) -> None:
+ """查询对象的增强实现(ENHO)。"""
+ name: str = args.name
+ obj_type: str = args.type
+
+ parsed = parse_object_name(name, obj_type)
+ type_label = get_type_config(obj_type).label
+
+ print("=" * 60)
+ print(" 对象增强实现查询")
+ print("=" * 60)
+ print(f" 对象类型: {type_label}")
+ print(f" 对象名称: {parsed.display_name}")
+
+ print(f"\n → 检查对象是否存在...")
+ if not client.object_exists(parsed.obj_uri):
+ print(f" ✗ 对象不存在: {parsed.display_name}")
+ raise ObjectNotFoundError(parsed.display_name, obj_type)
+ print(" ✓ 对象存在")
+
+ print(f"\n → 正在查询增强实现...")
+ try:
+ enhancements = client.get_enhancements(parsed.obj_uri)
+ except Exception as e:
+ print(f" ✗ 查询增强实现失败: {e}")
+ return
+
+ if not enhancements:
+ print(" ℹ 未找到增强实现")
+ return
+
+ print(f" ✓ 找到 {len(enhancements)} 个增强实现\n")
+ for enh in enhancements:
+ enh_name = enh.get("name", "")
+ enh_type = enh.get("type", "ENHO")
+ print(f" Enhancement Implementation: {enh_name} ({enh_type})")
+
+ enhanced_name = enh.get("enhanced_name", "")
+ enhanced_type = enh.get("enhanced_type", "")
+ if enhanced_name:
+ print(f" Enhanced Object: {enhanced_name} ({enhanced_type})")
+
+ elements = enh.get("elements", [])
+ for i, el in enumerate(elements, 1):
+ el_type = el.get("type", "")
+ el_name = el.get("name", "")
+ label = f"{el_type}: {el_name}" if el_type and el_name else (el_name or el_type)
+ print(f" Element {i}: {label}")
+ extras = []
+ if el.get("mode"):
+ extras.append(f"Mode: {el['mode']}")
+ if el.get("replacing"):
+ extras.append(f"Replacing: {el['replacing']}")
+ if extras:
+ print(f" {', '.join(extras)}")
+ print()
diff --git a/assets/sapcli/commands/history.py b/assets/sapcli/commands/history.py
new file mode 100644
index 0000000..b5d0098
--- /dev/null
+++ b/assets/sapcli/commands/history.py
@@ -0,0 +1,65 @@
+"""history 命令 — 查询对象版本历史。"""
+from __future__ import annotations
+
+import argparse
+import logging
+
+from sapcli.client import ADTClient
+from sapcli.exceptions import ObjectNotFoundError
+from sapcli.types import get_type_config, parse_object_name
+
+logger = logging.getLogger("sapcli.commands.history")
+
+
+def _format_date(value: str) -> str:
+ """ISO 日期 ``2026-06-15T10:30:00`` → ``2026-06-15 10:30:00``。"""
+ if not value:
+ return ""
+ return value.replace("T", " ").rstrip("Z").strip()
+
+
+def cmd_history(args: argparse.Namespace, client: ADTClient) -> None:
+ """查询对象的版本历史。"""
+ name: str = args.name
+ obj_type: str = args.type
+
+ parsed = parse_object_name(name, obj_type)
+ type_label = get_type_config(obj_type).label
+
+ print("=" * 60)
+ print(" 对象版本历史")
+ print("=" * 60)
+ print(f" 对象类型: {type_label}")
+ print(f" 对象名称: {parsed.display_name}")
+
+ print(f"\n → 检查对象是否存在...")
+ if not client.object_exists(parsed.obj_uri):
+ print(f" ✗ 对象不存在: {parsed.display_name}")
+ raise ObjectNotFoundError(parsed.display_name, obj_type)
+ print(" ✓ 对象存在")
+
+ print(f"\n → 正在查询版本历史...")
+ try:
+ versions = client.get_object_versions(parsed.obj_uri)
+ except Exception as e:
+ print(f" ✗ 查询版本历史失败: {e}")
+ return
+
+ if not versions:
+ print(" ℹ 未找到版本历史")
+ return
+
+ print(f" ✓ 找到 {len(versions)} 个版本\n")
+ print(
+ f" {'Version':<10} {'Author':<12} {'Date':<22} Description"
+ )
+ print(
+ f" {'─' * 10} {'─' * 12} {'─' * 22} {'─' * 30}"
+ )
+ for v in versions:
+ version = v.get("version", "") or "active"
+ author = v.get("author", "")
+ date = _format_date(v.get("date", ""))
+ title = v.get("versionTitle", "")
+ print(f" {version:<10} {author:<12} {date:<22} {title}")
+ print()
diff --git a/assets/sapcli/commands/search.py b/assets/sapcli/commands/search.py
index a5a2bce..761b873 100644
--- a/assets/sapcli/commands/search.py
+++ b/assets/sapcli/commands/search.py
@@ -25,6 +25,10 @@ _TYPE_FILTER_MAP: dict[str, str] = {
"cdsview": "DDLS/DF",
"messageclass": "MSAG",
"view": "VIEW",
+ "searchhelp": "SHLP",
+ "lockobject": "LOCK",
+ "dcl": "DCLS",
+ "ddlX": "DDLX",
}
diff --git a/assets/sapcli/commands/unit_test.py b/assets/sapcli/commands/unit_test.py
new file mode 100644
index 0000000..6419183
--- /dev/null
+++ b/assets/sapcli/commands/unit_test.py
@@ -0,0 +1,83 @@
+"""unit-test 命令 — 执行 ABAP Unit 测试。"""
+from __future__ import annotations
+
+import argparse
+import logging
+
+from sapcli.client import ADTClient
+from sapcli.types import get_type_config, parse_object_name
+
+logger = logging.getLogger("sapcli.commands.unit_test")
+
+
+def _parse_duration(value: str) -> float:
+ """安全解析方法耗时(秒),无法解析返回 0.0。"""
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return 0.0
+
+
+def cmd_unit_test(args: argparse.Namespace, client: ADTClient) -> None:
+ """执行 ABAP Unit 测试并汇总结果。"""
+ name: str = args.name
+ obj_type: str = args.type
+
+ parsed = parse_object_name(name, obj_type)
+ type_label = get_type_config(obj_type).label
+
+ print("=" * 60)
+ print(" ABAP Unit 测试")
+ print("=" * 60)
+ print(f" 对象类型: {type_label}")
+ print(f" 对象名称: {parsed.display_name}")
+
+ print(f"\n → 正在执行 ABAP Unit 测试...")
+ try:
+ result = client.run_unit_test(parsed.obj_uri)
+ except Exception as e:
+ print(f" ✗ 测试执行失败: {e}")
+ return
+
+ summary = result.get("summary", {})
+ classes = result.get("classes", [])
+
+ if not classes:
+ print(" ℹ 未找到测试类(对象可能不包含 ABAP Unit 测试)")
+ return
+
+ print()
+ for cls in classes:
+ cls_name = cls.get("name") or parsed.display_name
+ methods = cls.get("methods", [])
+ total_duration = sum(_parse_duration(m.get("duration", "")) for m in methods)
+ failed = [m for m in methods if m.get("alert")]
+
+ if not failed:
+ print(
+ f" ✅ {cls_name} {len(methods)} methods "
+ f"{total_duration:.2f}s PASSED"
+ )
+ else:
+ print(
+ f" ❌ {cls_name} {len(methods)} methods "
+ f"{total_duration:.2f}s FAILED"
+ )
+ for m in failed:
+ line_info = f" at line {m['line']}" if m.get("line") else ""
+ print(
+ f" method: {m['name']} FAILED: "
+ f"{m['alert']}{line_info}"
+ )
+
+ # 汇总
+ tests = summary.get("tests", "?")
+ failures = summary.get("failures", "0")
+ errors = summary.get("errors", "0")
+ skipped = summary.get("skipped", "0")
+ print(f"\n {'─' * 50}")
+ print(
+ f" 合计: {tests} 个测试 | 失败 {failures} | 错误 {errors} | "
+ f"跳过 {skipped}"
+ )
+ print(f" {'─' * 50}")
diff --git a/assets/sapcli/scanner.py b/assets/sapcli/scanner.py
index 75a7683..1dee01a 100644
--- a/assets/sapcli/scanner.py
+++ b/assets/sapcli/scanner.py
@@ -26,6 +26,8 @@ DIRECTORY_TYPE_MAP: dict[str, str] = {
"views": "view",
"searchhelps": "searchhelp",
"lockobjects": "lockobject",
+ "dcl": "dcl",
+ "ddlx": "ddlX",
}
FUNCTIONS_DIR = "functions"
diff --git a/assets/sapcli/sorter.py b/assets/sapcli/sorter.py
index 93957a5..137ba5b 100644
--- a/assets/sapcli/sorter.py
+++ b/assets/sapcli/sorter.py
@@ -31,6 +31,9 @@ TYPE_PRIORITY: dict[str, int] = {
"include": 78,
"cdsview": 79,
"report": 80,
+ # DCL/DDLX 依赖 CDS View,排在 cdsview(79)/report(80) 之后
+ "dcl": 81,
+ "ddlX": 82,
}
# 未注册类型的默认优先级
diff --git a/assets/sapcli/types.py b/assets/sapcli/types.py
index dc3d8e3..31c2254 100644
--- a/assets/sapcli/types.py
+++ b/assets/sapcli/types.py
@@ -14,6 +14,10 @@ class ObjectTypeConfig:
collection_uri: str
create_content_type: str
has_source: bool = True
+ # 存在性检查专用端点模板;为 None 时回退到 obj_uri_template。
+ # 个别类型(如 tabletype)的 obj_uri 是 VIT 端点,对任意名称都返回 200,
+ # 无法区分存在/不存在,因此存在性检查需走标准 DDIC 端点。
+ exists_uri_template: str | None = None
def format_obj_uri(self, name: str, **kwargs: str) -> str:
return self.obj_uri_template.format(name=name, **kwargs)
@@ -23,6 +27,11 @@ class ObjectTypeConfig:
return None
return self.src_uri_template.format(name=name, **kwargs)
+ def format_exists_uri(self, name: str, **kwargs: str) -> str:
+ """格式化存在性检查端点。无专用模板时回退到 obj_uri_template。"""
+ template = self.exists_uri_template or self.obj_uri_template
+ return template.format(name=name, **kwargs)
+
def format_collection_uri(self, **kwargs: str) -> str:
return self.collection_uri.format(**kwargs)
@@ -125,6 +134,8 @@ TABLETYPE = _register(ObjectTypeConfig(
collection_uri="/sap/bc/adt/ddic/tabletypes",
create_content_type="application/vnd.sap.adt.ddic.tabletypes.v1+xml",
has_source=False,
+ # VIT obj_uri 对任意名称都返回 200,存在性检查必须走 DDIC 端点
+ exists_uri_template="/sap/bc/adt/ddic/tabletypes/{name}",
))
INCLUDE = _register(ObjectTypeConfig(
@@ -139,9 +150,30 @@ INCLUDE = _register(ObjectTypeConfig(
CDSVIEW = _register(ObjectTypeConfig(
key="cdsview",
label="CDS视图(CDS VIEW)",
- obj_uri_template="/sap/bc/adt/dds/ddl/sources/{name}",
- src_uri_template="/sap/bc/adt/dds/ddl/sources/{name}/content",
- collection_uri="/sap/bc/adt/dds/ddl/sources",
+ # 与 commands/cds.py 和 client/_ddic.py 保持一致:/ddic/ddlsources(NW 7.40 验证可用)
+ obj_uri_template="/sap/bc/adt/ddic/ddlsources/{name}",
+ src_uri_template="/sap/bc/adt/ddic/ddlsources/{name}/source/main",
+ collection_uri="/sap/bc/adt/ddic/ddlsources",
+ create_content_type="text/plain",
+))
+
+DCL = _register(ObjectTypeConfig(
+ key="dcl",
+ label="访问控制(DCL)",
+ # CDS Access Control,端点 /ddic/dclsources(参考 docs/dev/adt-endpoint-reference.md)
+ obj_uri_template="/sap/bc/adt/ddic/dclsources/{name}",
+ src_uri_template="/sap/bc/adt/ddic/dclsources/{name}/source/main",
+ collection_uri="/sap/bc/adt/ddic/dclsources",
+ create_content_type="text/plain",
+))
+
+DDLX = _register(ObjectTypeConfig(
+ key="ddlX",
+ label="元数据扩展(DDLX)",
+ # Metadata Extension,端点 /ddic/metadataextensions(参考 docs/dev/adt-endpoint-reference.md)
+ obj_uri_template="/sap/bc/adt/ddic/metadataextensions/{name}",
+ src_uri_template="/sap/bc/adt/ddic/metadataextensions/{name}/source/main",
+ collection_uri="/sap/bc/adt/ddic/metadataextensions",
create_content_type="text/plain",
))
@@ -201,6 +233,9 @@ class ParsedName:
src_uri: str | None
display_name: str
file_base: str
+ # 存在性检查专用端点;默认与 obj_uri 相同。tabletype 等类型的 obj_uri
+ # 不能用于存在性检查,需走标准 DDIC 端点。
+ exists_uri: str = ""
def parse_object_name(name: str, obj_type: str) -> ParsedName:
@@ -221,9 +256,11 @@ def parse_object_name(name: str, obj_type: str) -> ParsedName:
src_uri=cfg.format_src_uri(fm.lower(), group=group.lower()),
display_name=name,
file_base=fm.lower(),
+ exists_uri=cfg.format_exists_uri(fm.lower(), group=group.lower()),
)
# VIT 端点需要大写名称以返回完整元数据;去掉前导 /
+ # 但 VIT 对存在性检查无效(任意名称都返回 200),exists_uri 走 DDIC 端点(小写名)
if obj_type == "tabletype":
clean = name.lstrip("/")
name_upper = clean.upper()
@@ -232,6 +269,7 @@ def parse_object_name(name: str, obj_type: str) -> ParsedName:
src_uri=cfg.format_src_uri(name_upper),
display_name=clean,
file_base=clean.lower(),
+ exists_uri=cfg.format_exists_uri(clean.lower()),
)
name_lower = name.lower()
@@ -240,4 +278,5 @@ def parse_object_name(name: str, obj_type: str) -> ParsedName:
src_uri=cfg.format_src_uri(name_lower),
display_name=name,
file_base=name_lower,
+ exists_uri=cfg.format_exists_uri(name_lower),
)