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), )